Mert Özen Further With Every Line
Backend I/O in .NET — #02

FileStream and Async File I/O: FileStreamOptions, RandomAccess and Atomic Writes

Mert Özen Nov 14, 2026 16 min 2 views
FileStream and Async File I/O: FileStreamOptions, RandomAccess and Atomic Writes

To replace a file without ever leaving it half-written, write the content to a temporary file in the same folder, push it to disk with Flush(flushToDisk: true) and swap it in with File.Move. We cover FileStreamOptions, async I/O on Windows vs Linux, offset-based reads with RandomAccess, race-free creation with CreateNew and a Worker that catches what FileSystemWatcher misses, using Bookstore examples.

Version note: The code in this post was built and run with .NET 10 and Microsoft.Extensions.Hosting 10.0.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, every order's invoice is written to disk as a PDF. The code looks like this:

await using var fs = new FileStream(path, FileMode.Create);
await fs.WriteAsync(pdfBytes);

If the code producing the PDF throws halfway through the write, what is left in place of yesterday's correctly written invoice file?

Second question: you call ReadAsync on a file opened with new FileStream(path, FileMode.Open). Does that call really run asynchronously on Windows, or does it keep a thread pool thread busy?

Third: every night the ERP system drops orders-2026-11-14.jsonl into an inbox folder. How many orders does a Worker see if it reads the file immediately in FileSystemWatcher's Created event?

In the previous post we looked at the Stream contract. This post moves to the stream we use most, FileStream, and to the parts of working with the file system that hurt in production. The examples live in the series' repo as the Bookstore.IO.Files console app and the Bookstore.OrderImport.Worker service:

dotnet run --project src/Bookstore.IO.Files -- buffering
dotnet run --project src/Bookstore.OrderImport.Worker

The .NET 6 Rewrite and FileStreamOptions

FileStream was almost completely rewritten in .NET 6. The most visible changes: the async path became noticeably faster, Position is no longer synchronized with the operating system on every read and write (it is tracked only in memory), and a single FileStreamOptions parameter replaced dozens of constructor overloads:

var options = new FileStreamOptions
{
    Mode = FileMode.CreateNew,
    Access = FileAccess.Write,
    Share = FileShare.None,
    Options = FileOptions.Asynchronous | FileOptions.WriteThrough,
    BufferSize = 0,
    PreallocationSize = 256 * 1024
};

await using var stream = new FileStream(path, options);
Console.WriteLine($"IsAsync: {stream.IsAsync}, Length after open: {stream.Length}");
await stream.WriteAsync(new byte[10_000]);
Console.WriteLine($"Length after writing 10,000 bytes: {stream.Length}, Position: {stream.Position}");
IsAsync: True, Length after open: 0
Length after writing 10,000 bytes: 10000, Position: 10000

Let's go through the fields. Mode decides what happens when the file does or does not exist; CreateNew throws if the file exists, and we will see shortly why that matters. Access defaults to Read, and so does Share. Share says what you allow other processes to do: as long as you hold the file open with FileShare.None, nobody else can open it.

BufferSize defaults to 4096. Passing 0 or 1 turns off FileStream's own buffer; 1 has meant that in every version, 0 was added in .NET 6. If you manage your own large buffer (say, 1 MB reads), FileStream does not need to make an extra copy through a 4 KB buffer. But if you write in small pieces, turning the buffer off is expensive. We wrote 100,000 short lines with three buffer sizes:

for (int i = 0; i < 100_000; i++)
{
    await fs.WriteAsync(line); // a 35-byte CSV line
}
BufferSize      0:   179 ms
BufferSize   4096:     8 ms
BufferSize  65536:     8 ms

Without a buffer, every WriteAsync becomes a separate system call. Absolute numbers depend on the machine and the operating system, but a difference of more than 20x is a serious difference anywhere.

PreallocationSize asks the operating system to reserve that much disk space when the file is created. As the output shows, Length stays 0; the reservation affects the space on disk, not the file's size. If you know the size up front (you already have the invoice bytes), passing it reduces fragmentation as the file grows, and if the space is not there you get the error when the file is opened rather than halfway through the write. FileOptions.WriteThrough asks writes to go to disk without waiting in the operating system's cache; it slows every write down, so use it only when you really need it.

Real Async I/O: Windows vs Linux

