Go/Gin

Propagating Context Cancellation and Timeouts in Gin Handlers

6 min read by DebuggedIt

Quick answer

A Gin handler that calls a database and two upstream APIs looks simple, until a client disconnects mid-request or an upstream stalls for thirty seconds....

A Gin handler that calls a database and two upstream APIs looks simple, until a client disconnects mid-request or an upstream stalls for thirty seconds. Without proper context handling, your server keeps working on requests nobody is waiting for, and slow dependencies quietly consume goroutines, connections, and memory. Understanding how cancellation and deadlines flow through a handler is one of the highest-leverage reliability skills for a Go API.

Where the Request Context Comes From

Every incoming request carries a context.Context, available as c.Request.Context(). The net/http server cancels that context when the client's connection closes, when the request is canceled (for HTTP/2), or when the ServeHTTP method returns. That makes it the correct parent for any work done on behalf of the request.

gin.Context also satisfies the context.Context interface, so you can pass c directly to functions that expect one. However, how its Done and Err methods behave has differed between Gin versions and can depend on configuration, so relying on it for cancellation is risky. Passing c.Request.Context() is explicit, works the same everywhere, and keeps framework types out of lower layers. Check the Gin documentation for your version if you need the exact behavior.

A related rule: a gin.Context is reused after the handler returns. If you start a goroutine that outlives the handler, do not use c inside it. Copy the values you need first, or use c.Copy() for read-only access, and give background work its own context rather than the request's, because the request context is canceled as soon as the handler finishes.

Setting Deadlines and Propagating Them

A server-level timeout is a safety net, not a budget. http.Server has ReadHeaderTimeout, ReadTimeout, WriteTimeout, and IdleTimeout, and you should set them, but WriteTimeout does not stop your handler from running; it only affects writing the response. To bound the work itself, derive a context with a deadline and pass it to everything that performs I/O.

func (h *Handler) GetReport(c *gin.Context) {
    ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
    defer cancel()

    report, err := h.svc.BuildReport(ctx, c.Param("id"))
    if err != nil {
        h.writeError(c, err)
        return
    }
    c.JSON(http.StatusOK, report)
}

The deadline only matters if downstream calls respect it. With database/sql, use QueryContext, ExecContext, and BeginTx. With outbound HTTP, build requests using http.NewRequestWithContext. With other clients such as Redis, gRPC, or message brokers, use the context-aware variants of their methods. A single call that ignores the context defeats the whole chain.

Deadlines nest. A child context never outlives its parent, so if the handler has two seconds in total and the first query consumes 1.5 seconds, the second call has 500 milliseconds left. That is usually what you want, but it means you should size the top-level budget by adding up what the slowest realistic path needs, and give individual calls tighter limits only when failing fast is valuable.

Handling the Resulting Errors

When a context ends, the operation returns an error that wraps either context.Canceled or context.DeadlineExceeded. Check with errors.Is rather than comparing strings. The two cases deserve different handling:

  • context.DeadlineExceeded means your own budget ran out. Return a 503 or 504 depending on whether the slowness came from an upstream, and log enough detail to find the slow dependency.
  • context.Canceled usually means the client went away. There is nobody to receive a response, so avoid logging it as an error and avoid noisy alerts. Some teams record a nonstandard 499 status in access logs for these cases.
  • Do not automatically retry on the same context after DeadlineExceeded. It is already expired, and any retry needs a fresh budget that you chose deliberately.

Also remember that cancellation is cooperative. A CPU-bound loop in your handler will not stop by itself. In long loops, check ctx.Err() or select on ctx.Done() periodically so the work ends soon after the context does.

for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    if err := process(ctx, item); err != nil {
        return err
    }
}

Timeout Middleware: Useful but Limited

A timeout middleware can apply a default deadline to every route, which is a good baseline. Be aware of the limits: many implementations run the handler in a separate goroutine and write a timeout response while the original handler may still be running, which can cause races on the response writer if the handler is not context-aware. A safer pattern is a small middleware that only replaces the request context with one carrying a deadline, and lets context-aware code stop on its own.

func Deadline(d time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), d)
        defer cancel()
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

Apply a sensible default globally, then override it per route for known slow endpoints such as exports or report generation. Keeping those overrides visible in the router makes the budget of each endpoint easy to review.

Frequently Asked Questions

Does Gin stop my handler when the client disconnects?

No. The request context is canceled, but your code only stops if it checks the context or passes it to context-aware calls. Nothing forcibly terminates a running handler.

What timeout should I choose for a handler?

Base it on measured latency: the p99 of the slowest realistic dependency chain plus a margin, and no more than the client is willing to wait. Review it when dependencies change, since a timeout that was generous last quarter may now be too tight.

Should I ever use context.Background() inside a handler?

Rarely. It is appropriate for work that must complete even if the client leaves, such as finishing a payment reconciliation. Even then, give it its own explicit timeout so it cannot run forever.

Can I store values in the request context?

Yes, for request-scoped data such as a request ID or the authenticated user. Use typed keys instead of plain strings to avoid collisions, and do not use the context as a way to pass optional function parameters.

Summary

Treat c.Request.Context() as the root of all request-scoped work, derive deadlines from it, and make sure every database, HTTP, and cache call accepts that context. Distinguish deadline errors from client cancellations in your error handling, and remember that cancellation only works when code cooperates. For exact behavior of gin.Context as a context.Context and of the server timeouts in your Go version, consult the official Gin and Go documentation.