Handling 413 and 507 Errors During Uploads
Neither code is transient: a 413 means this request will never fit and a 507 means the bytes have nowhere to land, so classify both into “tell the user” — with the single exception of a 413 on a chunk you are still free to make smaller.
Retrying a 413 is the most common wasted work in an uploader. The request is deterministic, the ceiling does not move between attempts, and each retry re-uploads the whole body before being rejected again — on a phone that is real money. This article is the classification half of upload error recovery patterns inside frontend UX, chunking and progress tracking; the timing half, backoff and jitter for the codes that are worth retrying, is in implementing exponential backoff for failed chunks.
When to use this approach
- Your uploader already retries on network errors and 5xx, and you are now seeing retry budgets burned on responses that will never change.
- You proxy bodies through infrastructure you do not fully control — a CDN, an ingress controller, an API gateway — and need to know from the browser which of them said no.
- You have per-tenant storage quotas, or an origin whose disk can fill, and want the UI to show a number rather than spin.
Prerequisites
- Node 20+ or any evergreen browser: the code uses global
fetch, theResponseclass andAbortSignal. - TypeScript 5.x with
lib: ["DOM", "ES2022"],stricton. - A chunked upload endpoint addressed by byte offset, so shrinking a chunk mid-flight is safe — the pattern from resuming uploads after network loss.
- CORS on the upload origin exposing whatever diagnostic headers you rely on. Without
Access-Control-Expose-Headers,res.headers.get("server")isnullin a cross-origin response and half of this page stops working.
Implementation
One function decides everything. It takes the failed Response plus the shape of the request that produced it, and returns a discriminated verdict the upload loop can switch on: retry after a delay, resize the chunk and try the same offset again, abort as a programming error, or stop and hand a specific message to the user.
export type Hop = "edge" | "proxy" | "gateway" | "app" | "unknown";
export type Verdict =
| { action: "retry"; delayMs: number; reason: string }
| { action: "resize"; nextChunkBytes: number; reason: string }
| { action: "abort"; reason: string }
| {
action: "user";
kind: "too-large" | "quota" | "type" | "auth" | "wait";
message: string;
limitBytes?: number;
};
export interface UploadContext {
chunkBytes: number; // size of the body that was just rejected
minChunkBytes: number; // floor below which shrinking stops being a fix
attempt: number; // retries already spent on this chunk
chunked: boolean; // false for a single-shot PUT of the whole file
}
const RETRYABLE = new Set([408, 425, 500, 502, 504]);
const TERMINAL = new Set([400, 401, 404, 405, 409, 410, 422, 501]);
/** Waiting longer than this in a live tab is worse than telling the user. */
const MAX_HONOURED_WAIT_MS = 300_000;
const HOP_LABEL: Record<Hop, string> = {
edge: "CDN edge",
proxy: "reverse proxy",
gateway: "API gateway",
app: "upload service",
unknown: "server",
};
const fmt = (bytes: number): string =>
bytes >= 2 ** 30 ? `${(bytes / 2 ** 30).toFixed(1)} GB` : `${Math.round(bytes / 2 ** 20)} MB`;
const backoff = (attempt: number): number =>
Math.random() * Math.min(30_000, 500 * 2 ** attempt);
function parseRetryAfter(header: string | null): number | null {
if (header === null) return null;
const secs = Number(header);
if (Number.isFinite(secs)) return Math.max(0, secs * 1000); // delta-seconds
const at = Date.parse(header); // HTTP-date
return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
}
/** Which box in the chain answered? Read the headers, never the status alone. */
export function identifyHop(res: Response): Hop {
const server = (res.headers.get("server") ?? "").toLowerCase();
if (res.headers.has("cf-ray") || server === "cloudflare") return "edge";
if (res.headers.has("x-amzn-requestid") || res.headers.has("x-amz-apigw-id")) return "gateway";
if (server.startsWith("nginx") || server.startsWith("awselb") || server === "envoy") return "proxy";
if ((res.headers.get("content-type") ?? "").includes("json")) return "app";
return "unknown";
}
/** Only your own service ever states the ceiling; proxies return HTML or plain text. */
async function readLimit(res: Response): Promise<number | undefined> {
const header = res.headers.get("x-upload-max-bytes");
if (header !== null && Number.isFinite(Number(header))) return Number(header);
if (!(res.headers.get("content-type") ?? "").includes("json")) return undefined;
const body = (await res.clone().json().catch(() => null)) as { maxBytes?: number } | null;
return typeof body?.maxBytes === "number" ? body.maxBytes : undefined;
}
async function readQuota(res: Response): Promise<{ used: number; limit: number } | undefined> {
const usedHdr = res.headers.get("x-quota-used");
const limitHdr = res.headers.get("x-quota-limit");
if (usedHdr !== null && limitHdr !== null) {
const used = Number(usedHdr);
const limit = Number(limitHdr);
if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) return { used, limit };
}
if (!(res.headers.get("content-type") ?? "").includes("json")) return undefined;
const body = (await res.clone().json().catch(() => null)) as
| { quota?: { used: number; limit: number } }
| null;
return body?.quota;
}
export async function classify(res: Response, ctx: UploadContext): Promise<Verdict> {
if (res.ok) return { action: "abort", reason: "classify() called on a successful response" };
if (res.status === 413) {
const hop = identifyHop(res);
// A per-REQUEST ceiling is not a per-FILE ceiling. If we are chunking and the
// chunk is still above the floor, halving it is a real fix, not a retry.
if (ctx.chunked && ctx.chunkBytes > ctx.minChunkBytes) {
return {
action: "resize",
nextChunkBytes: Math.max(ctx.minChunkBytes, Math.floor(ctx.chunkBytes / 2)),
reason: `413 from ${HOP_LABEL[hop]} on a ${fmt(ctx.chunkBytes)} chunk`,
};
}
const limitBytes = await readLimit(res);
return {
action: "user",
kind: "too-large",
limitBytes,
message: limitBytes === undefined
? `The ${HOP_LABEL[hop]} rejected this file as too large.`
: `This file is too large. The limit is ${fmt(limitBytes)}.`,
};
}
if (res.status === 507) {
// Quota arithmetic, not congestion. Only an explicit Retry-After makes it transient.
const wait = parseRetryAfter(res.headers.get("retry-after"));
if (wait !== null && wait <= MAX_HONOURED_WAIT_MS) {
return { action: "retry", delayMs: wait, reason: "507 with Retry-After — server says transient" };
}
const quota = await readQuota(res);
return {
action: "user",
kind: "quota",
message: quota === undefined
? "Storage is full on the server. Free up space or contact support — retrying will not help."
: `Storage full: ${fmt(quota.used)} of ${fmt(quota.limit)} used, and this upload needs ` +
`${fmt(ctx.chunkBytes)} more.`,
};
}
if (res.status === 429 || res.status === 503) {
const wait = parseRetryAfter(res.headers.get("retry-after"));
if (wait !== null && wait > MAX_HONOURED_WAIT_MS) {
return {
action: "user",
kind: "wait",
message: `The service asked us to wait ${Math.round(wait / 60_000)} minutes. ` +
"Your progress is saved — reopen this page later to continue.",
};
}
return { action: "retry", delayMs: wait ?? backoff(ctx.attempt), reason: `HTTP ${res.status}` };
}
if (res.status === 403) {
return { action: "user", kind: "auth", message: "This upload link expired. Reload to get a fresh one." };
}
if (res.status === 415) {
return { action: "user", kind: "type", message: "That file type is not accepted." };
}
if (RETRYABLE.has(res.status)) {
return { action: "retry", delayMs: backoff(ctx.attempt), reason: `HTTP ${res.status}` };
}
if (TERMINAL.has(res.status)) {
return { action: "abort", reason: `HTTP ${res.status} is deterministic — the request itself is wrong` };
}
return { action: "retry", delayMs: backoff(ctx.attempt), reason: `unmapped status ${res.status}` };
}
Line-by-line of the decisions that matter
413is checked before theRETRYABLE/TERMINALsets, because it is neither. It is the only status in the table whose verdict depends on the request, not just the response: the same 413 means “shrink the chunk” for a 64 MB part and “this file cannot be uploaded” for a single-shot PUT.ctx.minChunkBytesterminates the resize loop. Each 413 halves the chunk, so a 64 MB start reaches a 1 MiB floor in six steps; at the floorclassifyfalls through to theuserverdict. Without that floor you get an infinite bisection against a proxy that is rejecting for some other reason entirely.identifyHopreads headers, not the body.cf-rayis present on every Cloudflare response andx-amzn-RequestIdon every API Gateway one, which makes them reliable even when the body is a branded HTML page. Fall back toserver, then to the content type.readLimitrefuses to parse non-JSON.res.clone().json()on nginx’s HTML error page throws, and doing it on a 4 KB body per failed chunk is measurable. Theclone()matters: the calling code may still want to logres.text(), and aResponsebody can only be read once.507honoursRetry-Afterbut nothing else. A server that sendsRetry-Afteron a 507 is claiming the condition is temporary — a garbage-collection sweep, a volume being resized. A bare 507 is a quota statement, and there is no delay that makes 4 GB fit into 2.8 GB.MAX_HONOURED_WAIT_MScaps the wait at five minutes for429and503too. Sleeping a tab for the 3600 seconds some rate limiters ask for is not resilience; it is a hung UI. Above the cap, hand the user a message and rely on the persisted offset to resume later.- The final fallback retries. An unmapped status (a
418, a mangled 200 from a captive portal) is more likely a fluke than a permanent condition, and the attempt budget bounds the damage.
Reading a 413 to find the hop that sent it
The status line is identical whoever sent it; everything useful is in the headers and the body. Three real responses, captured against the same endpoint with three different body sizes:
HTTP/1.1 413 Request Entity Too Large
Server: nginx/1.24.0
Date: Sun, 26 Jul 2026 09:14:02 GMT
Content-Type: text/html
Content-Length: 183
Connection: close
<html>
<head><title>413 Request Entity Too Large</title></head>
<body>
<center><h1>413 Request Entity Too Large</h1></center>
<hr><center>nginx/1.24.0</center>
</body>
</html>
Connection: close is the tell that nginx gave up on the socket rather than draining it, and the reason the browser often reports a network error instead. Note what is missing: the configured client_max_body_size appears nowhere. Cloudflare is terser still:
HTTP/2 413
date: Sun, 26 Jul 2026 09:15:41 GMT
content-type: text/plain; charset=UTF-8
content-length: 16
cf-ray: 9a1f6c07d1e34a12-LHR
server: cloudflare
error code: 413
That body is the whole payload — fifteen characters and a newline. cf-ray proves the edge answered and your origin logged nothing, which is why the request is invisible in your access log. AWS API Gateway is the third shape:
HTTP/2 413
content-type: application/json
x-amzn-RequestId: 3f0a2b6c-9d41-4a77-8d02-5c1b7e9a6f30
x-amz-apigw-id: PkQ2wF3xDoEEJ1w=
{"message":"Request Entity Too Large"}
| Sender | Distinguishing header | Body | What the user should be told |
|---|---|---|---|
| nginx / ingress | Server: nginx/* |
HTML error page | “Files over N are not supported” — from your own config, not the response |
| Cloudflare | cf-ray, server: cloudflare |
error code: 413 |
The plan cap (100 MB Free/Pro); route large bodies off the zone entirely |
| API Gateway / ALB | x-amzn-RequestId, x-amz-apigw-id |
{"message":"Request Entity Too Large"} |
Hard 10 MB (1 MB for ALB→Lambda); use a signed URL instead |
| Envoy / Istio | server: envoy |
payload too large |
Raise max_request_bytes on the buffer filter |
| Your service | content-type: application/json |
your schema | The exact limit, plus an offer to compress |
| Amazon S3 | x-amz-request-id |
EntityTooLarge XML, status 400 |
Part above 5 GiB, or outside a POST policy’s content-length-range |
That last row is the trap in a direct-to-storage flow: S3 does not use 413 at all. An oversized PUT comes back as 400 with <Code>EntityTooLarge</Code> and a <MaxSizeAllowed> element, so a classifier keyed only on 413 files it under “abort” and loses the actionable message. If you sign uploads with a POST policy, the content-length-range condition produces the same 400 — see presigned POST vs presigned PUT for browser uploads for where that condition lives.
Making the 413 branch actually do something
The resize verdict is only useful if the loop treats it as progress rather than as a retry. It must not consume the attempt budget, and it must re-slice from the same offset:
export class UploadHalted extends Error {
constructor(readonly kind: string, message: string) {
super(message);
this.name = "UploadHalted";
}
}
export async function sendChunks(
file: File,
url: string,
initialChunkBytes = 16 * 1024 * 1024,
maxAttempts = 6,
): Promise<void> {
let chunkBytes = initialChunkBytes;
let offset = 0;
let attempt = 0;
while (offset < file.size) {
const blob = file.slice(offset, Math.min(offset + chunkBytes, file.size));
const res = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream", "Upload-Offset": String(offset) },
body: blob,
signal: AbortSignal.timeout(120_000),
});
if (res.ok) {
offset += blob.size;
attempt = 0;
continue;
}
const verdict = await classify(res, {
chunkBytes: blob.size,
minChunkBytes: 1024 * 1024,
attempt,
chunked: true,
});
switch (verdict.action) {
case "resize":
console.warn(`${verdict.reason}; retrying offset ${offset} at ${verdict.nextChunkBytes} bytes`);
chunkBytes = verdict.nextChunkBytes; // same offset, smaller body, no attempt spent
break;
case "retry":
attempt += 1;
if (attempt >= maxAttempts) throw new Error(`gave up after ${maxAttempts} attempts: ${verdict.reason}`);
await new Promise((r) => setTimeout(r, verdict.delayMs));
break;
case "user":
throw new UploadHalted(verdict.kind, verdict.message);
case "abort":
throw new Error(verdict.reason);
}
}
}
Halving works because the ceiling is per request. It does not work for a single-shot upload of a whole file, and it does not work below a floor where the per-request overhead swamps the payload — 1 MiB is a sensible stop, and the mechanics of re-slicing are in slicing large files with Blob.slice.
The better move is never to discover the ceiling from a 413. Publish it: a GET /api/upload/limits returning {"maxBytes":104857600,"maxChunkBytes":16777216} lets the client check file.size before a single byte leaves the device, which is the only way a browser can fail fast — it never sends Expect: 100-continue, so it uploads the entire body before reading the rejection. When the file genuinely is too big, the honest UX is a choice rather than an error: show the limit, show the file’s size, and offer a client-side re-encode. Images are the easy win — a 12 MP JPEG at quality 0.82 through a canvas typically lands 60–80% smaller, and optimizing payload size for mobile uploads covers the trade-offs. If compression cannot close the gap, say so instead of offering a button that will fail.
507 is arithmetic, not congestion
507 Insufficient Storage comes from RFC 4918 and means the server cannot store the representation needed to complete the request. In practice you see it from three places: WebDAV servers such as Nextcloud when a user is over quota, mod_dav when the underlying volume is full, and hand-written APIs that adopted it as their tenant-quota signal. All three share one property — the deficit does not shrink because you waited.
If you own the server, make the 507 self-describing. Two headers and a JSON body cost nothing and turn a dead end into a task:
import { createServer } from "node:http";
const QUOTA_BYTES = 50 * 2 ** 30;
createServer(async (req, res) => {
const used = await usedBytesForTenant(req.headers["x-tenant-id"] as string);
const incoming = Number(req.headers["content-length"] ?? 0);
if (used + incoming > QUOTA_BYTES) {
res.writeHead(507, {
"Content-Type": "application/json",
"X-Quota-Used": String(used),
"X-Quota-Limit": String(QUOTA_BYTES),
// Expose them, or a cross-origin client reads null for both.
"Access-Control-Expose-Headers": "X-Quota-Used, X-Quota-Limit",
});
const body = JSON.stringify({
error: "quota_exceeded",
quota: { used, limit: QUOTA_BYTES },
deficit: used + incoming - QUOTA_BYTES,
});
// Flush the response first, then stop accepting a body we have already refused.
res.end(body, () => req.destroy());
return;
}
res.writeHead(204).end();
}).listen(3000);
async function usedBytesForTenant(tenantId: string): Promise<number> {
// Replace with a real lookup; a cached counter is fine, exactness is not required here.
return tenantId === "t-over" ? 50_696_159_232 : 0;
}
Two subtleties. First, checking Content-Length before reading the body means the client learns the answer after the request line rather than after the upload, and req.destroy() stops the kernel accepting gigabytes you have already declined. Second, a per-chunk quota check must count bytes already committed for this upload, or a resumed transfer double-counts itself and 507s at 50% — the same class of bug as a stale offset in resumable upload state machines.
Configuration gotchas
TypeError: Failed to fetch with no status at all. nginx answers 413 while the browser is still pushing the body, then closes the socket; fetch rejects before you ever get a Response to classify. The origin log line reads client intended to send too large body: 268435456 bytes. There is no client-side fix — the size check has to happen before the request starts, and the proxy side is covered in raising nginx and Cloudflare upload size limits.
res.headers.get("server") is null cross-origin. Only CORS-safelisted headers are readable by default, and server, cf-ray and x-quota-limit are not among them. identifyHop then falls through to "unknown" and every 413 becomes a generic message. Add Access-Control-Expose-Headers on the origin — but note you cannot add it to Cloudflare’s own edge-generated 413, which is another reason to keep bodies off the proxied hostname.
PayloadTooLargeError: request entity too large. Express raises this from body-parser with err.type === "entity.too.large" and err.status === 413, and in production the default handler returns an HTML page. That page has no maxBytes, so your own client cannot show the ceiling. Catch it and re-emit JSON: res.status(413).json({ error: "too_large", maxBytes: LIMIT }).
FST_ERR_CTP_BODY_TOO_LARGE. Fastify’s version, returned as {"statusCode":413,"code":"FST_ERR_CTP_BODY_TOO_LARGE","error":"Payload Too Large","message":"Request body is too large"}. It is already JSON, so readLimit parses it happily and finds no maxBytes — add one via bodyLimit and a custom error handler rather than assuming JSON implies a limit.
A 507 that is really a full disk. If Retry-After is absent you cannot tell a tenant quota from an operator incident, and the two want opposite messages. Split them server-side: 507 with error: "quota_exceeded" for the tenant, and 503 with a Retry-After for “our disk is full, we are fixing it” — because that one genuinely is transient, and a 503 keeps it inside the retry path where it belongs.
Verification
Reproduce each hop and confirm the classifier’s verdict:
# 1. Which hop rejects a 200 MB body? Read the headers, ignore the body.
head -c 200M /dev/zero | curl -s -o /dev/null -D - -X PUT --data-binary @- \
-H 'Content-Type: application/octet-stream' \
https://uploads.example.com/api/upload/probe \
| grep -Ei '^(HTTP/|server:|cf-ray:|content-type:|x-amzn-requestid:)'
# 2. Force the application-level 413 by lying about the length (no body sent).
curl -s -o - -D - -X PUT -H 'Content-Length: 999999999' \
-H 'Content-Type: application/octet-stream' \
https://uploads.example.com/api/upload/probe
# 3. Force a 507 against the tenant that is already over quota.
curl -s -D - -o /dev/null -X PUT -H 'X-Tenant-Id: t-over' \
-H 'Content-Length: 4402341478' http://localhost:3000/api/upload/probe
# HTTP/1.1 507 Insufficient Storage
# X-Quota-Used: 50696159232
# X-Quota-Limit: 53687091200
Then assert the verdicts directly — no network needed, because classify only reads a Response:
const nginx413 = new Response("<html>413</html>", {
status: 413,
headers: { server: "nginx/1.24.0", "content-type": "text/html" },
});
const big = await classify(nginx413, {
chunkBytes: 64 * 1024 * 1024, minChunkBytes: 1024 * 1024, attempt: 0, chunked: true,
});
console.assert(big.action === "resize", "chunked 413 should halve, not retry");
const single = await classify(nginx413.clone(), {
chunkBytes: 64 * 1024 * 1024, minChunkBytes: 1024 * 1024, attempt: 0, chunked: false,
});
console.assert(single.action === "user", "single-shot 413 is terminal");
const full = new Response(null, {
status: 507,
headers: { "x-quota-used": "50696159232", "x-quota-limit": "53687091200" },
});
const quota = await classify(full, {
chunkBytes: 4_402_341_478, minChunkBytes: 1024 * 1024, attempt: 0, chunked: true,
});
console.assert(quota.action === "user" && quota.kind === "quota", "507 must not retry");
console.log(quota.action === "user" ? quota.message : "");
// Storage full: 47.2 GB of 50.0 GB used, and this upload needs 4.1 GB more.
The assertion that matters most in CI is the second one: it is the regression test that stops someone folding 413 back into the retryable set the next time an upload “randomly fails”.
Frequently Asked Questions
Is a 413 ever worth retrying?
Only when you change the request. Retrying the identical body against the identical hop is guaranteed to fail again, but halving a chunk and re-sending the same offset is a different request and frequently succeeds, because proxy limits are per request rather than per file. Cap the halving at a floor around 1 MiB so a 413 caused by something else cannot loop forever.
Why do I get a network error instead of a 413 in the browser?
The browser sends the whole body before the rejection arrives, and the proxy usually resets the connection rather than draining the remaining bytes, so fetch rejects with TypeError: Failed to fetch and no Response to inspect. Validate file.size against a published limit before starting the request, since that is the only client-side way to fail fast.
Should a 507 ever be retried automatically?
Only if the server sent a Retry-After, which is it explicitly claiming the condition is temporary. A bare 507 is a quota statement: the deficit is fixed, no delay changes it, and a retry loop re-uploads the same bytes for nothing. Surface used, limit and deficit and let the user delete something or upgrade.
How do I tell a Cloudflare 413 from an origin 413 in production?
Look for cf-ray in the response headers, or server: cloudflare; an origin nginx rejection carries Server: nginx/<version> and appears in your access log, whereas the edge rejection appears in neither your log nor your traces. Remember that cross-origin JavaScript cannot read either header unless the origin lists it in Access-Control-Expose-Headers.
What does S3 return when a part is too large?
400 Bad Request with an XML body containing <Code>EntityTooLarge</Code> and a <MaxSizeAllowed> element — not a 413. Any classifier used against direct-to-storage uploads has to special-case that code, or the most actionable error in the flow gets filed as an unrecoverable bug.