Buffered file I/O can pass through a language buffer, the kernel page cache, and the storage stack at different times. Modified cache pages need writeback so their data reaches the backing filesystem and device. This separation can combine work and improve throughput, but it means that another reader seeing new bytes is not, by itself, evidence that a crash-safe storage boundary has completed.

When it breaks

Buffered writes are not guaranteed to return instantly. Allocation, memory pressure, dirty-page throttling, or filesystem activity can make the writer wait. When the producer consistently outruns storage, dirty data cannot grow without limit; backpressure eventually affects the workload. Describing every write as a pure memory copy with all I/O deferred gives the wrong model for latency spikes under sustained load.

Language-level flush and filesystem synchronization have different meanings. Flush submits buffered application data to the next layer. Fsync requests a file synchronization boundary. Closing a normal file or reading the same bytes through another descriptor does not replace that explicit contract. A benchmark measuring only successful writes can therefore measure acceptance into cache instead of sustained durable throughput.

Writeback settings also are not per-file durability deadlines. Background thresholds encourage flusher activity, higher dirty thresholds affect generating processes, and periodic settings govern wakeups or eligibility of older dirty data. Device progress, filesystem behavior, and competing activity still matter. A chosen interval alone does not prove that every acknowledged update is safe after that many seconds.

Check it quickly

Save this as writeback.py and run python3 writeback.py on Linux with Python and strace, using an ext4 temporary directory. The recorded environment used Linux 6.12.76, Python 3.11.2, and strace 6.1. The fixture writes approximately four MiB, reads accounting information, and leaves global dirty-memory settings and cache controls unchanged:

from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,sys

program=r'''
from pathlib import Path
import json,os,subprocess,sys
root=Path(sys.argv[1]);path=root/'data'
def io():return {key:int(value) for key,value in (line.split(':',1) for line in Path('/proc/self/io').read_text().splitlines())}
def dirty():return int(next(line.split()[1] for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('Dirty:')))
with path.open('wb',buffering=65536) as handle:
    handle.write(b'buffered')
    assert path.stat().st_size==0 and path.read_bytes()==b''
    before=io();handle.flush();after=io()
    assert after['wchar']-before['wchar']>=8
    reader='from pathlib import Path;import sys;assert Path(sys.argv[1]).read_bytes()==b"buffered"'
    subprocess.run([sys.executable,'-c',reader,str(path)],check=True)
    print('visible before explicit fsync',flush=True)
    before=io();chunk=b'x'*(4*1024*1024);handle.write(chunk);handle.flush();after=io()
    assert after['wchar']-before['wchar']>=len(chunk)
    assert after['write_bytes']-before['write_bytes']>=len(chunk)
    dirty_before=dirty();os.fsync(handle.fileno());dirty_after=dirty()
    assert path.stat().st_size==len(chunk)+8
    print(json.dumps({'wchar_delta':after['wchar']-before['wchar'],'write_bytes_delta':after['write_bytes']-before['write_bytes'],'system_dirty_kib_before':dirty_before,'system_dirty_kib_after':dirty_after}))
'''
with TemporaryDirectory(prefix='xbcatm-writeback-') as directory:
    root=Path(directory);trace=root/'trace'
    result=subprocess.run(['strace','-f','-yy','-s','200','-e','trace=write,fsync,fdatasync','-o',str(trace),'--',sys.executable,'-c',program,str(root)],capture_output=True,text=True,timeout=10)
    assert result.returncode==0,result.stderr
    lines=trace.read_text().splitlines()
    visible=next(i for i,line in enumerate(lines) if 'visible before explicit fsync' in line)
    syncs=[i for i,line in enumerate(lines) if 'fsync(' in line and str(root/'data') in line]
    assert len(syncs)==1 and visible<syncs[0] and '= 0' in lines[syncs[0]]
    print(result.stdout,end='');print(lines[syncs[0]])
    print('another process read flushed bytes before the only explicit file synchronization; global Dirty is observational, not an isolated-device counter')

The child opens a file with a 65536-byte Python buffer and writes eight bytes. Before flushing, the pathname reports length zero and a separate open reads no content. The bytes are still in the application buffer. This establishes the earliest boundary directly instead of assuming every language-level write has already issued a kernel write.

After flush, the process’s successful-write byte count increases, and a separate child process reads the expected bytes. The writer prints a marker before its only explicit fsync. The strace output confirms that this visibility marker precedes synchronization. The result proves that another process can observe new data before the application requests that storage boundary.

The writer then submits four MiB more and checks increases in both wchar and write_bytes. These counters answer different accounting questions: successful write-family bytes versus storage-write activity attributed to the process. They are not interchangeable with business payload counts, device-completion timestamps, or a certificate that the corresponding update will survive a power failure.

The fixture records the system Dirty value around the successful fsync, then checks the final file length. In the recorded run, the values were 4148 and 56 KiB. Those numbers are observations of an aggregate kernel view, not isolated measurements of this file’s dirty pages. Other activity and normal writeback can change them, so the test does not assert a universal decrease or exact result.

All generated files are temporary, and the trace checks that the one explicit file synchronization succeeded. This experiment establishes buffering, visibility, and API ordering. It does not test a power cut, predict a device’s maximum write latency, or prove that one aggregate counter can explain every workload’s storage behavior.

Measure the boundary the workload needs

Track write latency, synchronization latency, dirty and writeback state, and the relevant device or cgroup activity together. A fast initial burst followed by stalls can indicate cache absorption followed by constrained drainage. A test should run long enough to exercise the sustained path relevant to the workload rather than reporting only the cache’s initial acceptance speed.

Interpret the configured dirty thresholds carefully. The ratio settings use eligible available memory, not simply all installed RAM, and byte-based alternatives express a different kind of limit. Read the effective settings before proposing changes. Reducing accumulation may improve one latency pattern while increasing write activity; increasing it can postpone pressure without increasing device throughput.

Let the application define when data may be acknowledged as committed. A database can group commits behind an appropriate log synchronization, while a replaceable cache may tolerate loss. Background writeback improves the ordinary buffered path, but it does not invent that business contract. Check write and synchronization errors, and validate recovery according to the promised persistence boundary.

Sources

  • write(2), buffered write visibility, completion, and delayed storage errors, checked 2026-09-12.
  • Linux virtual-memory sysctl documentation, dirty thresholds, background writeback, and periodic timing, checked 2026-09-12.
  • proc_pid_io(5), process I/O accounting and distinctions between byte counters, checked 2026-09-12.
  • proc_meminfo(5), Dirty and Writeback memory observations, checked 2026-09-12.
  • fsync(2), explicit file synchronization and failure reporting, checked 2026-09-12.