TTL & Expiration Handling in Web Push

Time-To-Live (TTL) dictates the maximum duration a push message remains queued in the browser vendor’s push service before automatic expiration. Proper configuration prevents stale alerts, reduces unnecessary device wake-ups, and optimizes queue resources within your broader Backend Delivery Architecture & Queue Management. This guide establishes the architectural boundary between transport-layer expiration and application-level lifecycle management, providing production-ready patterns for header injection, client-side filtering, and secure queue enforcement.

Prerequisites

TTL enforcement across three layers TTL is enforced three times: the broker evicts expired jobs before dispatch, the push service honors the RFC 8030 TTL header while the endpoint is offline, and the service worker filters payloads that arrive past their useful window. One TTL value, three independent enforcement points Broker queue setex eviction pre-dispatch check 1 Push service TTL header (RFC 8030) stores while offline 2 Service worker payload age check suppress if stale 3 if valid on reconnect evicted before dispatch logged to push:dlq:expired window elapsed discarded, no error returned arrived stale no notification shown Only the first drop is observable to your backend — layers 2 and 3 fail silently.
TTL is enforced three times — broker eviction, the push service's RFC 8030 header, and a final service-worker freshness check before display.

Understanding TTL in the Web Push Lifecycle

TTL operates at the transport layer, independent of your application’s business logic. When a push message is dispatched, the vendor push service (FCM, Mozilla Autopush, or APNs Web Push) stores the encrypted payload until the target endpoint reconnects or the TTL window elapses. Misconfigured TTL values directly impact infrastructure costs, battery consumption, and user trust.

The window is measured from the moment the push service accepts the request, not from the moment your scheduler created the job. Everything that happens before acceptance — queue wait, rate-limit backoff, DNS and TLS setup, an attempt that failed and had to be repeated — is spent out of a different budget, and the push service has no visibility into it. A job that sat in Redis for four minutes and is then dispatched with TTL: 300 receives a fresh 300-second window at the service, so the notification can surface almost nine minutes after the event that produced it. Compute the header per attempt from an absolute deadline instead of hardcoding a constant: write a deadline_at field onto the job when it is created, then send TTL: max(0, deadline_at − now) on every dispatch. When that subtraction reaches zero the job is already dead and must never leave your infrastructure.

Acceptance is also the last signal you get. The push service answers 201 Created as soon as it has durably queued the record, and that response says nothing about eventual delivery: the same 201 comes back for a message handed to a device in 40 ms and for one that expires a day later without the endpoint ever reconnecting. Most services return a Location header identifying the queued message and echo back the TTL they actually applied, both of which are worth logging — a service is permitted to clamp a value it considers excessive, so the window you asked for and the window you were granted are not always the same number. Treat TTL as an upper bound the service may shorten, never as a reservation it has promised to honour.

TTL budget accounting A stacked bar shows scheduler lag, broker wait and dispatch retries consuming time before the push service accepts the request. Only the last two segments, push-service storage and device reconnect, fall inside the TTL header window, so a fixed header value silently extends the lifetime of a stale message. The TTL clock starts at acceptance, not at enqueue 201 Created — the TTL clock starts here scheduler lag broker wait dispatch + retries push service store device reconnect your latency budget — invisible to the TTL header the window the TTL header buys A retry at t+90 s carrying TTL 120 grants 120 more seconds, not 30. The message outlives its purpose. Recompute per attempt instead: ttl = deadline_at − now Drop the job once it hits zero. A constant TTL header on every retry quietly resets the expiry the first attempt was meant to enforce.
Only the right-hand portion of the latency budget is governed by the TTL header. Deriving the header from an absolute deadline is what keeps a retried message from outliving the event it describes.

