OPFS vs File System Access for Creative Apps
Compare OPFS and user-visible file access through custody, permissions, quota, recovery, and a two-layer creative-project protocol.
OPFS vs File System Access is a custody decision before it is a storage API decision. A creative app usually needs a fast, interruption-tolerant working copy and a file the user can see, move, back up, and reopen—jobs that often belong to different layers.
Define who owns the project file
OPFS vs File System Access is a custody decision before it is a storage API decision. A live working set, an offline authority, a user-owned artifact, and a server-synced projection can all contain similar bytes while making different promises about visibility, deletion, portability, and recovery.
For the running example, imagine a layered illustration project with a manifest, thumbnails, imported binary assets, an undo journal, and periodic flattened previews. The editor needs frequent interruption-tolerant commits, while the artist expects a named project file they can move, back up, and open later. Those needs point to two owners.
The origin private file system can hold the app's fast working copy. A file selected or created through the File System Access API can be the explicit user-owned checkpoint. Calling both of them “saved” hides the most important difference, so the interface must name which promise completed.
This comparison is not a database-engine decision; SQLite in OPFS is one possible implementation inside the private layer. It is also not a claim that every app needs two stores. Choose the smallest architecture that can state ownership truthfully.
- Origin workspace
- Visible to the app, origin/profile/device scoped, quota exposed, and removable with site data.
- User file
- User-selected and visible, permission exposed, portable through the user's chosen storage, and deletable outside the app.
- Save portal
- Advances the exported revision only after a complete close.
- Open portal
- Validates a selected project before establishing a new working baseline.
Understand the OPFS boundary
OPFS is private to an origin and not ordinarily visible through the user's file browser. The WHATWG File System Living Standard defines the storage-directory entry point through navigator.storage.getDirectory() and the handles used inside that origin-scoped file system. This makes OPFS well suited to application-managed working data.
Private does not mean permanent. Browser quota, eviction policy, site-data controls, profile loss, device loss, and an origin change all belong in the risk model. Clearing site data can remove the working store; an OPFS-only write is therefore never labeled “saved to your file” or “backed up.”
The main thread uses asynchronous access, while synchronous access handles are restricted to workers. That can support tight binary workflows, but it is an implementation capability rather than a custody guarantee. Measure the real workload and keep UI state responsive during large commits.
In OPFS vs File System Access, the strongest OPFS promise is “autosaved locally for this site on this device,” qualified by storage conditions. The app should expose last committed revision, storage pressure, and a recovery/export path. Offline draft queues with IndexedDB may be simpler when the payload is ordinary document state rather than a file-like creative project.
Understand the File System Access boundary
A picker flow begins with explicit user activation and returns a handle to a user-selected file or directory where supported. Permission may need to be requested again, can be denied or revoked, and should be checked close to the operation. The visible path is valuable precisely because the user participates in choosing custody.
Writing needs a disciplined transaction: create a writable stream, write the complete encoded project, close successfully, then advance the exported-revision receipt. A failure before close must not claim that the visible file contains the new revision. Keep the earlier export and the newer local working revision distinguishable.
Support is uneven across browser and context combinations, so OPFS vs File System Access needs feature detection and a fallback. Import through a normal file input and export through a download can preserve user-owned project files even when persistent handles or save pickers are unavailable. Never silently substitute a private write for an explicit file save.
Model browser file storage as a capability matrix, not a browser-name branch. Test whether the private directory, picker, writable stream, persisted handle, and permission query you need actually exist in the current context. A fallback should preserve project bytes and honest copy even when it cannot preserve a handle or background cadence.
The MDN OPFS reference contrasts origin-private storage with user-visible picker flows and documents quota and worker boundaries. Treat that as API guidance; verify the current support matrix on the browsers your product actually ships.
Compare the same creative workload
Compare both layers against one project, not abstract speed claims. The lab models a 24 MB illustration with 18 assets, a 30-second autosave cadence, working revision W7, and last exported revision E4. It records manifest and asset digests rather than pretending a synthetic adapter benchmarks real disks.
OPFS handles frequent journal and thumbnail updates without exposing internal files the user must manage. The visible project file provides portability, deliberate naming, ordinary backup, and a boundary for reopening elsewhere. Neither alone satisfies every expectation: private storage is not a visible backup, and repeatedly rewriting a large user file may be a poor autosave experience.
Second-device use adds another owner. A user-visible file can travel through their chosen storage, while server sync needs its own version and conflict contract. OPFS remains scoped to the browser origin/profile/device unless the application explicitly copies state elsewhere.
List the package contents before comparing performance. A manifest may update every edit while large source assets remain content-addressed and unchanged; a preview can be regenerated instead of stored. That decomposition often matters more than choosing one API, because it controls write amplification, validates import boundaries, and lets recovery distinguish essential state from disposable derivatives.
For creative workflows, reversible layers should survive serialization in either layer. Flattened export is not the project, a thumbnail is not recovery, and a server preview is not necessarily authoritative. List each representation and the action that promotes it.
Use a two-layer save architecture when custody is split
The two-layer design makes the working revision and exported revision first-class. Every edit advances in memory; a successful local commit advances W; an explicit Save or Save As writes a complete portable package and advances E only after close. The UI can then say “Autosaved locally · W7” and “File last saved · E4” without collapsing them.
Open imports a user file into a validated working set, preserving its source identity separately from the local revision. Save updates an authorized handle; Save As establishes a new visible target; download is the fallback when a picker is unavailable. Each portal is explicit and can fail without erasing the last known-good layer.
OPFS vs File System Access often lands here because cadence and custody are split, not because hybrid architecture is inherently superior. A throwaway sketch may need only a download. A regulated workflow may require server authority and exclude either local layer from the official record.
Use WebCodecs creative video tools as a reminder that binary workloads need staged assets and exports, then validate the product-specific package. The lab's two adapters prove state transitions only; they do not measure throughput, grant permission, or persist real files.
Keep promotion atomic at the protocol level even when an API exposes several calls. Build and validate the complete package, write through the selected adapter, close it, then record the new digest and revision. If any step fails, retain the earlier receipt and the newer private work so recovery never depends on reconstructing which partial bytes escaped.
- Edit advances the in-memory project.
- Autosave commits working revision W7.
- A tab crash does not erase the committed W7.
- Reopen recovers W7 while the visible file remains E4.
- Permission denial leaves both last known-good revisions unchanged.
- Successful Save As promotes W7 into a new exported revision.
Design honest save and recovery states
Save labels should describe completed custody, not optimistic intent. “Saving locally” becomes “Autosaved locally” only after the private commit; “Saving file” becomes “Saved to your file” only after the writable stream closes. Permission prompts, storage pressure, and stale handles need their own visible states.
When W7 is newer than E4, show the divergence. After permission denial, keep W7 intact and say “Autosaved locally; file remains at E4. Choose Save As to update a user-owned copy.” That message gives the artist a next action without implying loss or completion.
Cleared origin state is different: if a user file exists, offer Open and explain that the local working copy is gone; if no exported file exists, disclose that recovery is unavailable. Do not call an empty recreated OPFS directory a successful restore. Rehearse this alongside service worker update flows so an application upgrade never masks local-state migration.
The status model is central to OPFS vs File System Access because words create product promises. Keep filenames out of telemetry, record only coarse state transitions and error classes, and let the user inspect which revision lives in each layer.
Give the history panel two clocks as well as two revision labels. “Autosaved locally at 14:32” and “File saved at 14:10” explain divergence without implying that one layer failed. If the external file changes elsewhere, show that as a third observed fact and ask before overwriting either authority.
Rehearse interruption, quota, and permission loss
A custody protocol earns trust under failure. Inject a stop before local flush: W remains at the last committed revision. Inject after local commit: W advances and can recover after a tab crash. Reject quota: preserve the previous W and ask for export or cleanup without silently discarding edits.
The visible layer needs parallel drills. Failure before export close leaves E unchanged; permission denial keeps the local revision and presents Save As; a stale handle asks the user to reselect a destination. The state machine never advances the receipt merely because writing began.
The web.dev OPFS implementation guide shows worker access, quota considerations, and copying data from private storage through an explicit save picker. Browser statements can change, so treat the page as implementation guidance and keep capability checks in the release matrix.
OPFS vs File System Access should also rehearse a cleared origin. With E4 available, the app can import that visible file and create a new W4 baseline; edits after E4 are honestly lost unless another authority exists. The independent tests replay all seven failure points and kill a mutation that falsely maps a local commit to “saved to file.”
| Failure | Working layer | User-visible layer and message |
|---|---|---|
| Tab crash | Recover committed W | E unchanged; “Recovered local work” |
| Quota refusal | Keep prior W | Offer export or cleanup |
| Site data cleared | W unavailable | Open E if present; never claim restore |
| Permission revoked | Keep W | Request permission or Save As |
| Stale external file | Compare revisions | Ask before overwrite |
| Unsupported picker | Keep W | Import input and download fallback |
Runnable artifact — Deterministic two-adapter custody protocol; not real disk performance, picker permission, browser support, or immunity from quota and clearing.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const MAX=65536,MAX_REVISION=Number.MAX_SAFE_INTEGER-1,failures=new Set(["none","before-local-flush","after-local-commit","before-export-close","permission-denied","quota-refusal","stale-handle","cleared-origin"]),hash=value=>createHash("sha256").update(JSON.stringify(value)).digest("hex");
const builtIn={name:"layered-poster.jpc",projectBytes:25165824,assets:18,autosaveSeconds:30,userFileRequired:true,offlineAuthority:"working",portability:"required",capabilities:{opfs:true,filePicker:true},workingRevision:6,exportedRevision:4};
function args(){const values=process.argv.slice(2),out={fixture:null,failure:"none"},seen=new Set();if(values.length%2)throw new Error("invalid-arguments");for(let i=0;i<values.length;i+=2){if(!["--fixture","--failure"].includes(values[i])||seen.has(values[i]))throw new Error("invalid-arguments");seen.add(values[i]);if(values[i]==="--fixture")out.fixture=values[i+1];else out.failure=values[i+1];}if(!failures.has(out.failure))throw new Error("unknown-failure");return out;}
function load(file){if(!file)return structuredClone(builtIn);const input=readFileSync(file);if(input.length>MAX)throw new Error("fixture-too-large");try{return JSON.parse(input.toString("utf8"));}catch{throw new Error("malformed-fixture");}}
function validate(fixture){if(!fixture||typeof fixture!=="object"||Array.isArray(fixture))throw new Error("invalid-fixture");const allowed=new Set(["name","projectBytes","assets","autosaveSeconds","userFileRequired","offlineAuthority","portability","capabilities","workingRevision","exportedRevision"]);if(Object.keys(fixture).some(key=>!allowed.has(key)))throw new Error("unknown-fixture-member");if(typeof fixture.name!=="string"||fixture.name.length<1||fixture.name.length>120||fixture.name.includes("..")||/[\\/\0]/.test(fixture.name))throw new Error("unsafe-name");for(const key of ["projectBytes","assets","autosaveSeconds","workingRevision","exportedRevision"])if(!Number.isSafeInteger(fixture[key])||fixture[key]<0)throw new Error("invalid-number:"+key);if(fixture.projectBytes>1073741824||fixture.assets>10000||fixture.autosaveSeconds>86400||fixture.workingRevision>MAX_REVISION||fixture.exportedRevision>MAX_REVISION)throw new Error("fixture-out-of-bounds");if(fixture.exportedRevision>fixture.workingRevision)throw new Error("impossible-revisions");if(!["working","user-file","server"].includes(fixture.offlineAuthority)||!["required","optional"].includes(fixture.portability)||typeof fixture.userFileRequired!=="boolean"||!fixture.capabilities||Object.keys(fixture.capabilities).some(key=>!["opfs","filePicker"].includes(key))||typeof fixture.capabilities.opfs!=="boolean"||typeof fixture.capabilities.filePicker!=="boolean")throw new Error("invalid-contract");return structuredClone(fixture);}
const options=args(),fixture=validate(load(options.fixture)),events=[],memoryRevision=fixture.workingRevision+1,working={revision:fixture.workingRevision,digest:fixture.workingRevision?hash({name:fixture.name,revision:fixture.workingRevision}):null},exported={revision:fixture.exportedRevision,digest:fixture.exportedRevision?hash({name:fixture.name,revision:fixture.exportedRevision}):null},outcomes={localPersistence:"not-attempted",userFile:"not-attempted"};
const recommendation=!fixture.capabilities.opfs?(fixture.capabilities.filePicker?"user-file-primary-with-memory-working-copy":"memory-working-copy-plus-import-download-fallback"):(fixture.userFileRequired||fixture.portability==="required"?(fixture.capabilities.filePicker?"two-layer-opfs-plus-user-file":"opfs-plus-import-download-fallback"):"opfs-working-store");
function event(type,status,detail){events.push({index:events.length+1,type,status,detail});}
event("edit","complete","memory revision M"+memoryRevision);
if(!fixture.capabilities.opfs){outcomes.localPersistence="unsupported";event("local-commit","unsupported","OPFS unavailable; W"+working.revision+" remains the last recorded local revision");}
else if(options.failure==="before-local-flush"){outcomes.localPersistence="failed";event("local-commit","failed","flush did not start; W"+working.revision+" remains last known-good");}
else if(options.failure==="quota-refusal"){outcomes.localPersistence="failed";event("local-commit","failed","quota refusal; W"+working.revision+" remains last known-good");}
else{working.revision=memoryRevision;working.digest=hash({name:fixture.name,revision:memoryRevision});outcomes.localPersistence="complete";event("local-commit","complete","autosaved locally as W"+memoryRevision);if(options.failure==="after-local-commit")event("tab-crash","recovered","W"+memoryRevision+" recovered; E"+exported.revision+" unchanged");}
if(options.failure==="cleared-origin"&&fixture.capabilities.opfs){working.revision=null;working.digest=null;outcomes.localPersistence="cleared";event("origin-clear","failed",exported.revision?"local copy unavailable; open E"+exported.revision:"local copy unavailable and no user file is recorded");}
const wantsUserFile=fixture.userFileRequired||fixture.portability==="required";
if(!wantsUserFile){outcomes.userFile="skipped";event("export","skipped","custody contract does not require a user file");}
else if(options.failure==="cleared-origin"&&fixture.capabilities.opfs){outcomes.userFile="skipped";event("export","skipped","no surviving in-memory revision is available to export");}
else if(fixture.capabilities.filePicker){if(options.failure==="permission-denied"){outcomes.userFile="failed";event("export","failed","permission denied; file remains E"+exported.revision);}else if(options.failure==="stale-handle"){outcomes.userFile="failed";event("export","failed","stale handle; reselect destination; E"+exported.revision+" unchanged");}else if(options.failure==="before-export-close"){outcomes.userFile="failed";event("export","failed","write not closed; file remains E"+exported.revision);}else{exported.revision=memoryRevision;exported.digest=hash({name:fixture.name,revision:memoryRevision});outcomes.userFile="picker-complete";event("export","complete","saved to user file as E"+memoryRevision);}}
else{event("picker-export","unsupported","save picker unavailable; use explicit download fallback");exported.revision=memoryRevision;exported.digest=hash({name:fixture.name,revision:memoryRevision});outcomes.userFile="download-complete";event("download-fallback","complete","prepared user-owned download E"+memoryRevision);}
let status;if(outcomes.localPersistence==="complete"&&outcomes.userFile==="picker-complete")status="Autosaved locally as W"+working.revision+" and saved to your file as E"+exported.revision+".";else if(outcomes.localPersistence==="complete"&&outcomes.userFile==="download-complete")status="Autosaved locally as W"+working.revision+"; a download for E"+exported.revision+" is ready.";else if(outcomes.localPersistence==="complete"&&outcomes.userFile==="failed")status="Autosaved locally at W"+working.revision+"; your file remains at E"+exported.revision+".";else if(outcomes.localPersistence==="complete")status="Autosaved locally at W"+working.revision+"; no user-file save was required.";else if(outcomes.userFile==="picker-complete")status="Local autosave "+outcomes.localPersistence+"; saved to your file as E"+exported.revision+".";else if(outcomes.userFile==="download-complete")status="Local autosave "+outcomes.localPersistence+"; a download for E"+exported.revision+" is ready.";else if(outcomes.localPersistence==="cleared")status=exported.revision?"Local working copy unavailable; open your saved file E"+exported.revision+".":"Local working copy unavailable and no exported file is recorded.";else status="Local autosave "+outcomes.localPersistence+"; your file remains at E"+exported.revision+".";
const checks={capabilitiesGoverned:(!fixture.capabilities.opfs?events.every(item=>!(item.type==="local-commit"&&item.status==="complete")):true)&&(!fixture.capabilities.filePicker?events.every(item=>!(item.type==="export"&&item.status==="complete")):true),safeRevisionAdvance:Number.isSafeInteger(memoryRevision)&&memoryRevision>fixture.workingRevision,honestStatus:!(outcomes.localPersistence!=="complete"&&/Autosaved locally/.test(status))&&!(outcomes.userFile!=="picker-complete"&&/saved to your file/.test(status)),fallbackExercised:fixture.capabilities.filePicker||!wantsUserFile||events.some(item=>item.type==="download-fallback"&&item.status==="complete")};
const receipt={schema:"creative-project-storage-receipt-v2",fixtureDigest:hash(fixture),failure:options.failure,capabilities:fixture.capabilities,recommendedArchitecture:recommendation,eventSequence:events,memory:{revision:memoryRevision,digest:hash({name:fixture.name,revision:memoryRevision})},working,exported,outcomes,checks,recoveryState:working.revision!=null?"working-available":exported.revision?"open-exported-file":"unrecoverable-in-model",userFacingStatus:status,claimBoundary:"Deterministic capability-driven custody protocol; not real disk performance, picker permission, browser support, or immunity from quota and clearing."};console.log(JSON.stringify({...receipt,receiptHash:hash(receipt)},null,2));
Choose OPFS vs File System Access with a scored contract
There is no universal winner. Score the required promise across write cadence, user visibility, permission friction, quota exposure, portability, reopen behavior, browser support, and recovery after site-data clearing. Weight custody and recovery more heavily than an unmeasured speed intuition.
Choose OPFS alone for replaceable caches or clearly labeled local drafts whose loss boundary is acceptable. Choose user-visible files for deliberate portable artifacts when picker support and explicit save cadence fit. Choose two layers when interruption-tolerant work and user-owned project files are both product requirements.
The downloadable storage lab accepts project size, asset count, cadence, custody flags, capabilities, and one failure injection. Its normalized receipt keeps working and exported digests separate, rejects unsafe names and impossible revisions, and recommends a bounded architecture. Run it with one real project's redacted dimensions, then write the status copy before implementing storage.
That is the practical answer to OPFS vs File System Access: decide who owns each revision, make promotion explicit, preserve the last known-good state, and tell the user exactly what completed. API choice follows the custody contract, not the other way around.
An OPFS vs File System Access decision is complete only when support fallbacks and user-facing status copy are reviewed with the same fixture. Revisit OPFS vs File System Access whenever that custody promise or capability matrix changes. That final review turns an API diagram into a product promise the team can test.