FCM vs Mozilla Autopush: Endpoint Differences

Two push services carry the overwhelming majority of web push traffic: Google’s FCM endpoint that every Chromium browser issues, and Mozilla’s Autopush that every Firefox install issues. They implement the same RFC 8030 protocol, and they behave differently in almost every operational detail that matters at volume.

Quick answer. Both accept a maximum of 4096 bytes of aes128gcm ciphertext and both authenticate with the same VAPID key pair. They diverge on TTL handling (FCM rejects an over-limit TTL, Autopush clamps it silently), on error bodies (FCM returns a nested error.status enum, Autopush returns a flat numeric errno), on Retry-After (FCM almost always sends it with a 429, Autopush frequently does not), and on how a dead subscription is reported. Anything in your dispatcher that branches on a raw status code is going to get one of those wrong.

The Comparison Table

Dimension FCM (Chrome, Edge, Opera, Brave, Samsung) Mozilla Autopush (Firefox)
Authority fcm.googleapis.com updates.push.services.mozilla.com
Path shape /wp/<token>, legacy /fcm/send/<token> /wpush/v2/<token>
Endpoint length ~150–200 chars ~180–220 chars
Self-hosting not possible yes — the server is open source, so other hosts appear
Max ciphertext 4096 bytes 4096 bytes
Content-Encoding aes128gcm required aes128gcm required
Auth VAPID (Authorization: vapid t=…, k=…) VAPID, identical header
Success status 201 Created, empty body 201 Created, Location header present
TTL header required; over-limit values rejected with 400 required; over-limit values clamped silently
Max TTL 2,419,200 s (28 days) server-configured maximum, then clamped
TTL: 0 delivered only if the device is connected right now same, and returns 201 either way
Urgency parsed; influences device wake batching parsed; influences bridge and wake behaviour
Topic supported, replaces the pending message for that endpoint supported, same replace semantics
Topic constraint ≤32 URL-safe base64 chars ≤32 URL-safe base64 chars, 400 if longer
Rate-limit status 429 with Retry-After 429, often without Retry-After
Overload status 500, 502, 503 503, sometimes 502
Error body JSON, nested error.code / error.status JSON, flat errno / message / more_info
Dead subscription 404 Not Found or 410 Gone 410 Gone with errno 102 or 106
Response to a bad JWT 401 with UNAUTHENTICATED 401 with errno 109

Everything after this point is the detail behind those rows, written from the perspective of the adapter layer described in Multi-Provider Push Gateway Design.

Endpoint Host and Shape

Both endpoints are opaque URLs: your server must POST to exactly the string the browser handed you, never a reconstructed one. The internal structure is still worth understanding, because it is what your router matches on and what your storage layer indexes.

FCM and Autopush endpoint anatomy The FCM endpoint splits into the fcm.googleapis.com authority, a wp or fcm send path, and an opaque registration token. The Autopush endpoint splits into the updates.push.services.mozilla.com authority, a versioned wpush path, and a combined user agent and channel identifier. Chromium browsers — FCM https://fcm.googleapis.com /wp/ cH7k…Qz9 (registration token) authority — picks the adapter also /fcm/send/ opaque — never rebuild it Firefox — Mozilla Autopush https://updates.push.services.mozilla.com /wpush/v2/ gAAAAA…l4M (uaid + chid) self-hosted copies use other hosts versioned path device id plus channel id Match on the registrable suffix, not on an exact hostname string FCM has two live path shapes; Autopush runs on hosts Mozilla does not own
Both endpoints are opaque, but only the authority is stable enough to route on — the path shape has already changed once on FCM.

The practical consequence is a routing rule, not a parsing rule. Match fcm.googleapis.com and *.push.services.mozilla.com by suffix; store the whole endpoint verbatim; and index a keyed hash of it rather than the raw string when you need to join to analytics. Because Autopush is open source, a subscription may legitimately arrive with a hostname belonging to a self-hosted deployment, which is exactly the case the fallback adapter in the gateway design exists to catch.

Payload Ceiling and Encoding

There is no difference here, and that is worth stating plainly because it is a common source of misinformation. Both services enforce the RFC 8291 limit of 4096 bytes of aes128gcm ciphertext, and both reject an oversized body with 413 Payload Too Large. What differs is only how much slack you have before you hit it: the encryption overhead (record header, salt, public key, authentication tag, and padding to the record boundary) is a fixed cost of roughly 100 bytes plus padding, so a plaintext of about 3,900 bytes is the practical ceiling on either service.