Implementation Directives:

  • Categorize TTL by Notification Type: Assign strict TTL windows based on payload criticality. OTPs and payment confirmations: TTL: 0–300 s. Promotional campaigns: TTL: 86400–172800 s. System health alerts: TTL: 3600 s.
  • Understand Vendor Defaults — and Override Them: The Web Push Protocol (RFC 8030) requires clients to supply a TTL header; it has no mandatory default. FCM uses 4 weeks if TTL is omitted. APNs Web Push uses the apns-expiration Unix timestamp header (not a duration), defaulting to 0 which means “deliver immediately or discard”. Always set TTL explicitly for deterministic behavior.
  • Model TTL Decay for Engagement Analytics: Track delivery latency vs. TTL expiration to build churn prediction curves. Messages delivered at >80% of their TTL window typically yield <5% open rates.

Plotted against a compressed time axis, the tiers barely overlap — which is the point. A single global TTL cannot serve both a 30-second passcode and a two-day digest, and a generous default is the main reason a notification can surface long after it stopped being useful, as covered in why push messages arrive hours late.

Recommended TTL windows by notification tier Five horizontal bars on a compressed time axis show the recommended TTL window for one-time passcodes, critical alerts, system health alerts, promotional campaigns and evergreen digests, from zero seconds up to twenty-eight days. TTL windows by notification tier OTP / payment code TTL 0–300 s Critical incident alert TTL 60–300 s System health alert TTL 300–3600 s Promotional campaign TTL 86400–172800 s Evergreen digest up to the 28-day cap 0 60 s 5 min 1 h 24 h 48 h 28 d Time axis is compressed. FCM applies the 28-day ceiling when the TTL header is omitted.
Recommended TTL windows per tier. The gap between the passcode tier and the campaign tier is three orders of magnitude, so tier the value at dispatch rather than configuring one global default.

Security & Compliance Note: TTL directly impacts data retention windows under GDPR/CCPA. Shorter TTLs reduce the attack surface for unauthorized data exposure in vendor queues. Treat the push service as an untrusted intermediary; assume any payload exceeding its TTL is cryptographically inaccessible but metadata may persist in vendor logs.

Implementing TTL in Push Payloads & HTTP Headers

Web push relies on the TTL HTTP header (RFC 8030) to communicate expiration to the push service. For time-sensitive campaigns, refer to our guide on Setting optimal TTL values for time-sensitive alerts.

Production HTTP Dispatch Pattern:

POST https://fcm.googleapis.com/fcm/send/<subscription_endpoint> HTTP/1.1
Authorization: WebPush <vapid_jwt>
Content-Type: application/octet-stream
Content-Encoding: aes128gcm
TTL: 3600
Urgency: high

<encrypted_binary_payload>

Implementation Directives:

  • Enforce Explicit TTL Headers: Inject TTL (in seconds) on every dispatch call. Reject requests lacking this header at the API gateway level.
  • Coordinate with Urgency: Pair TTL with the Urgency header (very-low, low, normal, high). High urgency influences vendor delivery prioritization within the TTL window but does not extend it.
  • Validate Against Provider Limits: The Web Push Protocol (RFC 8030) permits TTL values from 0 to 2419200 seconds (28 days). Clamp values to this range. APNs Web Push accepts apns-expiration as a Unix epoch timestamp; 0 means “discard if not immediately deliverable”. The behavioral gap between the two extremes — fire-and-forget versus day-long buffering — is unpacked in TTL 0 vs TTL 86400 delivery guarantees.

Security & Compliance Note: Never embed PII, session tokens, or routing metadata in the TTL header. Headers must remain strictly numeric and validated server-side to prevent header injection or parser exploitation.

Service Worker Expiration Handling & Stale Message Filtering

When a device reconnects after an extended offline period, queued messages may arrive past their useful window. Implement expiration checks directly in the push event listener using payload metadata. Integrate this logic with your Delivery Tracking & Acknowledgment pipeline to suppress expired notifications before rendering.

Production Service Worker Implementation:

