Skip to main content
SEMastery
ASP.NETintermediate

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.

12 min readUpdated September 24, 2025

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.

Middleware handles incoming requests; DelegatingHandlers handle outgoing requests.

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.

A request passes down through handlers, hits the network, then the response flows 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.

JobWhat the handler doesRuns before or after?
LoggingWrite a line for each request and responseBoth
AuthenticationAdd a token or API key headerBefore
Correlation IDAdd a tracing header so logs can be linkedBefore
TimingMeasure how long the call tookBoth
CachingReturn a saved response if one existsBoth

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

Register
Attach
Send
Return

Steps

1

Register

AddTransient<LoggingHandler>

2

Attach

AddHttpMessageHandler

3

Send

Handler logs, calls base

4

Return

Handler logs response

The two steps of registration, then the round trip at runtime.

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.

Handlers nest like layers of an onion; the response unwinds in reverse order.

Here is a table that maps the call order to the runtime behavior so you can see it at a glance.

Call orderHandlerOn the way outOn the way back
1 (outermost)CorrelationIdHandlerAdds tracing headerSees response last
2 (middle)AuthHandlerAdds bearer tokenSees response second
3 (innermost)LoggingHandlerLogs final requestSees response first

Outgoing pipeline order

Correlation
Auth
Logging
Wire

Steps

1

Correlation

Outermost, added first

2

Auth

Adds the token

3

Logging

Innermost, logs final

4

Wire

Bytes leave the app

The first handler you attach is the outermost layer; the last is closest to the wire.

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 breaker

The 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.

Choosing between a custom handler and a resilience handler.

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 AddTransient for 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

Build
Down
Wire
Up
Receive

Steps

1

Build

App makes request

2

Down

Handlers run outward

3

Wire

Network sends it

4

Up

Handlers run on response

5

Receive

App reads result

One request, many small helpers, one clean place for shared rules.

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 DelegatingHandler and overriding SendAsync, then calling base.SendAsync to pass the request along.
  • Register the handler with AddTransient, then attach it with AddHttpMessageHandler on 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 AddStandardResilienceHandler for retries, timeouts, and circuit breakers.
  • Keep handlers light, always call base.SendAsync, and watch your ordering.

References and further reading

Related Posts