The Developer's Guide to Timeouts
Run requests.get(url) in Python and it will wait forever. Not a long time: forever. The library’s own documentation says so, and recommends that production code should set a timeout on nearly all requests. The default in Go’s http.Client is the same. So is the default for a PostgreSQL query. The most popular tools in backend development ship with the single worst possible timeout value, which is none, and most codebases never change it.
That gap causes a specific, repeatable failure. A dependency slows down, requests pile up behind it, every thread or connection in the pool ends up parked waiting on something that will not answer, and a latency problem in one service becomes an outage in yours. Timeouts are the cheapest resilience mechanism you can deploy, and they cost one line per call site.
What Your Stack Does When You Set Nothing
| Tool | Default behaviour | Where to fix it |
|---|---|---|
Python requests | Waits indefinitely | timeout=(connect, read) per call |
Go http.Client zero value | No timeout | Client.Timeout or a request context |
Node fetch (undici) | 300s headers timeout | AbortSignal.timeout(ms) |
| PostgreSQL query | Runs until finished | statement_timeout |
| JDBC statement | Waits indefinitely | setQueryTimeout(seconds) |
Two of those defaults deserve a closer look because they are so widely deployed.
Go’s http.DefaultClient, the thing you use every time you call http.Get, has Timeout: 0, meaning none. Cloudflare’s engineering team wrote the canonical breakdown of this in their guide to Go net/http timeouts ↗, after finding that serving traffic to unbounded connections was leaking file descriptors at their edge. The server side is worse: a default http.Server has no read or write timeouts either, so a slow client can hold a connection open indefinitely.
Node is the interesting counterexample. The fetch implementation, undici, does apply a default headers timeout of 300 seconds. That sounds like progress until you ask what user is still waiting at the four minute mark. A timeout of five minutes is not meaningfully different from no timeout; it just changes what the postmortem graph looks like.
Pick the Number From Data, Not Vibes
Almost every hand-set timeout in the wild is 30 or 60 seconds, chosen because it is a round number. Amazon’s Builders’ Library article on timeouts, retries and backoff ↗ describes the method their teams actually use: look at the dependency’s latency distribution and set the timeout just above a high percentile, accepting that the small fraction of requests beyond it will fail.
The procedure takes ten minutes if you have metrics:
- Pull the dependency’s latency histogram for a typical week.
- Read off p99 or p99.9, depending on how expensive a false timeout is for you.
- Set the timeout slightly above that value.
- Alert on timeout rate, because a rising rate means the dependency’s distribution moved and your number needs revisiting.
For a service that answers in 80ms at p50 and 450ms at p99, that gives you a timeout around 1 second. Compare that with the 30 second default someone copied from a tutorial: during an incident, the 1 second timeout releases each held connection 29 seconds sooner. Multiply by your request rate and that difference is your entire connection pool.
If you run SLOs, the timeout ceiling is already decided for you: it cannot exceed the latency your SLO promises, because a request that takes longer than the objective is a failure whether it eventually succeeds or not.
One Request Is Several Timeouts
A single HTTP call has distinct phases, and collapsing them into one number produces bad settings for both.
The connect timeout covers TCP establishment. Healthy infrastructure connects in single-digit milliseconds, so this can be aggressive: 1 to 3 seconds is generous, and failing fast here is exactly what you want when a host is down, because connect failures are the cheapest failures to retry against another instance.
The read timeout covers waiting for the response, and this is the one that must reflect real work. A search endpoint that legitimately takes 2 seconds needs more room than a health check.
# connect fast or fail fast; allow real work time to respond
response = requests.get(
"https://api.example.com/search",
timeout=(3, 5), # (connect, read)
)
The subtlety with read timeouts in most libraries, Python included, is that they bound the gap between bytes, not the whole response. A server trickling one byte every 4 seconds never trips a 5 second read timeout. If you need a hard ceiling on total duration, enforce it separately: a context deadline in Go, AbortSignal.timeout() in Node.
Budgets: The Part Everyone Skips
Timeouts set in isolation lie to you. Suppose your endpoint has a 10 second gateway deadline, and internally it calls service A (timeout 6s) and then service B (timeout 6s). Both settings look sensible alone; together they promise 12 seconds of work inside a 10 second window. When A runs slow, B’s call starts with 4 seconds of real budget and a 6 second timeout, so the gateway kills the request while your code still believes it has time.
The fix is deadline propagation: the entry point sets one budget, and every downstream call receives the time remaining rather than a fresh allowance. Go’s context makes this the natural style; gRPC propagates deadlines across service boundaries automatically; for plain HTTP you pass the remaining budget yourself and derive each call’s timeout from it.
Retries live inside the same budget. Each attempt gets its own timeout, and an attempt that cannot finish before the deadline should not start. Our guide to retry and circuit breaker patterns covers how those layers fit together; the short version is that retries without a budget convert one slow request into three slow requests and a worse outage.
Databases: The Quietest Offender
PostgreSQL’s statement_timeout defaults to 0, disabled. Every ORM-generated query in your application can, on a bad plan or a lock conflict, run for as long as it likes while holding a pooled connection. Under load that is how a pool of 20 connections evaporates in seconds.
Set it at the role level so application traffic and long-running work get different ceilings:
ALTER ROLE app_rw SET statement_timeout = '5s';
ALTER ROLE analytics SET statement_timeout = '5min';
Then bound the other side of the pool: how long a request waits to acquire a connection at all. An exhausted pool with no acquisition timeout turns every incoming request into another parked thread, which is the exact failure the statement timeout was meant to prevent. Our post on connection pooling goes deeper on sizing and acquisition behaviour.
When It Fires, Fail Properly
A timeout is only half a decision; the other half is what the caller does with it. Timeouts on writes are ambiguous: the operation may have completed after you stopped waiting, so blind retries need idempotency, and user-facing code should say “we could not confirm” rather than “it failed”. Wire the timeout into the same error handling paths as any other dependency failure: a typed error, a metric, and a degraded response where one exists.
The whole discipline compresses to three rules. Every network call has an explicit timeout. Every number comes from a latency percentile, not folklore. Every chain of calls shares one budget. None of this needs a library or a platform team; the first rule alone, applied in one afternoon of grepping for bare requests.get and default clients, removes your most likely cause of the next cascading outage.
Frequently asked questions
What is a good default timeout for HTTP requests?
There is no universal number, but there is a universal method: set the timeout a little above the dependency's observed p99 latency, not its average. If a service answers in 80ms at p50 and 450ms at p99, a 1 second timeout protects you without cutting off legitimate slow responses. A 30 second timeout on that service is not a safety margin, it is 30 seconds of a held connection and a waiting user every time something goes wrong.
What is the difference between a connect timeout and a read timeout?
The connect timeout bounds how long you wait to establish the TCP connection, and it can be aggressive because a healthy host on a healthy network accepts connections in milliseconds. The read timeout bounds how long you wait for data once connected, and it needs to reflect how long the server legitimately takes to produce a response. Treating them as one number means either your connect phase is far too tolerant or your read phase is far too strict.
Should timeouts be shorter or longer than retries?
Each retry attempt gets its own timeout, and the total budget must fit inside whatever deadline your caller has. Three attempts at 5 seconds each behind a 10 second upstream deadline means the third attempt is pure waste: the caller has already given up. Work out the caller's deadline first, then divide it across attempts, and stop retrying when the remaining budget cannot fit another attempt.
Do databases need timeouts too?
Yes, and they are the most commonly missed. PostgreSQL's statement_timeout defaults to 0, which means disabled, so a runaway query holds its connection until it finishes or someone kills it. Set statement_timeout at the session or role level for application traffic, keep it generous for migrations and analytics roles, and pair it with a connection acquisition timeout on your pool so requests fail fast when the pool is exhausted.
Enjoyed this article? Get more developer tips straight to your inbox.
Comments
Join the conversation. Share your experience or ask a question below.
No comments yet. Be the first to share your thoughts.