Deep Engineering
Intermediate·Published·30 MIN

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.

Where to start
Before this lesson it is enough to understand
  • what a goroutine and an interface are;
  • how cancellation through context.Context works — that is the lesson on context;
  • what an HTTP request, headers and a status code are.
You do not need to know in advance
  • http.Handler, ServeMux, middleware;
  • a goroutine per connection, the server's four timeouts, the request context;
  • Shutdown versus Close, a panic in a handler, Go 1.22 ServeMux patterns.

What is really being asked

The ladder is almost always this one:

  1. "How do you build a web server in Go?" — the warm-up: http.HandleFunc and ListenAndServe.
  2. "What are http.Handler and middleware?" — this checks whether the candidate sees the interface behind the function.
  3. "How does net/http serve concurrent requests?" — the question whose answer explains half of the rest.
  4. "Which timeouts do you set on a server?" — this shows whether the candidate has run a server in production.
  5. "What happens to the work if the client has gone?" — about the request context.
  6. "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:

GO
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:

GO
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))
	})
}
language contractThe handling model, timeouts, request context, Shutdown and ServeMux rules are the documentation of the net/http package in go1.24.7. The numbers below are runs of bench/gohttp on loopback on one machine.

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.

— The net/http package — Server.Serve

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/http keeps no fixed-size pool of handlers. There are limits, but different ones: file descriptors and the accept queue, memory, for HTTP/2 the number of concurrent streams per connection (per the HTTP2Config documentation, 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.

— The net/http package — http.Server

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 prefer ReadHeaderTimeout, because ReadTimeout does 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 net/http package — Request.Context

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.

— The net/http package — Server.Shutdown

Typical code that looks right and cuts requests:

GO
go func() {
	<-sigterm
	srv.Shutdown(ctx)
}()
log.Fatal(srv.ListenAndServe()) // returns at once — and main exits

ListenAndServe 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

measured observationbench/gohttp/internals.go, sections 5 and 6, and bench/gohttp/protocols.go, section 3; go1.24.7 linux/amd64, loopback.

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.

— The net/http package — Handler
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.

— The net/http package — ServeMux

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

Next they ask

How do you limit the number of concurrent requests?

Short answer

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.

Next they ask

How do you test a handler?

Short answer

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.

Next they ask

What does a deadline context give Shutdown?

Short answer

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.

Next they ask

Why ReadHeaderTimeout if the client has a timeout?

Short answer

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

Claim

http.Server has sensible default timeouts

Actually

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.

Claim

When the client leaves, the server stops handling the request

Actually

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.

Claim

ListenAndServe returns when Shutdown has finished

Actually

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.

Claim

A panic in a handler brings the whole server down

Actually

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.

Claim

One server goroutine is one request

Actually

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.

Claim

ServeMux always answers 405 to a request with the wrong method

Actually

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

A Go 1.22 ServeMux with two patterns. The server receives GET /users/7, DELETE /users/7 and GET /users/7/posts, and the program prints the status code and body. What will it print?
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

Five requests of 300 ms each are running. 100 ms after they started, Shutdown is called. How many milliseconds after that call will Serve — and with it ListenAndServe — return?
ms

Knowledge check

Question 1 of 5

How does net/http serve concurrent requests?

Sources & further reading

4 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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