Why your apps poll instead of push — APNs as the push channel
The complete realtime story of traverse: polling, push, and the unread-count design.
Know your own system
The applied version: exactly how freshness works in your product, and every decision behind it.
The polling surfaces
- In-app notifications: fetched on screen focus; read/unread via
readAt;markRead/markAllReadmutate rows then invalidate the unread-count cache. Keyset-paginated (Day 41) so long inboxes page cheaply. - Unread count: derived query, cached; invalidated on mark-read (event-driven, not TTL-only) — the freshness contract for a badge is "immediately after I read one", which only explicit invalidation delivers.
- Stats/rings/streaks: 120–600 s TTL caches — a badge that's a minute stale is invisible to users.
- Revision "due now": computed on read from
scheduledFor <= now— no background flip needed (the data model makes "due" a query, not a state).
The push channel (APNs/FCM as infrastructure you don't run)
src/lib/apns.ts: HTTP/2 to Apple, ES256 provider JWT signed with jose, token-based auth (no certificate rotation pain), sessions pooled per origin (sandbox vs production tokens coexist), apns-collapse-id for coalescing, per-token invalid handling (410 → invalidate PushToken, terminal — Day 52). FCM mirrors the path for Android. Design principle to articulate: push is an unreliable, best-effort delivery channel — never the source of truth. The notification row exists regardless; push is one delivery attempt among channels, with its own ledger (NotificationDelivery) and status vocabulary (sent/failed/skipped). If push fails, the in-app inbox still has the truth — which is why the architecture is polling-first, push-optimized.
Quiet hours: delivery deferred to the user's local wake time — timezone math with zonedStartOfDay/zonedEndOfDay (DST-safe two-pass resolution — Day 36's detail). Deferral is implemented by scheduling the delivery row's next_attempt_at forward (the lease column doing double duty — elegant reuse, and worth pointing out).
Coalescing: apns-collapse-id at the provider plus coalesceKey at the row level (Day 64) — two layers because one is per-device display (OS replaces the notification) and the other is per-account data hygiene (the inbox doesn't accumulate 50 rows).
The end-to-end freshness map (recite this table)
| Surface | Freshness mechanism | Worst-case staleness |
|---|---|---|
| Badge/unread count | explicit invalidation on read + TTL | ~0 (after own action) |
| Inbox list | direct DB read (no cache) | 0 |
| Stats/rings | 120–600 s TTL cache | 10 min |
| Revision due | computed at read (scheduledFor <= now) | 0 |
| Streak break warning | push, quiet-hours-deferred | user's wake time |
| Friend activity | coalesced notification + push | minutes |
That table is the answer to "how does your realtime work?" — a per-surface contract rather than one transport choice. It shows you think in freshness requirements, not technologies.
The delivery path, end to end (the trace an interviewer wants)
Follow one notification from a fact to a phone screen.
- A fact is written. A worker derives something worth telling the user (a streak is about to break, a friend solved a problem, a revision is due) and writes a
Notificationrow — the intent — with the coalescing key, the type, and the payload needed to render it. - The intent fans out to channels. For each enabled channel (in-app, push) a
NotificationDeliveryrow is created. The in-app channel is satisfied by the row existing. The push channel is a state machine:pending. - The drain claims it. A worker claims eligible rows with
SKIP LOCKEDand a 120-second lease, incrementingattemptsand pushingnext_attempt_atforward in the same statement (Day 62). Claiming and lease-granting in one write is what makes concurrent drains safe without holding a lock across a network call. - Policy is applied at send time. Quiet hours: if now is inside the user's local night window the row is not sent —
next_attempt_atmoves to the user's local morning and the attempt is released. This is the reuse worth highlighting: deferral is just a longer lease, so there is no second scheduler to maintain. - The provider call.
apns.tsopens (or reuses, from the pooled HTTP/2 session) a connection to Apple, mints or reuses an ES256 provider JWT, and POSTs the payload with the device token,apns-collapse-id, and a priority. - The response is classified, not merely checked. 200 →
sent. 410 → the token is dead: mark thePushTokenterminal, exclude it forever, treat the delivery as a terminal failure (retrying a dead token is pure waste). 429/5xx → transient: backoff with jitter and let the lease re-expose it. 400 → the payload is malformed, which is a bug, not a retry: fail terminally and alert, because retrying a deterministic 400 produces an infinite loop with backoff. - The ledger records the outcome —
sent/failed/skipped(unconfigured)— and that third value is a design statement, not an error. Absent capability is a modeled state: if APNs is not configured, the delivery is skipped, the in-app row still exists, and the product degrades instead of erroring. Zoth does the same with RAG routes that mount only when the API key is present. Naming this pattern out loud — "capability absence is a modeled state, never an error, and never silently backfilled" — connects two of your projects in one sentence. - The user opens the app. The inbox is a direct database read with keyset pagination; the badge is a cached count explicitly invalidated on mark-read. Freshness is per-surface, and the table above is the contract.
What you can and cannot know
This is the part that separates people who have shipped push from people who have read about it. You can know: that the provider accepted the message (the HTTP status), that the token is dead (410), that your own pipeline succeeded or failed at each step (the ledger), and how long the whole thing took (the trace). You cannot know: whether the device was online, whether the OS displayed it, whether the user saw it, or whether they acted on it. There is no delivery receipt, and any design that assumes one is wrong.
The consequences are concrete and worth stating:
- Engagement analytics built on push delivery are meaningless. "We sent 10,000 pushes" is a fact about your pipeline; "users received 10,000 pushes" is a claim you cannot make. Report the ledger's own truth — attempted, accepted, failed, skipped — and nothing more.
- The in-app inbox is the reconciliation point. Every question of the form "did the user get told?" is answered by the row, not by the push.
- A user with notifications disabled must not lose functionality. So every push needs a polling fallback — which is exactly why Traverse is polling-first. If push were the only path, disabling notifications would silently break the product for that user, and you could not even detect it.
- Retry policy must distinguish transient from permanent, as in step 6. Retrying a dead token or a malformed payload is not resilience; it is a self-inflicted load generator. The general rule: retry only what can succeed on a retry, and make everything else terminal and visible.
That last rule is the same one the notification drain, the outbox relay, and the connector retry paths all obey — three subsystems, one policy, which is what consistent design actually looks like in a codebase.
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
How do you decide between polling, push, and sockets for a feature?
By freshness requirement and who's awake: a closed app can only be reached by OS push (APNs/FCM) — that's the only true realtime channel on mobile. An open app with minute-tolerance needs gets polling with TTL caches. Sub-second, one-way, open-app needs would get SSE. Bidirectional collaboration would get WebSockets with a pub/sub backplane. My product's table: badge/unread = immediate-on-own-action (explicit invalidation), stats = 2–10 min stale, due-ness = computed at read, urgent events = push. Each surface got the cheapest mechanism that meets its requirement.
Why is push notification delivery not the source of truth?
Because it's lossy by nature: tokens expire, users disable notifications, providers rate-limit, and delivery is best-effort with no ack of display. So the durable artifact is the Notification row (and per-channel delivery ledger); push is one channel with attempts and backoff. That's why a user who had push disabled still sees everything in the in-app inbox — and why I model 'unconfigured' as a terminal skip rather than a failure. The system's correctness never depends on a channel that can't promise delivery.
What's the hardest part of your notification system?
The state machine across three layers — intent (Notification), delivery attempt (NotificationDelivery with lease/backoff/terminal statuses), and transport (APNs collapse-id) — while keeping every transition idempotent and every failure mode bounded. The specific subtleties I'd defend: lease expiry double-sends are accepted and collapsed; token invalidation is terminal and precise (410, not 5xx); quiet-hour deferral reuses the lease column; and coalescing exists at both data and display layers because they solve different problems. Most systems get one of those right.