Why Push Messages Arrive Hours Late
A notification sent at 09:00 lands at 14:20 and the user reads it as a bug. Usually it is not one: web push is a store-and-forward protocol, and every hop between your dispatcher and the notification tray is allowed to hold the message. The work is separating the delay you caused from the delay the platform caused.
Quick Answer
Late delivery is almost always store-and-forward working as designed. The push service accepted your message, the device was unreachable, and the message waited. Your own queue contributes minutes; the device contributes hours.
| Source of delay | Typical magnitude | Whose fault |
|---|---|---|
Dispatcher queue lag before the POST |
Seconds to minutes | Yours — fixable |
| Push service storage while the device is offline | Minutes to the full TTL | Nobody’s — protocol design |
| Android Doze / App Standby deferral | 15 minutes to several hours | Platform, influenced by Urgency |
| iOS low-power and unused-app deferral | Minutes to hours | Platform |
| Browser not running (desktop) | Until the user launches it | User behaviour |
| Backlog drain on reconnect | Seconds to minutes | Push service pacing |
| Service worker handler slowness | Milliseconds to seconds | Yours — fixable |
Store-and-Forward Is the Intended Cause
RFC 8030 defines the push service as a store-and-forward relay. When you POST to the subscription endpoint with a positive TTL, you are explicitly instructing the service to hold the message until the user agent reconnects or the TTL elapses — whichever comes first. 201 Created is the acknowledgment of that instruction, not of delivery. A message sent with TTL: 86400 to a phone that is switched off all day is supposed to arrive when it is switched back on.
That means the first question in any late-delivery investigation is not “what broke” but “what TTL did we send”. A message that arrives 5 hours late under TTL: 86400 is conformant behaviour; the same delay under TTL: 300 is impossible and indicates a clock or attribution error somewhere in your instrumentation. The trade-off between these two worlds is laid out in TTL 0 vs TTL 86400 delivery guarantees.
Device-Side Deferral
Once the push service has the message, everything that follows is the device’s decision.
Android Doze. A stationary, unplugged, screen-off device enters Doze and suspends network access for applications. The push channel is held open only during periodic maintenance windows, which start a few minutes apart and back off to roughly hourly, then multi-hourly, the longer the device stays idle. A normal-urgency message sits in push-service storage across every closed window and is handed over during the next open one.
App Standby buckets. Android additionally buckets apps by recency of use — active, working set, frequent, rare, restricted. A browser or installed web app that the user has not opened in days lands in a low bucket with tighter delivery quotas, adding hours on top of Doze.
Battery saver and iOS low-power mode. Both further extend deferral windows. iOS web push, which requires the site to be installed to the Home Screen, applies its own budget: an app the user rarely opens receives pushes on a slower schedule, and Low Power Mode extends that again.
Urgency is the only lever you have. RFC 8030 §5.3 defines four values, and the push service maps them onto platform priority.
Urgency |
Intent | Practical effect |
|---|---|---|
very-low |
Advertisements | Delivered only when the device is on power and active |
low |
Topic updates | Delivered when the device is active; heavily deferred otherwise |
normal |
Chat, calendar | Default; waits for a maintenance window in Doze |
high |
Incoming call, time-sensitive alert | Requests a wake-up that bypasses Doze; rate-limited per origin |
Escalating everything to high does not work. Push services and Android both throttle origins that mark ordinary traffic as urgent, and the penalty lands on the messages that genuinely needed it.
The Browser Has to Be Running
On desktop, the push channel belongs to the browser process. Chrome and Edge keep a background process alive after the last window closes only when background apps are enabled; Firefox does not. A user who quits the browser on Friday and reopens it Monday has messages arriving on Monday — the “delay” is the interval between the send and the next browser launch.
Two consequences follow. First, desktop late-arrival distributions are bimodal: a tight peak in the first few seconds and a long flat tail matching your users’ work patterns. Averaging across them produces a meaningless number, so report the median and the 95th percentile separately. Second, a TTL shorter than a typical overnight gap silently converts these users from “late” to “never”, which is the loss pattern described in why push delivered counts exceed displayed counts.
Backlog Drain on Reconnect
When a device reconnects after a long absence, the push service does not necessarily flush everything at once. Firefox’s Autopush stores a bounded number of messages per subscription and evicts the oldest when the limit is reached, so a reconnecting device may receive only the tail of what you sent. FCM caps stored messages per device and, on overflow, delivers a single message telling the client that others were dropped.
Drain pacing also matters. Several messages arriving over 30–60 seconds rather than instantaneously is normal, and it is why “the last notification in the batch arrived a minute after the first” is not a bug. If you send bursts to the same subscription, use the Topic header so the service collapses them into one current message rather than a queue the user has to dismiss one by one.
Your Own Queue Lag
This is the part you own, and the part most often skipped in an investigation because it is invisible in the push-service response. Measure it as sent_at − enqueued_at per message and alert on the 95th percentile.
// Instrument the dispatcher so server-side lag is never guesswork.
async function dispatch(job, sender, metrics) {
const enqueuedAt = job.enqueuedAt; // set once, at enqueue time
const claimedAt = Date.now();
metrics.observe('push.queue_wait_ms', claimedAt - enqueuedAt);
const res = await sender.send(job.subscription, job.payload, {
TTL: job.ttl,
urgency: job.urgency,
topic: job.topic,
});
const sentAt = Date.now();
metrics.observe('push.dispatch_ms', sentAt - claimedAt);
metrics.observe('push.total_server_lag_ms', sentAt - enqueuedAt);
// Carry the timestamps into the payload so the device can report the rest.
return { messageId: job.messageId, enqueuedAt, sentAt, statusCode: res.statusCode };
}
Ship sentAt inside the encrypted payload and echo it from the service worker beacon. The difference between sentAt and the beacon’s receive time is the device-and-push-service segment; everything before sentAt is yours. Without both halves you cannot tell a saturated worker pool from a sleeping phone — and a saturated pool is a real possibility if your senders are running past the concurrency knee.
Diagnostic Procedure
Run these in order. Each step either attributes the delay or hands it to the next step.
- Confirm the TTL made the delay possible. Look up the
TTLsent with the message. If the observed delay exceeds it, the delay is not real — you have a timestamp attribution bug, not a delivery problem. Stop and fix the clocks. - Measure server-side lag. Compute
sent_at − enqueued_atfor the affected messages. Anything above 60 seconds is dispatcher backlog: check queue depth, consumer count and whether a batch of large payloads stalled the pool. This is fully fixable and must be eliminated before looking at devices. - Check the acceptance latency. Compare the HTTP request duration for the
POST. A slow201(multi-second) points at connection churn or throttling at the push service, not at the device. - Segment by endpoint host. Group the late messages by
fcm.googleapis.com,updates.push.services.mozilla.com,web.push.apple.comand the rest. A delay concentrated in one host is a push-service or platform issue; a delay spread evenly across all hosts is yours. - Compare receive-beacon time with
sent_at. If the service worker’s receive beacon lands hours aftersent_atwith no server-side lag, the message was held by the push service or the device. That is store-and-forward plus deferral — the expected path. - Compare display-beacon time with receive-beacon time. A gap of more than a couple of seconds between the two means your own handler is slow: a personalisation
fetch()without a timeout, a largeIndexedDBread, or an image fetched beforeshowNotification(). Fix it in the service worker. - Re-test with
Urgency: highon a single canary segment. If latency collapses for that segment only, the cause was Doze or a low App Standby bucket. Do not rollhighout fleet-wide; use it only for messages that genuinely warrant a wake-up.
Gotchas and Edge Cases
- Device clocks are not your clocks. A beacon carrying a device-generated timestamp can be minutes or hours off. Compute device-side latency from the server’s receive time of the beacon minus
sentAt, and treat any device timestamp as advisory only. - A late arrival and a duplicate look identical in the logs. When a device reconnects, a retried dispatch and the original may both be stored, producing two notifications seconds apart hours after the send. Deduplicate in the service worker on
messageIdbefore callingshowNotification(), and useTopicon the server so the push service collapses them for you. Urgency: highdoes not survive a badTopicstrategy. A high-urgency message that shares aTopicwith a queued low-urgency one replaces it and inherits nothing; the reverse also happens. Keep urgent traffic on its own topic namespace.- Late is often better than the alternative. Shortening the TTL to prevent late arrivals converts them into silent drops, not into on-time deliveries. Set the TTL from the content’s true relevance window using the method in setting optimal TTL values for time-sensitive alerts, then filter stale messages in the handler.
- Scheduled sends amplify queue lag. A campaign scheduled for exactly 09:00 dumps the whole audience into the queue at once; the last subscriber is dispatched when the pool drains. Spread scheduled sends over a window, or the tail of every campaign is late for reasons that have nothing to do with devices.
FAQ
Can I force a push service to deliver immediately?
No. The only control the protocol gives you is the Urgency header, which requests priority handling and, at high, asks the platform to wake a Doze-suspended device. Whether that wake-up happens is the operating system’s decision, and services rate-limit origins that overuse it. There is no API to flush a stored message or to query when it will be delivered.
Why do several notifications arrive at once after hours of silence?
The device reconnected and the push service drained everything it had stored for that subscription. Each message was accepted at a different time but delivered in the same window. If that is a poor experience, send bursts with a shared Topic so the push service keeps only the most recent message, or collapse them in the service worker with a shared tag before displaying.
Is a five-hour delay a sign that something is broken?
Only if the TTL you sent was shorter than five hours — in which case the message could not have been stored that long and your timestamps are wrong. With TTL: 86400 and a device that was offline or in Doze, a five-hour delay is the protocol working exactly as specified. Judge lateness against the TTL you chose, not against the user’s expectation.
Related
- Setting Optimal TTL Values for Time-Sensitive Alerts — deriving a TTL from the content’s relevance window so late arrivals become drops only when they should.
- TTL 0 vs TTL 86400 Delivery Guarantees — the drop-now versus store-and-forward decision that determines whether lateness is even possible.
- Why Push Delivered Counts Exceed Displayed Counts — the same deferral mechanisms seen as loss instead of latency, and the beacons this procedure depends on.
- Parallel vs Sequential Push Sending Benchmarks — measuring the dispatcher throughput that step 2 of the procedure holds responsible.
Back to TTL & Expiration Handling