Backend reliability
Making Notification Delivery Recoverable
A notification could save its inbox entry and still vanish before Firebase answered, leaving nothing durable to retry. I moved delivery intent, claims and retry state into Postgres, then load-tested where scaling stops paying off. Crashes are recoverable now. Delivery still isn't exactly-once.
- NestJS
- Prisma
- BullMQ
- PostgreSQL
- TypeScript
- Firebase

Contents · 6 sections
What Was Failing
I found the problem while changing the production notification flow behind a customer-facing app. Discounts, app milestones, stock changes and order updates could produce an in-app inbox entry, a push notification, or both.
The path looked reasonable when you read it top to bottom: a scheduled fetcher discovered eligible notifications, rendered their content, saved the inbox rows, called Firebase, then wrote the delivery logs. The problem was hiding between two of those steps, saving the inbox entry and recording Firebase's result.
Discovery treated the inbox row as proof that a notification was already handled. So if the process died after saving the row but before Firebase answered, the next run would just skip it. The entry existed, but nothing in Postgres said whether Firebase still needed to be called. Nothing durable to retry.
“Put it on a queue” was the obvious answer, and only half an answer. BullMQ could move the work into a worker and retry it. But a rerun hit the same flow: discovery saw the inbox row and skipped the notification. The crash window hadn't closed, it had just moved into a worker.
The Durable Handoff
Fetchers now turn notification triggers into PENDING Outgoing rows: rendered content, channel, delivery and retry state. I persist the rendered payload so old work stays sendable even if someone deletes the template in the meantime.
Postgres becomes the durable handoff between discovery and delivery. BullMQ is the alarm clock; Outgoing is the note on the desk explaining what still needs doing. If the alarm is late or a worker disappears, the note is still there.
Each sender records its verdict on the same Outgoing row. In the push path the inbox still comes before Firebase, but its unique outgoingId stops a reclaimed row from creating a second inbox entry. That closes one duplicate path. It doesn't make Firebase transactional; a unique constraint is not a distributed transaction.
The Fixed Lease
Several workers can poll the same backlog. They claim rows in one Postgres statement: FOR UPDATE SKIP LOCKED keeps concurrent claimers out of each other’s batches, while claimedUntil gives each claim an expiry. The row remains PENDING; it becomes claimable again when the lease expires.
WITH batch AS (
SELECT id FROM "Outgoing"
WHERE status = 'PENDING'
AND channel = ANY($1::"DeliveryChannel"[])
AND ("claimedUntil" IS NULL OR "claimedUntil" < NOW())
ORDER BY "createdAt"
LIMIT $2
FOR UPDATE SKIP LOCKED
)
UPDATE "Outgoing"
SET "claimedUntil" = NOW() + make_interval(mins => $3),
"updatedAt" = NOW()
WHERE id IN (SELECT id FROM batch)
RETURNING *;My first version had a PROCESSING status, a watchdog for stuck rows, and heartbeats so the watchdog wouldn't kill batches that were merely slow. At some point I was checking the thing that checks the thing that checks the workers, and had to admit the design was telling me something. So I sat down and thought: there has to be an easier way to do all this. I threw it all out for a fixed lease: the claim predicate itself is the recovery. Lease expired, row claimable. That's it.
Simpler, but not magical. A lease is a parking meter, not proof that the previous car has left: too short and a slow worker overlaps its replacement, too long and real crash recovery waits. I didn't add renewal or fencing tokens, so that trade-off stays out in the open.
Retry Is a Verdict
Once claimed, each sender does its channel-specific delivery and writes the result back to Outgoing. The retry loop is the boring part. The push path is where it gets interesting: when Firebase fails, how much does that failure actually tell you?
| Situation | Outgoing decision |
|---|---|
| Failure before Firebase, or no trustworthy verdict | Stay PENDING; do not spend retry budget |
| At least one device accepts the push | Mark SENT |
| All usable devices return provider rejections | Back off, increment retryCount, eventually mark DEAD |
| No active device, or only stale tokens remain | Keep the inbox delivery and classify the row SENT |
A missing verdict is not a failed delivery. Marking a Firebase outage as a pile of confident-looking DEAD rows would be tidy and wrong. The reverse is just as bad: call a permanent rejection an infrastructure failure and the row orbits PENDING forever. Treating “no viable push target” as SENT is a product decision. The user already has the inbox copy.
How Far It Scales
The next question was whether more NestJS instances actually bought throughput. So I ran two local tests: one shaped like a slow provider, one designed to pressure the database path.
Local benchmark setup
- Machine: M2 MacBook Pro, 32 GB unified memory, 512 GB SSD.
- Measured path: real claim, inbox insert, delivery-log insert and terminal update; only Firebase returned a synthetic success. These are indicative local numbers, not portable capacity claims.
Test A: provider latency dominates
- Workload: 12,000 notifications, batches of 500, 4s synthetic Firebase delay, 5s scheduler.
- Topology: 1, 2, 4 and 8 NestJS instances, one BullMQ sender job per instance.
- Result: 8 instances reached 683.8 notifications/s versus 96.9/s with one, a 7.06× speedup.
- Database cost at 8: 12.6% average Postgres CPU, about 108 MiB RAM, no lock waits or deadlocks.
Eight senders move 7× more work
With provider latency dominating, scaling came out close to linear and Postgres stayed almost idle. That's the shape you want in production: workers hide the provider's latency without the database becoming the bottleneck.
Postgres CPU stays under 13%
That test mostly measures how well the workers hide a slow provider. My first pressure run seemed to peak at eight instances and slow down at twelve. That was a real result and the wrong conclusion. Twelve watch-mode NestJS containers plus Postgres were fighting inside the same ten-core Docker allocation. I had found the edge of the MacBook, not the edge of the mechanism.
Test B: database pressure
- Workload: 100,000 notifications, batches of 500, 1ms synthetic Firebase delay, 10ms scheduler.
- Topology: one-process sweep from concurrency 1 to 64, then 1×16, 2×16, 4×16 and 4×32 workers; 20 DB connections per instance in the combined run.
- Result: one process plateaued near 3,500/s; 4×16 reached 9,400/s and 4×32 reached 10,118/s, only 7.6% more.
- Database cost at 4×32: 81–82 connections, about 730 MiB temporary I/O, no deadlocks or incorrect delivery counts.
64 workers get most of the throughput
This time, the database is the bottleneck
That's the knee on this machine. The claim-and-lease mechanism still held, but doubling the workers bought 7.6% more throughput while the shared database absorbed the extra load.
So, Did It Work?
Mostly, yes. It fixed the stupid failure mode that started this whole thing: a notification could disappear between the database and Firebase, with no durable state left to retry. Now the work exists before a sender touches it. If BullMQ is late, a worker dies or Firebase falls over, the row is still there.
The tests showed that workers can share a backlog without grabbing each other's rows, and that adding instances actually moves more work. They say nothing about production capacity. An M2 on my desk is not a datacenter. But they did show the next limit: once the provider stops being slow, the cost moves to Postgres and the gains flatten out.
It still doesn't give us exactly-once delivery. Firebase can accept a push and the process can die before SENT is written, so an expired lease may trigger another provider call. The gap is real, but the user sees less of it than the raw call count suggests: the unique outgoingId keeps inbox creation idempotent, and Firebase collapse identifiers coalesce pending repeats. A device can still get both pushes if the first was already delivered. In the usual retry path, though, the duplicate stays mostly invisible. The safeguards narrow the gap. They don't turn Postgres and Firebase into one transaction.
That's the whole deal. BullMQ wakes the sender up; Postgres remembers what needs to happen, who claimed it, and what happened last time. SENT means we recorded a final result, not that Firebase delivered exactly once. I'll take that over a missing notification and a shrug.