Timeout Middleware in Go: Simple in Theory, Complex in Practice

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.

  1. If the new timeout is shorter than the existing one (or there is no existing timeout), we simply use the standard context.WithDeadline.

  2. If we need to extend the timeout:

    • Use context.WithoutCancel to create a cleanCtx 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 to newCtx. This goroutine is never leaked, because ctx is automatically canceled by net/http when the request is completed or canceled.

Here’s a diagram that illustrates the complex case: Context Deadline Extension

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 Playground

Furthermore, 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:

  1. We’re only extending timeouts that we previously set ourselves
  2. 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.