The answer to the second question is FileOptions.Asynchronous. On Windows a file can be opened in two ways: with a synchronous handle or with an overlapped (asynchronous) one. Open the file with FileOptions.Asynchronous or useAsync: true, and ReadAsync hands a request to the operating system and releases the thread; completion is signaled through an I/O completion port. Call ReadAsync on a file opened without that flag, and .NET runs a synchronous read on a thread pool thread. The code looks async, but a thread is blocked for the duration of the read, and according to Microsoft's .NET 6 write-up that path also has no cancellation support. The reverse holds too: call synchronous Read on a file opened for async, and .NET starts an asynchronous operation and waits for it.

Linux and macOS are different. On these platforms .NET has no async file API to use for regular files; every async file operation runs as a synchronous operation on the thread pool, and there is no synchronous/asynchronous handle distinction. So the same code that waits without a thread on Windows keeps a thread busy on Linux. That is usually fine, but if a service doing hundreds of concurrent large file reads runs in a Linux container, keep an eye on thread pool growth.

The practical rule is simple: if you will use the async methods, open the file with FileOptions.Asynchronous. The ready-made methods such as File.ReadAllTextAsync, File.WriteAllBytesAsync and File.ReadLinesAsync already do this internally.

The Whole File or Line by Line: ReadAllTextAsync and ReadLinesAsync

File.ReadAllTextAsync is short and convenient, but it loads the whole file into memory as a single string. File.ReadLinesAsync, added in .NET 7, hands you the lines one by one as an IAsyncEnumerable<string>. We compared the two on a 68 MB JSON Lines file with 200,000 orders:

string all = await File.ReadAllTextAsync(path);

int count = 0;
int longest = 0;
await foreach (string line in File.ReadLinesAsync(path))
{
    count++;
    longest = Math.Max(longest, line.Length);
}
File: 68,001,648 bytes
ReadAllTextAsync: one string of 68,001,648 chars (136,003,296 bytes), allocated 275,416,712 bytes
ReadLinesAsync:   200,000 lines, longest 466 chars, allocated 158,499,368 bytes

The total allocations may look close, but the real difference is in what lives in memory at the same time. ReadAllTextAsync holds a single 136 MB string; .NET strings are UTF-16, so a 68 MB ASCII file doubles in memory. With ReadLinesAsync, only the current line is alive at any moment, the longest being 466 characters. The lines are short-lived, so the GC collects them cheaply. Prefer reading line by line anywhere you do not control the file size; on a 5 GB file ReadAllTextAsync will not work anyway.

RandomAccess: Offset-Based Reads Without a Position

FileStream has a Position, which is why reading the same stream from two tasks at once is not safe. The RandomAccess class added in .NET 6 removes that problem: you get a SafeFileHandle from File.OpenHandle and pass the offset yourself on every read and write. The handle has no shared position, so several tasks can use it safely. We split the order file into four ranges and counted line endings in each range in parallel:

using SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read,
    FileOptions.Asynchronous);
long length = RandomAccess.GetLength(handle);
const int parts = 4;
long chunk = (length + parts - 1) / parts;

// Each task reads its own range of the same handle; there is no shared Position.
long[] counts = await Task.WhenAll(Enumerable.Range(0, parts).Select(async p =>
{
    long start = p * chunk;
    long end = Math.Min(start + chunk, length);
    byte[] buffer = new byte[1 << 20];
    long lines = 0;
    for (long offset = start; offset < end;)
    {
        int toRead = (int)Math.Min(buffer.Length, end - offset);
        int n = await RandomAccess.ReadAsync(handle, buffer.AsMemory(0, toRead), offset);
        if (n == 0) break;
        lines += buffer.AsSpan(0, n).Count((byte)'\n');
        offset += n;
    }
    return lines;
}));
Length 68,001,648, 4 ranges of 17,000,412 bytes
Lines per range: 49,965 + 50,060 + 49,956 + 50,019 = 200,000

For counting line endings it does not matter that a range boundary falls in the middle of a line, because each \n byte is counted in exactly one range. If you actually parse the lines, you must align the boundaries to line starts; we will cover that, and measure when parallel reading really speeds things up, in the large files post of this series. One thing to note here: ReadAsync can also return fewer bytes than requested, which is why the loop advances by n. RandomAccess does not support handles without an offset concept, such as pipes and sockets.

Files That Never End Up Half-Written: Flush, Atomic Replacement and CreateNew

What a direct write leaves behind

Let's see the answer to the first question. While writing a new invoice over the old 200,000-byte one, we throw an exception at byte 80,000:

await using var fs = new FileStream(path, FileMode.Create);
await fs.WriteAsync(v2.AsMemory(0, 80_000));
throw new IOException("renderer crashed");
Direct write:  renderer crashed, file is now 80,000 bytes