The failure mode that looks like a provider difference usually is not. If Chrome subscribers receive a message and Firefox subscribers get 413, the cause is almost always a per-user personalisation field pushing that specific payload over the line, not a vendor limit — the measurement detail is in maximum payload size limits for Chrome vs Firefox.

TTL Clamping Behaviour

This is the sharpest divergence, and it silently corrupts delivery expectations. FCM treats a TTL above its maximum as a client error: the request fails with 400 and nothing is queued. Autopush treats the same value as a request it is entitled to reduce — RFC 8030 §5.2 explicitly permits a push service to shorten the TTL — and it accepts the message with the reduced value applied.

The result is that the same over-limit send produces a hard failure on Chrome and a quiet success on Firefox, with a lifetime nothing in your system recorded.

TTL handling compared A matrix showing four requested TTL values and how each service applies them: identical for zero and one hour, both accepting the twenty-eight day maximum, and diverging on an over-limit value where FCM returns a 400 error while Autopush clamps silently. The same TTL header, two different outcomes TTL requested FCM applies Autopush applies 0 201 — deliver now or drop 201 — deliver now or drop 3600 3600 s, honoured exactly 3600 s, honoured exactly 2419200 (28 d) accepted at the maximum accepted, may be reduced 9999999 400 — nothing is queued 201 — clamped, no warning Clamp in your own code so the value you log is the value that applies
Over-limit TTLs fail loudly on FCM and succeed quietly on Autopush — clamp before dispatch so both paths record the same number.

Fix it in the adapter, before the header is built. Clamping locally makes the applied TTL a known quantity on both services and removes an entire class of 400 from your error budget:

// Clamp once, log the clamped value, send the clamped value.
const PROVIDER_MAX_TTL = { fcm: 2419200, autopush: 2419200 };

export function applyTtl(providerId, requestedSeconds, logger) {
  const max = PROVIDER_MAX_TTL[providerId] ?? 86400;
  const requested = Number.isInteger(requestedSeconds) ? requestedSeconds : 86400;
  const applied = Math.max(0, Math.min(requested, max));
  if (applied !== requested) {
    logger.warn('ttl_clamped', { provider: providerId, requested, applied });
  }
  return applied;
}

Whatever value survives clamping should also be mirrored into your broker’s own expiry, which is the discipline covered in TTL & Expiration Handling.

Urgency, Topic and Collapse Semantics

Both services parse Urgency with the RFC 8030 vocabulary — very-low, low, normal, high — and both use it as an input to when a sleeping device is woken rather than as a queue priority. Neither service reorders messages because of it. Sending high on everything is therefore not a throughput trick; it just removes the signal that would have let a battery-conscious device batch your low-value notifications.

Topic is the collapse key, and both implement the same replace semantics: a new message with the same Topic for the same endpoint evicts the pending one, so a device that comes back online after three hours receives the latest message rather than three of them. Both enforce the same constraint — at most 32 characters from the URL-safe base64 alphabet — and Autopush is the stricter of the two about rejecting anything longer with a 400. Truncate or hash your topic key in the adapter rather than hoping the caller behaves.

The one behavioural nuance: collapse only applies to messages still pending on the push service. Once a message has been handed to the device, a later message with the same Topic is a separate notification, and suppressing it is your service worker’s job via the tag option on showNotification().

Rate Limiting and 429 Behaviour

FCM is the more forgiving of the two under burst, and the more informative when it does throttle. A 429 from FCM reliably carries a Retry-After header, usually a small integer number of seconds, and honouring it is normally enough to recover without a backoff ramp.

Autopush throttles less often but communicates less when it does. A 429 may arrive with no Retry-After at all, in which case you must fall back to exponential backoff with jitter rather than retrying immediately. Autopush also applies a per-endpoint message rate limit, so a runaway loop targeting one subscriber can be throttled while the rest of your Firefox traffic is untouched — a case that looks like random failure unless your metrics are labelled well enough to see it.

// Retry-After is optional on Autopush; fall back to jittered exponential backoff.
export function retryDelayMs(outcome, attempt) {
  if (outcome.retryAfterMs != null) return outcome.retryAfterMs;
  const base = Math.min(1000 * 2 ** attempt, 60000);
  return Math.round(base * (0.7 + Math.random() * 0.6));
}

