JSON.stringify Is Quietly Deleting Your File Uploads
An avatar upload form that passes every test, ships to production, returns 200 OK — and never actually uploads the picture. The bug is one function call that silently turns a File into an empty object, with no error anywhere.

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.
Here's roughly what shipped:
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 <input type="file" name="avatar"> in that same form goes in as a File object. And that's where it quietly falls apart.
Log data.avatar right after that first line and it looks completely normal:
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 {}.
So the request that leaves the browser looks like this:
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-Typeyourself.multipart/form-datarequests need aboundaryvalue in the header to separate fields, andfetchgenerates a fresh, unique one for you when it sees aFormDatabody. 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 <input type="checkbox" name="topics" value="css"> 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.
Runs right in your browser — poke at it and watch the concept react live.
Any time you're about to call JSON.stringify on something that came out of a <form>, 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.
FormData isn't a stepping stone to JSON. For anything with a file in it, it's the destination.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Go check your own upload forms — if you see Object.fromEntries(new FormData(...)) followed anywhere by JSON.stringify, that's worth a five-minute look today. What's the quietest "it returned 200 but didn't actually work" bug you've shipped?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.