FileMode.Create truncates the file as soon as it opens it. After the exception you have neither the old invoice nor the new one; you have an 80,000-byte PDF that does not open. A customer downloading their invoice at that moment gets the broken file.

A temporary file and File.Move

The fix is to write the new content to a temporary file in the same folder, make sure it reached the disk, and only then put it in place of the real file in a single step:

public static async Task WriteAllBytesAsync(string path, ReadOnlyMemory<byte> content, CancellationToken ct = default)
{
    string fullPath = Path.GetFullPath(path);
    string directory = Path.GetDirectoryName(fullPath)!;
    string tempPath = Path.Join(directory, $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");

    try
    {
        var options = new FileStreamOptions
        {
            Mode = FileMode.CreateNew,
            Access = FileAccess.Write,
            Share = FileShare.None,
            Options = FileOptions.Asynchronous,
            PreallocationSize = content.Length
        };

        await using (var stream = new FileStream(tempPath, options))
        {
            await stream.WriteAsync(content, ct);
            stream.Flush(flushToDisk: true);
        }

        File.Move(tempPath, fullPath, overwrite: true);
    }
    catch
    {
        File.Delete(tempPath);
        throw;
    }
}
Atomic write:  cancelled, file is 200,000 bytes of 'A'
Atomic write:  done, file is 200,000 bytes of 'B'
Temp files left: 0

When the write is cancelled, the old invoice (the As) stays as it was; when it succeeds, the new one (the Bs) arrives, and no temporary file is left behind. Three details matter. The first is Flush(flushToDisk: true): a normal Flush hands the data to the operating system, but the data may still sit in the operating system's cache. flushToDisk: true calls FlushFileBuffers on Windows and fsync on Unix and waits for the data to be written to disk. Skip it, and after a power cut the rename may have reached the disk while the content has not.

The second is that the temporary file lives in the same folder. A rename within the same volume only changes the file system entry, and other processes see either the old file or the new one, never a mix. If you write the temporary file to Path.GetTempPath() and that is on another disk, File.Move does a copy and delete and is no longer atomic. For the same reason File.Replace requires the source and the destination to be on the same volume. Prefer File.Replace when you want a backup of the old file, or when on Windows you want to keep the target's attributes and ACLs; it throws if the target file does not exist.

The third is Windows-specific: if another process keeps the target open without granting FileShare.Delete (a customer is downloading the invoice right then), the replacement can fail with an exception. In such an environment, add a retry that waits briefly and tries a few times.

The race between File.Exists and Open

An invoice number must be used once; if two requests try to produce the same invoice at the same time, the second must stop. The first code that comes to mind creates the file after an if (!File.Exists(path)) check. In the gap between the two, another request can create the file and both write. The right way is to leave the check to the operating system:

try
{
    await using var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
    await fs.WriteAsync(Encoding.ASCII.GetBytes($"worker {worker}"));
    return $"worker {worker}: created";
}
catch (IOException ex) when (File.Exists(path))
{
    return $"worker {worker}: {ex.GetType().Name}, file already exists";
}
worker 1: created
worker 2: IOException, file already exists
worker 3: IOException, file already exists
worker 4: IOException, file already exists

Four tasks tried at the same time and only one won. FileMode.CreateNew makes the operating system do "create if it does not exist" as a single operation. The same logic applies to .lock files used as folder locks.

A Worker That Watches the Daily Order File

On to the third question. FileSystemWatcher's Created event fires when the file is created, not when the write finishes. If the ERP writes the file in five pieces, code that reads on the first event either sees part of the file or gets an exception because the file is locked. On top of that, a single write can raise several Changed events, events arrive on another thread, and if the internal buffer that holds the operating system's notifications (8 KB by default, 64 KB at most) overflows, events are lost and only the Error event arrives. So FileSystemWatcher is reliable as a "something changed" signal, not as "the file is ready".

That is why the Worker does three things: it collapses events into a single entry per file (debounce), it tries to open the file exclusively once it has been quiet for a while, and it scans the folder at startup and at intervals to catch missed events:

public sealed class OrderFileWatcher(
    OrderFileImporter importer,
    IOptions<OrderImportOptions> options,
    TimeProvider time,
    ILogger<OrderFileWatcher> logger) : BackgroundService
{
    private readonly OrderImportOptions _options = options.Value;

    // Path -> time of the last event. Many events for one file collapse into one entry.
    private readonly ConcurrentDictionary<string, DateTimeOffset> _pending = new();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        string inbox = Path.GetFullPath(_options.InboxPath);
        Directory.CreateDirectory(inbox);
        Directory.CreateDirectory(_options.ProcessedPath);

        using var watcher = new FileSystemWatcher(inbox, _options.Filter)
        {
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size,
            InternalBufferSize = 64 * 1024
        };
        watcher.Created += (_, e) => Touch(e.FullPath);
        watcher.Changed += (_, e) => Touch(e.FullPath);
        watcher.Renamed += (_, e) => Touch(e.FullPath);
        watcher.Error += (_, e) =>
        {
            logger.LogWarning(e.GetException(), "Watcher error, rescanning {Inbox}", _options.InboxPath);
            Rescan(inbox);
        };
        watcher.EnableRaisingEvents = true;

        Rescan(inbox); // files that arrived while the service was down
        logger.LogInformation("Watching {Inbox} for {Filter}", _options.InboxPath, _options.Filter);

        DateTimeOffset lastRescan = time.GetUtcNow();
        using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250), time);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            if (time.GetUtcNow() - lastRescan >= _options.RescanInterval)
            {
                Rescan(inbox);
                lastRescan = time.GetUtcNow();
            }
            await ProcessDueFilesAsync(stoppingToken);
        }
    }

    private void Touch(string path) => _pending[path] = time.GetUtcNow();

    private void Rescan(string inbox)
    {
        foreach (string path in Directory.EnumerateFiles(inbox, _options.Filter))
        {
            _pending.TryAdd(path, DateTimeOffset.MinValue);
        }
    }

    private async Task ProcessDueFilesAsync(CancellationToken ct)
    {
        DateTimeOffset now = time.GetUtcNow();
        foreach (var (path, lastEvent) in _pending)
        {
            if (now - lastEvent < _options.QuietPeriod)
            {
                continue; // still receiving events: wait for the file to settle
            }
            if (!File.Exists(path))
            {
                _pending.TryRemove(path, out _);
                continue;
            }

            try
            {
                var result = await importer.ImportAsync(path, ct);
                string target = Path.Join(_options.ProcessedPath, Path.GetFileName(path));
                File.Move(path, target, overwrite: true);
                _pending.TryRemove(path, out _);
                logger.LogInformation("Imported {File}: {Orders} orders, {Total:N2} TRY",
                    Path.GetFileName(path), result.Orders, result.Total);
            }
            catch (IOException ex) when (ex is not FileNotFoundException)
            {
                // The producer still holds the file; try again on a later tick.
                logger.LogDebug("{File} is busy: {Message}", Path.GetFileName(path), ex.Message);
                _pending[path] = time.GetUtcNow();
            }
        }
    }
}

