I added the request timeouts middleware to an ASP.NET Core service last week, set a two-second default policy, and mentally filed the slow-endpoint problem under "solved". A few hours later I watched the slowest endpoint in the service take its usual six seconds and return a perfectly happy 200 OK. Nothing was misconfigured. I'd just misunderstood what I'd installed: I wanted a kill switch, and AddRequestTimeouts is a contract. Both sides have to sign it.
So I built the smallest repro that could settle it.
The setup
A minimal API on .NET 10 with a global two-second budget:
builder.Services.AddRequestTimeouts(options =>
{
options.DefaultPolicy = new RequestTimeoutPolicy
{
Timeout = TimeSpan.FromSeconds(2)
};
});
var app = builder.Build();
app.UseRequestTimeouts();
(RequestTimeoutPolicy lives in Microsoft.AspNetCore.Http.Timeouts, and the middleware has shipped in the box since .NET 8.)
Then one fake slow dependency, exposed two ways. The difference is a single parameter:
static async Task<IResult> SlowWork(CancellationToken ct)
{
await Task.Delay(TimeSpan.FromSeconds(6), ct); // stand-in for a 6s query
return Results.Json(new { done = true });
}
// passes the request's token into the work
app.MapGet("/honors-token", (CancellationToken ct) => SlowWork(ct));
// same work, token dropped on the floor
app.MapGet("/ignores-token", () => SlowWork(CancellationToken.None));
Binding a CancellationToken in a minimal API hands you HttpContext.RequestAborted, and with the middleware in the pipeline that's a linked token that fires when the request's budget runs out. The second endpoint is what most real codebases do by accident: somewhere down the call chain a method drops the token, and from that point on cancellation is a rumor.
The app probes itself with HttpClient and a stopwatch. .NET 10, Release build, small Linux container — wall clock, not a lab.
What actually happened
/honors-token -> 504 GatewayTimeout after 2.14s handler passed the token along
/ignores-token -> 200 OK after 6.02s handler ignored the token
The first line is the brochure version. At the two-second mark the middleware cancels the token, Task.Delay throws, and the middleware catches the OperationCanceledException and rewrites the response into a 504 before anything hits the wire. That status is the default; TimeoutStatusCode on the policy picks another. It also logs a Timeout exception handled warning with the full stack trace, which is a decent breadcrumb when you're later wondering where a 504 came from. (The 0.14s over budget is first-request warmup — the 500 ms policy below landed at 0.50 on the nose.)
The second line is the trap. The timeout fired there too, right on schedule, at two seconds. And then nothing happened, because cancellation in .NET is cooperative and nothing was cooperating. The middleware can't reach into a running task and stop it; its only levers are that token and catching the cancellation exception on the way out. If your handler completes normally, the response sails through — six seconds, 200 OK, no log, no trace that a deadline ever existed. This is the part I'd have gotten wrong in a job interview: I assumed you'd at least see a 504 after the six seconds. You don't. You get silence.
Budgets and escape hatches
Per-endpoint policies stack on top of the default, and some endpoints deserve to be let out of the contract entirely:
app.MapGet("/tight", (CancellationToken ct) => SlowWork(ct))
.WithRequestTimeout(TimeSpan.FromMilliseconds(500));
app.MapGet("/opted-out", (CancellationToken ct) => SlowWork(ct))
.DisableRequestTimeout();
/tight -> 504 GatewayTimeout after 0.50s per-endpoint 500ms policy
/opted-out -> 200 OK after 6.01s DisableRequestTimeout()
File exports, uploads, anything streaming — opt those out deliberately instead of skipping the global default. My take: a service with a two-second default and three explicit DisableRequestTimeout() calls is in far better shape than a service with no default because "some endpoints are slow". The slow ones should be slow on the record.
The part that actually fixes your service
The middleware config is the easy ten percent. The real work is making the token travel the entire call chain, because the mechanism is only as strong as its most forgetful method. EF Core query methods accept one. HttpClient.SendAsync accepts one. Dapper takes one through CommandDefinition. Every one of those is an overload someone can skip on a Tuesday, and each skip cuts the wire a little further from the socket.
Fair warnings before you ship this. Timeouts don't trigger while a debugger is attached — the docs say so, and it still cost me ten confused minutes — so verify with dotnet run, not F5. If the response has already started streaming, the middleware can't un-send it into a 504. Canceling the token doesn't refund the work either: a cancelled database call still needs the driver and the database to notice, so you're cutting losses, not reversing them. And if a handler is stuck in a CPU-bound loop or a blocking sync-over-async call, it never reaches an await that observes the token — you're right back to the 200-after-six-seconds case.
Still worth shipping? Absolutely. Somewhere upstream — nginx, the load balancer, the caller's HttpClient — a timeout already exists, and it's less polite than yours. Without your own deadline the client eventually gets a 502 or a dropped connection while your server keeps faithfully computing an answer nobody is waiting for. A 504 you chose beats a 502 someone's proxy chose for you.
Full runnable sample (the app self-probes all four endpoints and prints the table above): https://github.com/ssukhpinder/dev-to-code-samples/tree/main/007-request-timeout-cooperation
How do you keep tokens threaded through a big codebase — an analyzer, review vigilance, or hope? I've tried all three and only one of them scales, so tell me which one you bet on.
— still setting deadlines nobody asked me to set
Top comments (0)