TcpClient
EndConnect
NullReferenceException
socket
error-handling

TcpClient.EndConnect throws NullReferenceException when socket is

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

TcpClient.EndConnect throwing NullReferenceException usually indicates a race between asynchronous connect callbacks and socket disposal. Legacy Begin and End patterns are particularly vulnerable when cancellation and shutdown logic are not synchronized. A safer design validates client state and prefers task-based async APIs.

Why the Exception Happens

In Begin and End patterns, callback code can run after another thread has closed or disposed the client. If callback logic assumes socket fields are still available, null access can occur. The issue is often timing-dependent and appears intermittently in production.

Defensive Pattern with Begin and End

If you must keep Begin and End APIs, guard access carefully and synchronize disposal.

csharp
1using System;
2using System.Net.Sockets;
3
4public class LegacyConnector
5{
6    private readonly object _gate = new object();
7    private TcpClient? _client;
8
9    public void Connect(string host, int port)
10    {
11        lock (_gate)
12        {
13            _client = new TcpClient();
14            _client.BeginConnect(host, port, OnConnect, _client);
15        }
16    }
17
18    private void OnConnect(IAsyncResult ar)
19    {
20        try
21        {
22            var client = ar.AsyncState as TcpClient;
23            if (client == null) return;
24
25            lock (_gate)
26            {
27                if (!ReferenceEquals(client, _client)) return;
28            }
29
30            client.EndConnect(ar);
31            Console.WriteLine("Connected");
32        }
33        catch (ObjectDisposedException)
34        {
35            Console.WriteLine("Connect callback after disposal");
36        }
37        catch (SocketException ex)
38        {
39            Console.WriteLine($"Socket error: {ex.Message}");
40        }
41    }
42
43    public void Close()
44    {
45        lock (_gate)
46        {
47            _client?.Close();
48            _client = null;
49        }
50    }
51}

This pattern reduces races but still requires careful lifecycle control.

Prefer Task-Based ConnectAsync

Modern async code is easier to reason about and compose with cancellation.

csharp
1using System;
2using System.Net.Sockets;
3using System.Threading;
4using System.Threading.Tasks;
5
6public static class Connector
7{
8    public static async Task<TcpClient?> TryConnectAsync(string host, int port, CancellationToken token)
9    {
10        var client = new TcpClient();
11        try
12        {
13            await client.ConnectAsync(host, port, token);
14            return client;
15        }
16        catch
17        {
18            client.Dispose();
19            return null;
20        }
21    }
22}

Task-based flow makes cancellation and error paths explicit.

Connection Lifecycle Design

Treat connect, use, and close operations as one state machine. Keep transitions explicit and centralized so callback code does not act on stale state. If multiple components can close sockets, create one ownership boundary to avoid cross-thread teardown surprises.

A simple policy where one component owns client creation and disposal can remove many intermittent issues.

Logging and Diagnostics

Capture connect start time, target endpoint, cancellation events, and close operations. These logs make race conditions visible and help confirm whether callbacks are arriving after teardown. Intermittent network issues can otherwise look like random null failures.

Cancellation and Shutdown Coordination

Connection teardown should be coordinated with cancellation tokens and explicit shutdown steps. Avoid closing sockets from many call sites. Instead, route shutdown through one method that updates state, signals cancellation, and then disposes resources.

csharp
1private readonly CancellationTokenSource _cts = new CancellationTokenSource();
2
3public void Stop()
4{
5    _cts.Cancel();
6    Close();
7}

Centralized shutdown logic prevents overlapping disposal paths that often cause callback races.

Recovery Strategy After Failed Connect

When connect fails, retry policies should include bounded attempts and exponential backoff. Immediate tight loops can overload remote endpoints and hide root-cause logs. A small, controlled retry policy improves resilience without making diagnosis harder.

Design retry behavior as part of connection lifecycle, not as scattered catch-block code.

Consistent ownership rules are often the most effective long-term fix for these race conditions.

Well-scoped lifecycle boundaries make future refactoring safer as networking complexity grows.

Common Pitfalls

  • Disposing TcpClient from one thread while callback still assumes it is valid.
  • Accessing shared socket references without synchronization.
  • Swallowing exceptions and losing root cause details.
  • Continuing new feature work on legacy Begin and End flow without migration plan.

Summary

  • Null-reference errors in EndConnect often come from lifecycle races.
  • Synchronize client state when using Begin and End APIs.
  • Prefer ConnectAsync with cancellation for modern code.
  • Add connection lifecycle logging to diagnose timing failures.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.