EndRead
asynchronous programming
error handling
.NET
coding best practices

Must call EndRead in ALL cases?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In the classic .NET asynchronous programming model, BeginRead starts an operation and EndRead finishes it. If BeginRead returned successfully, the safe rule is that you must call EndRead exactly once for that operation, even when the read fails.

Why BeginRead and EndRead are a pair

The older APM pattern splits one logical read into two API calls. BeginRead queues the I/O work and gives you an IAsyncResult. EndRead completes the contract by retrieving the byte count, surfacing deferred exceptions, and letting the stream release any internal state associated with the operation.

That last point matters more than many examples suggest. A read can appear to start correctly and still fail later because the socket closes, the file handle becomes invalid, or the stream is disposed before completion. In APM, those failures often show up only when EndRead executes. If you skip EndRead, you are not just ignoring a return value. You are leaving the operation unfinished.

There is one narrow exception. If BeginRead throws before it returns an IAsyncResult, there is nothing to complete. In every other normal path, successful start means required completion.

A safe callback pattern

The main discipline is simple: the callback that owns the IAsyncResult should also own the matching EndRead call. Keep both steps close together so the code cannot accidentally complete the same operation twice or forget to complete it at all.

csharp
1using System;
2using System.IO;
3using System.Text;
4
5public sealed class ReaderState
6{
7    public Stream Stream { get; }
8    public byte[] Buffer { get; }
9
10    public ReaderState(Stream stream, byte[] buffer)
11    {
12        Stream = stream;
13        Buffer = buffer;
14    }
15}
16
17public static class ApmReader
18{
19    public static void StartRead(Stream stream)
20    {
21        var buffer = new byte[1024];
22        var state = new ReaderState(stream, buffer);
23
24        stream.BeginRead(buffer, 0, buffer.Length, ReadCompleted, state);
25    }
26
27    private static void ReadCompleted(IAsyncResult asyncResult)
28    {
29        var state = (ReaderState)asyncResult.AsyncState!;
30
31        try
32        {
33            int bytesRead = state.Stream.EndRead(asyncResult);
34            string text = Encoding.UTF8.GetString(state.Buffer, 0, bytesRead);
35            Console.WriteLine($"Read {bytesRead} bytes: {text}");
36        }
37        catch (IOException ex)
38        {
39            Console.WriteLine($"I/O failure: {ex.Message}");
40        }
41        catch (ObjectDisposedException ex)
42        {
43            Console.WriteLine($"Stream closed early: {ex.Message}");
44        }
45    }
46}

This pattern makes ownership obvious. BeginRead starts one operation, and the callback calls EndRead once inside a try block so deferred exceptions are handled in the same place.

Handling failures and cancellation paths

Developers often ask whether failure paths let them skip EndRead. Usually they do not. If the callback ran, you should assume an operation exists and complete it. EndRead is exactly where you learn whether the read produced bytes, hit end of stream, or failed.

The confusing part is that APM does not map well onto modern cancellation patterns. Many APM APIs were designed before CancellationToken became common, so cancellation often means closing the underlying stream or abandoning the workflow. Even then, the callback may still fire. The correct callback still attempts EndRead and handles the expected exception.

A useful mental model is this: BeginRead reserves the right for the stream to answer later, and EndRead is where you collect that answer.

Why modern async code is easier

New .NET code should usually avoid APM entirely. ReadAsync and await express the same intent without requiring manual pairing rules or callback ownership conventions.

csharp
1using System.IO;
2using System.Text;
3using System.Threading.Tasks;
4
5public static async Task<string> ReadChunkAsync(Stream stream)
6{
7    byte[] buffer = new byte[1024];
8    int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
9    return Encoding.UTF8.GetString(buffer, 0, bytesRead);
10}

With await, the completion step is built into the asynchronous method call. You still need normal error handling, but you no longer have to remember an extra EndRead call for each pending read. That makes the code easier to audit and much harder to misuse.

Common Pitfalls

  • Treating the callback as proof that the read already succeeded. The callback only means the operation finished; EndRead tells you how.
  • Ignoring EndRead when you do not need the byte count. Even if the count is irrelevant, the completion step is still required.
  • Calling EndRead from more than one branch. One BeginRead maps to one EndRead, no more and no less.
  • Assuming every error would have been thrown by BeginRead. Many I/O failures are deferred until completion.
  • Writing new code with APM out of habit. In most current codebases, ReadAsync is clearer and safer.

Summary

  • In APM, BeginRead and EndRead form one operation.
  • If BeginRead returns an IAsyncResult, call EndRead exactly once.
  • 'EndRead returns the byte count and surfaces deferred exceptions.'
  • The only normal case without EndRead is when BeginRead itself throws before the operation starts.
  • Prefer ReadAsync and await in new code, but keep the pairing rule in mind when maintaining older APIs.

Course illustration
Course illustration

All Rights Reserved.