The policy that wraps this — attempt caps, TTL-aware abandonment, dead-lettering — belongs in the shared retry layer described in handling 429 Too Many Requests from push services, not in the provider adapter.

Error Body Formats

An FCM error is a nested JSON envelope. The status code tells you the class and error.status tells you the specific condition:

{
  "error": {
    "code": 404,
    "message": "Requested entity was not found.",
    "status": "NOT_FOUND",
    "details": []
  }
}

An Autopush error is flat, numeric, and carries a documentation link:

{
  "code": 410,
  "errno": 103,
  "error": "Gone",
  "message": "Subscription has expired",
  "more_info": "http://autopush.readthedocs.io/en/latest/http.html#error-codes"
}

The errno values worth special-casing are 102 (invalid or unknown subscription), 103 (expired subscription), 106 (invalid or missing headers), 109 (invalid authentication), and 104 (an unparseable TTL header, which arrives as a 400 and is not a payload problem). Everything else can be handled by status code alone.

Both bodies collapse into the same internal outcome. Keep the vendor string in a rawDetail field for logs, and let only the normalised code influence control flow.

How Each Reports a Dead Subscription

This is the difference that costs money if you get it wrong, because a subscription you fail to delete is retried forever.

Dead subscription decision flow A push service response branches three ways: an FCM 404 or 410, an Autopush 410 carrying errno 102 or 103, or a 429 and 5xx from either service. The first two branches merge into deleting the subscription, while the third routes to backoff and retry. Response received status plus body FCM: 404 or 410 status NOT_FOUND Autopush: 410 errno 102 or 103 Either: 429 or 5xx service is healthy-ish GONE: delete row, stop retrying never counts against the breaker Requeue with backoff counts against the breaker A 404 from FCM is terminal; a 404 from Autopush means a bad path, not a dead device
Both services signal a dead subscription, but only FCM uses `404` for it — on Autopush a `404` means your request URL was wrong.

FCM returns 404 Not Found when the registration token is unknown and 410 Gone when it has been explicitly unregistered. Both are terminal: delete the row immediately. Autopush reserves 404 for a genuinely unroutable request path and signals a dead device with 410 plus an errno in the 102/103 range. Treating an Autopush 404 as a dead subscription therefore deletes live Firefox subscribers whenever a bug mangles the request URL — a quiet, permanent data loss that only shows up as unexplained list shrinkage weeks later. The bulk cleanup pattern for the genuine cases is in handling 410 Gone responses at scale.

Gotchas and Edge Cases

  • A Location header on the 201 is Autopush-only. It points at a receipt resource, not a delivery confirmation. Do not build attribution on it, and do not expect FCM to provide an equivalent.
  • Autopush hostnames are not a closed set. Firefox forks and self-hosted deployments issue endpoints on hosts Mozilla does not operate. Suffix-match push.services.mozilla.com and route anything else through a conservative default rather than rejecting it.
  • FCM’s legacy /fcm/send/ path is still live. Long-lived subscriptions created years ago still carry it. Never normalise or rewrite the path — POST to the stored string exactly.
  • errno 104 is a TTL problem wearing a 400. It means the TTL header could not be parsed, usually because a float or an empty string reached the wire. Classify it separately or it pollutes your generic bad-request bucket.
  • Neither service confirms display. A 201 from either means the payload was queued, nothing more. Display is only observable through events your service worker reports back, which is why delivered counts and displayed counts never match.

Back to Multi-Provider Push Gateway Design

FAQ

Do FCM and Autopush accept different maximum payload sizes?

No. Both enforce the RFC 8291 limit of 4096 bytes of aes128gcm ciphertext and both return 413 Payload Too Large above it. Keep plaintext under roughly 3.9 KB to leave room for the record header, salt, public key and authentication tag. A payload that fails on only one browser is a per-user personalisation problem, not a vendor limit.

Why does the same TTL succeed on Firefox and fail on Chrome?

Because FCM rejects a TTL above its 2,419,200-second maximum with 400 and queues nothing, while Autopush exercises its RFC 8030 right to reduce the value and returns 201 with a shorter lifetime applied. Clamp the TTL in your own adapter before building the header so both services receive — and you log — the same number.

Is a 404 always safe to treat as a dead subscription?

Only on FCM. FCM uses 404 for an unknown registration token, which is terminal. Autopush uses 404 for an unroutable request path, so treating it as a dead device deletes live Firefox subscribers whenever a bug mangles the URL. On Autopush, wait for 410 with an errno of 102 or 103 before deleting anything.