Fix

"http: panic serving" in Go

Web
Fix the 'http: panic serving' error in Go by wrapping your HTTP handler in a defer/recover block to catch panics and return a 500 error.

The broken connection

You push a new route to your Go web server. It works perfectly on your machine. In production, a single malformed request hits the endpoint, the server prints a massive stack trace to standard error, and the connection drops. The log line starts with http: panic serving. Your users get a 502 or a blank screen. The server process stays alive, but that one request just blew up.

This is not a server crash. It is a handler crash. Go isolates every incoming HTTP request in its own goroutine. When a handler panics, the standard library catches it, logs the stack trace, closes the connection, and moves on to the next request. The server survives, but the client receives a broken pipe. You need to intercept the panic, log it properly, and return a clean HTTP 500 response before the connection tears down.

What the panic actually does

Go treats panics as exceptional failures. They are not errors. Errors are values you return and handle. Panics are runtime crashes that unwind the stack. The standard library HTTP server catches panics inside handler goroutines automatically. It logs the stack trace with that http: panic serving prefix, closes the connection, and moves on to the next request.

Think of a panic like a power surge in a building. The circuit breaker trips to protect the wiring. The HTTP server is the building manager who notices the trip, writes it in a logbook, and tells the visitor to leave. Your recovery wrapper is the electrical engineer who installs a surge protector at the door, catches the spike, flips a safe switch, and hands the visitor a polite service unavailable card instead of leaving them in the dark.

The key difference between an error and a panic is control flow. Errors travel up the call stack as return values. You check them with if err != nil { return err }. That boilerplate is verbose by design. The community accepts it because it makes the unhappy path visible. Panics bypass return values entirely. They tear through function boundaries until they hit a recover call or the main goroutine exits.

Goroutines are cheap. Panics are not. Letting them escape into the HTTP server's default handler means you lose control over the HTTP status code, the response body, and the logging format. You also lose the chance to clean up request-specific resources before the connection closes.

The recovery wrapper

You fix this by wrapping your handler in a function that defers a recovery call. The defer keyword schedules a function to run when the surrounding function returns, whether it returns normally or panics. The recover builtin stops the panic unwind and returns the panic value. If you call recover outside a deferred function, it returns nil and does nothing.

// WrapHandler returns a new handler that catches panics and returns a 500 status.
func WrapHandler(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Defer ensures the recovery runs after the handler finishes or panics.
        defer func() {
            // recover() stops the panic unwind and returns the panic value.
            if rec := recover(); rec != nil {
                // Log the panic value before responding to the client.
                log.Printf("panic: %v", rec)
                // Write a safe 500 response so the client gets a valid HTTP message.
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        // Execute the actual handler logic.
        h.ServeHTTP(w, r)
    })
}

The wrapper implements the http.Handler interface. It returns another http.Handler that wraps the original. When a request arrives, the wrapper runs first. It schedules the deferred recovery function. Then it calls h.ServeHTTP(w, r). If the inner handler panics, the deferred function executes. recover() captures the panic value. The wrapper logs it and writes a 500 response. The connection closes cleanly.

Trust gofmt. Run it on save. The wrapper above follows standard formatting. Indentation, spacing, and brace placement are decided by the tool, not by personal preference. Focus your energy on the logic, not the whitespace.

How the runtime handles it

When http.ListenAndServe starts, it creates a listener and accepts connections in a loop. Each accepted connection spawns a new goroutine to handle the request. That goroutine runs your handler. If the handler panics, the runtime unwinds the stack frame by frame. It looks for a deferred function that calls recover. If it finds one, the panic stops. If it does not find one, the goroutine exits, the HTTP server logs the stack trace, and the connection closes.

The http: panic serving message comes from the server's internal serve method. It prints the client address, the panic value, and the full stack trace. It does not write anything to the response writer because the connection is already in a broken state. The client sees a network error. Your wrapper changes that behavior by catching the panic before the server's internal handler runs.

