Unlisted walkthrough · real architecture, shipping today
How NeuroSpicy actually runs.
Ten-second check-ins go in; burnout forecasts come out. This page walks the whole system in between, structured the way a systems-design interview answer is: requirements first, napkin math second, architecture, deep dives, bottlenecks, tradeoffs. Every box is real. Every label is the actual route, table, or package name.
- lines of mobile app
- ~33k
- lines of API
- ~10.5k
- lines of shared signal math
- ~7.6k
- numbered migrations
- 78
- computed signals
- 13
- servers to babysit
- 0
01 · requirements
Scope first, boxes later.
Every strong design answer starts here, because every choice downstream is judged against these. Note the third list: what this system deliberately does not do is as load-bearing as what it does.
Functional
- Log energy and mood in under ten seconds, three times a day
- See personal signals: burnout risk, phase, peak and dip windows, a 3-day forecast
- Add friends; send small supportive gestures
- Everything works offline; data syncs when the network returns
Non-functional
- Check-in flow must feel instant on a cold open, on a bad mobile network
- Personal data is never served from a shared cache
- Single region is acceptable: users only ever read their own rows and friends’ summaries
- Ship fixes without waiting for app-store review (JS over the air)
Non-goals
- No realtime collaboration or live presence
- No multi-region replication
- No ML training pipeline; the signals are explicit, inspectable math
- No self-hosted infrastructure of any kind
Deliberate exclusions are a design tool. Each of these would multiply operational load; none of them serve a person checking in at 3am.
02 · capacity estimation
Do the napkin math before you believe anyone’s architecture.
Interviews call this back-of-envelope estimation. The point is not precision; it is catching order-of-magnitude nonsense before it ships. Drag the slider: the math below recomputes live, and the honest surprise is how far a boring stack goes.
- Vercel edge cache absorbing public reads
- Serverless functions scale horizontally, cold starts only
- Connection pooler yawning
- Postgres writes ~1,500/sec ceiling is nowhere in sight
- Signal recompute (CPU) cheap at this size
- Notify cron sweep scans settings comfortably
Assumptions are on the napkin on purpose: ~15 API calls and ~4 writes per active user per day, 4× morning peak, ~150ms average function time, ~120 bytes per row. Change an assumption, redo the math; that is the whole skill.
Where this sits on the Postgres capacity line
Log scale. The lesson generalizes: most products never earn sharding, and proposing it anyway is the fastest way to fail a design review. A single well-indexed Postgres is a monster. This is the whole philosophy of the system in one chart: scale the easiest, cheapest way available, and spend the savings on the product.
03 · the interface
Seven routes carry the whole product.
The contract, annotated the way you’d defend it on a whiteboard. One rule worth stealing for any system: identity comes from the verified token, never from the request body.
/logs a check-in lands; Zod-validated, duplicate-safe /launch profile + predictions + today, in one cold-start-friendly call /predictions the full signal set, recomputed per request /sync/pull?since=… delta sync across the five tables the app mirrors /blog/:slug · /kb/* content; the function usually never wakes up /iap/webhook subscription state changes push in /health uptime check that doubles as a function warmer 04 · the data model
Tables shaped by their reads.
The interview move is connecting every table to the access pattern that shaped it. Row level security is part of the model here, not an afterthought: every table carries a policy, so the database enforces ownership even if the application forgets to.
energy_logs facts user_id · logged_at · energy 1–10 · mood? · time_of_day
immutableidx (user_id, logged_at DESC)
Every history query is “this user, newest first.” One composite index carries them all.
daily_summaries derived user_id · date · avg_energy · max · avg_mood · entry_count
trigger-maintained
Recomputed inside the write transaction. The signal engine reads days, not raw taps.
users · public_profiles identity display_name · timezone · public_id · tier · theme
tier from RevenueCat
The public tier is denormalized on purpose: friend screens read one row, not a join fan-out.
activity_logs · activities facts activity_id · energy · mood? · logged_at
rollup precomputed
Per-activity impact (energy delta vs baseline) is rolled up on write, read cheap forever.
friendships · friend_gestures social user_a · user_b · status · gesture kind · response
mutual-visibility RLS
Both sides must appear in the row for either to read it. The database enforces the friendship.
notification_settings · push_tokens delivery windows 1–3 · daily summary · expo push token
cron-swept
The notify cron reads these every 30 minutes to decide who gets nudged.
05 · deep dive: the write path
A check-in can’t wait on the network.
The core moment of this product happens at 6am with one eye open, or at 11pm in bed. This is “what happens when you press save,” narrated hop by hop.
-
Five taps in a bottom sheet
Energy bucket, energy 1 to 10, optional mood, confirm. The time-of-day slot is inferred from the clock (
morning05:00–11:59,afternoon12:00–17:59,eveningotherwise), so nobody files their feelings under a timestamp picker. Haptics confirm each step. -
Write locally, return instantly
enqueueLog()writes the check-in to on-device SQLite and the UI moves on in milliseconds. Network state is irrelevant.“Airplane mode? Fine by me. I’ll hold onto it.” — the outbox
-
The outbox drains when it can
A background worker ships queued rows to
POST /logs, each carrying a client-generated idempotency key. A409means the server already has it, so drop it. A5xxmeans retry with exponential backoff. Success is acknowledged exactly once. -
The API checks everything, then gets out of the way
Zod validates the shape; middleware verifies the Supabase JWT and talks to Postgres as that user. The row lands in
energy_logs, which is immutable: a check-in is a fact about a moment, and facts don’t get edited. -
Postgres finishes the job itself
A trigger recomputes that day’s row in
daily_summariesinside the same transaction. The pre-bucketed table the signal engine reads is never stale and never needs a batch job.
06 · deep dive: the signals engine
The math ships twice.
Every signal in the product, from burnout risk to the three-day forecast, is ~7,600 lines
of typed TypeScript in one package: @pulse/core. It compiles into the API
for the server path, and it ships inside the app so predictions still work with no
network. One implementation, two runtimes, zero drift.
How a pile of 1-to-10 taps becomes a forecast
- Bucket. The trigger-maintained
daily_summariestable gives the engine one clean row per day: average energy, max, mood, entry count. Raw logs from the trailing 90 days come along for the fine-grained work. - Segment. A sliding window walks the daily averages and labels phases: sustained ≥7 is a high streak, 4–6 is recovery, ≤3 is a crash. Phase history becomes the user’s personal rhythm fingerprint.
- Compare. Burnout risk scores the current streak against the user’s own history: a push score sums how far each day runs above 7, and four named pathways (streak, sliding recovery, emotional drain, crashed) decide what kind of risk it is. Mood decline escalates it.
- Correlate. Mood Shield runs a lagged Pearson correlation between mood and energy to catch decoupling, the state where mood drops days before energy follows. Stability compares 14-day standard deviation against all-time.
- Forecast. Past phases get re-indexed by their offset from peak day, recency-weighted, and extrapolated three days forward with confidence bands from recent variance. The highest window in that curve is the peak window the app shows.
07 · caching, delivery, and the classic bottleneck
Cache what’s public. Recompute what’s personal.
The canonical cache stack is client → CDN → application → database. Here is where each layer actually lives in this system, followed by the bottleneck every serverless + Postgres design must answer for.
- Client SWR cache + SQLite on the phone. Most screens render from local data before any request happens. The app is its own first cache.
- Edge Vercel’s CDN, public routes only.
s-maxage=300, stale-while-revalidate=3600on blog, knowledge base, community trends. Served in ~an eyeblink, revalidated in the background.“Seen this response 40 seconds ago. The function never even woke up.” — the edge cache
- App tier Deliberately nothing. Functions are stateless; there is no Redis. Personal responses use
no-cache, stale-while-revalidate=10800: the client may show slightly stale data while revalidating, but no person’s signals ever come out of a shared cache. - Database daily_summaries is a cache that can’t go stale. The trigger maintains it inside the write transaction. Precomputation is caching; doing it in the transaction is what makes it safe.
The funnel: serverless meets Postgres
The textbook failure mode of this exact architecture: every function instance wants its own database connection, and Postgres connections are expensive OS processes with a hard cap. The fix is a pooler in the middle.
“Two hundred of you, sixty real connections. Everybody shares, nobody notices.” — the pooler
Sync is a cursor, not a conversation
GET /sync/pull?since=<cursor> returns deltas across the five
tables the app mirrors locally, plus the next cursor. Reconnecting after a week
offline is the same code path as a routine refresh.
Ship JavaScript like a web page, natives like a release
EAS runs three channels (development, preview,
production). JS-only changes go over the air in minutes; app-store
builds are reserved for native changes.
Cron does the janitorial work
Notifications fire every thirty minutes against user-configured windows. Sunday 9am snapshots volatility profiles. Daily at 8am, entitlements reconcile against RevenueCat, so even a missed webhook self-heals within a day.
08 · bottleneck analysis
What breaks at 10x? At 100x?
Jeff Dean’s rule: design for 10x growth, plan to rewrite before 100x. Designing for 100x on day one is over-engineering. Here is the honest redline forecast for this system, in the order things would actually give out.
Today Everything is green
Peak load rounds to a handful of requests per second. The pooler, the database, and
the functions are all operating at a fraction of a percent of capacity. The only
felt cost is serverless cold starts on p99 launches, which /launch and
the keep-warm health check already blunt.
10x Two ambers, zero rewrites
Signal recompute goes amber first: computing 90 days of math per read is pure CPU, and function-seconds are the bill that grows linearly. The fix is already designed: memoize per user-day, or precompute for heavy users; the math is deterministic, so caching it is trivial. The notify cron goes amber second: sweeping every user’s settings every 30 minutes wants a windowed query and an index it doesn’t need yet.
100x Now the textbook applies
Pooled connections need a tier bump, hot public routes want a higher cache ratio, the notification sweep becomes a real fan-out problem (a queue finally earns its place), and signals move to precompute-on-write for everyone. Postgres itself? Still fine. The single node outlives every component around it.
The shape of the bill
No dollar amounts, but the shape matters more than the numbers: some lines scale with users, some with revenue, some barely move. Knowing which is which before you grow is the entire discipline of cost design.
- grows with users function invocations · database tier · push volume · signal CPU
- grows with revenue RevenueCat’s cut (which is the good kind of growing bill)
- mostly flat Sentry · domains · EAS builds · the boring fixed layer
09 · failure modes & guardrails
Design for the day something dies.
The failure matrix is the systems-design close: name what dies, what the user actually experiences, and what brings it back. Offline-first turns most of this table boring, which was the point all along.
The guardrails underneath
- The only door is the API. Clients hold no database credentials. Every request crosses middleware that verifies the Supabase-issued ES256 JWT against cached JWKS before anything else runs.
- The rows defend themselves. The API talks to Postgres as the authenticated user, so row-level security applies to every query on every table. Authorization lives in two places that fail independently.
- Facts are immutable, aggregates are floored. Check-ins insert and never update. Community trends only report aggregates above a k-anonymity floor, so “how is everyone doing” can never become “how is that person doing.”
- Both halves report in. Sentry instruments the app (launch traces, time-to-interactive) and the API (a root error handler that captures and flushes before any 500 leaves the building). Latency, traffic, errors, saturation: the four golden signals, covered by boring tools.
10 · tradeoffs
Why it’s built this way.
Every choice has an alternative that reasonable people would pick, and a cost we pay on purpose. Naming all three is what separates a design from a stack of defaults.
One math package, two runtimes
instead of A signals microservice, or duplicating the math in the client
Every signal lives in @pulse/core: esbuild inlines it into the API, and the same package ships inside the app for offline predictions. One implementation cannot drift from itself.
what it costs us The package must stay runtime-neutral: no Node APIs, no React imports, disciplined dependencies.
Signals are derived state, never stored
instead of Precomputing signals into a table on every write
Predictions recompute from logs on every read: stateless, deterministic, and a math fix reaches every user’s entire history the moment it deploys. No backfills, ever.
what it costs us CPU per read. Fine today; the first thing to revisit at scale (see chapter 08).
Offline-first, because 3am is the point
instead of Requiring a connection and showing a spinner
A check-in writes to SQLite and returns in milliseconds; an outbox drains later with idempotency keys. Airplane mode is a supported environment, not an error.
what it costs us Two sources of truth to reconcile, and sync code is the hardest code in the app.
Boring infrastructure, chosen on purpose
instead of Queues, Redis, Kubernetes, a dedicated ML service
Postgres, serverless functions, triggers, cron. Every exotic component you skip is a pager that never goes off. The complexity budget is spent on the product math instead.
what it costs us Some problems get solved “the boring way” (cron reconciliation instead of exactly-once events).
The API is the only door, and rows guard themselves
instead of Letting clients query the database directly with RLS alone
Clients never touch Postgres. The API verifies the JWT, then talks to the database as that user, so row-level security still applies underneath. Two layers must fail before data leaks.
what it costs us Every new feature needs an endpoint. That friction is the feature.
One request to wake up
instead of Three clean REST calls on app launch
/launch collapses profile, predictions, and today’s logs into one call: one function warm-up instead of three, one round trip on a cold mobile radio.
what it costs us A slightly lumpy endpoint that exists for physics, not purity.
Where it goes next
At 10x: memoized signals and a windowed notification sweep, both already sketched. At 100x: a queue for fan-out and precomputed signals, which is a refactor, not a rewrite. The boring stack stays; that was the design.
Built by Varnish Labs. The app itself lives at neurospicy.sh, on iOS and Android. Questions about any box on the map are exactly the conversation this page exists to start.