The symptom is specific. Every text field in the submission arrives intact, but the file field is empty. No error, no rejected request, just no bytes. There are two common causes, and they are easy to tell apart.
Cause 1: the form is not multipart
A form's encoding is application/x-www-form-urlencoded by default, and file content cannot be put inside URL parameters. So a file input needs two things: a POST method, because the content has nowhere to go in a URL, and a multipart encoding, because the data has to be split into a part per file.
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="contract" />
<button type="submit">Upload</button>
</form>Cause 2: you set Content-Type yourself
If you submit with fetch, the request needs a boundary string so the server can tell where each part starts and ends. fetch generates that boundary when you hand it a FormData body. Setting the header by hand overrides the generated one, and a literal "multipart/form-data" carries no boundary, so the parser has nothing to split on. Busboy 1.6.0 throws "Multipart: Boundary not found" rather than hand you a file.
const body = new FormData(document.querySelector('form'))
// Wrong: this header has no boundary, so the parser has nothing to split on
await fetch('/upload', {
method: 'POST',
body,
headers: { 'Content-Type': 'multipart/form-data' },
})
// Right: omit the header and let fetch generate it
await fetch('/upload', { method: 'POST', body })You can watch the header change in Node (this ran on v22), without a server running:
const fd = new FormData()
fd.set('contract', new Blob(['hello']), 'contract.pdf')
const auto = new Request('https://example.com/upload', { method: 'POST', body: fd })
const manual = new Request('https://example.com/upload', {
method: 'POST',
body: fd,
headers: { 'Content-Type': 'multipart/form-data' },
})
console.log(auto.headers.get('content-type'))
// multipart/form-data; boundary=----formdata-undici-<random>
console.log(manual.headers.get('content-type'))
// multipart/form-dataTwo things to check on the server
A JSON or urlencoded body parser will not read a multipart body. The body-parser docs say it outright: "This does not handle multipart bodies, due to their complex and typically large nature," and point you at busboy, multiparty, formidable, or multer instead. Each parser "only looks at requests where the Content-Type header matches the type option," so a multipart request passes straight through express.json() untouched.
Then check the request size limit before you rewrite anything. Nginx's client_max_body_size defaults to 1m and returns 413 past that, and your hosting platform likely has a cap of its own. A file that is simply too large fails in a different place than a file that was never encoded, and it is worth ruling out first.
Back to All Questions