QA signs off on the "edit profile" form. Change your display name, hit save, refresh — the new name is there. Ship it.
Two days later: "I uploaded a new profile picture and it just... didn't change?" You check the network tab. The request fired. Status 200 OK. The server logged a successful update. No red text anywhere. By every signal your tools give you, this worked.
It didn't. And the bug isn't in the upload handler, the server, or the image itself — it's already dead by the time the request leaves the browser.
Guess before you scroll: the file never made it into the request body in the first place. Not corrupted. Not rejected. Just — never there.
This is a pattern you've probably written yourself. FormData reads the form's current values, Object.fromEntries turns that into a plain object, JSON.stringify turns that into a request body. For a name field or an email field, it's exact and correct — the string goes in, the same string comes out the other end.
The in that same form goes in as a File object. And that's where it quietly falls apart.
Real file. Real name. Real size. Everything about it says "this is fine, carry on." So you do — straight into JSON.stringify.
That's the whole bug, in one line. Not an error, not undefined, not the string "[object File]" — an empty JSON object, every single time, for every file, no matter how big or what type. JSON.stringify walks an object's own enumerable properties to build its output. File — and Blob, which it extends — deliberately doesn't expose its data that way. name, size, and type are accessors defined on the prototype, not enumerable data sitting on the instance, and the actual bytes aren't reachable synchronously at all. JSON.stringify finds nothing to walk and does exactly what the spec says: it writes out {}.
The server receives valid JSON, updates the name, sees avatar: {}, probably ignores a field it doesn't recognize the shape of — and returns 200 OK, because as far as it's concerned, nothing went wrong. Nothing did go wrong, downstream of the browser. The bug already happened, silently, on your side of the wire.
The instinct once you find this is: fine, get the file's actual bytes into the JSON some other way. FileReader.readAsDataURL() will happily hand you a base64 string:
This does work — the image genuinely round-trips now. But you've traded a silent bug for three quiet costs that show up later instead of immediately: Base64 inflates the payload by roughly a third. A 3 MB photo becomes a ~4 MB string, because base64 spends 4 characters to encode every 3 bytes. The whole file sits in memory twice — once as the original Blob, once as the decoded string — for as long as the request is in flight. You've reinvented multipart/form-data, badly, using text encoding for something the browser already ships a binary-safe way to send.
None of that fails a test. It just makes uploads slower and heavier in a way nobody notices until someone tries to upload a 20 MB image from their phone.
FormData was never the problem. Converting it into something else was. fetch accepts a FormData object as a body directly:
Two things to notice, both easy to get backwards: Don't set Content-Type yourself. multipart/form-data requests need a boundary value in the header to separate fields, and fetch generates a fresh, unique one for you when it sees a FormData body. Set the header manually and you'll ship it without a boundary, which breaks the request in a way that's genuinely confusing to debug. The file's actual bytes travel this time. No encoding step, no size penalty, no extra copy in memory — the browser streams the binary data as part of the multipart body, the same mechanism a plain HTML form has used since the 90s.
The one real trade-off: your server needs to parse multipart/form-data, not application/json — most frameworks have a one-line answer for this already (Express: multer; Node's built-in http: formidable; plenty of others read it natively).
There's a second gotcha in that first line of code, unrelated to files: Object.fromEntries silently drops duplicate keys, keeping only the last one. Add a checkbox group like repeated three times, check two boxes, and Object.fromEntries(new FormData(form)).topics gives you exactly one string — not the array you'd expect.
FormData itself never had this problem. formData.getAll("topics") returns every checked value, in DOM order, from the start. It's specifically the trip through Object.fromEntries that quietly collapses them — one more reason to hand FormData to fetch as-is instead of reshaping it first.
Any time you're about to call JSON.stringify on something that came out of a , stop for one second and ask what's actually in it. Text fields survive the round trip. Files and repeated-name fields don't — not with an error, just with data quietly missing from the request that already reported success.
