1. Why you need recover middleware
Panics really do happen less often than regular errors, but the nasty part is that they usually show up at the most “convenient” moment: under load, in production, on Friday evening (weird coincidence). In an HTTP server, a panic in a handler can abort request processing and leave the client with an unclear outcome. We do not need to “hide the problem”; we need to do two things: first, not break the API contract (always a JSON error), and second, leave a trace in the logs so a developer can understand where everything blew up.
Important: recover middleware does not fix bugs. It makes bugs diagnosable and non-destructive to the client UX.
Panic vs error at the HTTP boundary
In Go, errors are typically returned explicitly via error, while panic is reserved for situations where “we fundamentally cannot continue down this path.” In application business code, we try to keep things under control: check input, validate, return errors, and carefully map them to HTTP responses. But panic can still happen: an index goes out of bounds, an unexpected nil, someone wrote panic("TODO") and forgot about it (it happens... not to me, of course).
At the HTTP boundary we have an additional responsibility: do not show internal details to the client. This is an important idea in the Go ecosystem: internal errors and types must not “leak” outward, otherwise clients start depending on implementation details and you start being afraid of refactoring. So the rule is simple: catch panic at the boundary (middleware), log enough for diagnostics, and return a stable 500 in an error envelope to the client.
Why recover() works only inside defer
In Go, recover() is not a “magic undo button,” but a fairly strict language rule: it triggers only if called inside a deferred function that runs while unwinding the panic stack. If you call recover() “just like that,” it returns nil, and you will be left without a life preserver but with an illusion of safety (which is worse).
Let’s look at a minimal example that shows the core idea:
package main
import "fmt"
func main() {
defer func() {
if p := recover(); p != nil {
fmt.Println("recovered:", p) // recovered: boom
}
}()
panic("boom")
}
What matters here is not that we “defeated” the panic, but that we intercepted it in one place instead of allowing it to tear execution apart like paper.
2. An error envelope for a panic response
For recover middleware to respond to the client cleanly, it needs the same tool other error handlers use: a function to write a JSON error. We will not expose p externally, even if “it’s convenient for debugging”: externally — a stable message; internally — details.
First, let’s define the structures for the error envelope (if they already exist in your project — great, treat this as a reminder):
package httpapi
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type errorEnvelope struct {
Error apiError `json:"error"`
}
Now the error-writing function. Note: we explicitly set Content-Type and write the status before the body.
package httpapi
import (
"encoding/json"
"net/http"
)
func writeAPIError(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{Code: code, Message: msg}})
}
Why are we so insistent on “not showing internals”? Because the client does not need to know whether we have “index out of range” or “nil pointer dereference.” The client needs to understand: “the server failed while processing; try later.” But the developer needs details — from the logs.
3. Implementing recover middleware and chain order
Now we’ll assemble the middleware itself. Its job sounds simple, but it’s important not to lose the order of operations: first set defer, then call next.ServeHTTP, and inside defer catch panic and respond.
We’ll log via log/slog so the log is structured: this way request_id, method, and path are not “part of a string” but proper fields (and then they’re nice to filter). Do not log secrets (for example, an API key).
package httpmw
import (
"log/slog"
"net/http"
)
func recoverMW(next http.Handler, getRID func(*http.Request) string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if p := recover(); p != nil {
slog.Error("panic recovered",
"request_id", getRID(r),
"method", r.Method,
"path", r.URL.Path,
"panic", p,
)
writeAPIError(w, http.StatusInternalServerError, "internal", "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
Note the getRID parameter: we do not reach into context details directly here; we ask, “give me the request_id.” This makes the middleware simpler and more testable: fewer hard dependencies, more explicit contracts.
Middleware order and request_id
Middleware order is not cosmetics; it is logic. In recover logs, we want to see request_id. That means by the time a panic happens, request_id must already be set in the request context (and ideally already written into the response header).
Context in Go is designed as a way to “carry” request-scoped values across API boundaries. That’s exactly why we put request_id there: it relates to a specific request, lives for exactly as long as the request lives, and it’s convenient to fetch at any layer when needed.
Schematically, the chain looks like this (outer on the left, inner on the right):
flowchart LR
RID[request_id MW] --> LOG[log MW] --> REC[recover MW] --> AUTH[auth MW] --> RL[rate limit MW] --> H[handler]
If you mix it up and put recover “outside” request_id, then on panic recover may not find request_id in the context and you’ll get “there is a panic, but there is no link to the request.” That’s like looking for lost keys without a flashlight: theoretically possible, but why?
Mini-check: an endpoint that panics on purpose
To make sure recover middleware works, it’s sometimes useful to have one “training” endpoint that is guaranteed to trigger panic. Yes, it sounds like sabotage. But it’s controlled sabotage: it helps you verify the insurance before you get into an accident.
package main
import "net/http"
func boom(w http.ResponseWriter, r *http.Request) {
panic("boom")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/boom", boom)
_ = http.ListenAndServe(":8080", mux)
}
If recover middleware is enabled correctly, instead of a connection drop (or strange behavior) the client will get:
{"error":{"code":"internal","message":"internal error"}}
And in the logs you’ll see the panic recovered event with request_id, method, and path.
If part of the response has already been sent
In an ideal world, a panic happens before we start writing the response body. In the real world, a panic can happen after a partial write as well. Then recover middleware can no longer “redo” headers and status: HTTP is designed such that once headers are sent, there is no going back (just like after git push --force, only less fun).
Within our current model, we keep a simple rule: handlers should write responses carefully, and recover middleware should be the safety net. If a panic happened after a partial write, that’s already a “we did everything we could” situation, and from there logging and diagnostics matter more.
4. Common mistakes when implementing recover middleware
When people write recover middleware for the first time, the brain often swings to two extremes: either “let’s catch everything everywhere,” or “panics don’t happen, we live without a safety net.” The truth is somewhere in the middle: catch panic at the HTTP boundary, keep the contract stable, and fix bugs in the code.
Mistake #1: calling recover() not in defer.
The most common slip is to write if p := recover(); p != nil { ... } right in the function body and expect a miracle. No miracle: recover works only in a deferred function; otherwise it returns nil, and the panic will fly further up the stack.
Mistake #2: returning the panic text to the client.
Writing writeAPIError(..., fmt.Sprint(p)) seems convenient until you realize the client gets internal details, and sometimes even sensitive data. It’s better to return a stable message externally and keep details internal, so you don’t “cement” internals as a public contract.
Mistake #3: request_id middleware is inside recover.
Then recover logs “panic recovered,” but request_id is either empty, random, or not there at all. The fix is simple: request_id is the outermost layer. Then any lower code (including recover) sees request_id in the context.
Mistake #4: recover middleware writes a response but forgets to log.
If recover silently turns a panic into a 500, you get “the app sometimes responds internal error,” and that’s it. That turns bug hunting into fortune-telling. The minimum log should contain request_id, method, path, and the panic value.
Mistake #5: different error formats in different places.
A common pattern is: auth responds with JSON, rate limit responds with JSON, but recover suddenly responds with plain text or http.Error. That makes clients unhappy because the contract “drifts.” The cure is discipline: recover also uses the same writeAPIError as the other error branches.
GO TO FULL VERSION