Every choice below optimises for the same constraint: a single developer must be able to hold the whole system in their head, ship daily, and not be woken at 2am. That rules out most of what a larger team would reach for — and rules in a few things that look almost too simple.
Three flows carry the product. They're diagrammed and steppable below.
TypeScript from the database types to the mobile screen. One language means a refactor crosses the whole system in a single pass, and AI assistance doesn't have to translate across a boundary. Click any layer to open it.
There is one developer. A distributed system turns every bug into a distributed debugging session, every deploy into a coordination problem, and every incident into a question of which service. The whole platform is a monolith behind a CDN until there is a team to justify splitting it — and at that point the seams are already drawn along packages/.
Everything in Mumbai (ap-south-1 / bom1). A US-East default would add 250ms+ to every query from Kochi, and each screen makes several.
Everything else is CRUD. These three are where a solo build quietly fails, so each is diagrammed step by step. Use the arrows, or press Play.
Twenty tables. Four of them — the green group — are the reason this product can make the promises it makes.
bookings has no price column. Prices are never updated — a change request writes a new estimate version pointing back at the old one. The current price is a query: the agreement on the highest agreed estimate version. The moment a price becomes mutable, every guarantee in the product becomes a claim you can't back up.
The governing rule: row-level security for reads, your own API for every write. RLS is excellent at "can this user see this row" and unmaintainable at "can this provider accept this booking given the round is open, he's in the service area, and nobody else has accepted."
| From | To | Mechanism | Mode | Why this way |
|---|---|---|---|---|
| Any client | Postgres | Supabase JS, RLS-scoped | sync read | No API layer to maintain for reads; realtime subscriptions come free. |
| Any client | API | tRPC over HTTPS + Idempotency-Key | sync write | Every business rule lives in one testable place. Idempotency because Indian networks drop requests mid-flight. |
| API | Postgres | Drizzle, single transaction | sync | State change + audit event + outbox rows commit together or not at all. |
| Postgres | Clients | Supabase Realtime on bookings | push | Live booking status without building a websocket layer. |
| API | QStash | HTTPS, delayed delivery | scheduled | The 45-second dispatch timeout, without Redis or an always-on worker. |
| QStash | API | POST /internal/dispatch/expire | callback | Handler must be idempotent — duplicate delivery has to be harmless. |
| Worker | Postgres | FOR UPDATE SKIP LOCKED | async poll | Postgres as the queue. Safe for concurrent workers, ~40 lines. |
| Worker | WhatsApp BSP | HTTPS, retry + backoff | async | Primary notification channel. Failures retry rather than vanish. |
| Provider phone | Customer phone | Exotel masked bridge | voice | Neither party ever sees the other's number. Privacy and anti-leakage in one line item. |
| pg_cron | Postgres | Function, every 60s | sweep | Catches expired rounds and stuck outbox rows if a webhook is ever dropped. |
Every mutating endpoint takes an Idempotency-Key, stored with a unique index on (key, user_id). A replay returns the stored response rather than acting twice. Without it, a double-tapped Accept creates two accepts and a double-tapped Agree creates two agreements — in the money ledger.
Provider position is pinged every 20–30 seconds, foreground only, and only between accepted and arrived. The latest point overwrites the last; no history is kept. Battery life, user trust and DPDP purpose-limitation all point the same way.
Two async mechanisms carry everything. Neither needs Redis, BullMQ, Kafka, or a machine that has to stay up.
When a dispatch round opens, the API schedules an HTTP callback for 45 seconds later. If nobody accepted by then, that callback closes the round and opens a broadcast round. No polling loop, no process to keep alive.
Notification intent is written inside the same transaction as the state change. A worker drains it with row-level locks that let several workers run safely without coordination.
The accept race is settled by a single SELECT booking FOR UPDATE. Two simultaneous accepts cannot both win — the second reads a row that is no longer matching.
Every timeout handler is idempotent, and a pg_cron sweep runs every 60 seconds. A QStash outage degrades latency, never correctness.
Add a real broker when a single Postgres instance can't keep up with outbox drain — realistically past 50,000 bookings a month. Not before.
local (Supabase CLI + Docker) → preview (Vercel preview + a Supabase branch per PR) → production. A staging environment nobody maintains is worse than none.
Applied by CI, never by hand. Never click-edit the production schema in the dashboard — that's how a solo project loses the ability to reproduce its own database.
Typecheck, lint, packages/core unit tests, migration dry-run. Any longer and you'll start skipping it, which makes it worthless.
JavaScript fixes ship over the air in ~10 minutes. Only native changes need a store round-trip — which matters enormously in the first months.
Near-total coverage on packages/core (pure, milliseconds). Integration tests on exactly three flows: dispatch race, the agreement chain, outbox drain. One Playwright end-to-end. No coverage gate.
Sentry for errors, PostHog for behaviour and session replay. But the real tool is booking_event — any booking can be replayed from the database, which beats distributed tracing at this size.
Two concurrent accepts on the same booking must produce exactly one winner. Write it in week seven, run it in CI forever. Everything else about dispatch can be fixed later; this one silently corrupts real jobs and real trust.
For a product owner, this list is often more informative than the stack itself — each line is a cost avoided.
One developer. A distributed system is a distributed debugging session.
Postgres plus delayed callbacks does the job. Add a broker when you can name the query it fixes.
tRPC gives end-to-end types with no schema layer, no resolvers, and no N+1 class of bug.
Wrong data model for an append-only money ledger with relational integrity and geo queries.
Nothing here needs it. Managed services and, at most, one small machine.
React Native Web and Solito cost more than they save at this size. Share tokens, not components.
The customer picks a building, not a pin. The provider needs directions — a deep link into the nav app he already trusts. Saves an SDK, a key, a billing account and about a week.
~95% of providers are on Android, where PWA web push works. Skips an entire store cycle, and never requires an install to receive work.
Adds 3–4 weeks plus a compliance surface. Cash works at launch; the ledger is built to accept payments the day you want them.