When a handler panics, the goroutine dies
A user hits your API endpoint. The handler tries to access a map key that doesn't exist. Or it dereferences a nil pointer. Or a third-party library throws an unexpected error. The runtime screams. The goroutine crashes. The client gets a broken pipe or a silent timeout. The server process stays alive, but that request is dead.
You need a safety net. In Go, that net is a recovery middleware. It wraps your HTTP handlers, catches panics before they kill the goroutine, and returns a clean 500 status code to the client. The server keeps running. The user sees an error page. You get a log entry to fix the bug.
How defer and recover work together
Go treats panics as exceptional. They are not errors. Errors are values you return and handle. Panics are runtime failures that unwind the stack until the program exits. Sometimes you want to stop that unwind and keep the program alive.
The defer statement schedules a function to run when the surrounding function returns. It runs in last-in-first-out order. If the function returns normally, the deferred function runs after the return. If the function panics, the deferred function runs during the stack unwind.
The recover built-in function stops the panic unwind. It returns the panic value. If there is no panic, recover returns nil. You can only use recover inside a deferred function. If you call it anywhere else, it returns nil and does nothing.
Think of it like a circus safety net. The trapeze artist is your handler. The net is the deferred recover function. If the artist falls, the net catches them. If they land safely, the net stays empty.
Minimal recovery middleware
The standard library provides the http.Handler interface. Any type with a ServeHTTP(http.ResponseWriter, *http.Request) method implements it. Middleware is a function that takes a handler and returns a new handler. It wraps the original handler with extra behavior.
Here is the simplest recovery middleware. It wraps a handler, defers a recover call, and returns a 500 error if a panic occurs.
// RecoveryMiddleware wraps an http.Handler to catch panics and return 500.
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Defer ensures this runs when the handler returns, even on panic.
defer func() {
// Recover stops the panic unwind and returns the panic value.
if err := recover(); err != nil {
// Log the panic value for debugging.
log.Printf("panic: %v", err)
// Send a safe error response to the client.
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
// Execute the actual handler.
next.ServeHTTP(w, r)
})
}
The middleware returns an http.HandlerFunc. This adapter lets you use a function as a handler. The function captures the next handler and the request/response objects. It registers the deferred recover block. Then it calls next.ServeHTTP.
If the handler runs normally, the deferred block runs after the response is sent. recover returns nil. The if block skips. The request finishes.
If the handler panics, the stack unwinds. The deferred block runs. recover catches the panic value. The if block runs. The middleware logs the error and sends a 500 response. The panic is contained. The goroutine survives.
Panic is a bug. Recovery is a bandage.
Walk through the execution
Understanding the flow helps you debug middleware. The request enters the middleware first. The defer statement registers the cleanup function. The function does not run yet. The middleware calls next.ServeHTTP.
The handler executes. It writes to the response writer. It returns. The deferred function runs. recover returns nil. The request completes.
Now imagine a panic. The handler executes. It hits a nil pointer dereference. The runtime panics. The stack starts unwinding. The deferred function runs immediately. recover captures the panic value. The unwind stops. The middleware sends the 500 response. The goroutine returns to the scheduler.
The order matters. The deferred function runs before the middleware returns. If you send the response after the defer block, you might send headers twice. Always send the error response inside the deferred block.
The compiler rejects the program with undefined: recover if you try to import it as a package function. recover is a built-in. It has no package. If you forget to wrap your function in http.HandlerFunc, the compiler complains with cannot use func type as http.Handler in argument. The adapter bridges the gap between functions and the interface.
Trust the defer. It runs when the function exits.
Realistic example with context and JSON
Production middleware does more than catch panics. It logs request metadata. It returns structured error responses. It respects context cancellation.
Here is a realistic recovery middleware. It extracts a request ID from the context. It logs the panic with the ID. It returns a JSON error response. It sets the content type header.
// RecoveryMiddlewareWithLogging catches panics, logs details, and returns JSON.
func RecoveryMiddlewareWithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Defer ensures cleanup runs even if the handler panics.
defer func() {
if err := recover(); err != nil {
// Extract request ID from context if present.
reqID := "unknown"
if id, ok := r.Context().Value("requestID").(string); ok {
reqID = id
}
// Log the panic with request metadata.
log.Printf("req_id=%s panic=%v", reqID, err)
// Set headers before writing the body.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
// Return a structured error response.
fmt.Fprintln(w, `{"error":"Internal Server Error"}`)
}
}()
// Execute the actual handler.
next.ServeHTTP(w, r)
})
}
The middleware checks the context for a request ID. Context values are key-value pairs. The key is a string. The value is an interface. The type assertion .(string) checks if the value is a string. If it is, the middleware uses it. If not, it falls back to "unknown".
The middleware sets the content type header. It writes the status code. It writes the JSON body. The response is complete. The client gets a valid error. The server logs the details.
Convention aside: context.Context always goes as the first parameter in Go functions. Middleware often adds values to the context. The recovery middleware reads from the context. It does not modify it. The context flows down through the handler chain.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and common mistakes
Recovery middleware is simple, but it has traps. Avoid these pitfalls.
Recover outside defer returns nil. If you call recover in the main body of the function, it returns nil. It only works inside a deferred function. The compiler does not catch this. The code compiles. The runtime returns nil. The panic propagates. The goroutine dies.
Panics are expensive. Stack unwinding takes time. Recovering from a panic is slower than returning an error. Do not use panics for control flow. Use errors for expected failures. Use panics for bugs. Recovery middleware protects against bugs. It does not make panics cheap.
Middleware order matters. Middleware wraps handlers. The outermost middleware runs first. The innermost middleware runs last. If you put recovery middleware inside another middleware, the outer middleware might panic before the recovery runs. Always put recovery middleware at the outermost layer.
// Correct order: Recovery wraps everything.
http.ListenAndServe(":8080", RecoveryMiddleware(AuthMiddleware(Handler)))
// Wrong order: Auth might panic and crash the server.
http.ListenAndServe(":8080", AuthMiddleware(RecoveryMiddleware(Handler)))
Masking bugs. Recovery middleware hides panics from the client. It logs them. If you ignore the logs, you ignore the bugs. The server stays alive, but the application degrades. Monitor your logs. Alert on panics. Fix the root cause.
The compiler complains with imported and not used if you import a package and don't use it. Remove unused imports. The compiler rejects the program with loop variable captured by func literal if you capture a loop variable in a closure. Go 1.22 fixed this by making loop variables per-iteration. Older versions require a copy.
Goroutines are cheap. Channels are not magic.
Decision: when to use recovery middleware
Choose the right tool for the job. Go favors explicit error handling. Panics are for exceptional cases. Recovery middleware bridges the gap.
Use recovery middleware when you need to protect the server process from handler panics. It keeps the server alive. It returns safe errors to clients. It logs details for debugging.
Use explicit error returns when the caller can handle the failure gracefully. Errors are values. You can check them. You can wrap them. You can return them. This is the standard Go way.
Use logging middleware when you need to track request metadata and timing. Log the method. Log the path. Log the duration. Log the status code. This helps you monitor performance.
Use context cancellation when you need to abort long-running operations. Check ctx.Done(). Return early if the context is cancelled. This prevents resource leaks.
Use a worker pool when you need bounded concurrency to protect a downstream service. Use a single goroutine plus a channel when one task feeds another in a pipeline. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Don't fight the type system. Wrap the value or change the design.