ASP.NET Core runs on a thread pool — there are a limited number of threads available to handle HTTP requests. When a request thread performs synchronous file I/O (blocking the thread while waiting for the OS to read from disk), that thread cannot handle other requests. Under load, this leads to thread pool exhaustion: all threads are blocked waiting on disk, and new requests queue up. Async file I/O (await File.ReadAllTextAsync()) releases the thread back to the pool while the OS reads the file asynchronously, allowing the thread to handle other requests in the meantime.
Async File Operations
// ── Async equivalents of synchronous File methods ─────────────────────────
string content = await File.ReadAllTextAsync("config.json");
string[] lines = await File.ReadAllLinesAsync("data.csv");
byte[] bytes = await File.ReadAllBytesAsync("image.png");
await File.WriteAllTextAsync("output.txt", content);
await File.WriteAllLinesAsync("lines.txt", lines);
await File.WriteAllBytesAsync("copy.png", bytes);
await File.AppendAllTextAsync("log.txt", $"\n{DateTime.Now}: entry");
// ── Async StreamReader ────────────────────────────────────────────────────
using var reader = new StreamReader("large.csv");
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
await ProcessLineAsync(line);
}
// ── With CancellationToken (allows cancellation of long-running reads) ────
await File.ReadAllTextAsync("config.json", cancellationToken);
// ── Async stream with CopyToAsync ─────────────────────────────────────────
// ASP.NET Core file upload: IFormFile.OpenReadStream() → copy to local storage
using var formStream = formFile.OpenReadStream();
using var fileStream = File.Create(Path.Combine(uploadPath, safeFileName));
await formStream.CopyToAsync(fileStream, cancellationToken);
File.ReadAllTextAsync() and its siblings use FileOptions.Asynchronous internally — the file is opened with the Windows FILE_FLAG_OVERLAPPED flag (on Windows) or uses an async I/O completion port, so the thread is truly released while the OS reads from disk. This is distinct from fake-async patterns that just call synchronous code on a thread pool thread (await Task.Run(() => File.ReadAllText(...))), which releases the calling thread but consumes another thread doing the blocking I/O. Use the built-in async methods for genuine async I/O.CancellationToken from the request’s HttpContext.RequestAborted — if the client disconnects mid-upload, the cancellation token fires and the CopyToAsync throws OperationCanceledException, which ASP.NET Core handles gracefully. This prevents zombie file operations continuing after the client has gone.Task.Run(() => File.ReadAllText(...)) in ASP.NET Core as an “async” workaround. This moves the blocking work off the request thread, but it still consumes a thread pool thread doing blocking I/O — you have not gained anything, you have just moved the bottleneck. The correct approach is the genuinely async await File.ReadAllTextAsync() which frees the thread pool entirely while the OS handles the I/O. If only synchronous I/O is available (e.g., a third-party library), Task.Run may be justified, but document it clearly.Processing Large Files Line by Line
// Process a large CSV import file without loading it all into memory
public async Task<ImportResult> ImportUsersAsync(
string filePath,
CancellationToken ct = default)
{
var result = new ImportResult();
int lineNumber = 0;
using var reader = new StreamReader(filePath, Encoding.UTF8);
// Skip header line
string? header = await reader.ReadLineAsync(ct);
if (header is null) return result;
string? line;
while ((line = await reader.ReadLineAsync(ct)) is not null)
{
lineNumber++;
try
{
var user = ParseUserLine(line, lineNumber);
await _userRepo.CreateAsync(user);
result.SuccessCount++;
}
catch (FormatException ex)
{
result.Errors.Add($"Line {lineNumber}: {ex.Message}");
}
}
return result;
}
// .NET 8 — File.ReadLinesAsync() returns IAsyncEnumerable<string>
await foreach (string csvLine in File.ReadLinesAsync("import.csv", ct))
{
await ProcessLineAsync(csvLine);
}
Common Mistakes
Mistake 1 — Blocking file I/O in ASP.NET Core request handlers
❌ Wrong — blocks the thread pool thread:
public IActionResult GetConfig()
{
string config = File.ReadAllText("appsettings.json"); // synchronous block!
return Ok(config);
}
✅ Correct — async all the way:
public async Task<IActionResult> GetConfig()
{
string config = await File.ReadAllTextAsync("appsettings.json");
return Ok(config);
}
Mistake 2 — Not passing CancellationToken to async file operations
❌ Wrong — operations continue even after client disconnects or request times out.
✅ Correct — always thread the CancellationToken through: await File.ReadAllTextAsync(path, cancellationToken).