[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-file-system-access-api-real-file-read-write":44,"search-suggestions":60,"quiz-article-file-system-access-api-real-file-read-write":108},[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},"01a0ec72-f865-70f7-8914-c0b62acc3aa1","file-system-access-api-real-file-read-write","PRACTICE_QUIZ","File System Access API: real files, real writes","Eight questions on why the classic download-link \"save\" can't overwrite a file, how showOpenFilePicker and createWritable actually work, and which browsers support any of it.",{"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,101,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",114,{"slug":66,"name":67,"articles":68},"javascript","Javascript",96,{"slug":70,"name":71,"articles":72},"frontend","Frontend",75,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",41,{"slug":78,"name":79,"articles":80},"css","Css",36,{"slug":82,"name":83,"articles":84},"typescript","Typescript",17,{"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",11,{"slug":98,"name":99,"articles":100},"node","Node",10,{"slug":102,"name":103,"articles":51},"html","Html",{"slug":105,"name":106,"articles":107},"accessibility","Accessibility",7,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":107,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":123,"_count":128,"playground":130,"body":132,"bodyMd":309,"seo":310,"translationGroupId":313,"series":52,"podcastUrl":52,"verticalId":5,"thread":314,"assessments":316,"translations":319,"quiz":321},"01a0ec72-f3f2-7568-9098-75685d817739","Your 'Save' Button Makes Copies. The File System Access API Doesn't.","The classic web 'save' — a Blob and an \u003Ca download> link — doesn't overwrite a file, it manufactures a new one every time, leaving notes.md, notes (1).md, notes (2).md scattered in Downloads. The File System Access API fixes this with real, permissioned read-write access to a file on disk. Here's the difference, and why it's Chromium-only progressive enhancement, not a drop-in replacement.","\u002Fmedia\u002Fcovers\u002Ffile-system-access-api-real-file-read-write.png","2026-09-29T12:10:22.219Z",76,{"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":94,"name":95,"color":52},{"slug":70,"name":71,"color":52},{"assessments":129},1,{"slug":46,"title":131},"File System Access API — same file, no duplicates",{"blocks":133,"version":129},[134,138,141,145,148,154,157,160,163,166,169,172,175,178,181,184,188,191,195,198,201,205,208,211,215,218,224,227,230,233,239,242,245,248,252,255,258,261,264,267,270,273,276,279,282,285,291,294,297,300,303],{"id":135,"html":136,"type":137},"b1","\u003Cp>You&#39;re building a little in-browser markdown editor. &quot;Save&quot; is one line: turn the textarea into a Blob, wrap it in an \u003Ccode>&lt;a download&gt;\u003C\u002Fcode>, click it programmatically. Ship it, feels great in your own testing — you save once, done.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>A real user opens \u003Ccode>notes.md\u003C\u002Fcode>, edits it, hits Save. Then edits it again, hits Save again. Then again. They go looking for their notes later and their Downloads folder has \u003Ccode>notes.md\u003C\u002Fcode>, \u003Ccode>notes (1).md\u003C\u002Fcode>, \u003Ccode>notes (2).md\u003C\u002Fcode>, \u003Ccode>notes (3).md\u003C\u002Fcode> — four files, three of them stale, and no indication which one is the one they actually want. They didn&#39;t do anything wrong. Your &quot;Save&quot; button was never a save button. It was a &quot;make a new file&quot; button that happened to reuse a familiar icon.\u003C\u002Fp>",{"id":142,"html":143,"text":143,"type":144,"level":31},"b3","The wrong way, and why it feels right","heading",{"id":146,"html":147,"type":137},"b4","\u003Cp>Here&#39;s the pattern, and it&#39;s everywhere — CodeSandbox-style playgrounds, browser-based note apps, config generators, anything that needs to hand the user a file:\u003C\u002Fp>",{"id":149,"code":150,"type":151,"language":152,"highlight":153},"b5","function saveTheOldWay(text, filename) {\n  const blob = new Blob([text], { type: \"text\u002Fplain\" });\n  const url = URL.createObjectURL(blob);\n  const a = document.createElement(\"a\");\n  a.href = url;\n  a.download = filename;\n  a.click();\n  URL.revokeObjectURL(url);\n}","code","js",[],{"id":155,"html":156,"type":137},"b6","\u003Cp>It works, in the sense that a file lands on disk. But look at what it actually is: \u003Ccode>&lt;a download&gt;\u003C\u002Fcode> triggers the browser&#39;s download pipeline, and the download pipeline has exactly one job — write a new file, and if the name collides, rename it so it doesn&#39;t clobber anything. That collision-avoidance is a deliberate safety feature of downloads in general (you don&#39;t want a sketchy site silently overwriting your existing files), and it&#39;s exactly the behavior you don&#39;t want from a save button. There is no API call in that snippet that means &quot;update the file the user already has open.&quot; There&#39;s no \u003Cem>handle\u003C\u002Fem> to the original file at all — just a one-shot blob with a suggested name.\u003C\u002Fp>",{"id":158,"html":159,"type":137},"b7","\u003Cp>Try it below and watch a simulated Downloads folder fill up — every click is a new file, even though it looks and feels like &quot;saving.&quot;\u003C\u002Fp>",{"id":161,"html":162,"type":137},"b8","\u003C!-- playground:start -->",{"id":164,"html":165,"text":165,"type":144,"level":31},"b9","🎮 Try it yourself",{"id":167,"html":168,"type":137},"b10","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":170,"html":171,"type":137},"b11","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":173,"html":174,"type":137},"b12","\u003C!-- playground:end -->",{"id":176,"html":177,"text":177,"type":144,"level":31},"b13","What was actually missing: a handle",{"id":179,"html":180,"type":137},"b14","\u003Cp>The gap isn&#39;t in your code, it&#39;s in the platform. A regular \u003Ccode>&lt;input type=&quot;file&quot;&gt;\u003C\u002Fcode> gives you a \u003Ccode>File\u003C\u002Fcode> object — bytes, a name, a size — but reading it doesn&#39;t hand you any way to write back to that same path. Browsers spent two decades treating &quot;the filesystem&quot; as something a web page should never be allowed to touch, for good reason: letting arbitrary sites read or overwrite files by name would be catastrophic. So \u003Ccode>&lt;input type=&quot;file&quot;&gt;\u003C\u002Fcode> and \u003Ccode>&lt;a download&gt;\u003C\u002Fcode> were built to avoid ever creating that connection.\u003C\u002Fp>",{"id":182,"html":183,"type":137},"b15","\u003Cp>The \u003Cstrong>File System Access API\u003C\u002Fstrong> is what closes that gap, deliberately and with permission gates at every step. \u003Ccode>window.showOpenFilePicker()\u003C\u002Fcode> doesn&#39;t just return a file&#39;s contents — it returns a \u003Ccode>FileSystemFileHandle\u003C\u002Fcode>, a persistent reference to that specific file that your page can ask to write to later:\u003C\u002Fp>",{"id":185,"code":186,"type":151,"language":152,"highlight":187},"b16","async function openForEditing() {\n  const [handle] = await window.showOpenFilePicker({\n    types: [{ description: \"Markdown\", accept: { \"text\u002Fmarkdown\": [\".md\"] } }],\n  });\n  const file = await handle.getFile();\n  const text = await file.text();\n  return { handle, text };\n}",[],{"id":189,"html":190,"type":137},"b17","\u003Cp>Saving back is where the difference actually shows up. You write through the \u003Cem>same handle\u003C\u002Fem> you opened:\u003C\u002Fp>",{"id":192,"code":193,"type":151,"language":152,"highlight":194},"b18","async function saveToHandle(handle, text) {\n  const writable = await handle.createWritable();\n  await writable.write(text);\n  await writable.close();\n}",[],{"id":196,"html":197,"type":137},"b19","\u003Cp>No new filename, no collision to resolve, no \u003Ccode>(1)\u003C\u002Fcode> appended anywhere. \u003Ccode>createWritable()\u003C\u002Fcode> opens a stream to the exact file the handle points at; \u003Ccode>write()\u003C\u002Fcode> sends the new bytes; \u003Ccode>close()\u003C\u002Fcode> commits them — until then the new bytes go to a temporary file (it starts empty, since \u003Ccode>keepExistingData\u003C\u002Fcode> defaults to \u003Ccode>false\u003C\u002Fcode>), and the original is only replaced when the stream closes. Same file, every time — which is the one property a save button actually needs and the download link structurally cannot provide.\u003C\u002Fp>",{"id":199,"html":200,"type":137},"b20","\u003Cp>That&#39;s not a toy example above — it&#39;s real. Try the second panel in the playground: pick an actual file from your machine, edit it, hit Save (Chrome asks once whether the site may save changes — that&#39;s the permission gate), then check the file on disk. It changed. Same file, same name, no sibling copies.\u003C\u002Fp>",{"id":202,"html":203,"text":204,"type":144,"level":31},"b21","The part that has to stay honest: this is Chromium, not &quot;the web&quot;","The part that has to stay honest: this is Chromium, not \"the web\"",{"id":206,"html":207,"type":137},"b22","\u003Cp>This is the part every &quot;just switch to the File System Access API&quot; take glosses over: \u003Cstrong>Firefox and Safari don&#39;t implement the part that matters here — the file pickers.\u003C\u002Fstrong> They do ship the handle-and-stream half (\u003Ccode>FileSystemFileHandle\u003C\u002Fcode>, \u003Ccode>getFile()\u003C\u002Fcode>, and since Safari 26 in September 2025, \u003Ccode>createWritable()\u003C\u002Fcode>), but only for the sandboxed origin private file system (\u003Ccode>navigator.storage.getDirectory()\u003C\u002Fcode>), which never touches the user&#39;s real files. The half that hands a page an actual file — \u003Ccode>showOpenFilePicker()\u003C\u002Fcode>, \u003Ccode>showSaveFilePicker()\u003C\u002Fcode>, \u003Ccode>showDirectoryPicker()\u003C\u002Fcode> — isn&#39;t &quot;not yet, check back next release&quot;: WebKit&#39;s standards position is &quot;oppose&quot; on security grounds, and Mozilla&#39;s is &quot;negative&quot;. Chrome, Edge, Opera and most other Chromium browsers support the pickers (Brave ships them switched off); that&#39;s the whole list.\u003C\u002Fp>",{"id":209,"html":210,"type":137},"b23","\u003Cp>Feature-detect before you touch it, every time:\u003C\u002Fp>",{"id":212,"code":213,"type":151,"language":152,"highlight":214},"b24","if (\"showOpenFilePicker\" in window) {\n  \u002F\u002F real file handles, real overwrite-in-place\n} else {\n  \u002F\u002F fall back to the Blob + \u003Ca download> approach from above\n}",[],{"id":216,"html":217,"type":137},"b25","\u003Cp>Two more constraints worth knowing before you reach for it, both there on purpose:\u003C\u002Fp>",{"id":219,"type":220,"items":221,"ordered":18},"b26","list",[222,223],"\u003Cstrong>It needs a secure context\u003C\u002Fstrong> (HTTPS or \u003Ccode>localhost\u003C\u002Fcode>) — same rule as most powerful browser APIs.","\u003Cstrong>It needs a real user gesture.\u003C\u002Fstrong> You can&#39;t call \u003Ccode>showOpenFilePicker()\u003C\u002Fcode> on page load or from a timer with no recent click; it needs transient user activation from a click or keypress (and it&#39;s refused outright inside a cross-origin iframe), the same restriction that stops a page from silently spawning a file dialog the moment you land on it.",{"id":225,"html":226,"type":137},"b27","\u003Cp>Neither is a bug to work around — they&#39;re the reason this API was allowed to exist at all. A picker that could open without a click, or run over plain HTTP, would just be the file-access catastrophe browsers spent years avoiding, with better ergonomics.\u003C\u002Fp>",{"id":228,"html":229,"text":229,"type":144,"level":31},"b28","So when is the download link still the right call?",{"id":231,"html":232,"type":137},"b29","\u003Cp>Don&#39;t treat this as &quot;the File System Access API is strictly better, migrate everything.&quot; The download link is still correct when:\u003C\u002Fp>",{"id":234,"type":220,"items":235,"ordered":18},"b30",[236,237,238],"It&#39;s a one-off export that will never be re-saved — a generated report, a chart image, a zip.","You need it to work in Firefox or Safari without a degraded experience — plenty of products can&#39;t accept &quot;20% of visitors get worse behavior.&quot;","The mental model really is &quot;produce a new file,&quot; like exporting a CSV snapshot of current state.",{"id":240,"html":241,"type":137},"b31","\u003Cp>The File System Access API earns its place specifically when the mental model is \u003Cem>edit and re-save an existing file\u003C\u002Fem> — editors, config tools, anything where &quot;Save&quot; should mean &quot;update what&#39;s already there,&quot; not &quot;hand me a fresh copy and I&#39;ll sort out the mess myself.&quot;\u003C\u002Fp>",{"id":243,"html":244,"text":244,"type":144,"level":31},"b32","The fix for the notes app",{"id":246,"html":247,"type":137},"b33","\u003Cp>Back to the markdown editor. The real fix isn&#39;t switching APIs wholesale — it&#39;s keeping a handle around once you have one, and only falling back to the download link when you don&#39;t:\u003C\u002Fp>",{"id":249,"code":250,"type":151,"language":152,"highlight":251},"b34","let currentHandle = null;\n\nasync function save(text) {\n  if (currentHandle) {\n    await saveToHandle(currentHandle, text); \u002F\u002F overwrite in place\n    return;\n  }\n  if (\"showSaveFilePicker\" in window) {\n    currentHandle = await window.showSaveFilePicker({\n      suggestedName: \"notes.md\",\n    });\n    await saveToHandle(currentHandle, text);\n    return;\n  }\n  saveTheOldWay(text, \"notes.md\"); \u002F\u002F Firefox\u002FSafari: still a new file each time\n}",[],{"id":253,"html":254,"type":137},"b35","\u003Cp>First save in a supporting browser prompts once, gets a handle, and keeps it. Every save after that writes to the same file with no dialog and no duplicate. Users on Firefox or Safari get the old, honest-about-its-limits download behavior — worse, but not broken, and not lied to about what &quot;Save&quot; means.\u003C\u002Fp>",{"id":256,"html":257,"type":137},"b36","\u003C!-- quiz:start -->",{"id":259,"html":260,"text":260,"type":144,"level":31},"b37","🧠 Test yourself",{"id":262,"html":263,"type":137},"b38","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":265,"html":266,"type":137},"b39","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":268,"html":269,"type":137},"b40","\u003C!-- quiz:end -->",{"id":271,"html":272,"text":272,"type":144,"level":31},"b41","The takeaway",{"id":274,"html":275,"type":137},"b42","\u003Cp>A &quot;Save&quot; button built on \u003Ccode>&lt;a download&gt;\u003C\u002Fcode> was never overwriting anything — it was generating a new file and letting the browser&#39;s own download manager paper over the difference with a renamed copy. The File System Access API is the first time the platform gives a page an actual handle to write back through, and it comes wrapped in exactly the restrictions you&#39;d want from something that powerful: Chromium-only pickers, secure contexts only, user-gesture only. Feature-detect it, keep the download link as your fallback, and stop calling the old behavior a save.\u003C\u002Fp>",{"id":277,"html":278,"type":137},"b43","\u003Cp>Have you shipped a &quot;save&quot; that was quietly cloning files in someone&#39;s Downloads folder? I&#39;d bet more of us have than will admit it in the comments.\u003C\u002Fp>",{"id":280,"html":281,"type":137},"b44","\u003C!-- related:start -->",{"id":283,"html":284,"text":284,"type":144,"level":31},"b45","📚 Read next",{"id":286,"type":220,"items":287,"ordered":18},"b46",[288,289,290],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap\">JSON.stringify Is Quietly Deleting Your File Uploads\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fclipboard-writetext-focus-bug\">The await That Silently Breaks navigator.clipboard.writeText()\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpermissions-api-query-before-prompt\">getCurrentPosition() Doesn&#39;t Just Check — It Prompts\u003C\u002Fa>",{"id":292,"html":293,"type":137},"b47","\u003C!-- related:end -->",{"id":295,"type":296},"b48","divider",{"id":298,"html":299,"type":137},"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":137},"b50","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":304,"type":220,"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>","You're building a little in-browser markdown editor. \"Save\" is one line: turn the textarea into a Blob, wrap it in an `\u003Ca download>`, click it programmatically. Ship it, feels great in your own testing — you save once, done.\n\nA real user opens `notes.md`, edits it, hits Save. Then edits it again, hits Save again. Then again. They go looking for their notes later and their Downloads folder has `notes.md`, `notes (1).md`, `notes (2).md`, `notes (3).md` — four files, three of them stale, and no indication which one is the one they actually want. They didn't do anything wrong. Your \"Save\" button was never a save button. It was a \"make a new file\" button that happened to reuse a familiar icon.\n\n## The wrong way, and why it feels right\n\nHere's the pattern, and it's everywhere — CodeSandbox-style playgrounds, browser-based note apps, config generators, anything that needs to hand the user a file:\n\n```js\nfunction saveTheOldWay(text, filename) {\n  const blob = new Blob([text], { type: \"text\u002Fplain\" });\n  const url = URL.createObjectURL(blob);\n  const a = document.createElement(\"a\");\n  a.href = url;\n  a.download = filename;\n  a.click();\n  URL.revokeObjectURL(url);\n}\n```\n\nIt works, in the sense that a file lands on disk. But look at what it actually is: `\u003Ca download>` triggers the browser's download pipeline, and the download pipeline has exactly one job — write a new file, and if the name collides, rename it so it doesn't clobber anything. That collision-avoidance is a deliberate safety feature of downloads in general (you don't want a sketchy site silently overwriting your existing files), and it's exactly the behavior you don't want from a save button. There is no API call in that snippet that means \"update the file the user already has open.\" There's no *handle* to the original file at all — just a one-shot blob with a suggested name.\n\nTry it below and watch a simulated Downloads folder fill up — every click is a new file, even though it looks and feels like \"saving.\"\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## What was actually missing: a handle\n\nThe gap isn't in your code, it's in the platform. A regular `\u003Cinput type=\"file\">` gives you a `File` object — bytes, a name, a size — but reading it doesn't hand you any way to write back to that same path. Browsers spent two decades treating \"the filesystem\" as something a web page should never be allowed to touch, for good reason: letting arbitrary sites read or overwrite files by name would be catastrophic. So `\u003Cinput type=\"file\">` and `\u003Ca download>` were built to avoid ever creating that connection.\n\nThe **File System Access API** is what closes that gap, deliberately and with permission gates at every step. `window.showOpenFilePicker()` doesn't just return a file's contents — it returns a `FileSystemFileHandle`, a persistent reference to that specific file that your page can ask to write to later:\n\n```js\nasync function openForEditing() {\n  const [handle] = await window.showOpenFilePicker({\n    types: [{ description: \"Markdown\", accept: { \"text\u002Fmarkdown\": [\".md\"] } }],\n  });\n  const file = await handle.getFile();\n  const text = await file.text();\n  return { handle, text };\n}\n```\n\nSaving back is where the difference actually shows up. You write through the *same handle* you opened:\n\n```js\nasync function saveToHandle(handle, text) {\n  const writable = await handle.createWritable();\n  await writable.write(text);\n  await writable.close();\n}\n```\n\nNo new filename, no collision to resolve, no `(1)` appended anywhere. `createWritable()` opens a stream to the exact file the handle points at; `write()` sends the new bytes; `close()` commits them — until then the new bytes go to a temporary file (it starts empty, since `keepExistingData` defaults to `false`), and the original is only replaced when the stream closes. Same file, every time — which is the one property a save button actually needs and the download link structurally cannot provide.\n\nThat's not a toy example above — it's real. Try the second panel in the playground: pick an actual file from your machine, edit it, hit Save (Chrome asks once whether the site may save changes — that's the permission gate), then check the file on disk. It changed. Same file, same name, no sibling copies.\n\n## The part that has to stay honest: this is Chromium, not \"the web\"\n\nThis is the part every \"just switch to the File System Access API\" take glosses over: **Firefox and Safari don't implement the part that matters here — the file pickers.** They do ship the handle-and-stream half (`FileSystemFileHandle`, `getFile()`, and since Safari 26 in September 2025, `createWritable()`), but only for the sandboxed origin private file system (`navigator.storage.getDirectory()`), which never touches the user's real files. The half that hands a page an actual file — `showOpenFilePicker()`, `showSaveFilePicker()`, `showDirectoryPicker()` — isn't \"not yet, check back next release\": WebKit's standards position is \"oppose\" on security grounds, and Mozilla's is \"negative\". Chrome, Edge, Opera and most other Chromium browsers support the pickers (Brave ships them switched off); that's the whole list.\n\nFeature-detect before you touch it, every time:\n\n```js\nif (\"showOpenFilePicker\" in window) {\n  \u002F\u002F real file handles, real overwrite-in-place\n} else {\n  \u002F\u002F fall back to the Blob + \u003Ca download> approach from above\n}\n```\n\nTwo more constraints worth knowing before you reach for it, both there on purpose:\n\n- **It needs a secure context** (HTTPS or `localhost`) — same rule as most powerful browser APIs.\n- **It needs a real user gesture.** You can't call `showOpenFilePicker()` on page load or from a timer with no recent click; it needs transient user activation from a click or keypress (and it's refused outright inside a cross-origin iframe), the same restriction that stops a page from silently spawning a file dialog the moment you land on it.\n\nNeither is a bug to work around — they're the reason this API was allowed to exist at all. A picker that could open without a click, or run over plain HTTP, would just be the file-access catastrophe browsers spent years avoiding, with better ergonomics.\n\n## So when is the download link still the right call?\n\nDon't treat this as \"the File System Access API is strictly better, migrate everything.\" The download link is still correct when:\n\n- It's a one-off export that will never be re-saved — a generated report, a chart image, a zip.\n- You need it to work in Firefox or Safari without a degraded experience — plenty of products can't accept \"20% of visitors get worse behavior.\"\n- The mental model really is \"produce a new file,\" like exporting a CSV snapshot of current state.\n\nThe File System Access API earns its place specifically when the mental model is *edit and re-save an existing file* — editors, config tools, anything where \"Save\" should mean \"update what's already there,\" not \"hand me a fresh copy and I'll sort out the mess myself.\"\n\n## The fix for the notes app\n\nBack to the markdown editor. The real fix isn't switching APIs wholesale — it's keeping a handle around once you have one, and only falling back to the download link when you don't:\n\n```js\nlet currentHandle = null;\n\nasync function save(text) {\n  if (currentHandle) {\n    await saveToHandle(currentHandle, text); \u002F\u002F overwrite in place\n    return;\n  }\n  if (\"showSaveFilePicker\" in window) {\n    currentHandle = await window.showSaveFilePicker({\n      suggestedName: \"notes.md\",\n    });\n    await saveToHandle(currentHandle, text);\n    return;\n  }\n  saveTheOldWay(text, \"notes.md\"); \u002F\u002F Firefox\u002FSafari: still a new file each time\n}\n```\n\nFirst save in a supporting browser prompts once, gets a handle, and keeps it. Every save after that writes to the same file with no dialog and no duplicate. Users on Firefox or Safari get the old, honest-about-its-limits download behavior — worse, but not broken, and not lied to about what \"Save\" means.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write\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\n## The takeaway\n\nA \"Save\" button built on `\u003Ca download>` was never overwriting anything — it was generating a new file and letting the browser's own download manager paper over the difference with a renamed copy. The File System Access API is the first time the platform gives a page an actual handle to write back through, and it comes wrapped in exactly the restrictions you'd want from something that powerful: Chromium-only pickers, secure contexts only, user-gesture only. Feature-detect it, keep the download link as your fallback, and stop calling the old behavior a save.\n\nHave you shipped a \"save\" that was quietly cloning files in someone's Downloads folder? I'd bet more of us have than will admit it in the comments.\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [JSON.stringify Is Quietly Deleting Your File Uploads](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fformdata-file-upload-json-stringify-trap)\n- [The await That Silently Breaks navigator.clipboard.writeText()](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fclipboard-writetext-focus-bug)\n- [getCurrentPosition() Doesn't Just Check — It Prompts](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpermissions-api-query-before-prompt)\n\n\u003C!-- related:end -->\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":110,"canonical":311,"description":312},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write","The classic web 'save' — a Blob and an \u003Ca download> link — doesn't overwrite a file, it manufactures a new one every time, leaving notes.md, notes (1).md, notes (2).md scattered in","01a0ec72-f3f3-7702-94bc-4d05e2e3235e",{"id":315,"locked":18},"01a0ec72-f7b4-76f4-8095-2d9cd8d5d2c3",[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}]