Mar 15, 2025·7 min read

Go context timeouts for APIs: from HTTP handlers to SQL

Go context timeouts for APIs help you pass deadlines from HTTP handlers to SQL calls, prevent stuck requests, and keep services stable under load.

Go context timeouts for APIs: from HTTP handlers to SQL

Why requests get stuck (and why it hurts under load)

A request gets "stuck" when it waits on something that doesn't return: a slow database query, a blocked connection from the pool, a DNS hiccup, or an upstream service that accepts the call but never answers.

The symptom looks simple: some requests take forever, then more and more pile up behind them. You'll often see rising memory, a growing goroutine count, and a queue of open connections that never seems to drain.

Under load, stuck requests hurt twice. They keep workers busy, and they hold scarce resources like database connections and locks. That makes normally fast requests slow, which creates more overlap, which creates even more waiting.

Retries and traffic spikes make this spiral worse. A client times out and retries while the original request is still running, so you now pay for two requests. Multiply that by many clients during a brief slowdown, and you can overload the database or hit connection limits even if average traffic is fine.

A timeout is simply a promise: "we will not wait longer than X." It helps you fail fast and free resources, but it doesn't make work finish sooner.

It also doesn't guarantee that the work stops instantly. For example, the database might keep executing, an upstream service might ignore your cancellation, or your own code might not be safe when cancellation happens.

What a timeout does guarantee is that your handler can stop waiting, return a clear error, and release what it holds. That bounded waiting is what keeps a few slow calls from turning into a full outage.

The goal with Go context timeouts is one shared deadline from the edge to the deepest call. Set it once at the HTTP boundary, pass the same context through your service code, and use it in database/sql calls so the database is also told when to stop waiting.

Context in Go in plain terms

A context.Context is a small object you pass through your code to describe what is happening right now. It answers questions like: "Is this request still valid?", "When should we give up?", and "What request-scoped values should travel with this work?"

The big win is that one decision at the edge of your system (your HTTP handler) can protect every downstream step, as long as you keep passing the same context along.

What context carries

Context isn't a place for business data. It's for control signals and a small amount of request scope: cancellation, a deadline/timeout, and small metadata such as a request ID for logs.

Timeout vs cancellation is simple: a timeout is one reason for cancellation. If you set a 2 second timeout, the context will be canceled when 2 seconds pass. But a context can also be canceled early if the user closes the tab, the load balancer drops the connection, or your code decides the request should stop.

Context flows through function calls by being an explicit parameter, usually the first one: func DoThing(ctx context.Context, ...). That's the point. It's hard to "forget" it when it shows up at every call site.

When the deadline expires, anything watching that context should stop quickly. For example, a database query using QueryContext should return early with an error like context deadline exceeded, and your handler can respond with a timeout instead of hanging until the server runs out of workers.

A good mental model: one request, one context, passed everywhere. If the request dies, the work should die too.

Setting a clear deadline at the HTTP boundary

If you want end-to-end timeouts to work, decide where the clock starts. The safest place is right at the HTTP edge, so every downstream call (business logic, SQL, other services) inherits the same deadline.

You can set that deadline in a few places. Server-level timeouts are a good baseline and protect you from slow clients. Middleware is great for consistency across route groups. Setting it inside the handler is also fine when you want something explicit and local.

For most APIs, per-request timeouts in middleware or the handler are easiest to reason about. Keep them realistic: users prefer a fast, clear failure over a request that hangs. Many teams use shorter budgets for reads (like 1-2s) and a bit longer for writes (like 3-10s), depending on what the endpoint does.

Here is a simple handler pattern:

func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusGatewayTimeout)
        return
    }

    json.NewEncoder(w).Encode(report)
}

Two rules keep this effective:

  • Always call cancel() so timers and resources are released quickly.
  • Never replace the request context with context.Background() or context.TODO() inside the handler. That breaks the chain, and your database calls and outbound requests can run forever even after the client has gone away.

Propagating context through your codebase

Once you set a deadline at the HTTP boundary, the real work is making sure that same deadline reaches every layer that can block. The idea is one clock, shared by the handler, service code, and anything that touches the network or disk.

A simple rule keeps things consistent: every function that might wait should accept a context.Context, and it should be the first parameter. That makes it obvious at call sites, and it becomes a habit.

A practical signature pattern

Prefer signatures like DoThing(ctx context.Context, ...) for services and repositories. Avoid hiding context inside structs or recreating it with context.Background() in lower layers, because that silently drops the caller's deadline.

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
        // map context errors to a clear client response elsewhere
        http.Error(w, err.Error(), http.StatusRequestTimeout)
        return
    }
}

