Every save on Flutter Web drops a new numbered copy in Downloads. The File System Access API lets you write back to the file the user actually opened.
Build an editor on Flutter Web. The user opens budget.csv, edits it, hits
save. They get budget.csv in their Downloads folder.
They edit again, save again: budget (1).csv.
Again: budget (2).csv.
Their real file — the one on their Desktop that they opened — has never been
touched. By the end of an afternoon they have nine numbered copies and no idea
which is current. This is not a Flutter problem; it is what "saving" has meant on
the web for twenty years. The browser hands you a download, and a download is
always a new file.
The File System Access API gives a page a real handle to a real file, with
the user's permission, granted through the browser's own picker. With a handle
you can write back to the same file. Not a copy. That one.
It is a genuine capability, gated properly: the user picks the file, the browser
mediates every access, and the permission can be revoked. There is no way for a
page to reach a file the user has not deliberately handed over.
final file = await FileSystemAccess.openFile();await file.write(bytes); // the same file, in place
No download. No (1). The file on their Desktop now has their edits.
The download-a-copy behaviour is not laziness on anyone's part. For most of the
web's history, a page having write access to your filesystem would have been an
enormous security hole, so the only sanctioned direction was out: the page
produces bytes, the browser saves them somewhere the page cannot see or reach.
That is why <a download> and the blob-URL trick that every Flutter Web file-save
snippet uses cannot ever overwrite. They are not writing a file. They are handing
the browser a payload and asking it to save one, and "save one" means a new one.
Understanding that is what tells you the File System Access API is a genuinely
different mechanism rather than a nicer wrapper over the same thing. The
permission model changed; the API followed.
The second half is what makes it feel like an application rather than a web page.
Handles can be persisted. Store one, and after a page reload — or the next
morning — you can ask for that same file again. The browser will re-prompt for
permission, which is correct and is the point, but the user is confirming access
to a file they already chose rather than hunting through a picker to find it
again.
That single behaviour is the difference between "a web tool I paste things into"
and "the thing I edit my file with".
Handles are stored in IndexedDB — they are structured-cloneable objects, not
strings — so they persist like any other browser data and survive until the user
clears site data. Which means the recovery flow is:
Step four is the honest version of "restore my session". You are not
re-prompting for a file the user must find again; you are asking them to confirm
a file you both already know about.
The same API grants handles to whole directories. That unlocks a different class
of application entirely — a project folder, a photo set, a folder of Markdown
notes — where the app can enumerate, read and write within the folder the user
chose, and nothing outside it.
For anything resembling an editor, that is the difference between a single-file
toy and something people keep their work in.
This is where an honest package earns its keep, because the API is not
universally available and pretending otherwise produces a broken app on a third
of browsers.
Support is real but partial. Chrome, Edge and other Chromium browsers have
it. Safari and Firefox largely do not. So the capability check is not optional
housekeeping — it decides which UI you show:
if (await FileSystemAccess.isSupported) { // "Save" — writes in place} else { // "Download a copy" — the old behaviour, honestly labelled}Label the fallback accurately. A button that says Save and silently produces
budget (3).csv is worse than a button that says Download a copy, because the
first one lies about what just happened.
Permission is per-handle and revocable. A write can fail because the user
revoked access, or the file moved, or the browser dropped the grant. Handle it as
a normal outcome rather than an exception path you never test.
It needs a user gesture. The picker cannot be opened from a timer or an
async continuation far from a tap. Open it directly in the button handler.
The temptation is to write the good path and add a fallback later. That produces
an app whose Safari experience is an afterthought, and Safari is not a rounding
error.
A structure that works: define one interface with open, save and saveAs.
Implement it twice — once over the File System Access API, once over the classic
picker-and-download. Choose at startup. The rest of your app never branches, and
the difference is confined to which implementation is behind the interface and
what the save button says.
The label matters more than it sounds. If the fallback button says Save and
produces budget (3).csv, the app has lied about what it did. If it says
Download a copy, the user understands the platform limitation immediately and
does not blame the app when their original file is unchanged.
Flutter Web is unusually good at the kind of app this unlocks — editors,
spreadsheets, diagram tools, code playgrounds, anything with a document. Those
are exactly the apps where "download another copy" is most obviously wrong.
It is also the gap people cite when they say Flutter Web "isn't ready for real
apps". Not being able to save a file properly is a fair thing to hold against a
platform. It is no longer true, on the browsers that matter, and it is worth
knowing.
Every time this API comes up someone asks whether a web page can now read their
whole disk. It cannot, and the reasons are worth knowing so you can answer the
question when a user asks you:
A page cannot construct a handle to a path it names.
budget.csv grants nothing about thefolder it sits in, or any other file.
Chromium's case a maintained blocklist that includes things like the user's
home root and library folders.
granted permanently in the background.
The design is closer to "the user hands your app a file" than to "your app gets
filesystem access", which is the right shape for a capability this significant.
file_system_access is on pub.dev — MIT, 160/160
pub points. It wraps the picker, the in-place write, the persisted handle and the
capability check.
It returns false from the capability check on browsers that cannot do this,
rather than throwing or half-working — so your fallback is a decision you make at
startup rather than a crash you discover from a Safari user three weeks after
launch.