An integration works fine in testing and then starts returning 413 in production. The PDFs are under the documented limit. The limit is 6 MB, the file is 5 MB, and the request is rejected anyway.
The missing 33% is Base64.
The arithmetic
Base64 encodes three bytes of input as four printable ASCII characters, padding the final group with = so the output length is always a multiple of four. The encoded length is therefore exactly:
encodedLength = ceil(byteLength / 3) * 4
That is a 33.3% increase, with no compression, tuning or encoder choice that changes it. It is a property of the alphabet: 64 symbols carry 6 bits each, and 6 bits per character against 8 bits per byte is a ratio of 4 to 3.
| PDF size | Base64 length | | --- | --- | | 45 KB | 60 KB | | 250 KB | 334 KB | | 1.2 MB | 1.6 MB | | 5 MB | 6.7 MB | | 8 MB | 10.7 MB |
And that is before the JSON wrapper. A Base64 string inside a JSON document costs two more bytes for the quotes, plus whatever your field names and structure add, plus escaping if you did something unwise like embedding newlines.
The limits it breaks
Work backwards. To find the largest PDF that fits a given request-body ceiling, divide by four and multiply by three:
maxPdfBytes = floor(limitBytes / 4) * 3
- Vercel serverless function request body — 4.5 MB. Largest PDF: about 3.4 MB.
- AWS Lambda synchronous invoke payload — 6 MB. Largest PDF: about 4.5 MB.
- Amazon API Gateway payload — 10 MB. Largest PDF: about 7.5 MB.
The pattern that bites people is that these are *hard* platform limits, not application configuration. You cannot raise them from your code. A 413 from the platform never reaches your handler, so your logging shows nothing and your error handling never runs.
Why the failure is confusing
Three things conspire.
It passes in testing. Test fixtures are small. A one-page generated invoice is 40 KB; a customer's scanned twelve-page contract is 6 MB. The difference only appears with real documents.
The error surfaces in the wrong place. The gateway rejects the request before your function starts, so your application logs are clean and the failure looks like a client or network problem.
Compression does not save you. A PDF is already compressed internally — its streams are Flate-encoded. Gzipping a Base64-encoded PDF recovers most of the encoding overhead in transit, which makes people think the problem is solved, but many platforms measure the limit against the decoded body size. And if the PDF contains JPEG images, gzip achieves almost nothing on them anyway.
What to do instead
Send it as multipart/form-data
The obvious answer and usually the right one. Multipart transmits the bytes as bytes, so there is no 33% penalty at all, and every HTTP stack and platform supports it.
const form = new FormData()
form.append('document', file, file.name)
form.append('reference', 'INV-2026-0418')
await fetch('/api/documents', { method: 'POST', body: form })
Use a pre-signed upload URL
Better still for anything large: have your API return a short-lived URL, let the client upload directly to object storage, and send only the resulting key through your API. The document never passes through your function, so the body limit stops mattering, and you stop paying compute time to shuttle bytes.
const { uploadUrl, key } = await (await fetch('/api/uploads', { method: 'POST' })).json()
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/pdf' } })
await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, reference: 'INV-2026-0418' }),
})
Keep Base64, but only where it earns its place
Base64 is genuinely the right answer in three situations:
- The transport is JSON-only and you do not control it.
- The document is part of a payload that is signed or hashed as one unit, so it cannot be split across a separate upload.
- You need a self-contained fixture, sample or test case with no file storage attached.
In all three, keep the documents small and check the size before you send.
Checking the size before you send
const MAX_BODY_BYTES = 4.5 * 1024 * 1024
function willFit(file) {
const encoded = Math.ceil(file.size / 3) * 4
// Leave room for the JSON envelope around it.
return encoded + 512 < MAX_BODY_BYTES
}
Doing this on the client turns an opaque 413 into a message you can write yourself: "This file is 6.2 MB. Files above 3.4 MB have to be uploaded directly — use the upload button."
Line wrapping, briefly
Base64 in a MIME body is conventionally wrapped at 76 characters (RFC 2045); PEM-style blocks wrap at 64. Wrapping adds one byte per line, which is under 2% and rarely the deciding factor.
What matters more is that a newline inside a data URI, a JSON string value, an HTML attribute or a CSS url() breaks the parser reading it. Wrap for MIME and PEM; never wrap for anything else. Our PDF to Base64 converter offers 76- and 64-character wrapping for raw output only, and tells you when it has ignored the setting because the format you chose needs one unbroken run.
The one-line summary
Base64 costs a third. If the file is small, pay it and move on. If the file is large, do not — send bytes as bytes, and let the document go straight to storage.
Related: PDF to Base64 encodes locally in nine output shapes, Base64 to PDF decodes a string back into a file, and How to Return a PDF in a JSON API Response covers the API design side of the same problem.