self.addEventListener('push', (event) => {
  // The browser decrypts the aes128gcm payload automatically before delivering
  // it to the service worker. event.data.json() returns the decrypted JSON.
  const payload = event.data ? event.data.json() : {};
  const maxAgeSec = payload.ttl ?? 0;
  const sentAtMs  = payload.timestamp ?? Date.now();
  const nowMs     = Date.now();

  // Allow ±5 s clock skew tolerance for offline devices
  const CLOCK_SKEW_MS = 5000;
  const isExpired = maxAgeSec > 0
    && (nowMs - sentAtMs) > (maxAgeSec * 1000) + CLOCK_SKEW_MS;

  if (isExpired) {
    console.debug(`[Push TTL] Expired message suppressed (age: ${Math.round((nowMs - sentAtMs) / 1000)}s)`);
    event.waitUntil(Promise.resolve());
    return;
  }

  event.waitUntil(
    self.registration.showNotification(payload.title, {
      body:   payload.body,
      tag:    payload.tag || 'default',
      badge:  '/icons/badge.png',
      data: {
        expiresAt:  sentAtMs + (maxAgeSec * 1000),
        trackingId: payload.id
      }
    })
  );
});

Implementation Directives:

  • Embed Metadata in Encrypted Payload: Always include timestamp (epoch ms) and ttl (seconds) inside the aes128gcm encrypted body, which is capped at 4 KB per RFC 8291. The push service cannot read these fields.
  • Apply Clock-Skew Tolerance: Offline devices often drift. Use a ±5 s buffer before marking a payload expired to prevent false negatives.
  • Fail Fast on Expiration: Return early from the push event to conserve battery, prevent UI flicker, and avoid unnecessary network calls to your tracking endpoints.

The listener therefore has exactly two exits, and only one of them calls showNotification():

Service worker freshness check The push listener checks whether a TTL is embedded, then whether the payload age exceeds the TTL plus a clock-skew tolerance. Stale payloads return early with no notification; fresh payloads call showNotification with an expiresAt value. push event fires event.data.json() payload.ttl greater than 0? no no window embedded render immediately yes now − timestamp > ttl + skew? yes suppress: return early no notification, no beacon no showNotification() data.expiresAt set for the click CLOCK_SKEW_MS 5000 absorbs offline drift
The freshness check inside the push listener. A suppressed payload must not be logged or beaconed — it is discarded in place.

Security & Compliance Note: Client-side filtering must never log expired payloads to localStorage, IndexedDB, or analytics beacons. Use console.debug exclusively for development. Expired payloads must be discarded immediately.

Backend Queue TTL Enforcement & Cleanup Strategies

Message brokers must enforce TTL before dispatch to prevent unnecessary push service load. Configure queue-level expiration policies and dead-letter routing for expired jobs. When coordinating with Message Batching & Throughput Optimization, ensure TTL is evaluated per-job to avoid partial delivery of stale alerts.

Production Redis Queue Pattern (Python):

import time
import json
import logging
from redis import Redis, ConnectionPool

logger = logging.getLogger(__name__)
pool   = ConnectionPool(host='redis.internal', port=6379, db=0, max_connections=50)
redis  = Redis(connection_pool=pool)


def enqueue_push(job_id: str, payload: dict, ttl_seconds: int) -> None:
    """Atomically enqueue a push job with strict TTL enforcement."""
    if not (0 <= ttl_seconds <= 2419200):
        raise ValueError("TTL must be between 0 and 2419200 seconds (RFC 8030)")

    queue_key   = f"push:queue:{job_id}"
    dispatch_key = "push:dispatch_list"

    # setex guarantees automatic eviction at TTL boundary
    redis.setex(queue_key, ttl_seconds, json.dumps(payload))
    redis.lpush(dispatch_key, job_id)


def validate_and_dispatch(job_id: str) -> bool:
    """Atomic pre-dispatch check. Returns False if expired."""
    pipe = redis.pipeline()
    try:
        pipe.exists(f"push:queue:{job_id}")
        pipe.get(f"push:queue:{job_id}")
        exists, raw_payload = pipe.execute()

        if not exists:
            logger.info(f"Job {job_id} expired in queue. Skipping dispatch.")
            redis.lpush("push:dlq:expired", json.dumps({"job_id": job_id, "reason": "ttl_expired"}))
            return False

        payload = json.loads(raw_payload)
        # Proceed with HTTP push dispatch
        return True
    except Exception as e:
        logger.error(f"Dispatch validation failed for {job_id}: {e}")
        return False

