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.

NeuroSpicy system map An Expo mobile app with local SQLite and an outbox talks over HTTPS to a Hono API on Vercel, which reads and writes Supabase Postgres. A shared core package of signal math runs in both the app and the API. Satellites: Apple Health, push notifications, EAS over-the-air updates, RevenueCat webhooks, Vercel cron jobs, and Sentry. POST /logs GET /launch insert + trigger summaries back public · cached 300s EXPO 55 · REACT NATIVE The app check-in sheet · 5 steps expo-sqlite + outbox SWR cache · secure store @pulse/core · offline math HONO · VERCEL FUNCTIONS The API Zod validate · JWT verify /logs /launch /predictions @pulse/core · baked in SUPABASE · POSTGRES The data RLS on every table energy_logs · immutable daily_summaries · trigger @pulse/core — the same signal math, running in both boxes above Apple Health / Health Connect Expo Push 3 windows / day Vercel Cron notify · reconcile RevenueCat entitlement webhook EAS · OTA updates dev / preview / prod Sentry app + API traces Vercel Edge public cache
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.

5,000
requests / day users × ~15 calls 75k
peak requests / sec avg × 4 (mornings cluster) 3.5
concurrent functions peak rps × ~150ms ~1
check-in rows / year users × ~4 × 365 7.3M
storage / year rows × ~120 bytes 0.9 GB
  • 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.

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.

POST /logs JWTidempotent a check-in lands; Zod-validated, duplicate-safe
GET /launch JWTaggregate profile + predictions + today, in one cold-start-friendly call
GET /predictions JWTcomputed the full signal set, recomputed per request
GET /sync/pull?since=… JWTcursor delta sync across the five tables the app mirrors
GET /blog/:slug · /kb/* publicedge 300s content; the function usually never wakes up
POST /iap/webhook secretRevenueCat subscription state changes push in
GET /health publickeep-warm 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.

  1. 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 (morning 05:00–11:59, afternoon 12:00–17:59, evening otherwise), so nobody files their feelings under a timestamp picker. Haptics confirm each step.

  2. 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

  3. The outbox drains when it can

    A background worker ships queued rows to POST /logs, each carrying a client-generated idempotency key. A 409 means the server already has it, so drop it. A 5xx means retry with exponential backoff. Success is acknowledged exactly once.

  4. 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.

  5. Postgres finishes the job itself

    A trigger recomputes that day’s row in daily_summaries inside 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

  1. Bucket. The trigger-maintained daily_summaries table 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

  1. Client
    SWR cache + SQLite on the phone. Most screens render from local data before any request happens. The app is its own first cache.
  2. Edge
    Vercel’s CDN, public routes only. s-maxage=300, stale-while-revalidate=3600 on 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

  3. 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.
  4. 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.

The connection funnel Many short-lived serverless functions connect to a pooler, which multiplexes them onto a small number of real Postgres connections. fn fn fn spawns on demand… …drains to zero Supavisor pooled clients → few real conns Postgres one node · happily bored

“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.

what dies what the user sees what recovers it
The user’s network Nothing. Check-ins hit SQLite; signals compute locally via @pulse/core. The outbox drains on reconnect; cursor sync catches up.
The API The app runs on local data and local predictions. Writes queue. Vercel redeploys/recovers; the outbox replays safely (idempotency).
Postgres Public content still serves stale from the edge; the app behaves as if offline. Supabase failover; nothing was lost because writes waited in outboxes.
A RevenueCat webhook Worst case, a tier change lags. The daily reconcile cron re-fetches truth; foreground refresh checks too.
A bad app release A bug ships to phones. OTA rollback in minutes on the same channel; no store review in the loop.

The guardrails underneath

  1. 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.
  2. 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.
  3. 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.”
  4. 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.

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.