✓
.NET

ASP.NET Core Request Timeouts: Stop Slow API Calls from Consuming Your Capacity

Advertisement
Ad space
.NET

ASP.NET Core Request Timeouts: Stop Slow API Calls from Consuming Your Capacity

A slow request is not automatically a failed request. It can keep a server worker, database connection, outbound HTTP socket, and memory alive long after the caller has stopped caring. In a busy API, that is how a small tail-latency issue becomes a capacity incident.

ASP.NET Core’s request-timeout middleware gives an API a way to define an upper bound per endpoint or per policy. It is deliberately opt-in: a sensible timeout for a small read endpoint is often the wrong choice for a report export, a file upload, or a streaming connection. The useful goal is not “time out everything quickly”; it is to make every endpoint’s time budget explicit.

This article builds a practical Minimal API setup, shows how cancellation reaches your code, and explains the production decisions that turn a timeout from a cosmetic 504 into real resource protection.

What request timeouts do—and do not—do

After you register and add the middleware, an endpoint needs a configured policy (or a global default) before a timeout applies. When the limit expires, ASP.NET Core signals HttpContext.RequestAborted. It does not magically stop arbitrary work: code and downstream calls must observe the cancellation token. If your handler has not written its own response, the default timeout behavior can result in a 504 response.

This distinction matters. Cancelling the request token can stop an EF Core query, an outbound HttpClient call, or a delay only when that operation receives the token. A CPU-bound loop or an SDK call started without cancellation support can continue consuming resources.

Start with named endpoint budgets

Named policies make an API reviewable. A reader can see that a catalog lookup has a short interactive budget while an import has a longer, intentional budget. Keep the first policy set small; add exceptions only when you can explain their workload and caller expectation.

using Microsoft.AspNetCore.Http.Timeouts;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRequestTimeouts(options =>
{
    options.AddPolicy("interactive", TimeSpan.FromSeconds(2));
    options.AddPolicy("report", TimeSpan.FromSeconds(20));
});

var app = builder.Build();

app.UseRequestTimeouts();

app.MapGet("/products/{id:int}", async (
    int id,
    ProductClient client,
    HttpContext context) =>
{
    var product = await client.GetAsync(id, context.RequestAborted);
    return product is null ? Results.NotFound() : Results.Ok(product);
}).WithRequestTimeout("interactive");

app.MapGet("/reports/daily", async (
    ReportService reports,
    HttpContext context) =>
{
    var report = await reports.BuildDailyAsync(context.RequestAborted);
    return Results.File(report.Bytes, "text/csv", "daily-report.csv");
}).WithRequestTimeout("report");

app.Run();

The middleware must be in the request pipeline. In an app that calls UseRouting explicitly, Microsoft documents placing UseRequestTimeouts after routing. Keep authentication and authorization design independent from the timeout decision: a timeout must not become a shortcut around access control.

Pass cancellation all the way down

A controller or handler that accepts a token but drops it at the first service call has not gained much. Make cancellation part of the service contract, and pass it to database and network APIs that support it. This is especially important on endpoints that fan out to another service.

public sealed class ProductClient(HttpClient httpClient)
{
    public async Task<Product?> GetAsync(int id, CancellationToken cancellationToken)
    {
        using var response = await httpClient.GetAsync(
            $"v1/products/{id}",
            HttpCompletionOption.ResponseHeadersRead,
            cancellationToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<Product>(
            cancellationToken: cancellationToken);
    }
}

Use the request-aborted token for request-scoped work. If a business action must complete after the response ends—such as charging a payment or generating a large export—model it as durable background work with an idempotent job and a status resource. Do not rely on an HTTP request that may be cancelled.

Choose timeouts from evidence, not folklore

Classify the endpoint first

Interactive reads generally need a tight budget because a user or gateway is waiting. Bounded writes may need more time but should be idempotent and protected against duplicate retries. Long-running jobs should return an accepted response and run outside the request. Streaming, Server-Sent Events, and WebSockets are special cases; apply an endpoint-specific policy or explicitly disable a global policy after a deliberate review.

Work backward from the caller deadline

Your application timeout should leave time for a caller, proxy, and load balancer to receive and process a response. It should also be compatible with outbound client timeouts. A downstream timeout that is longer than the remaining request budget merely guarantees wasted work. Capture deadlines and cancellation reasons in traces so the team can tune real behavior rather than guessing from averages.

Return useful failure behavior

For a policy, you can set a timeout status code or write a consistent response. Pick a contract clients can act on, then document whether they may retry. A retry-safe read may be retried with exponential backoff; an indeterminate write should use an idempotency key or status lookup instead of a blind retry.

builder.Services.AddRequestTimeouts(options =>
{
    options.AddPolicy("interactive", new RequestTimeoutPolicy
    {
        Timeout = TimeSpan.FromSeconds(2),
        TimeoutStatusCode = StatusCodes.Status503ServiceUnavailable,
        WriteTimeoutResponse = async context =>
        {
            context.Response.ContentType = "application/problem+json";
            await context.Response.WriteAsJsonAsync(new
            {
                title = "The request exceeded its time budget.",
                status = StatusCodes.Status503ServiceUnavailable,
                retryAfterSeconds = 5
            });
        }
    });
});

Only emit a retry suggestion if the operation and downstream behavior make it safe. A timeout is an operational signal, not proof that no mutation happened.

Test the behavior outside the debugger

The timeout middleware does not trigger while debugging. Test a deliberately slow endpoint in a non-debug run, assert the expected status and body, and verify that a cancellable dependency observes cancellation. Also test the inverse: an endpoint with its own longer policy must survive the default interactive budget.

Timeouts work best beside the other resilience measures that answer different questions: health checks for readiness and liveness reveal whether the service should receive traffic, while request timeouts cap the work a single request can consume. For container deployments, pair them with the smaller, safer images in these ASP.NET Core Dockerfile practices. The .NET and ASP.NET Core learning track also provides the foundations for the code shown here.

Production checklist

  • Give each endpoint class an intentional time budget; avoid one arbitrary global number.
  • Pass RequestAborted to EF Core, HTTP, and other cancellable operations.
  • Keep proxy, client, and application deadlines compatible.
  • Use idempotency or an asynchronous workflow for writes that may outlive a request.
  • Log timeout policy, route, downstream dependency, and cancellation outcome without logging secrets.
  • Test timeout behavior outside the debugger and alert on sustained timeout-rate changes.

Conclusion

Request timeouts are a capacity boundary, not a performance cure. Start with a few endpoint-specific policies, propagate cancellation honestly, and move work that cannot finish within a request into a durable workflow. That gives legitimate slow operations an explicit design while preventing accidental slow ones from quietly exhausting the API.

Sources

Sponsored
Ad space
🎉 Finished reading. Nice work!