OrderFileImporter opens the file with FileShare.None. If the producer still holds the file open for writing, that open fails with IOException and the file waits for the next round. The quiet period and the lock check complement each other: one says "no event for the last second", the other says "nobody is holding the file". Taking TimeProvider from the constructor is a deliberate choice too, so that a test can move time forward by hand.

To try it, we started the Worker. There was a file in inbox that had arrived while the service was down. Then a producer wrote one file directly under its final name in five pieces, 400 ms apart; after that it wrote another under a temporary name that does not match the filter and renamed it in one step. dotnet run runs the Worker in its own project folder, so inbox lives under that folder:

dotnet run --project src/Bookstore.IO.Files -- drop src/Bookstore.OrderImport.Worker/inbox slow
dotnet run --project src/Bookstore.IO.Files -- drop src/Bookstore.OrderImport.Worker/inbox atomic
03:50:07.133 producer: wrote 2000 orders
03:50:07.557 producer: wrote 2000 orders
03:50:07.970 producer: wrote 2000 orders
03:50:08.374 producer: wrote 2000 orders
03:50:08.783 producer: wrote 2000 orders
03:50:09.185 producer: done
03:50:12.302 producer: renamed into place
03:50:12.317 producer: done
03:50:04.210 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Watching inbox for orders-*.jsonl
03:50:04.529 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Imported orders-2026-11-13.jsonl: 10000 orders, 8,966,678.30 TRY
03:50:10.295 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Imported orders-2026-11-14.jsonl: 10000 orders, 8,966,678.30 TRY
03:50:13.530 info: Bookstore.OrderImport.Worker.OrderFileWatcher[0] Imported orders-2026-11-15.jsonl: 10000 orders, 8,966,678.30 TRY

