The HTTP server in Go: what net/http does by itself and what it does not do until asked
Interview questions on the HTTP server climb a ladder: how to build a web server — how it serves concurrent requests — which timeouts to set — what to do when the client has gone — how to stop the server without cutting requests. A run answers each step: every connection gets its own goroutine and there is no built-in limit; http.Server{} has all four timeouts at zero, and a silent client holds a connection for as long as it likes; the request context is cancelled but the work is not; and Serve returns right after Shutdown is called, while requests are still in flight.
Full technical treatment
TL;DR
An HTTP server in Go is http.Handler and http.Server. A handler is
any type with a ServeHTTP(w, r) method; ServeMux routes requests to
handlers; ListenAndServe listens on a port. A server is ready in five lines,
and that is exactly why interview questions start after those five lines.
The server is concurrent by itself: each connection gets its own
goroutine, and under HTTP/2 each request stream inside a connection does.
Ten requests on ten connections to a handler that sleeps 200 ms took
209 ms, not two seconds. Hence the first consequence: mutable state shared
by handlers needs synchronization. And the second: net/http sets no fixed
limit on the number of concurrent requests — no handler pool, no default
timeouts. http.Server{} has all four timeouts at zero, and zero means
"unlimited": a client that sent the start of its headers and went silent was
still holding the connection after 2 s; with ReadHeaderTimeout of 300 ms the
server closed it after 301 ms.
Beyond that comes what separates real understanding from having read about
it. When the client has gone, r.Context() is cancelled, but only a handler
that listens to that context stops working: the other one finished its 500 ms.
Shutdown waits for requests in flight, Close cuts them — but
ListenAndServe returns immediately after Shutdown is called, after
0 ms, while the requests are still running. A program that exits at that point
cuts them just like Close.
- what a goroutine and an interface are;
- how cancellation through
context.Contextworks — that is the lesson on context; - what an HTTP request, headers and a status code are.
http.Handler,ServeMux, middleware;- a goroutine per connection, the server's four timeouts, the request context;
ShutdownversusClose, a panic in a handler, Go 1.22ServeMuxpatterns.
What is really being asked
The ladder is almost always this one:
- "How do you build a web server in Go?" — the warm-up:
http.HandleFuncandListenAndServe. - "What are
http.Handlerand middleware?" — this checks whether the candidate sees the interface behind the function. - "How does
net/httpserve concurrent requests?" — the question whose answer explains half of the rest. - "Which timeouts do you set on a server?" — this shows whether the candidate has run a server in production.
- "What happens to the work if the client has gone?" — about the request context.
- "How do you stop the server without cutting requests?" — graceful shutdown, and the trap inside it.
The ladder has one spine: by default net/http limits nothing and waits for
nothing by itself. Every "limit" and every "wait" is a line the developer
writes, and the interview questions are about exactly those lines.
Base: Handler, ServeMux and middleware
Everything in net/http rests on one interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}http.HandlerFunc turns an ordinary function into a Handler, ServeMux is
also a Handler that picks another handler by path, and http.Server takes
one Handler and serves connections with it. Hence middleware — a function
that takes a Handler and returns a Handler, wrapping the call in its own
code:
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}A junior-level answer to "how do you build a web server": declare handlers —
functions func(w http.ResponseWriter, r *http.Request) — register them in an
http.ServeMux and run an http.Server with that multiplexer. A handler is
the http.Handler interface, so middleware is written as a function
Handler → Handler. And a caveat worth saying right away: in production you
use not http.ListenAndServe(addr, mux) but your own http.Server — because
only it has the timeout fields and the Shutdown method.
Mechanism 1: a goroutine per connection — and no built-in limit
Serve accepts incoming HTTP connections on the listener l, creating a new
service goroutine for each.
Ten clients, each on its own connection, to a handler that sleeps 200 ms:
1) A GOROUTINE PER CONNECTION
─────────────────────────────
10 requests, the handler sleeps 200 ms: 209 ms in total
handlers at once, peak: 10
In this measurement every request went over its own connection. But a connection is not a request, and the model "one request = one connection = one goroutine" is wrong both ways:
1) HTTP/1.1 KEEP-ALIVE: REQUESTS IN SEQUENCE
────────────────────────────────────────────
10 requests in sequence from one client: server connections 1
2) HTTP/2: CONCURRENT REQUESTS
──────────────────────────────
10 concurrent requests: 202 ms, new connections 0, handlers at once 10, response 200 HTTP/2.0
Under HTTP/1.1 with keep-alive one connection serves requests one after
another. Under HTTP/2 concurrent streams go over one connection, and each
stream gets its own handler goroutine (a run of bench/gohttp/protocols.go,
HTTP/2 without TLS through Server.Protocols in go1.24).
Three things worth being able to say follow from this.
- Handlers run at the same time. Mutable state that handlers access concurrently, with at least one writer, is a data race without a mutex or atomic operations (the lesson on data races). Immutable state — the configuration read at startup — can be shared freely.
- A slow handler by itself does not delay other connections. But a shared mutex, a database connection pool or a semaphore couple the latencies of requests: whoever waits for the shared resource waits for the slow neighbour too.
- There is no built-in limit on the number of requests.
net/httpkeeps no fixed-size pool of handlers. There are limits, but different ones: file descriptors and theacceptqueue, memory, for HTTP/2 the number of concurrent streams per connection (per theHTTP2Configdocumentation, at least 100 by default), the load balancer in front of the server. Limits are set explicitly — with timeouts (Mechanism 2) or a semaphore in middleware.
Mechanism 2: four timeouts, all of them zero
http.Server has four timeout fields, and by default they are all zero:
A zero or negative value means there will be no timeout.
What that means in practice: a client opened a connection, sent the start of its headers and went silent.
2) SERVER TIMEOUTS BY DEFAULT
─────────────────────────────
http.Server{}: ReadTimeout=0s ReadHeaderTimeout=0s WriteTimeout=0s IdleTimeout=0s
the client sent the start of its headers and went silent:
ReadHeaderTimeout=0s after 2000 ms: the connection is still open
ReadHeaderTimeout=300ms after 301 ms: the server closed the connection
2 s is the client's own waiting limit in the measurement; a server without a timeout would have waited longer. Each such connection holds a goroutine and a descriptor, and from Mechanism 1 we know there is no limit on them.
Which timeout limits what is the main thing asked here:
ReadHeaderTimeout— only reading the headers. The documentation recommends it: most users will preferReadHeaderTimeout, becauseReadTimeoutdoes not let the handler decide what is too slow for a particular request body;ReadTimeout— all of reading the request, body included;WriteTimeout— counted from reading the headers, so it covers the handler's work too: a long handler hits it even if the response itself is written quickly;IdleTimeout— idle time between requests on a keep-alive connection.
The practical set for a short API that is named in an interview is
ReadHeaderTimeout and IdleTimeout, plus WriteTimeout. It is a
recommendation, not a rule: for streaming responses — SSE, long downloads,
long-lived connections — a server-wide WriteTimeout would cut a legitimate
response. There the write deadline is set per request through
http.NewResponseController(w) and its SetWriteDeadline method, and only the header-reading
protection is kept for the whole connection.
Next to the timeouts stands one more built-in limit, and this one is not zero:
MaxHeaderBytes.
5) MaxHeaderBytes BY DEFAULT
────────────────────────────
http.DefaultMaxHeaderBytes = 1048576
header 4096 bytes: 200 OK
header 2097152 bytes: 431 Request Header Fields Too Large
A megabyte of headers per request is a lot; if the service does not expect them, the limit is lowered.
Why a timeout limits waiting but not work, and why timeouts along a chain of services do not add up, is the lesson on timeouts and deadlines in the SRE track.
Mechanism 3: the client has gone — the context is cancelled, the work goes on
What the server does when the client stops waiting:
For incoming server requests, the context is canceled when the client's
connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP
method returns.
The context is cancelled when the server has detected the disconnect or the cancellation — it is a signal for code, not an instant notice that the client is gone. And the server does nothing more. Two handlers with the same 500 ms of work; the client waits 100 ms and leaves:
3) THE REQUEST CONTEXT WHEN THE CLIENT HAS GONE
───────────────────────────────────────────────
the client waits 100 ms for a response and leaves; the handler's work is 500 ms
listens to r.Context() after 101 ms: stopped: context canceled
ignores the context after 500 ms: finished the work; context: context canceled
The second handler spent 400 ms on a response nobody will read — even though
its context had been cancelled long before. Cancellation in Go is
cooperative: only code that listens to ctx.Done() stops. That is why
r.Context() is passed on — to a database query, to an outgoing HTTP request,
to a long loop. How context cancellation works is covered in the lesson on
context.
Where this context comes from is configurable too: Server.BaseContext sets
the base context for all of the server's requests, Server.ConnContext the
context of a connection. Through them handlers receive, for example, a shared
stop signal or data about the connection.
Mechanism 4: Shutdown, Close — and the ListenAndServe that does not wait
There are two methods to stop a server. Shutdown closes the listeners,
closes idle connections and waits for active ones to become idle. Close
immediately closes the listeners and the ordinary connections — new, active
and idle. Five requests of 300 ms each, the call 100 ms after
they started:
4) STOPPING: Shutdown, Close AND THE RETURN FROM Serve
──────────────────────────────────────────────────────
Shutdown 100 ms after five 300 ms requests started:
Serve returned http: Server closed 0 ms after the call
Shutdown returned after 276 ms
requests in flight: 200 — 5, error — 0
a new request after the call: rejected
Close 100 ms after five 300 ms requests started:
Serve returned http: Server closed 0 ms after the call
Close returned after 1 ms
requests in flight: 200 — 0, error — 5
a new request after the call: rejected
Shutdown waited for all five, Close cut all five. But look at the first
line of each block: Serve returned after 0 ms — in both cases, while the
requests were still running. The documentation warns about this directly:
When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS
immediately return ErrServerClosed. Make sure the program doesn't exit and waits
instead for Shutdown to return.
Typical code that looks right and cuts requests:
go func() {
<-sigterm
srv.Shutdown(ctx)
}()
log.Fatal(srv.ListenAndServe()) // returns at once — and main exitsListenAndServe returned ErrServerClosed after 0 ms, log.Fatal ended the
process — and Shutdown did not get to wait for a single request. The right
order is the reverse: ListenAndServe in a goroutine, while main waits for
the signal, calls Shutdown with a deadline context and waits for it to
return. ErrServerClosed from ListenAndServe is not an error but the
normal signal.
Hijacked connections are outside the server's lifecycle. WebSockets and
anything a handler took over through Hijack are touched by neither Close
nor Shutdown:
4) Close AND A HIJACKED CONNECTION
──────────────────────────────────
after srv.Close() the hijacked connection answered: "echo: after Close"
For them there is RegisterOnShutdown: the function is called on Shutdown
and must start the protocol-specific shutdown — for example, send a close
frame to WebSocket clients — but not wait for it to complete. Waiting for the
connections themselves is separate work for the application.
What to do before Shutdown — take the instance out of the load balancer, let
it drain, fit into the orchestrator's grace period — is not specific to Go, and
it is covered in the lesson on graceful shutdown in the SRE track.
Deeper: a panic in a handler and ServeMux patterns
A panic does not bring the process down. Unlike a panic in an ordinary goroutine, a panic in a handler is recovered by the server:
If ServeHTTP panics, the server (the caller of ServeHTTP) assumes that the
effect of the panic was isolated to the active request.
5) A PANIC IN A HANDLER
───────────────────────
GET /boom: error on the client: true
GET /ok after the panic: 200
server log: http: panic serving 127.0.0.1:…: boom
The client got no response, the server wrote the stack to the log and carried
on. What happened to the connection depends on the protocol — the
documentation says the server either closes the network connection or sends
an HTTP/2 RST_STREAM. The same handler under HTTP/1.1 and under HTTP/2:
3) A PANIC IN A HANDLER: HTTP/1.1 VERSUS HTTP/2
───────────────────────────────────────────────
HTTP/1.1: /ok -> 200 HTTP/1.1; /boom -> error; /ok -> 200 HTTP/1.1
connection reused: false, connections in total 3, /boom handler called 2 times
HTTP/2 : /ok -> 200 HTTP/2.0; /boom -> error; /ok -> 200 HTTP/2.0
connection reused: true, connections in total 1, /boom handler called 1 times
Under HTTP/1.1 the connection was closed, and the next request went over a new
one. Under HTTP/2 only the request's stream was reset — the connection stayed
alive and the next request went over it. And a finding nobody expected: under
HTTP/1.1 the /boom handler was called twice — the client Transport
retried the idempotent GET by itself after a network error on an
already-used connection. For a handler with side effects on GET that means
two executions.
The protection covers only the handler's goroutine: if the handler started a goroutine of its own and the panic happened there, the process crashes (the mechanism is in the lesson on defer, panic and recover). And "the effect is isolated to the request" is the server's assumption, not a guarantee: a mutex locked before the panic and not unlocked stays locked.
ServeMux patterns since Go 1.22 can carry a method and a path parameter:
6) ServeMux WITH GO 1.22 PATTERNS
─────────────────────────────────
GET /items/42 -> 200 id=42
POST /items/42 -> 405 Method Not Allowed | Allow: GET, HEAD
GET /items/42/x -> 404 404 page not found
The pattern is GET /items/{id}, the value comes from r.PathValue("id");
for another method — 405 with an Allow header, without a single line of code.
If several patterns match a request, the most specific one is chosen:
If two or more patterns match a request, then the most specific pattern takes
precedence.
Hence the trap the first practice problem checks: the server returns 405 only if the path matches no other pattern.
How to answer in an interview
Short answer: a handler is the http.Handler interface, ServeMux routes
requests by path, middleware is a function Handler → Handler. The server
serves each connection in its own goroutine (under HTTP/2, each stream), so
mutable state shared by handlers must be protected. In production — your own http.Server with timeouts and stopping
through Shutdown.
That is enough for a correct answer. What follows is what you add if the interviewer digs.
If the interviewer digs deeper
On "which timeouts" — what the default is. "http.Server{} has all four
at zero, that is, none. A client that did not finish its headers still held the
connection after 2 s in my run; with ReadHeaderTimeout of 300 ms it was
closed after 301 ms. The minimum is ReadHeaderTimeout and IdleTimeout;
WriteTimeout covers the handler's work too."
On "the client has gone" — cooperative cancellation. "r.Context() is
cancelled, but only code that listens to it stops working: my handler without
a context check finished 500 ms of work after the client left at 100. The
request context is passed to the database and outgoing calls."
On graceful shutdown — the return trap. "Shutdown waits for requests,
Close cuts them. But ListenAndServe returns ErrServerClosed right after
Shutdown is called — after 0 ms in my run. If main exits there, the
requests are cut. You wait for Shutdown to return."
On "what if a handler panics" — the boundary. "The server recovers a panic
that reaches ServeHTTP and logs it; the process lives. Under HTTP/1.1 it
closes the connection, under HTTP/2 it resets only the stream. But a panic in
a goroutine started from the handler is not covered by this protection and
crashes the process."
On "how does the server serve concurrent requests" — "a goroutine per connection, and under HTTP/2 per stream; there is no built-in pool and no limit on the number of requests: ten requests of 200 ms on ten connections took 209 ms in my run. A connection is not a request: keep-alive passes requests over it one after another, HTTP/2 concurrently."
Next they ask
How do you limit the number of concurrent requests?
net/http does not do it by itself. The usual way is middleware with a
semaphore: a buffered channel with N slots; the handler takes a slot before
calling next and releases it after; if there is no slot — an immediate 503,
not a queue. For limiting the number of connections there is
golang.org/x/net/netutil.LimitListener.
The point is that refusal beats a queue: a request that waited in a queue longer than the client's timeout runs for nothing — just like the handler in Mechanism 3.
How do you test a handler?
net/http/httptest. httptest.NewRecorder() is a ResponseWriter that
records the code, the headers and the body; the handler is called directly,
h.ServeHTTP(rec, req), without the network. httptest.NewServer starts a
real server on loopback when you need to check client code or a whole
middleware stack.
A handler that takes its dependencies through interfaces is tested as easily as a function — that is covered in the lesson on testing.
What does a deadline context give Shutdown?
A limit on waiting. If the requests in flight have not finished by the
deadline, Shutdown returns the context's error and the connections stay
open — after that Close is usually called. The deadline is chosen shorter
than the grace period the orchestrator gives between SIGTERM and SIGKILL —
otherwise the process is killed in the middle of waiting.
And one more thing: Shutdown neither closes nor waits for hijacked
connections such as WebSockets. RegisterOnShutdown gives a moment to start
stopping them, but the function must not wait for them — the waiting is
organized separately.
Why ReadHeaderTimeout if the client has a timeout?
The client's timeout protects the client, not the server. A slow or malicious client decides for itself how long to hold the connection: in the run it held it for 2 s only because it chose to. A server without its own timeout cannot reclaim the resource — and each such connection holds a goroutine and a descriptor.
Common misconceptions
http.Server has sensible default timeouts
All four are zero, and zero means "unlimited". A client that did not finish its headers still held the connection after 2 s; with ReadHeaderTimeout of 300 ms the server closed it after 301 ms.
When the client leaves, the server stops handling the request
The server only cancels r.Context(). A handler that does not listen to the context finished all its work — 500 ms, after the client had left at 100.
ListenAndServe returns when Shutdown has finished
Right after Shutdown is called — after 0 ms in the run, while five requests were still running. A program that exits on that return cuts them. Wait for Shutdown itself to return.
A panic in a handler brings the whole server down
The server recovers a panic that reaches ServeHTTP: the next request got 200, the log has http: panic serving. Under HTTP/1.1 the connection is closed, under HTTP/2 only the stream is reset. What crashes the process is a panic in a goroutine the handler started itself.
One server goroutine is one request
A goroutine is per connection, and a connection is not a request: ten sequential requests over HTTP/1.1 keep-alive went over one connection, and under HTTP/2 ten concurrent requests went over one connection with ten handlers at once.
ServeMux always answers 405 to a request with the wrong method
Only if the path matches no other pattern. With the patterns GET /users/{id} and /users/, the request DELETE /users/7 went to the second and got 200.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "user ", r.PathValue("id"))
})
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "fallback")
})Practice · estimate
Knowledge check
How does net/http serve concurrent requests?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- An HTTP server in Go is
http.Handlerandhttp.Server. A handler is any type with aServeHTTP(w, r)method;ServeMuxroutes requests to handlers;ListenAndServelistens on a port. A server is ready in five lines, and that is exactly why interview questions start after those five lines. - The server is concurrent by itself: each connection gets its own goroutine, and under HTTP/2 each request stream inside a connection does. Ten requests on ten connections to a handler that sleeps 200 ms took 209 ms, not two seconds. Hence the first consequence: mutable state shared by handlers needs synchronization. And the second:
net/httpsets no fixed limit on the number of concurrent requests — no handler pool, no default timeouts.http.Server{}has all four timeouts at zero, and zero means "unlimited": a client that sent the start of its headers and went silent was still holding the connection after 2 s; withReadHeaderTimeoutof 300 ms the server closed it after 301 ms. - Beyond that comes what separates real understanding from having read about it. When the client has gone,
r.Context()is cancelled, but only a handler that listens to that context stops working: the other one finished its 500 ms.Shutdownwaits for requests in flight,Closecuts them — butListenAndServereturns immediately afterShutdownis called, after 0 ms, while the requests are still running. A program that exits at that point cuts them just likeClose.
In fact
- All four are zero, and zero means "unlimited". A client that did not finish its headers still held the connection after 2 s; with
ReadHeaderTimeoutof 300 ms the server closed it after 301 ms. - The server only cancels
r.Context(). A handler that does not listen to the context finished all its work — 500 ms, after the client had left at 100. - Right after
Shutdownis called — after 0 ms in the run, while five requests were still running. A program that exits on that return cuts them. Wait forShutdownitself to return. - The server recovers a panic that reaches
ServeHTTP: the next request got 200, the log hashttp: panic serving. Under HTTP/1.1 the connection is closed, under HTTP/2 only the stream is reset. What crashes the process is a panic in a goroutine the handler started itself. - A goroutine is per connection, and a connection is not a request: ten sequential requests over HTTP/1.1 keep-alive went over one connection, and under HTTP/2 ten concurrent requests went over one connection with ten handlers at once.
- Only if the path matches no other pattern. With the patterns
GET /users/{id}and/users/, the requestDELETE /users/7went to the second and got 200.
What is covered
- What is really being asked
- Base: Handler, ServeMux and middleware
- Mechanism 1: a goroutine per connection — and no built-in limit
- Mechanism 2: four timeouts, all of them zero
- Mechanism 3: the client has gone — the context is cancelled, the work goes on
- Mechanism 4: Shutdown, Close — and the ListenAndServe that does not wait
- Deeper: a panic in a handler and ServeMux patterns
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
4 SOURCES
- The net/http package — Server.Serve and HandlerOfficial documentation. The concurrency model: «Serve accepts incoming HTTP connections on the listener l, creating a new service goroutine for each». And what happens on a panic: «If ServeHTTP panics, the server (the caller of ServeHTTP) assumes that the effect of the panic was isolated to the active request» — and then «either closes the network connection or sends an HTTP/2 RST_STREAM, depending on the HTTP protocol».https://pkg.go.dev/net/http#Server.Serve
- The net/http package — http.Server timeoutsOfficial documentation. What zero means: «A zero or negative value means there will be no timeout». And why ReadHeaderTimeout is the usual choice: «most users will prefer to use ReadHeaderTimeout».https://pkg.go.dev/net/http#Server
- The net/http package — Server.ShutdownOfficial documentation. The main trap of stopping: «When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn't exit and waits instead for Shutdown to return». On Close and hijacked connections: «Close does not attempt to close (and does not even know about) any hijacked connections, such as WebSockets». And what a RegisterOnShutdown function must do: «This function should start protocol-specific graceful shutdown, but should not wait for shutdown to complete».https://pkg.go.dev/net/http#Server.Shutdown
- The net/http package — Request.Context and ServeMuxOfficial documentation. When the request context is cancelled: «For incoming server requests, the context is canceled when the client's connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns». How ServeMux picks a pattern: «If two or more patterns match a request, then the most specific pattern takes precedence».https://pkg.go.dev/net/http#ServeMux