You can also capture the stack trace. The debug package provides debug.Stack() which returns a byte slice of the current goroutine's stack. Printing it alongside the panic value makes debugging production issues much faster.

import "runtime/debug"

// WrapHandlerWithStack returns a handler that logs the full stack trace on panic.
func WrapHandlerWithStack(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // Capture the stack trace for debugging.
                stack := debug.Stack()
                log.Printf("panic: %v\n%s", rec, stack)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        h.ServeHTTP(w, r)
    })
}

Context is plumbing. Run it through every long-lived call site. The request context carries cancellation signals, deadlines, and request-scoped values. If your handler starts a background goroutine, pass the context so it can stop when the client disconnects. The wrapper does not interfere with context propagation because it runs synchronously around the handler call.

Production ready pattern

Real applications rarely log plain text. They use structured loggers that output JSON. They also return JSON error responses instead of plain text. The wrapper pattern adapts easily.

// JSONError writes a structured error response to the client.
func JSONError(w http.ResponseWriter, msg string, code int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    // Encode the error payload as JSON.
    json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

// SafeHandler wraps a handler with panic recovery and structured logging.
func SafeHandler(h http.Handler, logger *slog.Logger) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // Log the panic with request metadata.
                logger.Error("handler panic", "value", rec, "method", r.Method, "path", r.URL.Path)
                JSONError(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        h.ServeHTTP(w, r)
    })
}

The context.Context always goes as the first parameter in Go functions, conventionally named ctx. Handlers receive it via r.Context(). Functions that take a context should respect cancellation and deadlines. If you pass ctx to a database query or an external API call, the operation stops when the client disconnects. The panic wrapper does not change this convention. It sits outside your business logic and protects the transport layer.

Public names start with a capital letter. Private names start lowercase. The wrapper above exports SafeHandler and JSONError because other packages might need them. Internal helpers stay lowercase. Go has no public or private keywords. Capitalization is the access modifier.

Common traps

Developers make predictable mistakes when writing panic recovery. The compiler and runtime will catch some. Others slip through until production.

If you forget to import the log package, the compiler rejects the program with undefined: log. If you pass the wrong type to http.Error, you get cannot use x (type string) as type int in argument. These are easy to fix. Runtime traps are harder.

Calling recover outside a deferred function returns nil. The panic continues unwinding. You must wrap the call in defer func() { ... }(). Anonymous deferred functions are the standard pattern because they capture the surrounding scope without polluting the package namespace.

Swallowing panics without logging is dangerous. If you recover and return a 500 response but never log the panic value, you lose the root cause. Production systems rely on logs to diagnose failures. Always log the panic value and the stack trace.

Recovering from expected errors breaks Go conventions. Panics should not replace error returns. If a database query fails, return an error. If a JSON decode fails, return an error. Use if err != nil { return err } and let the caller decide how to handle it. Reserve panics for truly exceptional states like nil pointer dereferences, out of bounds slices, or third-party library bugs you cannot control.

The worst goroutine bug is the one that never logs. If your handler spawns a background goroutine and that goroutine panics, the wrapper will not catch it. The panic escapes into the background goroutine, prints a stack trace to stderr, and exits silently. Always attach a context to background work and handle errors explicitly. Do not rely on panic recovery for asynchronous code.

When to catch and when to let it crash

You need a clear rule for when to recover and when to let the program terminate. Go gives you the tools. You decide the policy.

Use defer and recover when you need to protect a long-running server from third-party library panics or accidental nil dereferences. Use explicit error returns when the failure is expected, like missing parameters, validation failures, or database timeouts. Let the program crash when the state is fundamentally corrupted and continuing would cause data loss or security breaches. Use a middleware framework when you need structured logging, metrics, or request tracing alongside panic recovery.

The decision matrix keeps your code predictable. Recovery is a safety net, not a control flow mechanism. Errors are values. Panics are emergencies. Treat them accordingly.

Where to go next