📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Explain the layers of buffering between your .NET code and the disk. What does Flush actually guarantee, and how do you write a file so it survives a crash or power loss?

Application, FileStream, OS page cache and device buffers; Flush vs Flush(true)/fsync; WriteThrough; atomic write-then-rename; ArrayPool for I/O buffers.

Senior Theory Advanced buffers durability file-system filestream fsync io performance

🎯 Key points

  • Data passes through layers in order: your own buffer, the StreamWriter char buffer, the FileStream byte buffer (4 KB default), the OS page cache (dirty pages), the device's write cache, then persistent media.
  • Flush() copies .NET buffers into the OS page cache with a write system call: other processes can see the data and it survives your process crashing, but it is not durable against power loss or a kernel crash.
  • Flush(flushToDisk: true) calls fsync on Linux or FlushFileBuffers on Windows, asking the OS and the device to persist the data; it is only as honest as the hardware's cache-flush behaviour.
  • Dispose flushes .NET buffers and closes the handle but does not fsync, and StreamWriter has no finaliser, so an undisposed writer silently loses its last block.
  • For crash-safe updates: write a temp file in the same folder, Flush(true), then File.Move(temp, path, overwrite: true), which is an atomic rename on one volume; strict designs also fsync the directory.
  • Buffer size should match the workload: 4 KB is fine for small files, 64 KB to 1 MB helps large sequential writes, and bufferSize: 0 avoids a double copy when you already write big blocks.
  • fsync costs about 1 to 10 ms on cloud disks, so durability is bought in batches (group commit), not per record; WriteThrough and AutoFlush on every write collapse throughput.
  • If you need logs with checksums, recovery and replication, the data probably belongs in a database or queue that already solves durability (write-ahead log, transactional outbox).

⏱ 30-second answer

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.
  • For safe updates, write to a temp file, 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.

🗣 Full answer (senior)

The layers

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

Why buffer at all?

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.

Production example

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.

🔍 What the interviewer is checking

Probes depth on I/O: whether the candidate knows what is actually guaranteed and can design for crash safety.

Green flags

  • Names the layers including the OS page cache.
  • Distinguishes Flush() from Flush(true)/fsync.
  • Describes write-temp, fsync, rename.
  • Talks about the throughput cost of durability and batching.

Red flags

  • Thinks Flush or Dispose guarantees data on disk.
  • Suggests WriteThrough for all writes without considering cost.

📚 Learn in depth

1. FileStream buffering

  • Default buffer size is 4,096 bytes. Pass bufferSize: 0 or 1 to disable it (useful when you already write large blocks).
  • Larger buffers (64–128 KiB) help sequential throughput for big files; beyond that the gain is small.
  • 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.

2. The OS page cache

The kernel keeps file data in RAM after reads and writes:

  • Reads: the second read of a file comes from RAM. That is why benchmarks must account for cold vs warm cache.
  • Writes: data becomes "dirty" pages that the kernel writes back later (on Linux usually within ~30 seconds, controlled by vm.dirty_* settings).
  • free -m on Linux shows this as buff/cache; it is reclaimable and not a memory leak.

3. Atomic replace

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.

4. Buffer management in code

  • Rent large temporary buffers from ArrayPool<byte>.Shared instead of allocating new byte[1_000_000] per request (avoids LOH churn).
  • Use stackalloc or Span<byte> for small, short-lived buffers.
  • Always Return rented arrays in finally, and do not use them after returning.

5. Cost of durability

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.

6. Try it yourself

  1. Write 1 million short lines with bufferSize: 1 and AutoFlush = true, then with a 64 KiB buffer. Compare times.
  2. Write a file without disposing the StreamWriter and exit the process: see the missing tail.
  3. On Linux, run strace -f -e trace=write,fsync dotnet App.dll to see how many write and fsync calls each version makes.

💻 Code

// 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
}

⚠ Common mistakes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate is unclear how streams wrap each other Why: Checks the Stream abstraction behind the buffers
    How to answer

    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.

    • Backing streams talk to a store: FileStream, MemoryStream, NetworkStream.
    • Wrapper (decorator) streams transform data on its way through: BufferedStream, GZipStream, BrotliStream, CryptoStream, SslStream.
    • Readers and writers add text or binary formats on top: 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.

  2. When: Candidate says buffering reduces system calls Why: Checks understanding of system call cost
    How to answer

    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.

  3. When: Candidate mentions writing a temp file and renaming Why: Probes rename and delete behaviour with open files and locks
    How to answer

    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.

    • Windows enforces share modes (mandatory). A conflicting open fails with a sharing violation IOException (Win32 error 32).
    • Linux has no share modes. .NET emulates 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.

🛠 Real-world problems: how you handle them

Corrupt outbox files after a host power loss
Situation
[Payment gateway] on .NET [10] wrote outgoing messages to a local folder as JSON files before sending them to [the bank's API].
Signals
After an unplanned host [power loss], the service crashed on start with `JsonException`; [6] files were zero-length or truncated.
Investigation
Found `File.WriteAllText` overwriting files in place with no fsync. Reproduced by killing a VM mid-write in a test environment.
Resolution
Recovered the messages from upstream logs, then switched to write-temp, `Flush(true)`, atomic `File.Move`, and made the reader ignore and clean up `.tmp` files.
Prevention
Later moved the outbox into [SQL Server] with a transactional outbox pattern; added a chaos test that kills the process during writes.

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.

↩ Usually asked after

More in File System

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me