Extending HttpClient With Delegating Handlers in ASP.NET Core
Learn how DelegatingHandlers build a middleware pipeline for HttpClient in ASP.NET Core to add logging, auth, and retries with IHttpClientFactory.
A relay race with batons
Think about a relay race on sports day. The first runner carries a baton, runs their part, and hands the baton to the next runner. That runner runs their part and passes it on again. The baton travels through every runner until it reaches the finish line. Then, in some races, a message travels all the way back to the start.
Sending an HTTP request from your app works in a very similar way. Your HttpClient is the baton. Before the request leaves your computer, it can pass through a line of small helpers. Each helper does one little job, then hands the request to the next helper. The last helper actually sends it over the network. When the answer comes back, it travels back up the same line, and each helper gets one more chance to do something.
These helpers are called DelegatingHandlers. In this guide we will learn what they are, why they are so useful, and how to build and register your own. By the end you will be able to add logging, authentication headers, and request tracing to every outgoing call in one clean place, without copy-pasting code everywhere.
What is a DelegatingHandler?
A DelegatingHandler is a class you write that wraps around an outgoing HTTP request. The word "delegating" is the key. The handler does a little bit of work, and then it delegates (hands off) the rest of the job to the next handler in the line.
If you have read about ASP.NET Core middleware, this will feel familiar. Middleware sits in front of requests coming into your app. A DelegatingHandler sits in front of requests going out of your app. Same idea, opposite direction.
Every outgoing request travels through a small chain. At the very end of that chain is a special handler called the HttpClientHandler (or SocketsHttpHandler). That last one is the one that actually opens the network connection and sends bytes over the wire. Everything before it is a chance for you to peek at the request or change it.
Here is the shape of the chain. Notice how the request goes down and the response comes back up.
Why not just put the code inline?
You might ask: why bother with a special class? Why not add a log line or an auth header right where I call the API?
That works for one call. But real apps make the same call patterns in many places. Imagine you need to add an API key header to every request to a payment service. If you write that header by hand at each call site, you will forget one. When the key changes, you will hunt through the whole codebase.
A DelegatingHandler fixes this. You write the rule once, register it once, and every request through that client follows the rule. This is the same reason we like middleware: cross-cutting concerns belong in one shared place.
Here are the kinds of jobs handlers are great at.
| Job | What the handler does | Runs before or after? |
|---|---|---|
| Logging | Write a line for each request and response | Both |
| Authentication | Add a token or API key header | Before |
| Correlation ID | Add a tracing header so logs can be linked | Before |
| Timing | Measure how long the call took | Both |
| Caching | Return a saved response if one exists | Both |
Writing your first handler
To make a handler you do two things. You derive from DelegatingHandler, and you override the SendAsync method. Inside SendAsync you do your work, then you call base.SendAsync to pass the request to the next handler in the chain.
Let us build a simple logging handler. It writes a line before the request goes out and another line after the response comes back.
public class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(ILogger<LoggingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Sending {Method} request to {Url}",
request.Method,
request.RequestUri);
// Hand the request to the next handler in the chain.
HttpResponseMessage response =
await base.SendAsync(request, cancellationToken);
_logger.LogInformation(
"Received {StatusCode} from {Url}",
(int)response.StatusCode,
request.RequestUri);
return response;
}
}Read that carefully. The line await base.SendAsync(...) is the most important one. It is the "pass the baton" step. Everything above that line runs on the way out. Everything below it runs on the way back, after the response has arrived. If you forget to call base.SendAsync, the request never goes anywhere and your code will hang or fail.
Registering the handler with IHttpClientFactory
Writing the class is only half the job. We must tell ASP.NET Core to use it. We do this with IHttpClientFactory, the recommended way to create HttpClient instances in modern .NET. The factory manages the underlying connections for you and lets you attach handlers to a named or typed client.
There are two steps. First register the handler in the dependency injection container. Then chain it onto a client with AddHttpMessageHandler.
var builder = WebApplication.CreateBuilder(args);
// Step 1: register the handler so DI can create it.
builder.Services.AddTransient<LoggingHandler>();
// Step 2: attach the handler to a named client.
builder.Services
.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
})
.AddHttpMessageHandler<LoggingHandler>();
var app = builder.Build();Now any code that asks the factory for the "github" client gets a client whose outgoing requests pass through LoggingHandler automatically. You did not change any call site. You added the behavior in one place.
From request to response through one handler
Steps
Register
AddTransient<LoggingHandler>
Attach
AddHttpMessageHandler
Send
Handler logs, calls base
Return
Handler logs response
Chaining several handlers (and why order matters)
The real power shows up when you stack handlers. Say we want three jobs: add an auth token, log the call, and add a correlation ID for tracing. Each is its own small class. We chain them by calling AddHttpMessageHandler several times.
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddTransient<AuthHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services
.AddHttpClient("orders", client =>
{
client.BaseAddress = new Uri("https://api.orders.example");
})
.AddHttpMessageHandler<CorrelationIdHandler>() // runs first (outermost)
.AddHttpMessageHandler<AuthHandler>() // runs second
.AddHttpMessageHandler<LoggingHandler>(); // runs last (innermost)The order you call AddHttpMessageHandler is the order the handlers run on the way out. The first one you add is the outermost. The last one you add sits closest to the network. On the way back, the response travels in reverse.
This matters a lot. Picture logging. If you want your log line to record the final request after the auth header was added, the logging handler must sit inside the auth handler. If logging sat outside, it would log the request before the token existed.
Here is a table that maps the call order to the runtime behavior so you can see it at a glance.
| Call order | Handler | On the way out | On the way back |
|---|---|---|---|
| 1 (outermost) | CorrelationIdHandler | Adds tracing header | Sees response last |
| 2 (middle) | AuthHandler | Adds bearer token | Sees response second |
| 3 (innermost) | LoggingHandler | Logs final request | Sees response first |
Outgoing pipeline order
Steps
Correlation
Outermost, added first
Auth
Adds the token
Logging
Innermost, logs final
Wire
Bytes leave the app
A practical auth handler
Let us write the AuthHandler so the pattern is concrete. It reads a token from a service and adds it as a bearer header on every request.
public class AuthHandler : DelegatingHandler
{
private readonly ITokenProvider _tokens;
public AuthHandler(ITokenProvider tokens)
{
_tokens = tokens;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
string token = await _tokens.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}Notice the handler takes ITokenProvider in its constructor. Because we registered the handler with AddTransient, the dependency injection container builds a fresh handler for each request and supplies the token provider for us. A new handler per request means it is safe to use scoped services inside.
A note on scope and lifetime
One thing trips up beginners. The message handlers created by IHttpClientFactory are pooled and reused for a while, but each handler instance in the active chain is built per request scope. That means it is fine to inject scoped services into your handler's constructor, such as something that reads the current user from the request.
If you need the current request's data, like the incoming correlation ID, inject IHttpContextAccessor. This lets an outgoing handler copy a header from the incoming request onto the outgoing request, which is a common tracing pattern.
public class CorrelationIdHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _accessor;
public CorrelationIdHandler(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
string? incomingId = _accessor.HttpContext?
.Request.Headers["X-Correlation-Id"];
string id = incomingId ?? Guid.NewGuid().ToString();
request.Headers.Add("X-Correlation-Id", id);
return base.SendAsync(request, cancellationToken);
}
}Remember to call builder.Services.AddHttpContextAccessor() so this works.
Handlers and typed clients
So far we used a named client (the "github" and "orders" strings). You can also attach handlers to a typed client. A typed client is a class that takes an HttpClient in its constructor and wraps your API calls in friendly methods. This is often the cleaner choice for a real codebase.
builder.Services.AddTransient<LoggingHandler>();
builder.Services
.AddHttpClient<GitHubService>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
})
.AddHttpMessageHandler<LoggingHandler>();Now GitHubService gets an HttpClient whose calls pass through LoggingHandler. Your service class stays clean and only worries about turning API responses into objects.
When to use a resilience handler instead
It is tempting to write your own handler for retries, timeouts, and circuit breakers. You can, but there is a better tool. The Microsoft.Extensions.Http.Resilience package adds a single, well-ordered resilience pipeline built on top of Polly, the popular .NET resilience library. You add it with one call.
builder.Services
.AddHttpClient<GitHubService>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
})
.AddStandardResilienceHandler(); // retries, timeout, circuit breakerThe standard handler chains five strategies in a sensible order for you: a total request timeout, a retry policy, a circuit breaker, a per-attempt timeout, and rate limiting. The current guidance is to add one resilience handler rather than stacking many handlers of your own. If you need finer control, use AddResilienceHandler and configure the strategies yourself.
So the simple rule is this. Use your own DelegatingHandlers for logging, auth, correlation IDs, and small custom tweaks. Use the resilience handler for retries, timeouts, and circuit breakers.
Common mistakes to avoid
A few traps catch people when they start with handlers. Knowing them now saves you hours later.
- Forgetting
base.SendAsync. If you do not call it, the request never goes out. Always pass the baton. - Registering the handler with the wrong lifetime. Use
AddTransientfor handlers. The factory manages reuse for you. - Reading the request body twice. The request content can sometimes be read only once. If you log the body, be careful, and consider buffering.
- Wrong order. If your logs show a request without the auth header, your logging handler is probably sitting outside the auth handler. Move it inward.
- Heavy work on every call. Handlers run on every single request. Keep them light. Do not do slow database calls on the hot path unless you must.
Putting it all together
Here is the mental model. Your app builds a request. That request walks down a chain of handlers you chose. Each handler can read or change the request, then hands it to the next. The last handler sends it over the network. The response walks back up the same chain. Each handler gets one more turn. Finally your app receives the answer.
The full round trip
Steps
Build
App makes request
Down
Handlers run outward
Wire
Network sends it
Up
Handlers run on response
Receive
App reads result
Once this clicks, you will reach for handlers often. They keep your calling code clean and put shared concerns in one tidy spot, exactly like middleware does for incoming requests.
Quick recap
- A DelegatingHandler is like middleware, but for the HTTP requests your app sends out.
- You build one by deriving from
DelegatingHandlerand overridingSendAsync, then callingbase.SendAsyncto pass the request along. - Register the handler with
AddTransient, then attach it withAddHttpMessageHandleron a named or typed client. - The order you attach handlers is the order they run on the way out, and reverse on the way back.
- Use your own handlers for logging, auth, and tracing. Use
AddStandardResilienceHandlerfor retries, timeouts, and circuit breakers. - Keep handlers light, always call
base.SendAsync, and watch your ordering.
References and further reading
- HTTP requests with IHttpClientFactory — Microsoft Learn
- Use the IHttpClientFactory — Microsoft Learn
- Build resilient HTTP apps: key development patterns — Microsoft Learn
- Troubleshoot IHttpClientFactory issues — Microsoft Learn
- HttpClientFactory outgoing request middleware — Steve Gordon
- Polly resilience library on GitHub
Related Posts
3 Ways To Create Middleware In ASP.NET Core (Beginner Guide)
Learn the 3 ways to create middleware in ASP.NET Core: inline request delegates, convention-based classes, and factory-based IMiddleware, with simple diagrams and code.
Better Request Tracing with User Context in ASP.NET Core
Learn how to trace requests in ASP.NET Core by adding user context and correlation IDs to your logs using middleware, logging scopes, and Activity.
Building Async APIs in ASP.NET Core the Right Way
Learn to build fast, safe async APIs in ASP.NET Core: async/await, CancellationToken, avoiding .Result deadlocks, and thread pool tips.
Logging Requests and Responses for APIs and HttpClient in ASP.NET Core
Learn to log incoming API requests and outgoing HttpClient calls in ASP.NET Core using built-in HTTP logging and a custom DelegatingHandler, step by step.
Refit in .NET: Building Robust API Clients in C#
Learn Refit in .NET to build type-safe REST API clients in C#. Define an interface, add attributes, and Refit writes the HttpClient code for you.
The Right Way to Use HttpClient in .NET (Beginner Guide)
Learn the right way to use HttpClient in .NET. Avoid socket exhaustion and stale DNS with IHttpClientFactory, typed clients, and resilience.