devShakib

Saving a Real File From Flutter Web, Instead of Downloading Another Copy

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 API that changed this

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.

Why the old way works the way it does

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.

Coming back after a reload

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.

Directories, not just files

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.

What you must handle

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.

Design for the fallback, do not bolt it on

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.

Why this matters more on Flutter Web than elsewhere

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.

A note on security, since people ask

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.

folder 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.

Try it

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.