At startup the service found the waiting file through the scan. The file written piece by piece was processed once and completely, about 1.1 seconds after the producer finished, that is, once the quiet period had passed. The renamed file arrived through the Renamed event. That last one is the cleanest way: if you control the producer, ask it to write under a temporary name and rename the file when done. If you do not (a bank, an ERP, another team), debounce and the lock check are a must. The general use of BackgroundService and hosting was covered in the Background Jobs and Scheduled Jobs series.

Cross-Platform Paths

When you develop on Windows and run the app in a Linux container, paths bring surprises. Watch for three differences.

Case: NTFS is case-insensitive by default, Linux file systems are case-sensitive. A file looked up as Invoice-BS-42.PDF on Windows is found as invoice-BS-42.pdf; in the container it is not. Generate file names in code by a single rule (for example, always lowercase) and do not search the file system with a name that came from the user.

Path.Combine versus Path.Join: if the second argument starts with a root, Combine drops the first argument entirely:

Console.WriteLine($"Combine: {Path.Combine("invoices", "/etc/passwd")}");
Console.WriteLine($"Join:    {Path.Join("invoices", "/etc/passwd")}");

string root = Path.GetFullPath("invoices");
string requested = Path.GetFullPath(Path.Join(root, "../appsettings.json"));
bool inside = requested.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal);
Combine: /etc/passwd
Join:    invoices/etc/passwd
'../appsettings.json' stays inside invoices: False

Join only concatenates the arguments, but it also leaves .. segments as they are. If you build a path from user input, the only safe way is to normalize the result with Path.GetFullPath and check that it stays inside the root folder you allow. The details of path traversal attacks are in the OWASP Top 10 series' post on path traversal; here we only recall this check. Also, the separator is \ on Windows and / on Linux; build paths with these methods, not by concatenating strings.

Invalid characters differ by platform too. Path.GetInvalidFileNameChars() returns many characters on Windows, such as :, *, ?, ", <, > and |, but only / and the null character on Linux. A file called orders:2026-11-14.jsonl produced on Linux cannot be copied to Windows. Limiting file names to letters, digits, dashes and dots causes the fewest problems.

In Production

The disk can fill up. Catching and logging IOException is not enough; you also need to delete the half-written temporary file so the full disk does not also fill with garbage files. The catch block in the atomic write does that. Adding a step that cleans up old .tmp files when the service starts is a good idea too, because the catch does not run when the process is killed.

Antivirus and backup software on Windows keep files open briefly. When you move a file you just wrote, you can occasionally get a sharing violation; that is why a short retry around file operations is common practice.

On network folders mounted over SMB or NFS, trust FileSystemWatcher and file locks less. Events can be delayed or never arrive; the periodic scan becomes the real mechanism there, and events only shorten the delay.

Metrics to watch: the number of files waiting to be processed, the age of the oldest waiting file, and the count of Watcher error logs. If the age of the waiting files grows, either the producer is not closing the file or processing is failing.

Common Pitfalls

Using the async API without FileOptions.Asynchronous. Opening with new FileStream(path, FileMode.Open) and calling ReadAsync blocks a thread pool thread on every call on Windows. Pass useAsync: true to the constructor or set FileStreamOptions.Options.

Deciding between File.Exists and Open. The file's state can change between the check and the operation. Use FileMode.CreateNew for "create if missing", and for "open if present" open it directly and catch FileNotFoundException.

Writing the invoice or the export directly under its real name. If the write stops halfway, readers see a broken file. Temporary file, Flush(true), File.Move within the same folder.

Reading the file immediately in the Created event. The file may still be being written. Debounce, an exclusive open attempt and a periodic scan.

Passing user input to Path.Combine. Input that starts with a root replaces the whole path; input with .. leaves the folder. Normalize and check that it stays inside the root.

A Small Experiment

★☆☆ Add BufferSize = 1 and BufferSize = 1 << 20 to the buffering command. Measure that 1 gives the same result as 0, and whether 1 MB gains anything over 64 KB.

★★☆ Add a retry for the Windows sharing violation to AtomicFile.WriteAllBytesAsync: when an IOException arrives, try three times, waiting 50, 100 and 200 ms. To test it, keep the target file open in another FileStream with FileShare.Read.

★★★ Write a test for OrderFileWatcher: move time forward by hand with FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package, and verify that the file is not processed before the quiet period ends and is processed exactly once after it.

So far we have read data either line by line or in large byte blocks. The next post moves to System.IO.Pipelines: we will see the case where a message is split across two buffers, how to parse buffers without copying them, and backpressure with PipeReader, measured on an NDJSON order stream.