
Why PWAs Freeze After Running for Hours
1 September 2026How to Send 1 Million Notifications Without Taking Down Your App
A practical walkthrough of queues, rate limits, idempotency and batching — with Node.js examples.
It is 11:59 PM on Black Friday. Marketing has a campaign ready and wants every single user to get a push notification at midnight. All one million of them.
The request sounds trivial. Send a message, loop over users, done. But “send a million of anything at once” is one of those problems that quietly breaks four different parts of your stack at the same time. Let us walk through what goes wrong and how to build it properly.
The naive version, and why it dies
Almost every first attempt looks something like this:
app.post('/campaigns/send', async (req, res) => {
const users = await db.users.findAll(); // 1,000,000 rows
for (const user of users) {
await pushService.send(user.deviceToken, req.body.message);
}
res.json({ status: 'sent' });
});
This is doing real work inside an HTTP request handler, and it fails on every axis at once:
- Loading a million rows into memory spikes heap usage until the process is OOM-killed.
- The request blocks for hours. The load balancer times it out in 30 seconds.
- Your event loop is saturated, so every other API request on that instance slows to a crawl.
- If the process restarts at notification 600,000, you have no idea which ones already went out.
- The fix is not “make the loop faster”. The fix is to stop doing the work in the request at all.
Step 1: Accept fast, deliver later
Split the system into two things: an API that records intent, and workers that do the delivery. A queue sits between them.
API → Queue → Worker fleet → Push provider
The API touches the queue and nothing else. All the slow work happens downstream.
With BullMQ (Redis-backed) that endpoint becomes a few milliseconds of work:
const { Queue } = require('bullmq');
const campaignQueue = new Queue('campaigns');
app.post('/campaigns/send', async (req, res) => {
const campaign = await db.campaigns.create({
message: req.body.message,
segment: req.body.segment,
status: 'QUEUED'
});
await campaignQueue.add('expand', { campaignId: campaign.id });
res.status(202).json({ campaignId: campaign.id }); // 202 Accepted
});
Notice the 202 instead of 200. You are not telling the client “this is done”, you are telling it “this is accepted and in progress”. That honesty is what lets everything downstream be asynchronous.
A common worry here is that the queue makes delivery slower. It does not — it makes delivery controlled. Throughput is now a function of how many workers you run. Two hundred workers each handling 100 notifications per second still clears a million in under a minute, but the load lands on a worker fleet you can scale instead of on the API servers your customers depend on.
Step 2: Expand the campaign in chunks
Do not push a million individual jobs into the queue in one go either — you will just move the memory spike from your API to your queue. Instead, have one job that pages through users and emits batched jobs.
const { Worker, Queue } = require('bullmq');
const sendQueue = new Queue('sends');
const CHUNK = 500;
new Worker('campaigns', async (job) => {
const { campaignId } = job.data;
const campaign = await db.campaigns.findById(campaignId);
let cursor = 0;
while (true) {
const users = await db.users.page({
segment: campaign.segment, after: cursor, limit: CHUNK
});
if (users.length === 0) break;
await sendQueue.add('deliver', {
campaignId,
tokens: users.map(u => ({ userId: u.id, token: u.deviceToken }))
});
cursor = users[users.length - 1].id;
}
});
Memory stays flat at 500 rows regardless of whether the campaign targets one million users or one hundred million. And because the cursor is stored per page, a crash halfway through only costs you one chunk.
Step 3: Respect the provider, not your own ambition
Here is the trap that catches most designs. You scale to 1,000 workers, feel good about yourself, and then FCM or APNs starts returning 429s. The bottleneck was never your infrastructure — it was the dependency.
Workers must be rate limited to whatever the provider actually allows. BullMQ has this built in:
new Worker('sends', deliver, {
concurrency: 20,
limiter: { max: 5000, duration: 1000 } // 5,000 jobs/sec, fleet-wide
});
This will cause the queue to back up, and that is completely fine. A growing queue is a buffer doing its job. An angry provider that has throttled or banned your account is a much worse outcome than a backlog that drains in ninety seconds.
Step 4: Make retries harmless
A worker sends 500 notifications, then crashes before acknowledging the job. The queue does what it is supposed to do and redelivers it. Now 500 people get the same notification twice.
You cannot prevent redelivery — at-least-once is the guarantee almost every queue gives you. What you can do is make processing idempotent, so a repeat is a no-op. Give every notification a deterministic ID and claim it before sending:
async function claim(campaignId, userId) {
const key = `notif:${campaignId}:${userId}`;
// NX = only set if absent. Returns null if it already existed.
const claimed = await redis.set(key, '1', 'EX', 86400, 'NX');
return claimed === 'OK';
}
async function deliver(job) {
const { campaignId, tokens } = job.data;
const fresh = [];
for (const t of tokens) {
if (await claim(campaignId, t.userId)) fresh.push(t);
}
if (fresh.length) await push.sendEach(campaignId, fresh);
}
This is exactly the pattern payment systems use for double-charge protection. Retries are inevitable in distributed systems; the goal is never to eliminate them, only to make them boring.
Step 5: Batch the API calls
One notification per HTTP request means one million TLS handshakes, one million round trips, and a bill to match. Most providers offer a multicast or batch endpoint — FCM accepts 500 tokens per call.
// 1,000,000 notifications / 500 per request = 2,000 requests
await messaging.sendEachForMulticast({
tokens: fresh.map(t => t.token),
notification: { title: 'Black Friday', body: campaign.message }
});
Two thousand requests instead of a million. Same result, three orders of magnitude less network overhead. This is why the chunk size in step 2 was 500 and not an arbitrary number — the job size is chosen to line up exactly with one provider call.
Two thousand requests instead of a million. Same result, three orders of magnitude less network overhead. This is why the chunk size in step 2 was 500 and not an arbitrary number — the job size is chosen to line up exactly with one provider call.
Step 6: Give permanent failures somewhere to go
Some notifications will never succeed. The device token was revoked, the user uninstalled the app, the payload is malformed. Retrying those forever burns capacity and hides real problems.
Cap the retries with exponential backoff, then let the job fall into a dead letter queue that a human can inspect later.
await sendQueue.add('deliver', payload, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 } // 2s, 4s, 8s, 16s, 32s
});
new Worker('sends', deliver).on('failed', async (job, err) => {
if (job.attemptsMade >= job.opts.attempts) {
await deadLetterQueue.add('review', { data: job.data, error: err.message });
}
});
A steadily growing DLQ is one of the most useful alerts you can have. It usually means a provider changed something, or a whole segment of tokens went stale.
Step 7: Track delivery out of band
Marketing will ask how many were sent, delivered, opened and failed. Do not compute this inside the send path — every extra database write there costs you throughput at the exact moment you have none to spare.
Providers emit callbacks. Take them on a separate endpoint, drop them on their own queue, and aggregate asynchronously:
app.post('/webhooks/delivery', async (req, res) => {
await eventsQueue.add('event', req.body);
res.sendStatus(200); // acknowledge immediately
});
Analytics now scale independently, and a slow reporting query can never slow down a live campaign.
The checklist
If you are asked this in an interview, or you are about to build it for real, these are the nine things worth saying out loud:
- Accept the request asynchronously and return 202 — never send inside the request handler.
- Expand campaigns into chunked jobs with a cursor, not one giant fan-out.
- Run workers as an independent fleet, scaled on queue depth.
- Rate limit at the worker layer to match the provider, and accept the backlog.
- Claim every notification ID before sending so retries are idempotent.
- Batch provider calls — 500 tokens per request, not one.
- Bounded retries with exponential backoff.
- A dead letter queue for permanent failures, monitored.
- Delivery tracking through asynchronous webhook events.
Most engineers optimise for sending notifications quickly. Systems that survive Black Friday optimise for sending them safely. Getting a million messages out is not the hard part — the hard part is making sure those million messages do not take the rest of your platform down with them.




