OPFS: creating, opening, closing and deleting a file performs ~21 fsyncs (7 `metadata.sqlite` transactions in rollback-journal mode); ~75 ms per file on Linux vs ~0.6 ms in Chromium
Categories
(Core :: DOM: File, defect)
Tracking
()
People
(Reporter: iam, Unassigned)
Details
Note: AI was used extensively in developing this benchmark and bug report, though I have made some effort to verify its claims, remove glaring issues, and produce something helpful while being totally unfamiliar with Firefox's codebase, C++, and not being in a position to devote much of my time to help or understand further.
Populating the Origin Private File System from a snapshot, either as many small files or as one file written in chunks, takes significantly longer in Firefox than in Chrome on the same machine. The attached benchmark attempts to separate the per-call cost of FileSystemSyncAccessHandle.write() (filed as bug 2072923) from the per-file cost of creating, opening, closing and deleting files. This bug is about the per-file cost.
You can find more information and view the benchmark runs here.
Steps to reproduce
git clone https://github.com/tbrockman/firefox-opfs-simple-bench && cd firefox-opfs-simple-bench && python3 serve.py 8000- Open http://localhost:8000/ in Firefox (normal window) and in Chrome, choose the Full preset, run, and compare the "new-file" rows. Each row is one step of
getFileHandle({create: true})+createSyncAccessHandle(), firstwrite()of 4 KiB,close(),removeEntry(), timed per file, plus the full cycle, for 2000 files, median of 5 runs after a warm-up. - Or unattended:
cd automation && npm install && npx puppeteer browsers install firefox && node run.mjs --preset full.
Actual results
Linux (Ryzen 7 7800X3D, Samsung 990 PRO, ext4 on LVM on dm-crypt; one fsync = 5.4 ms on this volume), Firefox 155.0.1 vs Chrome 152, mean per file (Linux run):
| Step | Firefox 155 | Chrome 152 | ratio |
|---|---|---|---|
| create + open | 34.0 ms | 0.38 ms | 91× |
| first write (4 KiB) | 0.10 ms | 0.09 ms | 1.2× |
| close | 22.2 ms | 0.08 ms | 262× |
| delete | 22.9 ms | 0.15 ms | 156× |
| full cycle | 79.2 ms | 0.69 ms | 114× |
The same run on macOS (Apple M4 Pro, APFS), Firefox 155.0.1 vs Chrome 153 (macOS run):
| Step | Firefox 155 | Chrome 153 | ratio |
|---|---|---|---|
| create + open | 0.93 ms | 0.26 ms | 3.5× |
| first write (4 KiB) | 0.04 ms | 0.06 ms | 0.7× |
| close | 0.47 ms | 0.08 ms | 5.7× |
| delete | 0.71 ms | 0.11 ms | 6.3× |
| full cycle | 2.14 ms | 0.51 ms | 4.2× |
The difference between the two hosts is fsync cost alone: macOS fsync() returns without flushing the drive cache, Linux ext4 does a journal commit plus a cache flush. With the browser profiles on tmpfs (fsync free) the cycle costs 1.34 ms in Firefox vs 0.57 ms in Chrome, which is the CPU/IPC floor (tmpfs control run).
Traced with strace -f -e trace=fsync,fdatasync,syncfs,sync_file_range (the benchmark's --strace-fsync option attributes calls to workloads by wall-clock window; traced run, quick preset, M = 200):
| Firefox 155 | Chrome 152 | |
|---|---|---|
| fsyncs per create/write/close/delete cycle | 21.0 | 0 |
fsyncs per flush() call (control) |
1.01 | 1.01 |
The 21 are 7 SQLite transactions on storage/default/<origin>/fs/metadata.sqlite, each costing three fsyncs in rollback-journal (DELETE) mode at synchronous = NORMAL: the journal, the fs directory (the journal is a new file every time), then the database. Per 800 cycles: 5,750 fsyncs on metadata.sqlite-journal, 5,750 on the fs directory, 5,750 on metadata.sqlite. The data file itself is never synced except by flush().
Where the seven transactions come from (mozilla-central 2026-09-15, 8d76a2ba4c)
Where AI thinks the seven transactions come from (unconfirmed)
GetStorageConnection(dom/fs/parent/datamodel/FileSystemDataManager.cpp) opens the database withOpenDatabaseWithFileURL(..., CONNECTION_DEFAULT)and sets nojournal_mode; mozStorage's default issynchronous = NORMALwith the comment "Normal is the suggested value for WAL journals" (storage/mozStorageConnection.cpp), but the connection never switches to WAL.- create:
GetOrCreateFile(FileSystemDatabaseManagerVersion001.cpp), one explicit transaction. (1) - open:
FileSystemDataManager::LockExclusivecallsEnsureFileId(Version002.cpp, one explicit transaction) and thenBeginUsageTracking, whoseSetUsageTrackingImplUPDATE autocommits. (2) - close:
UnlockExclusivecallsUpdateUsage(UpdateUsageInDatabaseupsert, autocommit) and thenEndUsageTracking(UPDATE, autocommit). (2) - delete:
RemoveFilerunsRemoveFileId(DELETE FROM FileIds) andDeleteEntry(DELETE FROM Entries), each autocommitted. (2)
Expected results
Per-file cost within a small factor of other engines.
AI also alleges that
The File System standard ties persistence to flush(); nothing in the spec requires a durable commit per create, open, close or delete. Chrome writes its directory metadata to LevelDB without syncing and reconciles on the next open.
Demonstration of the impact (not proposed as the fix)
A one-line PRAGMA journal_mode = WAL at GetStorageConnection, built on mozilla-central (158.0a1, plain opt build; the unpatched local build reproduces the release numbers), measured with the same benchmark on the same volume:
| Build | create+open | close | delete | full cycle | fsyncs/cycle |
|---|---|---|---|---|---|
| unpatched | 32.4 ms | 21.0 ms | 21.7 ms | 75.2 ms | 21.1 |
| WAL (synchronous stays NORMAL) | 0.73 ms | 0.52 ms | 0.67 ms | 1.91 ms | 0.12 (checkpoints only) |
| WAL + synchronous = FULL (every transaction still durable) | 16.2 ms | 10.7 ms | 10.8 ms | 37.8 ms | 7.1 |
AI goes on to suggest what a real fix might look like
The write workloads are unchanged by either. A real fix would also need to account the -wal/-shm files in origin usage (IndexedDB has precedent), bound the WAL, and decide the crash-consistency story (RescanTrackedUsages already re-measures files whose tracking was left open). Independently of the journal mode, the seven transactions could become four by committing each operation once (open, close and delete each run two autocommitted statements), and fewer still if the per-open/close usage-tracking flags were kept in memory and flushed lazily; the tmpfs row above is what that would approach.
Updated•1 month ago
|
Description
•