func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
    // parsing or validation can still respect cancellation
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    return s.repo.InsertOrder(ctx, /* data */)
}

Handling early exits cleanly

Treat ctx.Done() as a normal control path. Two habits help:

  • Check ctx.Err() before starting expensive work, and after long loops.
  • Return ctx.Err() upward unchanged, so the handler can respond quickly and stop wasting resources.

When every layer passes the same ctx, a single timeout can cut off parsing, business logic, and database waits in one go.

Applying deadlines to database/sql queries

Ship an admin dashboard fast
Use AppMaster to build internal tools to spot slow requests and timeouts early.
Start Now

Once your HTTP handler has a deadline, make sure your database work actually listens to it. With database/sql, that means using the context-aware methods every time. If you call Query() or Exec() without context, your API can keep waiting on a slow query even after the client has given up.

Use these consistently: db.QueryContext, db.QueryRowContext, db.ExecContext, and db.PrepareContext (then QueryContext/ExecContext on the returned statement).

func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
	row := s.db.QueryRowContext(ctx,
		`SELECT id, email FROM users WHERE id = $1`, id,
	)
	var u User
	if err := row.Scan(&u.ID, &u.Email); err != nil {
		return nil, err
	}
	return &u, nil
}

func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE users SET email = $1 WHERE id = $2`, email, id,
	)
	return err
}

Two things are easy to miss.

First, your SQL driver must honor context cancellation. Many do, but confirm it in your stack by testing a deliberately slow query and checking that it cancels quickly when the deadline is exceeded.

Second, consider a database-side timeout as a backstop. For example, Postgres can enforce a per-statement limit (often called a statement timeout). That protects the database even if an app bug forgets to pass context somewhere.

When an operation stops due to timeout, handle it differently from a normal SQL error. Check errors.Is(err, context.DeadlineExceeded) and errors.Is(err, context.Canceled) and return a clear response (like a 504) rather than treating it as "database is broken." If you generate Go backends (for example with AppMaster), keeping these error paths distinct also makes logs and retries easier to reason about.

Downstream calls: HTTP clients, caches, and other services

Even if your handler and SQL queries respect context, a request can still hang if a downstream call waits forever. Under load, a few stuck goroutines can pile up, eat connection pools, and turn a small slowdown into a full outage. The fix is consistent propagation plus a hard backstop.

Outbound HTTP

When calling another API, build the request with the same context so the deadline and cancellation flow through automatically.

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)

Don't rely on context alone. Also configure the HTTP client and transport so you're protected if code accidentally uses a background context, or if DNS/TLS/idle connections stall. Set http.Client.Timeout as an upper bound for the whole call, set transport timeouts (dial, TLS handshake, response header), and reuse a single client instead of creating a new one per request.

Caches and queues

Caches, message brokers, and RPC clients often have their own wait points: acquiring a connection, waiting for a reply, blocking on a full queue, or waiting for a lock. Make sure those operations accept ctx, and also use library-level timeouts where available.

A practical rule: if the user request has 800ms left, don't start a downstream call that might take 2 seconds. Skip it, degrade, or return a partial response.

Decide ahead of time what a timeout means for your API. Sometimes the right answer is a fast error. Sometimes it's partial data for optional fields. Sometimes it's stale data from a cache, clearly marked.

If you build Go backends (including generated ones, like in AppMaster), this is the difference between "timeouts exist" and "timeouts consistently protect the system" when traffic spikes.

Step-by-step: refactor an API to use end-to-end timeouts

Build APIs with clear timeouts
Create a Go backend in AppMaster and keep deadlines consistent from handler to SQL.
Try AppMaster

Refactoring for timeouts comes down to one habit: pass the same context.Context from the HTTP edge all the way down to every call that could block.

A practical way to do it is to work top-down:

  • Change your handler and core service methods to accept ctx context.Context.
  • Update every DB call to use QueryContext or ExecContext.
  • Do the same for external calls (HTTP clients, caches, queues). If a library doesn't accept ctx, wrap it or replace it.
  • Decide who owns timeouts. A common rule is: the handler sets the overall deadline; lower layers only set shorter deadlines for specific operations when needed.
  • Make errors predictable at the edge: map context.DeadlineExceeded and context.Canceled to clear HTTP responses.

Here is the shape you want across layers:

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    _ = json.NewEncoder(w).Encode(order)
}

func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
    row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
    // scan...
}

Timeout values should be boring and consistent. If the handler has 2 seconds total, keep DB queries under 1 second to leave room for JSON encoding and other work.

To prove it works, add a test that forces a timeout. One simple approach is a fake repository method that blocks until ctx.Done() and then returns ctx.Err(). Your test should assert that the handler returns a 504 quickly, not after the fake delay.

If you build Go backends with a generator (for example, AppMaster generates Go services), the rule is the same: one request context, threaded everywhere, with clear ownership of the deadline.

Observability: proving timeouts are working

Turn observability ideas into tools
Use AppMaster to ship internal portals for latency and timeout tracking.
Create Portal

Timeouts only help if you can see them happening. The goal is simple: every request has a deadline, and when it fails you can tell where the time went.

Start with logs that are safe and useful. Instead of dumping full request bodies, log enough to connect the dots and spot slow paths: request ID (or trace ID), whether a deadline is set and how much time is left at key points, the operation name (handler, SQL query name, outbound call name), and the result category (ok, timeout, canceled, other error).

Add a few focused metrics so behavior under load is obvious:

  • Timeout count by endpoint and dependency
  • Request latency (p50/p95/p99)
  • In-flight requests
  • Database query latency (p95/p99)
  • Error rate split by type

When you handle errors, tag them correctly. context.DeadlineExceeded usually means you hit your budget. context.Canceled often means the client went away or an upstream timeout fired first. Keep these separate because the fixes are different.

Tracing: find the time sink

Tracing spans should follow the same context from the HTTP handler into database/sql calls like QueryContext. For example, a request times out at 2 seconds and the trace shows 1.8 seconds spent waiting for a DB connection. That points to pool size or slow transactions, not the query text.

If you build an internal dashboard for this (timeouts by route, top slow queries), a no-code tool like AppMaster can help you ship it quickly without making observability a separate engineering project.

Common mistakes that negate your timeouts

Most "it still hangs sometimes" bugs come from a few small mistakes.

  • Resetting the clock mid-flight. A handler sets a 2s deadline, but the repository creates a fresh context with its own timeout (or no timeout). Now the database can keep running after the client is gone. Pass the incoming ctx through and only tighten it when you have a clear reason.
  • Starting goroutines that never stop. Spawning work with context.Background() (or dropping the ctx entirely) means it will keep running even after the request is canceled. Pass the request ctx into goroutines and select on ctx.Done().
  • Deadlines that are too short for real traffic. A 50ms timeout might work on your laptop and fail in production during a small spike, causing retries, more load, and a mini-outage you caused yourself. Pick timeouts based on normal latency plus headroom.
  • Hiding the real error. Treating context.DeadlineExceeded as a generic 500 makes debugging and client behavior worse. Map it to a clear timeout response and log the difference between "canceled by client" and "timed out."
  • Leaving resources open on early exits. If you return early, make sure you still defer rows.Close() and call the cancel function from context.WithTimeout. Leaked rows or lingering work can exhaust connections under load.

A quick example: an endpoint triggers a report query. If the user closes the tab, the handler ctx is canceled. If your SQL call used a new background context, the query still runs, tying up a connection and slowing everyone down. When you propagate the same ctx into QueryContext, the database call is interrupted and the system recovers faster.

Quick checklist for reliable timeout behavior

Model Postgres data visually
Design your schema in the Data Designer and connect it to your API logic.
Design Data

Timeouts only help if they're consistent. A single missed call can keep a goroutine busy, hold a DB connection, and slow down the next requests.

  • Set one clear deadline at the edge (usually the HTTP handler). Everything inside the request should inherit it.
  • Pass the same ctx through your service and repository layers. Avoid context.Background() in request code.
  • Use context-aware DB methods everywhere: QueryContext, QueryRowContext, and ExecContext.
  • Attach the same ctx to outbound calls (HTTP clients, caches, queues). If you create a child context, keep it shorter, not longer.
  • Handle cancellations and timeouts consistently: return a clean error, stop work, and avoid retry loops inside a canceled request.

After that, verify behavior under pressure. A timeout that triggers but doesn't free resources fast enough still hurts reliability.

Dashboards should make timeouts obvious, not hidden inside averages. Track a few signals that answer "are deadlines actually enforced?": request timeouts and DB timeouts (separately), latency percentiles (p95, p99), DB pool stats (in-use connections, wait count, wait duration), and a breakdown of error causes (context deadline exceeded vs other failures).

If you build internal tools on a platform like AppMaster, the same checklist applies to any Go services you connect to it: define deadlines at the boundary, propagate them, and confirm in metrics that stuck requests become fast failures instead of slow pileups.

Example scenario and next steps

A common place where this pays off is a search endpoint. Imagine GET /search?q=printer slows down when the database is busy with a big report query. Without a deadline, each incoming request can sit waiting on a long SQL query. Under load, those stuck requests pile up, tie up worker goroutines and connections, and the whole API feels frozen.

With a clear deadline in the HTTP handler and the same ctx passed down to your repository, the system stops waiting when the budget is spent. When the deadline hits, the database driver cancels the query (when supported), the handler returns, and the server can keep serving new requests instead of waiting forever.

User-visible behavior is better even when things go wrong. Instead of spinning for 30 to 120 seconds and then failing in a messy way, the client gets a fast, predictable error (often a 504 or 503 with a short message like "request timed out"). More importantly, the system recovers quickly because new requests aren't blocked behind old ones.

Next steps to make this stick across endpoints and teams:

  • Pick standard timeouts per endpoint type (search vs writes vs exports).
  • Require QueryContext and ExecContext in code review.
  • Make timeout errors explicit at the edge (clear status code, simple message).
  • Add metrics for timeouts and cancellations so you notice regressions early.
  • Write one helper that wraps context creation and logging so every handler behaves the same.

If you're building services and internal tools with AppMaster, you can apply these timeout rules consistently across generated Go backends, API integrations, and dashboards in one place. AppMaster is available at appmaster.io (no-code, with real Go source code generation), so it can be a practical fit when you want consistent request handling and observability without hand-building every admin tool.

FAQ

What does it mean when a request gets “stuck” in a Go API?

A request is “stuck” when it’s waiting on something that doesn’t return, like a slow SQL query, a blocked pool connection, DNS issues, or an upstream service that never responds. Under load, stuck requests pile up, tie up workers and connections, and can turn a small slowdown into a wider outage.

Where should I set the timeout: middleware, handler, or deeper in the code?

Set the overall deadline at the HTTP boundary and pass that same ctx to every layer that can block. That shared deadline is what prevents a few slow operations from holding resources long enough to snowball into timeouts everywhere.

Why do I need to call `cancel()` if the timeout will fire anyway?

Use ctx, cancel := context.WithTimeout(r.Context(), d) and always defer cancel() in the handler (or middleware). The cancel call releases timers and helps stop waiting promptly when the request finishes early.

What’s the biggest mistake that makes timeouts useless?

Don’t replace it with context.Background() or context.TODO() in request code, because that breaks cancellation and deadlines. If you drop the request context, downstream work like SQL or outbound HTTP can keep running even after the client is gone.

How should I handle `context deadline exceeded` vs `context canceled`?

Treat context.DeadlineExceeded and context.Canceled as normal control outcomes and propagate them upward unchanged. At the edge, map them to clear responses (often 504 for timeouts) so clients don’t retry blindly on what looks like a random 500.

Which `database/sql` calls should use context?

Use the context-aware methods everywhere: QueryContext, QueryRowContext, ExecContext, and PrepareContext. If you call Query() or Exec() without context, your handler can time out but the database call may keep blocking your goroutine and holding a connection.

Does canceling a context actually stop a running PostgreSQL query?

Many drivers do, but you should verify in your own stack by running a deliberately slow query and confirming it returns quickly after the deadline. It’s also smart to use a database-side statement timeout as a backstop in case some code path forgets to pass ctx.

How do I apply the same deadline to outbound HTTP calls?

Build outbound requests with http.NewRequestWithContext(ctx, ...) so the same deadline and cancellation apply. Also configure client and transport timeouts as a hard upper bound, since context won’t protect you if someone accidentally uses a background context or a lower-level stall happens.

Should lower layers (repo/services) create their own timeouts?

Avoid creating fresh contexts that extend the time budget in deeper layers; child timeouts should be shorter, not longer. If the request has little time left, skip optional downstream calls, return partial data when appropriate, or fail fast with a clear error.

What should I monitor to prove end-to-end timeouts are working?

Track timeouts and cancellations separately by endpoint and dependency, along with latency percentiles and in-flight requests. In traces, follow the same context through handler, outbound calls, and QueryContext so you can see whether time was spent waiting for a DB connection, running a query, or blocked on another service.

Easy to start
Create something amazing

Experiment with AppMaster with free plan.
When you will be ready you can choose the proper subscription.

Get Started