Implementation Directives:

  • Mirror TTL Across Layers: Set Redis/Kafka/RabbitMQ message TTL to match the intended HTTP TTL header. Mismatches cause phantom deliveries or premature drops.
  • Implement Pre-Dispatch Validation: Use atomic EXISTS + GET checks before invoking vendor APIs. Drop expired jobs immediately to preserve throughput.
  • Route to Dead-Letter Queues (DLQ): Capture expired job metadata in a dedicated DLQ for churn modeling and campaign effectiveness analysis, not for re-delivery. The same window must bound your retry pipeline — a retry scheduled past the original TTL is wasted compute.

Read left to right, the same ttl_seconds value has to be present at every hop. Where it is missing, the message either leaks past its window or dies without a trace:

Broker TTL enforcement and expiry branches A job is enqueued with SETEX, checked atomically before dispatch, sent with the TTL header, and stored by the push service. Expiry in the broker routes to a dead-letter queue for analytics, while expiry at the push service is a silent discard with no status code. Where an expired job actually goes one ttl_seconds value, mirrored at every hop enqueue_push() SETEX ttl_seconds clamped 0–2419200 pre-dispatch check EXISTS + GET one pipeline round trip HTTP dispatch TTL header sent Urgency paired push service stores while offline 201 Created either way key gone push:dlq:expired metadata for churn never re-delivered TTL ends silent discard no 4xx, no callback invisible to your logs Broker eviction is measurable; push-service expiry is not — which is why both checks exist.
Broker eviction produces a DLQ record you can count. Expiry inside the push service produces nothing at all, so the broker check is your only observable expiry signal.

Compliance Alignment & Secure TTL Practices

Expired push messages containing PII or promotional offers can violate GDPR/CCPA data minimization principles if cached or logged improperly. Enforce strict TTL boundaries, encrypt payloads end-to-end, and purge delivery logs post-expiration.

Implementation Directives:

  • Zero-Trust Log Rotation: Configure log aggregation pipelines (e.g., Vector, Fluentd) to scrub push payload contents after TTL + 24h. Retain only anonymized delivery status codes and timestamps.
  • VAPID Encryption Enforcement: Never dispatch unencrypted payloads. RFC 8291 end-to-end encryption ensures only the target service worker can decrypt the message.
  • Audit TTL Overrides: Restrict TTL modification to authorized service accounts. Log all programmatic TTL changes with actor_id, previous_value, new_value, and timestamp for regulatory audits.
  • Disable Infinite Expiration: Remove any internal APIs that allow TTL: -1 or unbounded expiration. Hardcode maximum TTL caps at the infrastructure level.

Testing TTL Behavior & Edge Cases

Validate TTL expiration across network conditions, browser states, and vendor throttling. Use browser developer tools to simulate offline periods and verify service worker filtering.

Implementation Directives:

  • Inspect Queued State: Navigate to chrome://serviceworker-internals or Firefox about:debugging#/runtime/this-firefox to monitor pending push events and verify expiration timestamps.
  • Simulate Network Transitions: Use DevTools Network throttling (OfflineFast 3GOnline) to trigger delayed delivery. Verify that expired payloads are suppressed without console errors.
  • Validate Boundary Conditions:
    • TTL: 0: Should bypass vendor queueing entirely. Verify immediate delivery or discard if the endpoint is unreachable. The push service does not queue it.
    • Short TTL (e.g., TTL: 5): Confirm the service worker drops the message if processing latency causes the payload to arrive stale.
  • Monitor Vendor Responses: Track HTTP 410 (Gone) and HTTP 429 (Too Many Requests) in your dispatch logs. These indicate expired endpoints or vendor throttling, not TTL misconfiguration. Route them to Delivery Tracking & Acknowledgment rather than the TTL purge path.

TTL Configuration Reference

Set TTL by notification class, and mirror that value across every layer so the broker, the push service, and the service worker agree on the same window.

