Application, FileStream, OS page cache and device buffers; Flush vs Flush(true)/fsync; WriteThrough; atomic write-then-rename; ArrayPool for I/O buffers.
Data passes through several buffers: your own buffer → StreamWriter/FileStream buffer (4 KiB default for FileStream) → the OS page cache → the disk's own cache → the storage media.
Flush() moves data from the .NET buffer into the OS. It survives a process crash, not a power loss or kernel crash.Flush(flushToDisk: true) calls fsync/FlushFileBuffers, asking the OS and device to persist it.Flush(true), then rename over the target (File.Move(temp, path, overwrite: true)), so readers see either the old or the new file, never half of one.Buffers exist for speed: many small writes become a few large system calls and disk operations.
your code: writer.Write("line\n")
│
▼
StreamWriter char buffer ─┐
│ encoder │ lost if the PROCESS crashes
FileStream byte buffer (4 KiB) ─┘ before Flush/Dispose
│ write() system call
▼
OS page cache (dirty pages) ─┐ lost if the MACHINE crashes or loses power
│ writeback (seconds later) │ before fsync
▼ ─┘
Disk / controller cache ── lost on power loss unless battery/PLP backed
│ (fsync asks for a cache flush too)
▼
Persistent media
| Call | What it guarantees |
|---|---|
writer.Write |
Data is in a .NET buffer only |
writer.Flush() / fs.Flush() |
Handed to the OS; visible to other processes; survives process crash |
fs.Flush(true) |
fsync / FlushFileBuffers: persisted as far as the OS and device honour it |
FileOptions.WriteThrough |
Each write goes through the cache to the device; very slow for small writes |
Dispose |
Flushes .NET buffers and closes the handle; does not fsync |
Writing 1 million 50-byte log lines with no buffering means 1 million system calls. With a 64 KiB buffer it is about 800. System calls and device operations have fixed costs, so batching gives large speedups.
A payment gateway's local outbox wrote each message to a JSON file with File.WriteAllText. After a host lost power, the service restarted and found several zero-length or half-written files, which crashed the outbox processor. The data had been in the page cache, not on disk, and WriteAllText overwrote files in place.
The fix was the standard durable write pattern: write to name.tmp, Flush(true), then File.Move(tmp, name, overwrite: true), and make the reader skip .tmp files. Later we moved the outbox into the database, which already handles durability with its write-ahead log.
Probes depth on I/O: whether the candidate knows what is actually guaranteed and can design for crash safety.
Green flags
Flush() from Flush(true)/fsync.Red flags
Flush or Dispose guarantees data on disk.WriteThrough for all writes without considering cost.bufferSize: 0 or 1 to disable it (useful when you already write large blocks).StreamWriter has its own char buffer and an AutoFlush property. AutoFlush = true flushes on every Write, which is safe but slow.StreamWriter does not flush in a finaliser. If you never dispose it, the last buffer is simply lost.The kernel keeps file data in RAM after reads and writes:
vm.dirty_* settings).free -m on Linux shows this as buff/cache; it is reclaimable and not a memory leak.1. write orders.json.tmp (complete content)
2. fsync orders.json.tmp (Flush(true))
3. rename orders.json.tmp → orders.json (atomic on the same file system)
4. (Linux, strict) fsync the directory so the rename itself is durable
Reader sees: old orders.json ───────────► new orders.json
never a half-written file
Rename is atomic within one volume on POSIX and NTFS. Across volumes it becomes copy plus delete, which is not atomic, so keep the temp file in the same folder. .NET does not expose directory fsync directly; databases and some libraries do it via native calls when they need the strictest guarantee.
ArrayPool<byte>.Shared instead of allocating new byte[1_000_000] per request (avoids LOH churn).stackalloc or Span<byte> for small, short-lived buffers.Return rented arrays in finally, and do not use them after returning.fsync latency on a cloud SSD can be 1–10 ms. Calling it per record limits you to hundreds of writes per second per file. Databases solve this with group commit: batch many transactions into one fsync. Apply the same idea: batch writes, then sync once.
bufferSize: 1 and AutoFlush = true, then with a 64 KiB buffer. Compare times.StreamWriter and exit the process: see the missing tail.strace -f -e trace=write,fsync dotnet App.dll to see how many write and fsync calls each version makes.// Durable, atomic file replace with safe cleanup
public static async Task WriteAtomicallyAsync(
string path, ReadOnlyMemory<byte> content, ILogger logger, CancellationToken ct)
{
var directory = Path.GetDirectoryName(Path.GetFullPath(path))!;
var tempPath = Path.Join(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
await using (var fs = new FileStream(tempPath, new FileStreamOptions
{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Options = FileOptions.Asynchronous,
BufferSize = 0 // one large write; skip the extra copy
}))
{
await fs.WriteAsync(content, ct);
fs.Flush(flushToDisk: true); // fsync / FlushFileBuffers
}
File.Move(tempPath, path, overwrite: true); // atomic on the same volume
}
catch (Exception ex)
{
logger.LogError(ex, "Atomic write to {Path} failed", path);
try
{
File.Delete(tempPath);
}
catch (Exception cleanupEx)
{
// Do not hide the original failure; a startup sweep removes stray .tmp files
logger.LogWarning(cleanupEx, "Could not delete temp file {TempPath}", tempPath);
}
throw;
}
}
// High-volume sequential write: larger buffer, one fsync for the whole file
await using var fs = new FileStream("export.csv", FileMode.Create, FileAccess.Write,
FileShare.Read, bufferSize: 64 * 1024, useAsync: true);
await using var writer = new StreamWriter(fs, new UTF8Encoding(false)); // AutoFlush stays false
await foreach (var row in rows.WithCancellation(ct))
await writer.WriteLineAsync(row.ToCsv());
await writer.FlushAsync(ct); // .NET buffers -> OS
fs.Flush(flushToDisk: true); // OS -> disk, once
// Pooled buffer for copying between streams without allocating per call
byte[] buffer = ArrayPool<byte>.Shared.Rent(81_920);
try
{
int n;
while ((n = await source.ReadAsync(buffer, ct)) > 0)
await destination.WriteAsync(buffer.AsMemory(0, n), ct);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // never touch buffer after this
}
Mistake: Believing Flush() puts data on disk.
Why it is wrong: It only hands the data to the OS page cache, which is lost on a power failure or kernel crash.
Instead: Call Flush(true) when durability matters, and batch writes so you sync rarely.
Mistake: Overwriting important files in place.
Why it is wrong: A crash mid-write leaves a truncated or mixed file and readers can see half-written data.
Instead: Write a temp file in the same folder, Flush(true), then File.Move(..., overwrite: true).
Mistake: Not disposing a StreamWriter.
Why it is wrong: It has no finaliser flush, so the last buffered block is silently lost when the process exits.
Instead: Use await using, and treat a missing dispose on writers as a bug.
Mistake: Turning on AutoFlush or WriteThrough everywhere to be safe.
Why it is wrong: Every small write becomes a system call or device write and throughput collapses, often by 100 times.
Instead: Keep buffering and flush at meaningful boundaries: end of a batch, end of a file, a commit.
Mistake: Swallowing errors during temp-file cleanup.
Why it is wrong: If the cleanup delete throws inside a catch, it can hide the original exception, and orphaned temp files pile up unnoticed.
Instead: Guard the cleanup separately, log both failures, and sweep stray .tmp files at startup.
I frame this as visibility versus durability. Flush makes data visible and safe from my process crashing; only fsync (and honest hardware) makes it safe from the machine crashing. Most code only needs the first. When I need the second, I pay for it deliberately and in batches.
| Technique | Survives process crash | Survives power loss | Throughput cost |
|---|---|---|---|
| Buffered write, no flush | No | No | Lowest |
Flush() |
Yes | No | Low |
Flush(true) per batch |
Yes | Yes | Moderate |
Flush(true) per record or WriteThrough |
Yes | Yes | Very high |
Temp file + Flush(true) + atomic rename |
Yes, never partial | Yes | Moderate |
| Database or durable queue | Yes | Yes, with replication | Handled for you |
Atomic rename is the workhorse for config files, caches and exports: readers never see partial files and recovery is just deleting stray temp files. For anything transactional, I prefer a database with a transactional outbox, plus backups and restore tests, because durability on one disk is not the same as recoverability.
A Stream is a sequence of bytes you read or write in order, without needing it all in memory. Stream is an abstract class with Read/Write (sync and async), optional Seek/Position/Length (only if CanSeek), and Flush/Dispose.
FileStream, MemoryStream, NetworkStream.BufferedStream, GZipStream, BrotliStream, CryptoStream, SslStream.StreamReader, StreamWriter, BinaryReader.Key gotcha: Read may return fewer bytes than you asked for, and 0 means end of stream. Loop, or use ReadExactly (throws EndOfStreamException if the stream ends early) or ReadAtLeast, both available in .NET 10.
A system call is how a user-mode program asks the kernel to do something privileged: read a file, send data, allocate memory, create a thread. The program puts arguments in registers and executes a special instruction (syscall on x64); the CPU switches to kernel mode, the kernel validates the request, does the work and returns. In .NET, File.ReadAllBytesAsync goes through FileStream/RandomAccess and a SafeFileHandle to ReadFile on Windows or pread on Linux; Socket.SendAsync uses I/O completion ports on Windows and epoll on Linux. Each call costs hundreds of nanoseconds to microseconds, more with security mitigations, so thousands of tiny calls add up. Buffering, batching and async I/O keep the count down.
When you open a file you choose an access (Read, Write) and a share mode (FileShare.None, Read, ReadWrite, Delete) saying what others may do while you hold it.
IOException (Win32 error 32).FileShare.None with an advisory flock, which only other cooperating processes respect.Debug by finding the holder (Process Explorer / handle.exe on Windows, lsof / fuser on Linux). Fix by opening with the right FileShare, disposing handles promptly, retrying transient locks (antivirus, indexers) with backoff, and coordinating writers explicitly instead of relying on lock errors.
How to tell it (STAR)
**S:** After a [power loss] on one host, our [payment gateway] would not start because some outbox files were empty. **T:** I had to recover the messages and make sure it could not happen again. **A:** I found we overwrote files in place and only flushed to the OS, not the disk. I recovered the [6] messages from upstream logs, then changed writes to temp file, `Flush(true)` and atomic rename. **R:** A kill test during writes never produced a corrupt file again, and we later moved the outbox to the database for full transactional safety.
Last updated 23 Oct 2026.