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
Anatomy of a five-hour-late push notification A timeline from 09:00 to 14:20. Fourteen minutes of dispatcher queue lag precede acceptance at 09:14. The push service then stores the message for four hours and thirty-eight minutes while the device is unreachable. At 13:52 the device wakes and takes twenty-six minutes to reconnect and drain, and the service worker runs in the final two minutes. A legend below breaks down each segment. Anatomy of a 5 h 20 m late notification Sent 09:00, displayed 14:20. Only fourteen minutes of it belonged to the sender. 09:14 — accepted, 201 Created 13:52 — device wakes stored at the push service — the TTL clock keeps running 09:00 11:00 13:00 14:20 Dispatcher queue lag before the POST — 14 min Push service storage while the device is unreachable — 4 h 38 min Device wake, reconnect and channel re-establishment — 26 min Backlog drain and service worker execution — 2 min At TTL 3600 this message would have been discarded at 10:14 and never arrived at all.
Five of the five and a half hours were spent in push-service storage. The sender's contribution — fourteen minutes of queue lag — is the only segment anyone can fix from the server.

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.

How Doze maintenance windows decide when a push lands Three tracks over six hours from 20:00 to 02:00. The device state track is mostly suspended with four narrow maintenance windows. A message sent shortly after 20:30 with normal urgency waits until the third maintenance window before being delivered. The same message sent with high urgency wakes the device immediately and is delivered within seconds. Doze maintenance windows decide when a normal-urgency push lands message sent Device state Urgency: normal held at the push service, 3 h 18 min delivered at the third window Urgency: high wakes the device immediately — rate-limited per origin 20:00 21:00 22:00 23:00 00:00 01:00 02:00 Doze maintenance window Network suspended by Doze
Maintenance windows widen the longer a device stays idle. The gap between the send and the next open window is the whole delay for normal-urgency traffic.

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.

  1. Confirm the TTL made the delay possible. Look up the TTL sent 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.
  2. Measure server-side lag. Compute sent_at − enqueued_at for 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.
  3. Check the acceptance latency. Compare the HTTP request duration for the POST. A slow 201 (multi-second) points at connection churn or throttling at the push service, not at the device.
  4. Segment by endpoint host. Group the late messages by fcm.googleapis.com, updates.push.services.mozilla.com, web.push.apple.com and the rest. A delay concentrated in one host is a push-service or platform issue; a delay spread evenly across all hosts is yours.
  5. Compare receive-beacon time with sent_at. If the service worker’s receive beacon lands hours after sent_at with 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.
  6. 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 large IndexedDB read, or an image fetched before showNotification(). Fix it in the service worker.
  7. Re-test with Urgency: high on a single canary segment. If latency collapses for that segment only, the cause was Doze or a low App Standby bucket. Do not roll high out fleet-wide; use it only for messages that genuinely warrant a wake-up.
Separating server-side lag from device-side deferral Starting from a notification that arrived hours late, three sequential timestamp comparisons route the diagnosis. A gap between enqueue and send above sixty seconds means dispatcher backlog. A large gap between send and the first service worker beacon means the push service or the device held the message. A large gap between the receive beacon and the display beacon means the service worker handler is slow. If none apply, the timestamps disagree and clocks must be normalised first. Notification arrived hours late Step 1 — enqueued_at to sent_at gap above 60 seconds? Server-side: dispatcher backlog add consumers, not urgency yes no Step 2 — sent_at to receive beacon gap of minutes to hours? Store-and-forward plus deferral device offline, Doze or browser shut yes no Step 3 — receive to display beacon gap above a few seconds? Handler is slow untimed fetch or storage read yes no Timestamps disagree normalise clocks before concluding Each comparison uses two timestamps you already have — no push-service cooperation is required.
Three timestamp comparisons partition the entire delay. Whichever gap is largest names the owner of the problem.

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 messageId before calling showNotification(), and use Topic on the server so the push service collapses them for you.
  • Urgency: high does not survive a bad Topic strategy. A high-urgency message that shares a Topic with 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.

Back to TTL & Expiration Handling