Parameter Type Default Notes
ttlSeconds (RFC 8030 TTL header) integer per-tier 02419200; clamp at the API gateway
urgency enum normal very-low | low | normal | high; affects prioritization, not the window
brokerTtlSeconds integer = ttlSeconds Redis setex / RabbitMQ message TTL; must match the header
clockSkewMs integer 5000 Tolerance applied in the service worker before marking a payload stale
payload.timestamp epoch ms dispatch time Embedded in encrypted body for client-side age checks
expiredDlqRetention integer 604800 How long expired-job metadata is kept for churn analysis

Suggested TTL tiers: OTP / payment confirmation 0–300 s, system health alert 3600 s, promotional campaign 86400–172800 s. The reasoning behind time-sensitive choices is in Setting optimal TTL values for time-sensitive alerts.

Verifying the TTL Path End to End

TTL is one of the few push parameters you can verify deterministically, because you control both ends of the window: you choose the header, and you choose how long the device stays offline. Run the check against a real subscription on a real device or profile, not against a mocked endpoint — the behaviour you are testing lives inside the vendor’s service.

Start at the wire. Sign a request as usual, point it at a live endpoint, and keep the response headers:

# Dispatch a pre-encrypted aes128gcm body with a deliberately short window.
curl -i -X POST "$PUSH_ENDPOINT" \
  -H "Authorization: vapid t=$VAPID_JWT,k=$VAPID_PUBLIC_KEY" \
  -H "Content-Type: application/octet-stream" \
  -H "Content-Encoding: aes128gcm" \
  -H "TTL: 10" \
  -H "Urgency: high" \
  --data-binary @payload.bin

A healthy dispatch answers immediately, and the response tells you which window was actually granted:

HTTP/2 201
location: https://fcm.googleapis.com/fcm/send/0:1719...%3AAPA91b
ttl: 10
content-length: 0

Two follow-ups turn that single call into a real test. First, repeat it with the browser fully closed for twenty seconds, then reopen: nothing should arrive, because the record expired inside the service. Second, repeat with TTL: 600 under the same conditions: the notification should appear within a second or two of the browser reconnecting. If both runs behave identically, your header is not reaching the service — check that the gateway is not stripping unknown headers, and that TTL is being set on the request rather than on a wrapper object your client library ignores. Sending TTL: 4000000 is a useful third probe: a service that clamps returns 201 with a smaller ttl echoed back, while a service that validates strictly returns 400 Bad Request. Either answer is fine; silently believing you have a 46-day window is not.

Then verify the client half in DevTools. In Chrome, open the Application panel, select Service Workers, tick Offline, dispatch a short-TTL message, wait past the window, and untick it. The push event should not fire at all — the service dropped the record before the socket came back. Next, dispatch a message whose embedded timestamp is deliberately backdated so the record arrives but is stale: now the event does fire, and the console should show the suppression line from the freshness check, [Push TTL] Expired message suppressed (age: 47s), with no notification rendered. chrome://serviceworker-internals lists the registration with its running status and lets you confirm the worker woke at all; Firefox exposes the same information under about:debugging#/runtime/this-firefox, where the Inspect button opens a console scoped to the worker. Safari requires a real device with Web Inspector attached from the Develop menu; the simulator does not deliver push records.

Finally, verify the broker half without any browser involved. Enqueue a job with a two-second TTL, sleep past it, and call the pre-dispatch validator directly: it must return False and leave exactly one record in push:dlq:expired. That assertion is cheap enough to keep in your integration suite, and it is the only expiry signal in the whole pipeline that you can measure from your own logs.

Error & Edge-Case Matrix

