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.
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.
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.
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
Locationheader on the201is 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.comand 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 104is a TTL problem wearing a400. It means theTTLheader 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
201from 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.
Related
- Multi-Provider Push Gateway Design — the adapter and routing layer that absorbs every difference on this page.
- HTTP/2 connection pooling for web push at scale — why each of these authorities needs its own connection pool.
- Maximum payload size limits for Chrome vs Firefox — measuring the shared 4 KB ciphertext ceiling in practice.
- TTL & Expiration Handling — keeping broker expiry and the
TTLheader in agreement.
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.