Fix Web Push 413 Payload Too Large Errors
A 413 Payload Too Large from the push service means the encrypted request body exceeded the 4,096-byte limit — and because encryption adds a fixed 103 bytes of framing, the plaintext you can actually send is smaller than 4 KB.
Quick answer
RFC 8030 requires a push service to accept a body of at least 4,096 octets, and every major service treats that as a hard ceiling rather than a floor. The body it measures is the encrypted one. RFC 8291’s aes128gcm framing prepends an 86-byte header and appends a 16-byte authentication tag, plus at least one padding delimiter byte, so your usable plaintext budget is 3,993 bytes — before JSON syntax, before UTF-8 multi-byte characters, and before the URLs you embed. Measure the ciphertext length, not the object you serialised, and if it does not fit, send an identifier and let the service worker fetch the rest.
| Component | Bytes | Notes |
|---|---|---|
| Salt | 16 | fresh random value per record |
Record size (rs) |
4 | big-endian unsigned integer |
| Key ID length | 1 | always 65 for web push |
| Ephemeral P-256 public key | 65 | uncompressed point |
| Padding delimiter | ≥ 1 | 0x02 on the final record |
| AES-128-GCM tag | 16 | appended to the ciphertext |
| Fixed overhead | 103 | |
| Plaintext ceiling | 3,993 | 4,096 − 103 |
Where the limit is enforced
The push service enforces it, not the browser. Your POST to the subscription endpoint carries the encrypted body; the service checks Content-Length against its configured maximum and rejects with 413 before it queues anything. FCM, Mozilla autopush and Apple’s web push gateway all use 4,096. The check happens at the edge, so a 413 costs you no quota and no delivery attempt — but it also means the message is gone unless your sender treats 413 as a permanent failure and re-sends a smaller version.
Two consequences follow. First, 413 is not retryable. Unlike a 429, which the retry and backoff guidance tells you to re-queue with an increasing delay, replaying the identical body will produce the identical rejection forever. Route 413 to a size-reduction path or a dead-letter store, never to the retry queue.
Second, the limit binds per message, not per subscription. A 413 on one send says nothing about the endpoint’s health, so do not mark the subscription stale — that treatment belongs to 404 and 410, not 413.
There is also a failure mode that looks like 413 but is not. If your body slips under 4,096 but your padding scheme or record framing is wrong, the service accepts the request with 201 Created and the browser silently discards the record. That is a decryption failure, covered in aes128gcm encoding and decryption errors, and it is worth ruling out before you spend a day shrinking a payload that was never too big.
Measuring the encrypted size before you send
The mistake that produces most 413s is measuring the wrong thing. JSON.stringify(payload).length counts UTF-16 code units, not bytes; Buffer.byteLength() counts plaintext bytes but ignores the 103-byte framing; and neither accounts for the padding your library may add. The reliable check is to build the request and measure the body you are about to transmit.
The web-push library exposes generateRequestDetails(), which performs the full ECDH, HKDF and AES-128-GCM pipeline and hands back the exact bytes without sending them.
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:ops@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
const PUSH_SERVICE_LIMIT = 4096; // RFC 8030 minimum, universally the maximum
const RFC8291_OVERHEAD = 103; // salt 16 + rs 4 + idlen 1 + key 65 + pad 1 + tag 16
const PLAINTEXT_CEILING = PUSH_SERVICE_LIMIT - RFC8291_OVERHEAD; // 3993
function measure(subscription, payload) {
const plaintext = JSON.stringify(payload);
const plaintextBytes = Buffer.byteLength(plaintext, 'utf8');
const details = webpush.generateRequestDetails(subscription, plaintext, {
contentEncoding: 'aes128gcm',
});
return {
plaintextBytes,
encryptedBytes: details.body.length,
headroom: PUSH_SERVICE_LIMIT - details.body.length,
fits: details.body.length <= PUSH_SERVICE_LIMIT,
};
}
async function sendChecked(subscription, payload) {
const m = measure(subscription, payload);
if (!m.fits) {
// Do NOT retry this body. Shrink it and rebuild.
throw Object.assign(new Error('payload exceeds 4096 encrypted bytes'), {
code: 'PAYLOAD_TOO_LARGE',
...m,
});
}
return webpush.sendNotification(subscription, JSON.stringify(payload), { TTL: 86400 });
}
Because the overhead is constant, Buffer.byteLength(plaintext) <= 3993 is a perfectly good cheap pre-check for the hot path; reserve the full generateRequestDetails() measurement for a test that runs in CI against a fixture subscription. Assert on the number in that test, so a copy change that adds a long tracking URL fails the build rather than a campaign.
Shrinking the payload
The durable fix is to stop treating the push message as a data transport. A push payload is a trigger: it should carry only what the service worker needs to decide what to display and where to look up the rest.
Send an identifier, fetch on the client. Replace long article bodies, product descriptions, rendered HTML and image data URLs with a short opaque id. In the push handler, fetch() the detail from your API inside event.waitUntil(), then call showNotification() with the result. You keep the rich notification and reduce the payload to a couple of hundred bytes. The cost is a network round trip at display time and a fallback path for when that fetch fails — always show something, using a generic title from the payload, rather than letting the handler reject.
Move constants into the worker. Icon and badge URLs, brand strings and default action titles never change per message. Hardcode them in the service worker bundle and strip them from the payload; two CDN URLs with content hashes can easily be 200 bytes.
Shorten the keys, not just the values. JSON key names are transmitted verbatim. {"notificationTitle":…,"notificationBody":…} costs 38 bytes of key names per message; {"t":…,"b":…} costs 8. It is unglamorous and it works.
Strip tracking parameters. A deep link with six UTM parameters and a click id is often the largest single field. Send the canonical path plus a campaign id and reassemble the tracked URL in the notificationclick handler.
Do not compress. Gzipping before encryption saves bytes but leaks length information and, more practically, is not something the browser will decompress for you — the service worker receives exactly the plaintext you encrypted. If you compress, you must decompress in the worker, which costs more bundle size than the payload you saved.
// service-worker.js — trigger payload, hydrate on display
const ICON = '/icons/notification-192.png';
const BADGE = '/icons/badge-96.png';
self.addEventListener('push', (event) => {
const trigger = event.data ? event.data.json() : {};
event.waitUntil((async () => {
let title = trigger.t || 'New update';
let body = trigger.b || '';
let url = '/';
try {
const res = await fetch('/api/notifications/' + encodeURIComponent(trigger.id), {
credentials: 'include',
});
if (res.ok) {
const full = await res.json();
title = full.title;
body = full.body;
url = full.url;
}
} catch (err) {
// Network unavailable: fall back to the trigger fields.
}
await self.registration.showNotification(title, {
body,
icon: ICON,
badge: BADGE,
tag: trigger.id,
data: { url, id: trigger.id },
});
})());
});
Fix procedure
- Confirm the status code is really
413. Log the response status and body from the push service. Some gateways return400with a size message instead; treat both the same way but do not confuse either with a403VAPID rejection. - Measure the encrypted body for the failing message. Run
generateRequestDetails()against a fixture subscription with the exact payload that failed and recordbody.length. If it is under 4,096, the size is not your problem — check the record framing instead. - Attribute the bytes. Serialise each top-level field separately and log
Buffer.byteLength()per field. In almost every case one field is over half the payload. - Remove constants from the payload. Move icon, badge and any fixed strings into the service worker bundle and re-measure.
- Replace the largest variable field with an id and add the hydrating
fetch()shown above, including the fallback branch. - Add a hard guard at the send boundary. Throw before the network call when the plaintext exceeds 3,993 bytes, and emit a metric so the ceiling is visible in dashboards rather than in support tickets.
- Add a CI assertion that renders your largest realistic payload for each notification type and fails if any exceeds the ceiling.
Gotchas and edge cases
- Emoji and non-Latin text cost more than one byte. A title of 40 emoji is 160 bytes, not 40. Budget in bytes and test with your worst-case locale, not with English.
- Padding is optional but not free. Libraries that pad to a fixed record size to obscure message length consume real budget; if you enable it, subtract the padding length from 3,993 as well.
- The 4 KB figure is a minimum in the RFC. RFC 8030 says services must accept at least 4,096 octets, and a service could accept more — none does, and per-browser variation is examined in maximum payload size limits for Chrome vs Firefox. Never rely on headroom you did not measure.
- A
413on a batch send is per-message. If your sender fans out over an HTTP/2 connection, one oversized message must not fail the batch; isolate the rejection and let the rest complete. - Hydrating fetches need credentials and a timeout. A
fetch()inside thepushhandler runs in the worker with its own cookie jar rules; if it hangs, the browser may terminate the worker and show a generic notification instead. Always race it against a short timeout.
Related
- Push API Payload Encryption — the RFC 8291 workflow whose framing consumes the 103 bytes described here.
- Debugging aes128gcm Encoding & Decryption Errors — for payloads that are accepted but never displayed.
- Maximum Payload Size Limits: Chrome vs Firefox — per-service thresholds and silent-drop behaviour.
- Handling 429 Too Many Requests — the retryable rejection that
413is often wrongly grouped with.
Back to Push API Payload Encryption
FAQ
What is the real maximum payload size for a web push notification?
The push service accepts an encrypted body of at most 4,096 bytes. RFC 8291 framing adds a fixed 103 bytes — a 16-byte salt, a 4-byte record size, a 1-byte key id length, a 65-byte ephemeral public key, at least one padding delimiter and a 16-byte GCM tag — so the plaintext you can send is 3,993 bytes. Measure in bytes, because non-ASCII characters consume two to four bytes each.
Should I retry a push send that returned 413?
Not with the same body. A 413 is deterministic: the identical request will be rejected identically every time, so re-queueing it only burns worker cycles. Route it to a size-reduction path that rebuilds the message with a lookup identifier, or to a dead-letter store for inspection. Reserve retries with backoff for 429 and 5xx responses.
How do I send rich content if the payload will not fit?
Send a trigger rather than the content. Put a short opaque identifier plus a fallback title in the payload, then fetch the full notification from your API inside the push handler’s waitUntil and call showNotification with the result. Always keep a fallback branch that displays the payload’s own title if the fetch fails, so a network problem degrades to a plain notification instead of no notification.