← back to journal

On a Mac, a real fsync takes a whole number of milliseconds

2026-10-14 · Kit Wickham

On macOS, fsync() hands your data to the drive but doesn't tell the drive to write its cache to flash. If the power goes, so can the data. To get the flush you need fcntl(fd, F_FULLFSYNC). I wanted to know what that costs on an Apple Silicon Mac's internal SSD. Plain fsync of 4 KiB took 16 µs. A full flush took 2 to 3 ms, 120 to 190 times longer. And 98% of the full flushes finished within 100 µs of an exact millisecond.

Three ways to sync

Each iteration overwrites the same block of a file (or appends a new one), then syncs. I ran 1,000 iterations per cell, 250 for the 1 MiB rows. F_BARRIERFSYNC is the middle option: it keeps writes in order across the barrier but doesn't wait for them to reach flash. Medians:

writeno syncfsyncbarrierfull flush
overwrite 4 KiB1.2 µs16.5 µs109 µs2.00 ms
overwrite 64 KiB13 µs32 µs108 µs3.00 ms
overwrite 1 MiB53 µs163 µs316 µs4.00 ms
append 4 KiB3.4 µs24 µs404 µs2.99 ms
append 64 KiB18 µs43 µs438 µs3.00 ms
append 1 MiB131 µs174 µs691 µs2.98 ms

Look at the last column. The medians come out at 2.00, 3.00, 4.00 ms, not 2.3 or 3.7.

A one-millisecond staircase

To check, I timed 3,000 back-to-back 4 KiB overwrites with a full flush each. 92% took 3 ms, rounded to the nearest millisecond, and 8% took 4 ms. A handful took 5 to 8 ms. Measured against the nearest whole millisecond, the middle 80% landed between 45 µs early and 34 µs late. If the times were spread evenly, only 20% would fall within 100 µs of a whole millisecond. 97.7% did. The barrier sync, timed the same way, came to 5.2%, which is chance.

So the flush is done, or at least reported as done, on a 1 ms tick. My guess is something in the storage stack checks for flush completion once a millisecond rather than taking an interrupt. I can't see that from user space. Either way, the practical floor is about 2 ms per durable write, and you pay it in whole steps.

What it does to SQLite

SQLite has PRAGMA fullfsync for this, and it is off by default (it was off here: Python 3.14.8, SQLite 3.53.1). I inserted 2,000 small rows, one commit each:

journalsynchronousfullfsync=0fullfsync=1
WALNORMAL143,769 /s58,850 /s
WALFULL40,742 /s331 /s
rollbackFULL6,655 /s100 /s

WAL with synchronous=FULL looks like 40,000 durable commits a second. With the real flush it is 331, one every 3.0 ms, which is the staircase again. Rollback journal mode needs about three flushes per commit, so it gets 100 a second. synchronous=NORMAL in WAL mode doesn't sync on commit at all, only at checkpoints, so it stays fast either way. It can lose the most recent commits on power loss but won't corrupt the file.

Inside a Linux VM on the same Mac

I ran the same test in a Linux VM on the same machine, writing to a folder shared from the host. fsync there took 64 µs and WAL synchronous=FULL did 7,090 commits a second. That is far below the 2 ms flush floor, so the guest's fsync is not reaching flash either. If you run a database in a VM or container on a Mac and believe its fsync, you are getting the same promise as plain macOS fsync: your data is ordered and handed off, not stored.

So what

On a Mac, "durable" costs about 3 ms per commit and comes in whole milliseconds, so group commits: one flush for a hundred rows costs the same as one flush for one row. If you can lose the last second of writes after a power cut, WAL with synchronous=NORMAL is about 400 times faster than a full flush per commit. If you can't, turn on fullfsync and batch. Plain fsync is the one you probably shouldn't trust. It's fast because it doesn't do the flush.

Method: one Mac, internal SSD, APFS, Python os.pwrite plus os.fsync or fcntl with codes 51 (F_FULLFSYNC) and 85 (F_BARRIERFSYNC), timed with perf_counter_ns. Other jobs were running on the machine, which mostly shows up in the tails. I didn't pull the power to check what actually survives. These are latencies, not a crash test.