Timeouts are crucial for any HTTP server. They prevent slow clients from hogging resources and protect against various DoS attacks.
I’ve been happily using standard timeout middleware in my Go services, until one day I needed to add a new file upload endpoint that required a much longer timeout than the others. What seemed like a one-minute job turned into an unexpected challenge. And the worst part is that the most obvious solution fails silently, frustraiting both users and developers.
Let’s explore how to build a robust, chainable timeout middleware that solves these challenges.
Middleware Pattern in Go
In Go web applications, middleware is a powerful pattern that allows us to wrap HTTP handlers with new functionality. A middleware function takes an http.Handler
and returns a new http.Handler
that adds behavior before and/or after calling the original handler.
Typically it looks like this:
func SomeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Do something before the original handler
next.ServeHTTP(w, r) // Call the original handler
// Do something after the original handler
})
}
This pattern enables a clean separation of concerns and makes the code more modular and maintainable. The middleware can modify the request before passing it to the next handler, inspect or modify the response after the handler executes, or even short-circuit the request entirely by not calling the next handler. Most importantly, middlewares can be chained, building a processing pipeline for HTTP requests. Common use cases include authentication, logging, request tracing, CORS handling, and of course, implementing timeouts.
Most Go web frameworks and routers (like Gin, Echo, and Chi) support the middleware pattern out of the box. Even when using the standard library’s net/http
package alone, we can easily apply middlewares since they’re just functions.
The rest of this article uses the chi
router. It can apply middlewares both at the router and at individual endpoint levels:
// Router level
router.Use(TimeoutMiddleware, AuthMiddleware)
// Endpoint level
router.With(MaxSizeMiddleware).Post("/messages", messagesHandler.Create)
Basic Timeout Middleware
Now that we understand the middleware pattern, let’s look at how timeout middleware is typically implemented. The chi
router ships with a Timeout
function that works as follows:
func Timeout(timeout time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer func() {
cancel()
if ctx.Err() == context.DeadlineExceeded {
w.WriteHeader(http.StatusGatewayTimeout)
}
}()
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
This middleware first modifies the request context by applying a timeout to it, then calls the original handler, and finally responds with a 504 Gateway Timeout
error if the timeout is exceeded.
In the projects I’ve worked on, I’ve often used an even simpler version that doesn’t return the HTTP 504
code, since errors were handled in a centralized way elsewhere in the application:
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))
})
}
}
Note: To be precise, these functions themselves are not middlewares, but rather are constructors. They take a time.Duration
and construct a middleware function that can then be used in routing code.
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 code 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 users confused and frustrated when their uploads suddenly fail.
The Obvious Solution (That Doesn’t Always Work)
The first solution 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 Better Solution: Chainable Timeouts
What we really need is a timeout middleware that can be chained, where inner middlewares can both reduce and extend the timeout set by outer middlewares. Before Go 1.21, this required custom context implementations, but now there’s the context.WithoutCancel
function, which provides a clean way to do this.
Here’s the enhanced version of our middleware:
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) {
newDeadline := time.Now().Add(timeout)
ctx := r.Context()
// Simple case: new timeout is shorter than the current one.
// Just use WithDeadline
if deadline, ok := ctx.Deadline(); !ok || newDeadline.Before(deadline) {
ctx, cancel := context.WithDeadline(ctx, newDeadline)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Complex case: we need to first remove the existing deadline
// and then apply the new one
cleanCtx := context.WithoutCancel(ctx)
newCtx, cancel := context.WithDeadline(cleanCtx, newDeadline)
defer cancel()
// Propagate cancellations not related to the timeout
go func() {
<-ctx.Done() // Wait for the parent context
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
cancel() // Cancel newCtx
}
}()
next.ServeHTTP(w, r.WithContext(newCtx))
})
}
}
Let’s break down how this works.
-
If the new timeout is shorter than the existing one (or there is no existing timeout), we simply use the standard
context.WithDeadline
. -
If we need to extend the timeout:
- Use
context.WithoutCancel
to create acleanCtx
that inherits parent’s values (green arrows), but ignores its cancellation signals (red arrows) - Apply timeout to
cleanCtx
to get the final context —newCtx
- Start a goroutine that propagates non-timeout cancellation signals from
ctx
tonewCtx
. This goroutine is never leaked, becausectx
is automatically canceled bynet/http
when the request is completed or canceled.
- Use
Here’s a diagram that illustrates the complex case:
Such middleware design enables chaining timeout middlewares in any order. The file upload example discussed above will now work as expected.
You can see it in action in the Go playground:
Run in Go PlaygroundFurthermore, when needed, this design enables more sophisticated chaining patterns, where different stages of request processing have their own timeout requirements:
Timeout(5) -> authMiddleware -> Timeout(30) -> handler
When to Use Chainable Timeouts
This enhanced timeout middleware is particularly useful when:
- Your service has both quick API endpoints and long-running operations
- Routes are already grouped by other criteria (auth, rate limits, etc.)
- You want to keep the routing structure clean and maintainable
- You need different timeouts for different stages of request processing
A Note on context.WithoutCancel
context.WithoutCancel
is a relatively new addition to Go (1.21). As the documentation warns, it breaks the normal context cancellation chain and should be used with caution. However, our use case is one of the few where it’s appropriate because:
- We’re only extending timeouts that we previously set ourselves
- We properly propagate cancellations not related to the timeout
Conclusion
Building a proper timeout middleware isn’t as straightforward as it might seem at first. The simple approach works for basic cases, but real-world applications often need more flexibility. By using Go 1.21’s context.WithoutCancel
, we can build a chainable timeout middleware that:
- Handles both quick and long-running operations
- Preserves our preferred route organization
- Keeps the code clean and DRY
- Properly propagates cancellation signals
This pattern is especially valuable for services that handle file uploads, WebSocket connections, or any other operations that need different timeout policies while keeping the codebase clean and maintainable.