I’d been happily using standard timeout middleware in my Go services until one day I needed to add a file upload endpoint – one that required a much longer timeout than the rest. What seemed like a one-minute job turned into an unexpected challenge, and the worst part is that the most obvious solution – chaining a second timeout middleware onto that route – fails silently.
Let’s build a timeout middleware that can be chained.
Update (June 2026): This is a new version of the post. The original recommended a
context.WithoutCancel-based solution that turned out to be subtly broken: a client that disconnected mid-request could go unnoticed, and the request would run to the full extended timeout instead of stopping. This Go Playground runs that old code against the new test suite, so you can watch it fail, and the comment there explains why.The approach in this updated post is less elegant than the original, but it’s a battle-tested one I’ve relied on for years.
Basic Timeout Middleware
A middleware is just an http.Handler wrapper, so the pattern works with any router. The basic timeout middleware is only a few lines:
func Timeout(timeout time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
It sets a deadline on the request context and calls the handler – that’s it. When the deadline fires, a long-running call – a database query, say – returns a context.DeadlineExceeded error, which flows up the stack like any other error and becomes a 504 Gateway Timeout at a central error layer.
The Problem
The basic timeout middleware works well for simple applications, but what happens when our requirements become more complex? Consider a situation where most of the routes have a default timeout of 30 seconds, but we need to override it with a larger timeout for several slow routes that handle file uploads or WebSockets:
router.Use(Timeout(30*time.Second)) // Default timeout
router.Get("/messages", messagesHandler.List)
router.Post("/messages", messagesHandler.Create)
//... many other routes
// Slow route
router.
With(Timeout(10*time.Minute)). // Override the default timeout for /files
Post("/files", filesHandler.Upload)
This uses chi to define the routes and looks reasonable, but it doesn’t work as expected. The inner 10-minute timeout middleware can’t extend the 30-second timeout set by the outer middleware.
What actually happens behind the scenes is that the first middleware creates a context with a 30-second timeout. Then the second middleware attempts to create a new context with a 10-minute timeout, but since this new context is a child of the original one, it just inherits the parent’s deadline. In Go’s context implementation, a child cannot extend its parent’s deadline – it can only make it shorter.
As a result, any file upload will still be cut off after 30 seconds, leaving both users and developers frustrated when an upload suddenly fails.
The Obvious Solution
The first thing that comes to mind is to use route groups:
// Short-timeout routes
router.Group(func(r chi.Router) {
r.Use(Timeout(30*time.Second))
r.Get("/messages", messagesHandler.List)
r.Post("/messages", messagesHandler.Create)
})
// Long-timeout routes
router.Group(func(r chi.Router) {
r.Use(Timeout(10*time.Minute))
r.Post("/files", filesHandler.Upload)
})
While this approach works for simple cases, it quickly becomes unwieldy when routes are already grouped by other criteria such as:
- Public vs authenticated routes
- API versions
- Feature modules
To use this approach, we’d have to split existing groups into “slow” and “fast” subgroups, leading to a complex and hard-to-maintain routing structure. It wouldn’t be much better than setting timeouts for each endpoint separately.
A Resettable Timeout
Route groups don’t scale, so instead of reorganizing routes, let’s make the Timeout middleware itself chainable. The core idea is simple: we don’t use the built-in context.WithTimeout. Instead we create a timer that cancels the context when it fires, and we let a later Timeout in the chain find that timer via the context and reset it.
This might look like a hack, but it’s not that unusual. For example, otel has otelhttp.LabelerFromContext and oteltrace.SpanFromContext which both let you reach into upstream state and mutate it. Here we trade a little purity for clean route definitions – and it’s the right trade, because the impurity lives in the Timeout helper, written once, while the routes are read hundreds of times by different devs.
The middleware looks like this:
type resetFunc = func(time.Duration)
var resetKey = new(int)
func Timeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// A later Timeout in the chain finds the existing timer and resets it.
if reset, ok := ctx.Value(resetKey).(resetFunc); ok {
reset(d)
next.ServeHTTP(w, r)
return
}
// The first Timeout sets everything up.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
timer := time.AfterFunc(d, cancel)
defer timer.Stop()
reset := func(d time.Duration) { timer.Reset(d) }
ctx = context.WithValue(ctx, resetKey, reset)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
When our timer fires it calls cancel(), and the request context is done – but ctx.Err() returns context.Canceled. For a timed-out request, we want ctx.Err() to return context.DeadlineExceeded – but cancel() can’t do that.
Reporting the Right Error
Go has a lesser-known variant of WithCancel for exactly this situation: context.WithCancelCause. It can record why a context was cancelled, not just that it was. So let’s create a sentinel errDynamicTimeout and attach it as the cause on timeout cancellations:
var errDynamicTimeout = errors.New("dynamic timeout")
// ...in the middleware, swapping WithCancel for WithCancelCause:
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
timer := time.AfterFunc(d, func() { cancel(errDynamicTimeout) })
There is one catch. WithCancelCause does not change Err() – it still returns context.Canceled. The cause lives separately, behind context.Cause(ctx). If you are happy to have callers check context.Cause, you can stop here. But to keep the standard errors.Is(err, context.DeadlineExceeded) working, we need a tiny context wrapper that overrides the Err method:
type dynamicContext struct {
context.Context
}
func (ctx *dynamicContext) Err() error {
if errors.Is(context.Cause(ctx.Context), errDynamicTimeout) {
return context.DeadlineExceeded
}
return ctx.Context.Err()
}
To complete the solution we just need to put the wrapped context into the request:
next.ServeHTTP(w, r.WithContext(&dynamicContext{ctx}))
Tidying the Middleware
While the snippets above work, we can separate concerns properly and move the timer logic into a withDynamicTimeout constructor, resulting in a function that’s very similar to context.WithTimeout. The main difference is that our constructor returns an additional closure that allows resetting the timer:
type resetFunc = func(time.Duration)
type cancelFunc = func()
var errDynamicTimeout = errors.New("dynamic timeout")
func withDynamicTimeout(parent context.Context, timeout time.Duration) (context.Context, cancelFunc, resetFunc) {
parent, parentCancel := context.WithCancelCause(parent)
// timeout
timer := time.AfterFunc(timeout, func() {
parentCancel(errDynamicTimeout)
})
// normal cancellation
cancel := func() {
timer.Stop()
parentCancel(nil)
}
// changes the timeout
reset := func(timeout time.Duration) {
timer.Reset(timeout)
}
return &dynamicContext{parent}, cancel, reset
}
And the middleware becomes very straightforward:
var resetKey = new(int)
func Timeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// there is a resetFunc in the context: use it
if reset, ok := ctx.Value(resetKey).(resetFunc); ok {
reset(d)
next.ServeHTTP(w, r)
return
}
// otherwise wrap with dynamicContext and put
// the resetFunc in the context
ctx, cancel, reset := withDynamicTimeout(ctx, d)
ctx = context.WithValue(ctx, resetKey, reset)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
That’s pretty much it. The full code and synctest-powered test suite are available in the Go Playground. That code also implements a Deadline method, which I left out here to keep the post shorter.
While we’ve essentially built a dynamic-deadline context here, it’s safe as long as it stays scoped within the middleware. By the time handler code runs, the deadline has become stable – the chain is done resetting the timer – and there’s no exported way to reset it after that.
Testing It with synctest
Timeouts are miserable to test the usual way: real sleeps make tests slow and flaky. Go 1.25 stabilized the testing/synctest package, which makes it painless. We write normal sleeps and timeouts, and synctest’s bubble runs them on a fake clock, so tests stay instant and deterministic. Below is the test for the trickiest case: a timeout that’s been extended, with the client disconnecting in the window between the original and the new deadline.
// handler that simulates a blocking operation that takes a certain amount of time
// depending on the query parameter "work_size"
func handler(w http.ResponseWriter, r *http.Request) {
workSize, _ := time.ParseDuration(r.URL.Query().Get("work_size"))
select {
case <-r.Context().Done():
if errors.Is(r.Context().Err(), context.DeadlineExceeded) {
w.WriteHeader(504) // timeout
return
}
w.WriteHeader(499) // client disconnected
case <-time.After(workSize):
w.WriteHeader(200) // success
}
}
// helper for creating a chain of timeout middlewares
func timeoutChain(timeouts ...time.Duration) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
for _, d := range slices.Backward(timeouts) {
h = Timeout(d)(h)
}
return h
}
}
func TestDisconnectAfterOriginalDeadline(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// 30s timeout overridden with 60s timeout
h := timeoutChain(30*time.Second, 60*time.Second)(http.HandlerFunc(handler))
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Client will disconnect after 45s
go func() {
time.Sleep(45 * time.Second)
cancel()
}()
// request takes 50s (within the 60s timeout)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/messages?work_size=50s", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
// expect the "client disconnected" status
if rec.Code != 499 {
t.Fatalf("got status %d, want 499", rec.Code)
}
})
}
Conclusion
Building a proper timeout middleware isn’t as straightforward as it might seem at first. The root cause is buried in Go’s context semantics: a child context can shorten its parent’s deadline, but never extend it. Once we sidestep that constraint with a resettable timer, timeouts compose like any other middleware: the Timeout closest to the handler wins, so a per-route override always beats the default, and our routing structure stays organized the way we want it.
Update: Timeouts aren’t the only limit with this problem. The same endpoint also needed a larger request body limit, and the story repeats one layer down – MaxBytes Middleware in Go: The Same Trap, Again.