Loading
← All notes

Sending 800k push notifications without loading 800k rows

Chunked reads, a semaphore of two, and one retry pass at the end: the design behind a scheduler that sends 700-800k notifications per run in 6-8 minutes.

Filed under.NET, Messaging and Scalability

At DPL I built the scheduler behind bulk push campaigns. An admin imports a file of MSISDNs in the back office and sets the notification's body, type, and send time. The scheduler then delivers it to Android (FCM) and Huawei devices: 700-800k notifications per run, in 6-8 minutes.

Sending one notification is easy. The interesting part is sending 800k without holding 800k of anything in memory, and without one bad device slowing everybody else down.

The naive version

Load every recipient, loop, send. It works in testing and falls over in production: the whole list sits in memory at once, one slow provider call holds up the rest, and a crash halfway means starting again with no idea who already got the message.

Two tables and a scheduler

The import writes the notification to one table and its MSISDNs to another, both marked unprocessed. The scheduler picks up unprocessed notifications, then works through the MSISDNs stored against each one. Keeping "what to send" and "who to send it to" apart is what makes the rest possible: the recipient list can be read piece by piece, and progress is recorded per recipient.

Read in chunks

Holding 800k+ records in memory at once is how a scheduler takes a server down with it. So recipients are read in fixed-size chunks, and only a chunk at a time is ever in flight per worker. A keyset query (WHERE Id > @lastId ORDER BY Id) is the simplest way to page through them; it stays fast however deep into the list you are, where OFFSET gets slower with every page.

A semaphore of two

Chunks run in parallel, but not unboundedly. A semaphore set to 2 means two workers each process one chunk at a time. That number is a deliberate ceiling: memory stays at two chunks, the database sees two readers instead of dozens, and the push providers see a steady rate rather than bursts that invite throttling.

MSISDNs for one notification, read a chunk at a time12345678failed sendsemaphore = 2Worker 1chunk 1chunk 3chunk 5chunk 7Worker 2chunk 2chunk 4chunk 6chunk 8all chunks sentretry failedtime →
Fig. 1Illustrative timing. The semaphore keeps two chunks in flight, one per worker, until the list is done. Failed sends wait for a single retry pass at the end instead of slowing the run down.

A simplified sketch of the loop:

var gate = new SemaphoreSlim(2);          // at most two chunks in flight
var sends = new List<Task>();

await foreach (var chunk in recipients.ReadChunksAsync(notification.Id, ChunkSize, ct))
{
    await gate.WaitAsync(ct);
    sends.Add(Task.Run(async () =>
    {
        try { await SendChunkAsync(notification, chunk, ct); }
        finally { gate.Release(); }
    }, ct));
}

await Task.WhenAll(sends);
await RetryFailedAsync(notification, ct);  // one pass, after every chunk is delivered

Look up the device, then send

An MSISDN isn't an address a push service understands. For each chunk the worker looks up the device information, which says whether it's an FCM (Android) or Huawei device, and the notification content, then sends each group through the right provider.

async Task SendChunkAsync(Notification n, IReadOnlyList<Recipient> chunk, CancellationToken ct)
{
    var devices = await deviceStore.GetAsync(chunk.Select(r => r.Msisdn), ct);

    foreach (var platform in devices.GroupBy(d => d.Platform))    // Fcm or Huawei
    {
        var result = await senders[platform.Key].SendAsync(n.Content, platform, ct);
        await recipients.MarkProcessedAsync(result.Delivered, ct); // failures stay unprocessed
    }
}

Mark as you go, retry at the end

  • Sent records are marked processed straight away, so a crashed run resumes from what's left instead of starting over, and nobody gets the message twice.
  • Failures stay unprocessed and are retried once every chunk has been delivered. Retrying inline would let a handful of bad devices slow down everyone else's delivery.

Check the arithmetic

800k messages in 8 minutes is about 1,700 per second, sustained. That number is what the chunk size and the parallelism have to deliver, and it's worth working out before choosing either. When a run gets slower, time each stage: reading recipients, looking up devices, or the push providers themselves. The fix is different for each.

the project behind itthe project behind it

Read the case study

Messaging at scaleDPL, 2024 – 2026

Bulk Push Notification Scheduler

Built the scheduler behind bulk push campaigns: 700–800k notifications per run in 7–8 minutes, read in chunks and sent with controlled parallelism to Android (FCM) and Huawei devices.

700–800knotifications per run

Built with.NET, Schedulers, Semaphore and FCM

Read case study
keep readingkeep reading

More notes

keep in touchkeep in touch

New notes go up here first, then on LinkedIn.

Short write-ups on problems I run into while building payment and backend systems. Follow along, or tell me what you'd like me to write about next.

Let's talk