Condition Cause Fix
400 Bad Request on dispatch TTL header non-numeric, negative, or above 2419200 Clamp to 02419200 at the gateway before signing the JWT
Response echoes a smaller ttl than requested Push service clamped an excessive window Log the echoed value and treat it as the real deadline
201 Created but the message never appears Endpoint stayed offline longer than the window Expected behaviour; raise the tier’s TTL or accept the loss
Notification arrives long after it mattered Generous TTL combined with a long offline period Tier the TTL down; see why push messages arrive hours late
Fresh message suppressed by the worker Device clock drift exceeds clockSkewMs Widen the tolerance, or compare against a server-issued expiresAt instead of a duration
Broker evicts a job that still had a valid window Broker TTL shorter than the header value Mirror one ttl_seconds value across both layers
Retry succeeds but the alert is obsolete Constant TTL header re-sent on each attempt Derive the header from deadline_at − now per attempt
Every message treated as expired payload.timestamp written in seconds, compared as milliseconds Normalise both sides to epoch milliseconds at the boundary
410 Gone on a short-TTL send Subscription is dead, unrelated to expiry Delete the stored subscription; do not retry or re-tier

The last row is worth internalising, because expiry and endpoint death produce similar-looking dashboards. Expiry is silent and costs you a delivery; a dead endpoint is loud and costs you a subscriber. Route the two to different remediations so a spike in one is never absorbed by the other.

Cross-Provider and Cross-Browser Divergence

The TTL header is the same three characters everywhere, but the three services behind the four major browsers interpret its edges differently, and one of them does not use it at all.

FCM (Chrome, Edge, Opera, Samsung Internet) applies a four-week ceiling when the header is absent, which is why an omitted header is the single most common cause of ancient notifications appearing after a laptop has been shut for a fortnight. It accepts the full RFC 8030 range and clamps rather than rejects at the top end. On Android, delivery inside the window is additionally gated by the OS power state, so a valid window is a permission to deliver, not a schedule.

Mozilla autopush (Firefox) treats TTL: 0 strictly: if the endpoint is not currently connected, the record is discarded rather than briefly buffered. It also survives a browser restart better than a Chromium profile does, so a message with a multi-hour window is more likely to be delivered after a quit-and-relaunch on Firefox than on Chrome.

APNs Web Push (Safari on macOS and installed iOS PWAs) does not read a duration at all. It uses apns-expiration, an absolute Unix timestamp, so your sender must convert: apns-expiration = now + ttl_seconds, with 0 meaning discard unless immediately deliverable. That conversion depends on your server’s clock being correct, which makes NTP drift on a sender host an expiry bug rather than a logging inconvenience. Because Safari relays through APNs, coalescing is more aggressive: several records queued for the same installed app can arrive as one, and the ones that were folded away are indistinguishable from expiry in your metrics. The per-engine feature and version detail behind these differences is catalogued in the browser compatibility reference.

A practical consequence for anyone running one send pipeline across all three: store the tier’s duration, not the provider’s representation. Convert to TTL or apns-expiration at the last possible moment, in the transport adapter, so the tier definition stays provider-agnostic and a clock or unit bug is confined to one function.

Back to Backend Delivery Architecture & Queue Management

FAQ

What is the maximum TTL for a web push message?

The Web Push Protocol (RFC 8030) permits a TTL header from 0 to 2419200 seconds (28 days). FCM defaults to 4 weeks if the header is omitted, but you should always set it explicitly. APNs Web Push instead uses the apns-expiration Unix timestamp.

What does TTL: 0 actually do?

TTL: 0 tells the push service to deliver the message only if the endpoint is reachable right now; if the device is offline, the message is discarded rather than queued. It is fire-and-forget delivery, contrasted with longer windows in TTL 0 vs TTL 86400 delivery guarantees.

Why filter expired messages in the service worker if the push service already expires them?

Because a device that reconnects near the end of the TTL window can still receive a message that is no longer useful to the user. A freshness check in the push listener — using the embedded timestamp and ttl plus a small clock-skew tolerance — suppresses those late arrivals before they render.

Should broker TTL match the HTTP TTL header?

Yes. Mismatches cause phantom deliveries (broker keeps a job the service would have dropped) or premature drops (broker evicts a job that still had a valid window). Set the Redis/RabbitMQ/Kafka message TTL equal to the intended TTL header value.

Does higher Urgency extend the TTL?

No. Urgency (very-low through high) only influences how the push service prioritizes delivery within the existing window. It never lengthens the TTL. Use the TTL header alone to control lifetime.