Push notifications: the inbox is the record, the push is the interruption
Notifications look like a simple 'send a push when something happens' feature and are actually two systems with opposite jobs: a durable in-app inbox that is the record of what happened, and a transient push that is a best-effort interruption you must earn the right to send. Fable separates them, delivers pushes via FCM only when the user isn't already looking, gates every push through per-category preferences, group mutes, and cooldowns, and prunes dead device tokens — because the fastest way to lose notification permission forever is to over-notify. This chapter is that architecture and the restraint behind it.
Push notifications: the inbox is the record, the push is the interruption
Push notifications are deceptively simple to describe — "when something happens to you, send a push" — and getting them right means understanding that this is two systems, not one, with almost opposite properties. There's the in-app inbox: a durable, ordered record of everything that happened to you (someone added an expense, a settlement was confirmed), which lives as rows in the database and is always there when you open the app. And there's the push notification: a transient, best-effort interruption delivered to a device, which may never arrive, which the user can turn off, and which — used carelessly — gets your app's notification permission revoked forever. Conflating them is the classic mistake. Fable keeps them separate, and the separation is the whole design.
Two channels, two purposes
When something notification-worthy happens, Fable's notification service does two things:
- Writes an inbox entry — a durable row (kind, payload, timestamp, read-state). This is the record: it persists, it's the thing the in-app notifications screen reads, and it's correct whether or not any push was delivered. Like the chat message, the inbox entry is written first and is the source of truth.
- Fires a push — a transient FCM message to the user's devices, conditionally (see the gates below). This is the interruption: a nicety that pulls the user back to the app, explicitly allowed to be lossy because the inbox already holds the truth.
The mental model, identical to the chat lesson's "socket is delivery, database is truth": the inbox is the record; the push is best-effort delivery on top. A dropped or disabled push loses nothing — the inbox still has it. That's what lets the push be casual about delivery, which in turn is what lets Fable be restrained about sending it.
The gates: earning the right to interrupt
Here's the part that separates a good notification system from an annoying one. Every push passes through a series of gates, and failing any one means no push is sent (the inbox entry still gets written — the record is unconditional; only the interruption is gated):
- Are they already looking? If the user is currently online and live in the app (
isUserOnlinevia the realtime layer), Fable skips the push — they're already seeing the update through the realtime channel, so a push would be a redundant buzz for something on their screen. Don't interrupt someone to tell them what they're already looking at. - Did they ask for this category? Preferences are per-category (
message,expense,settlement,trip) on aNotificationPreference— a user who turned offmessagepushes gets none, even as the inbox keeps recording them. Each notification kind maps to a category, and the category's toggle gates its push. - Did they mute this group? An active group mute suppresses that group's pushes regardless of category — the "this trip's chat is blowing up and I need quiet" control.
- Is this too soon? Some notifications (settlement nudges — "you still owe ₹500") are rate-limited by a cooldown (the
settlement_reminderscooldown), so you can remind someone without being able to spam them into muting you.
Only a push that clears all the gates is actually delivered. This is a lot of machinery guarding a single buzz, and the callout is why it's worth every line.
Delivery: FCM, fail-open, and token hygiene
Delivery itself goes through Firebase Cloud Messaging — the same Firebase that issues identity, so Fable doesn't own the OS-level push plumbing (FCM handles Android natively and iOS via APNs underneath; replyable chat messages carry an iOS category so you can reply from the notification). Fable sends a multicast to all of a user's registered device tokens at once, and the data payload carries a deep_link so tapping the notification lands on the right screen.
Two operational details matter. Token hygiene: device tokens go stale constantly (uninstalls, reinstalls, OS token rotation), so FCM reports back which tokens are permanently dead, and Fable prunes them — otherwise you accumulate ghost tokens and waste sends forever. (This is why the devices.push_token needed a partial unique index — a reinstall soft-revokes the old device row, and the token can legitimately reappear.) And fail-open: the online-check that decides whether to skip a push is treated as "assume offline" if it errors — a Redis hiccup should never drop a real notification, so the failure mode is "send it anyway," never "silently swallow it." The record is unconditional; the interruption fails toward delivery, not toward silence.
Go deeper
- Firebase Cloud Messaging documentation — The delivery layer Fable uses: multicast sends, data vs notification payloads, token management, and the Android/APNs bridge — the mechanics under the push channel.
- Apple — Notifications (Human Interface Guidelines) — The definitive argument for restraint and relevance in notifications, and why over-notifying loses the channel — the product reasoning behind Fable's four gates.
- FCM — managing device tokens (staleness & pruning) — Why tokens go dead and how to detect and prune them — the token-hygiene operational detail that keeps sends from rotting.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Notifications are described as two systems with opposite jobs. Name them, state which is the durable record and which is best-effort, and explain why that split lets the push channel be selective.
- Fable skips the push entirely if the user is currently live in the app. What is the reasoning, and what still happens (the inbox) even when the push is skipped?
- List the gates a push must clear (online-check, per-category preference, group mute, cooldown) and what each one specifically prevents. Which of these still writes the inbox entry regardless?
- Explain the "fail-open" behavior of the online-check and why dropping a real notification would be worse than occasionally sending a redundant one.
- Why is over-notification a "one-way door"? Explain the cost asymmetry of pushes and how it justifies wrapping a single buzz in four gates and a quiet default.
- What is token hygiene, why do device tokens go stale, and how does pruning connect to the partial unique index on devices.push_token from the data model?
The media pipeline: uploads you can trust, bytes you don't proxy
Receipts and photos are the one place a splitting app handles arbitrary user bytes, and that makes the media path both a bandwidth problem and a security problem. Fable's answer: the API never touches the bytes (clients upload and download directly to Cloudflare R2 via presigned URLs, served through a CDN), and it never trusts what the client says a file is (every upload is validated against its actual magic-byte signature, not its claimed MIME type). This chapter walks that pipeline, the access control that gates private media, and the honest list of what it deliberately doesn't do yet.
Infrastructure evolution: when ₹0/month started costing 70 milliseconds
Fable launched on an all-free-tier stack — Neon Postgres and Upstash Redis in Singapore, a Mumbai VM for the API, Cloudflare R2 for media — because validating the product mattered more than owning the infrastructure. Then every screen felt slow, and the reason was geography: the API in Mumbai paid a cross-region round trip to the database in Singapore several times per request, on top of serverless cold starts. This chapter is the honest migration story — why free tiers were the right first call, the options weighed, the move to managed Cloud SQL in Mumbai with self-hosted Redis on the VM, and the real gotchas that turned a one-afternoon migration into four commits: a Postgres major-version surprise, unused PostGIS extensions that broke the restore, and a health check that wouldn't let the API start.