[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-formdata-file-upload-json-stringify-trap":44,"search-suggestions":60,"quiz-article-formdata-file-upload-json-stringify-trap":107},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a0b426-705a-77fc-9c7f-a33c5b694b54","formdata-file-upload-json-stringify-trap","PRACTICE_QUIZ","FormData vs JSON.stringify — the file-eating trap","Eight questions on why JSON.stringify quietly turns a File into {}, why FormData never had that problem, and how to send a form with a file the way the platform already supports.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,100,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",111,{"slug":66,"name":67,"articles":68},"javascript","Javascript",93,{"slug":70,"name":71,"articles":72},"frontend","Frontend",73,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",39,{"slug":78,"name":79,"articles":80},"css","Css",36,{"slug":82,"name":83,"articles":84},"typescript","Typescript",16,{"slug":86,"name":87,"articles":88},"performance","Performance",14,{"slug":90,"name":91,"articles":92},"react","React",13,{"slug":94,"name":95,"articles":96},"browser","Browser",10,{"slug":98,"name":99,"articles":96},"node","Node",{"slug":101,"name":102,"articles":51},"html","Html",{"slug":104,"name":105,"articles":106},"accessibility","Accessibility",7,{"id":108,"slug":46,"title":109,"subtitle":52,"excerpt":110,"coverUrl":111,"locale":13,"readingMinutes":112,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":123,"_count":128,"playground":130,"body":131,"bodyMd":309,"seo":310,"translationGroupId":313,"series":52,"podcastUrl":52,"verticalId":5,"thread":314,"assessments":316,"translations":319,"quiz":321},"01a0b426-701c-74eb-b94f-6cad6f72a710","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.","\u002Fmedia\u002Fcovers\u002Fformdata-file-upload-json-stringify-trap.png",5,"2026-09-26T12:24:36.794Z",46,{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":122},"JavaScript",[124,125,126,127],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":74,"name":75,"color":52},{"slug":101,"name":102,"color":52},{"assessments":129},1,{"slug":46,"title":48},{"blocks":132,"version":129},[133,137,140,143,146,150,153,159,162,165,169,172,176,179,183,186,189,194,197,201,204,208,211,218,221,224,227,231,234,239,242,246,249,252,255,258,261,264,267,270,273,276,279,282,285,288,291,294,297,300,303],{"id":134,"html":135,"type":136},"b1","\u003Cp>QA signs off on the &quot;edit profile&quot; form. Change your display name, hit save, refresh — the new name is there. Ship it.\u003C\u002Fp>","paragraph",{"id":138,"html":139,"type":136},"b2","\u003Cp>Two days later: &quot;I uploaded a new profile picture and it just... didn&#39;t change?&quot; You check the network tab. The request fired. Status \u003Ccode>200 OK\u003C\u002Fcode>. The server logged a successful update. No red text anywhere. By every signal your tools give you, this worked.\u003C\u002Fp>",{"id":141,"html":142,"type":136},"b3","\u003Cp>It didn&#39;t. And the bug isn&#39;t in the upload handler, the server, or the image itself — it&#39;s already dead by the time the request leaves the browser.\u003C\u002Fp>",{"id":144,"html":145,"type":136},"b4","\u003Cp>\u003Cstrong>Guess before you scroll:\u003C\u002Fstrong> the file never made it into the request body in the first place. Not corrupted. Not rejected. Just — never there.\u003C\u002Fp>",{"id":147,"html":148,"text":148,"type":149,"level":31},"b5","The code that looks completely fine","heading",{"id":151,"html":152,"type":136},"b6","\u003Cp>Here&#39;s roughly what shipped:\u003C\u002Fp>",{"id":154,"code":155,"type":156,"language":157,"highlight":158},"b7","form.addEventListener(\"submit\", async (e) => {\n  e.preventDefault();\n  const data = Object.fromEntries(new FormData(form));\n  await fetch(\"\u002Fapi\u002Fprofile\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application\u002Fjson\" },\n    body: JSON.stringify(data),\n  });\n});","code","js",[],{"id":160,"html":161,"type":136},"b8","\u003Cp>This is a pattern you&#39;ve probably written yourself. \u003Ccode>FormData\u003C\u002Fcode> reads the form&#39;s current values, \u003Ccode>Object.fromEntries\u003C\u002Fcode> turns that into a plain object, \u003Ccode>JSON.stringify\u003C\u002Fcode> turns \u003Cem>that\u003C\u002Fem> into a request body. For a \u003Ccode>name\u003C\u002Fcode> field or an \u003Ccode>email\u003C\u002Fcode> field, it&#39;s exact and correct — the string goes in, the same string comes out the other end.\u003C\u002Fp>",{"id":163,"html":164,"type":136},"b9","\u003Cp>The \u003Ccode>&lt;input type=&quot;file&quot; name=&quot;avatar&quot;&gt;\u003C\u002Fcode> in that same form goes in as a \u003Ccode>File\u003C\u002Fcode> object. And that&#39;s where it quietly falls apart.\u003C\u002Fp>",{"id":166,"html":167,"text":168,"type":149,"level":31},"b10","What \u003Ccode>Object.fromEntries\u003C\u002Fcode> actually hands you","What Object.fromEntries actually hands you",{"id":170,"html":171,"type":136},"b11","\u003Cp>Log \u003Ccode>data.avatar\u003C\u002Fcode> right after that first line and it looks completely normal:\u003C\u002Fp>",{"id":173,"code":174,"type":156,"language":157,"highlight":175},"b12","console.log(data.avatar);\n\u002F\u002F File { name: \"sunset.jpg\", size: 482113, type: \"image\u002Fjpeg\" }",[],{"id":177,"html":178,"type":136},"b13","\u003Cp>Real file. Real name. Real size. Everything about it says &quot;this is fine, carry on.&quot; So you do — straight into \u003Ccode>JSON.stringify\u003C\u002Fcode>.\u003C\u002Fp>",{"id":180,"code":181,"type":156,"language":157,"highlight":182},"b14","console.log(JSON.stringify(data.avatar));\n\u002F\u002F \"{}\"",[],{"id":184,"html":185,"type":136},"b15","\u003Cp>\u003Cstrong>That&#39;s the whole bug, in one line.\u003C\u002Fstrong> Not an error, not \u003Ccode>undefined\u003C\u002Fcode>, not the string \u003Ccode>&quot;[object File]&quot;\u003C\u002Fcode> — an empty JSON object, every single time, for every file, no matter how big or what type. \u003Ccode>JSON.stringify\u003C\u002Fcode> walks an object&#39;s own enumerable properties to build its output. \u003Ccode>File\u003C\u002Fcode> — and \u003Ccode>Blob\u003C\u002Fcode>, which it extends — deliberately doesn&#39;t expose its data that way. \u003Ccode>name\u003C\u002Fcode>, \u003Ccode>size\u003C\u002Fcode>, and \u003Ccode>type\u003C\u002Fcode> are accessors defined on the prototype, not enumerable data sitting on the instance, and the actual bytes aren&#39;t reachable synchronously at all. \u003Ccode>JSON.stringify\u003C\u002Fcode> finds nothing to walk and does exactly what the spec says: it writes out \u003Ccode>{}\u003C\u002Fcode>.\u003C\u002Fp>",{"id":187,"html":188,"type":136},"b16","\u003Cp>So the request that leaves the browser looks like this:\u003C\u002Fp>",{"id":190,"code":191,"type":156,"language":192,"highlight":193},"b17","{ \"displayName\": \"Alex Chen\", \"avatar\": {} }","json",[],{"id":195,"html":196,"type":136},"b18","\u003Cp>The server receives valid JSON, updates the name, sees \u003Ccode>avatar: {}\u003C\u002Fcode>, probably ignores a field it doesn&#39;t recognize the shape of — and returns \u003Ccode>200 OK\u003C\u002Fcode>, because as far as it&#39;s concerned, nothing went wrong. Nothing \u003Cem>did\u003C\u002Fem> go wrong, downstream of the browser. The bug already happened, silently, on your side of the wire.\u003C\u002Fp>",{"id":198,"html":199,"text":200,"type":149,"level":31},"b19","The &quot;fix&quot; that makes it worse","The \"fix\" that makes it worse",{"id":202,"html":203,"type":136},"b20","\u003Cp>The instinct once you find this is: fine, get the file&#39;s actual bytes into the JSON some other way. \u003Ccode>FileReader.readAsDataURL()\u003C\u002Fcode> will happily hand you a base64 string:\u003C\u002Fp>",{"id":205,"code":206,"type":156,"language":157,"highlight":207},"b21","const toBase64 = (file) =>\n  new Promise((resolve) => {\n    const reader = new FileReader();\n    reader.onload = () => resolve(reader.result);\n    reader.readAsDataURL(file);\n  });\n\ndata.avatar = await toBase64(data.avatar); \u002F\u002F now it's a string!\nbody: JSON.stringify(data);",[],{"id":209,"html":210,"type":136},"b22","\u003Cp>This does work — the image genuinely round-trips now. But you&#39;ve traded a silent bug for three quiet costs that show up later instead of immediately:\u003C\u002Fp>",{"id":212,"type":213,"items":214,"ordered":18},"b23","list",[215,216,217],"\u003Cstrong>Base64 inflates the payload by roughly a third.\u003C\u002Fstrong> A 3 MB photo becomes a ~4 MB string, because base64 spends 4 characters to encode every 3 bytes.","\u003Cstrong>The whole file sits in memory twice\u003C\u002Fstrong> — once as the original \u003Ccode>Blob\u003C\u002Fcode>, once as the decoded string — for as long as the request is in flight.","\u003Cstrong>You&#39;ve reinvented \u003Ccode>multipart\u002Fform-data\u003C\u002Fcode>\u003C\u002Fstrong>, badly, using text encoding for something the browser already ships a binary-safe way to send.",{"id":219,"html":220,"type":136},"b24","\u003Cp>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.\u003C\u002Fp>",{"id":222,"html":223,"text":223,"type":149,"level":31},"b25","The actual fix: stop converting to JSON",{"id":225,"html":226,"type":136},"b26","\u003Cp>\u003Ccode>FormData\u003C\u002Fcode> was never the problem. Converting it into something else was. \u003Ccode>fetch\u003C\u002Fcode> accepts a \u003Ccode>FormData\u003C\u002Fcode> object as a body directly:\u003C\u002Fp>",{"id":228,"code":229,"type":156,"language":157,"highlight":230},"b27","form.addEventListener(\"submit\", async (e) => {\n  e.preventDefault();\n  const data = new FormData(form); \u002F\u002F don't unwrap it\n  await fetch(\"\u002Fapi\u002Fprofile\", {\n    method: \"PUT\",\n    body: data, \u002F\u002F no headers, no JSON.stringify\n  });\n});",[],{"id":232,"html":233,"type":136},"b28","\u003Cp>Two things to notice, both easy to get backwards:\u003C\u002Fp>",{"id":235,"type":213,"items":236,"ordered":17},"b29",[237,238],"\u003Cstrong>Don&#39;t set \u003Ccode>Content-Type\u003C\u002Fcode> yourself.\u003C\u002Fstrong> \u003Ccode>multipart\u002Fform-data\u003C\u002Fcode> requests need a \u003Ccode>boundary\u003C\u002Fcode> value in the header to separate fields, and \u003Ccode>fetch\u003C\u002Fcode> generates a fresh, unique one for you when it sees a \u003Ccode>FormData\u003C\u002Fcode> body. Set the header manually and you&#39;ll ship it \u003Cem>without\u003C\u002Fem> a boundary, which breaks the request in a way that&#39;s genuinely confusing to debug.","\u003Cstrong>The file&#39;s actual bytes travel this time.\u003C\u002Fstrong> 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.",{"id":240,"html":241,"type":136},"b30","\u003Cp>The one real trade-off: your server needs to parse \u003Ccode>multipart\u002Fform-data\u003C\u002Fcode>, not \u003Ccode>application\u002Fjson\u003C\u002Fcode> — most frameworks have a one-line answer for this already (Express: \u003Ccode>multer\u003C\u002Fcode>; Node&#39;s built-in \u003Ccode>http\u003C\u002Fcode>: \u003Ccode>formidable\u003C\u002Fcode>; plenty of others read it natively).\u003C\u002Fp>",{"id":243,"html":244,"text":245,"type":149,"level":31},"b31","One more thing \u003Ccode>Object.fromEntries\u003C\u002Fcode> was hiding","One more thing Object.fromEntries was hiding",{"id":247,"html":248,"type":136},"b32","\u003Cp>There&#39;s a second gotcha in that first line of code, unrelated to files: \u003Ccode>Object.fromEntries\u003C\u002Fcode> silently drops duplicate keys, keeping only the last one. Add a checkbox group like \u003Ccode>&lt;input type=&quot;checkbox&quot; name=&quot;topics&quot; value=&quot;css&quot;&gt;\u003C\u002Fcode> repeated three times, check two boxes, and \u003Ccode>Object.fromEntries(new FormData(form)).topics\u003C\u002Fcode> gives you exactly one string — not the array you&#39;d expect.\u003C\u002Fp>",{"id":250,"html":251,"type":136},"b33","\u003Cp>\u003Ccode>FormData\u003C\u002Fcode> itself never had this problem. \u003Ccode>formData.getAll(&quot;topics&quot;)\u003C\u002Fcode> returns every checked value, in DOM order, from the start. It&#39;s specifically the trip through \u003Ccode>Object.fromEntries\u003C\u002Fcode> that quietly collapses them — one more reason to hand \u003Ccode>FormData\u003C\u002Fcode> to \u003Ccode>fetch\u003C\u002Fcode> as-is instead of reshaping it first.\u003C\u002Fp>",{"id":253,"html":254,"type":136},"b34","\u003C!-- playground:start -->",{"id":256,"html":257,"text":257,"type":149,"level":31},"b35","🎮 Try it yourself",{"id":259,"html":260,"type":136},"b36","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":262,"html":263,"type":136},"b37","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":265,"html":266,"type":136},"b38","\u003C!-- playground:end -->",{"id":268,"html":269,"text":269,"type":149,"level":31},"b39","The lesson",{"id":271,"html":272,"type":136},"b40","\u003Cp>Any time you&#39;re about to call \u003Ccode>JSON.stringify\u003C\u002Fcode> on something that came out of a \u003Ccode>&lt;form&gt;\u003C\u002Fcode>, stop for one second and ask what&#39;s actually in it. Text fields survive the round trip. Files and repeated-name fields don&#39;t — not with an error, just with data quietly missing from the request that already reported success.\u003C\u002Fp>",{"id":274,"html":275,"type":136},"b41","\u003Cp>\u003Ccode>FormData\u003C\u002Fcode> isn&#39;t a stepping stone to JSON. For anything with a file in it, it&#39;s the destination.\u003C\u002Fp>",{"id":277,"html":278,"type":136},"b42","\u003C!-- quiz:start -->",{"id":280,"html":281,"text":281,"type":149,"level":31},"b43","🧠 Test yourself",{"id":283,"html":284,"type":136},"b44","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":286,"html":287,"type":136},"b45","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":289,"html":290,"type":136},"b46","\u003C!-- quiz:end -->",{"id":292,"html":293,"type":136},"b47","\u003Cp>Go check your own upload forms — if you see \u003Ccode>Object.fromEntries(new FormData(...))\u003C\u002Fcode> followed anywhere by \u003Ccode>JSON.stringify\u003C\u002Fcode>, that&#39;s worth a five-minute look today. What&#39;s the quietest &quot;it returned 200 but didn&#39;t actually work&quot; bug you&#39;ve shipped?\u003C\u002Fp>",{"id":295,"type":296},"b48","divider",{"id":298,"html":299,"type":136},"b49","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":301,"html":302,"type":136},"b50","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":304,"type":213,"items":305,"ordered":18},"b51",[306,307,308],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","QA signs off on the \"edit profile\" form. Change your display name, hit save, refresh — the new name is there. Ship it.\n\nTwo 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.\n\nIt 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.\n\n**Guess before you scroll:** the file never made it into the request body in the first place. Not corrupted. Not rejected. Just — never there.\n\n## The code that looks completely fine\n\nHere's roughly what shipped:\n\n```js\nform.addEventListener(\"submit\", async (e) => {\n  e.preventDefault();\n  const data = Object.fromEntries(new FormData(form));\n  await fetch(\"\u002Fapi\u002Fprofile\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application\u002Fjson\" },\n    body: JSON.stringify(data),\n  });\n});\n```\n\nThis 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.\n\nThe `\u003Cinput type=\"file\" name=\"avatar\">` in that same form goes in as a `File` object. And that's where it quietly falls apart.\n\n## What `Object.fromEntries` actually hands you\n\nLog `data.avatar` right after that first line and it looks completely normal:\n\n```js\nconsole.log(data.avatar);\n\u002F\u002F File { name: \"sunset.jpg\", size: 482113, type: \"image\u002Fjpeg\" }\n```\n\nReal file. Real name. Real size. Everything about it says \"this is fine, carry on.\" So you do — straight into `JSON.stringify`.\n\n```js\nconsole.log(JSON.stringify(data.avatar));\n\u002F\u002F \"{}\"\n```\n\n**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 `{}`.\n\nSo the request that leaves the browser looks like this:\n\n```json\n{ \"displayName\": \"Alex Chen\", \"avatar\": {} }\n```\n\nThe 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.\n\n## The \"fix\" that makes it worse\n\nThe 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:\n\n```js\nconst toBase64 = (file) =>\n  new Promise((resolve) => {\n    const reader = new FileReader();\n    reader.onload = () => resolve(reader.result);\n    reader.readAsDataURL(file);\n  });\n\ndata.avatar = await toBase64(data.avatar); \u002F\u002F now it's a string!\nbody: JSON.stringify(data);\n```\n\nThis 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:\n\n- **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.\n- **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.\n- **You've reinvented `multipart\u002Fform-data`**, badly, using text encoding for something the browser already ships a binary-safe way to send.\n\nNone 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.\n\n## The actual fix: stop converting to JSON\n\n`FormData` was never the problem. Converting it into something else was. `fetch` accepts a `FormData` object as a body directly:\n\n```js\nform.addEventListener(\"submit\", async (e) => {\n  e.preventDefault();\n  const data = new FormData(form); \u002F\u002F don't unwrap it\n  await fetch(\"\u002Fapi\u002Fprofile\", {\n    method: \"PUT\",\n    body: data, \u002F\u002F no headers, no JSON.stringify\n  });\n});\n```\n\nTwo things to notice, both easy to get backwards:\n\n1. **Don't set `Content-Type` yourself.** `multipart\u002Fform-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.\n2. **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.\n\nThe one real trade-off: your server needs to parse `multipart\u002Fform-data`, not `application\u002Fjson` — most frameworks have a one-line answer for this already (Express: `multer`; Node's built-in `http`: `formidable`; plenty of others read it natively).\n\n## One more thing `Object.fromEntries` was hiding\n\nThere'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 `\u003Cinput 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.\n\n`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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The lesson\n\nAny time you're about to call `JSON.stringify` on something that came out of a `\u003Cform>`, 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.\n\n`FormData` isn't a stepping stone to JSON. For anything with a file in it, it's the destination.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nGo 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?\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":109,"canonical":311,"description":312},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap","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 ","01a0b426-701c-74eb-b94f-70c9a27741dd",{"id":315,"locked":18},"01a0b426-703f-71b8-a1f9-824891608455",[317],{"id":45,"slug":46,"title":48,"_count":318},{"questions":51},[320],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":322,"questionCount":51},{"questions":51}]