net/http Server Concurrency — Professional Level¶
Table of Contents¶
- Introduction
- Production server template
- Tuning ReadTimeout, WriteTimeout, IdleTimeout
- Structured graceful shutdown
- pprof on a busy server
- Finding handler-leaked goroutines
- Goroutine quotas and back-pressure
- Custom net.Listener wrappers
- ConnState callback for connection metrics
- Custom ErrorLog
- Working with TLS handshake timeouts
- Memory and GC pressure under load
- Coding patterns
- Common production mistakes
- What you can build
Introduction¶
Focus: production-grade
*http.Serverconfiguration, profiling, and operational hygiene. Assumes everything from junior, middle, and senior.
At the professional level you can stand up an HTTP server that serves real traffic without surprises. You know which knobs to set, which middleware to layer, how to read pprof output, how to find a handler that leaks goroutines, and how to write a graceful shutdown that doesn't drop in-flight requests but also doesn't hang on a wedged handler. This file is a catalogue of the patterns and tools that production Go services use, with concrete code.
You should already understand: - The goroutine-per-connection model and HTTP/1.1 keep-alive loop (junior). - (*conn).serve and the active-connection map (middle). - HTTP/2 framer / per-stream goroutines, flow control (senior).
This file is about putting all that to work under real load.
Production server template¶
The default http.ListenAndServe(":8080", handler) is fine for prototypes and dangerous in production. Here is a starting template for a real server:
package main
import (
"context"
"crypto/tls"
"errors"
"log/slog"
"net"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/api/work", workHandler)
handler := recoverer(logger)(accessLog(logger)(timeout(30*time.Second)(mux)))
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16, // 64 KiB
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, connKey{}, c.RemoteAddr().String())
},
ConnState: connMetrics.track,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
},
}
// pprof on internal port
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", srv.Addr)
errCh <- srv.ListenAndServe()
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
logger.Info("shutdown signal", "sig", sig)
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
logger.Error("server error", "err", err)
os.Exit(1)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "err", err)
_ = srv.Close()
}
logger.Info("server stopped")
}
Notes on this template: - Timeouts are all set. Defaults are 0 (no timeout), which is unsafe. - ErrorLog is wired to your structured logger so server-side errors aren't dropped. - pprof on a separate port (127.0.0.1:6060) — internal, not exposed. - Signal handling with a fixed 30s shutdown deadline. Failed graceful → forceful Close. - ConnContext to inject per-connection metadata (here, the peer address; could be a TLS cert subject). - ConnState to update connection metrics.
Tuning ReadTimeout, WriteTimeout, IdleTimeout¶
These four fields cover four distinct phases of a connection's life. Misunderstanding them is the most common production bug.
| Field | Covers | Server.go reference |
|---|---|---|
ReadHeaderTimeout | Time to read the request line + all headers | server.go:1957 |
ReadTimeout | Time to read the entire request including body | server.go:1955 |
WriteTimeout | Time to write the entire response (set before handler invocation) | server.go:1959 |
IdleTimeout | Time between requests on a keep-alive connection | server.go:1962 |
Recommended baselines¶
For a JSON API with small requests:
ReadHeaderTimeout: 5*time.Second,
ReadTimeout: 30*time.Second, // covers a 30s upload at minimum sane rate
WriteTimeout: 30*time.Second, // covers slow client receivers
IdleTimeout: 120*time.Second, // keep keep-alives for 2 min
For an upload service:
ReadHeaderTimeout: 5*time.Second, // slowloris protection
ReadTimeout: 0, // no overall read deadline
// per-handler: r.Body = http.MaxBytesReader(w, r.Body, 1<<30)
// and use ctx deadline inside the handler
WriteTimeout: 30*time.Second,
IdleTimeout: 60*time.Second,
For a long-poll / SSE server:
ReadHeaderTimeout: 5*time.Second,
ReadTimeout: 0, // streams indefinitely
WriteTimeout: 0, // streams indefinitely (use Flusher and ctx)
IdleTimeout: 0, // SSE never goes idle
// protect against zombie connections in handler
Slowloris exposure. Any time ReadHeaderTimeout is 0, you are vulnerable to slowloris. Always set it.
Why per-request deadlines also matter¶
Server-level timeouts kill connections. They don't kill handlers running off-network. If your handler does a slow DB query, WriteTimeout will fire when the handler eventually returns and tries to write — but the handler itself ran for as long as the DB took.
The fix: use r.Context() and context.WithTimeout inside handlers:
func workHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT ...")
if err != nil { ... }
// ...
}
Now if the DB takes too long, the handler exits with context.DeadlineExceeded regardless of WriteTimeout.
Structured graceful shutdown¶
Server.Shutdown(ctx) is the API; using it correctly requires care.
type App struct {
srv *http.Server
db *sql.DB
queue *workqueue.Queue
logger *slog.Logger
shutdownTimeout time.Duration
}
func (a *App) Run() error {
errCh := make(chan error, 1)
go func() { errCh <- a.srv.ListenAndServe() }()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-sigCh:
return a.Shutdown()
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("server crashed: %w", err)
}
return nil
}
}
func (a *App) Shutdown() error {
a.logger.Info("shutdown started")
ctx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
defer cancel()
// 1. Stop accepting new HTTP requests.
srvErr := a.srv.Shutdown(ctx)
// 2. Stop background workers (independent of HTTP).
a.queue.Stop(ctx)
// 3. Close DB last, after handlers and workers are done.
if err := a.db.Close(); err != nil {
a.logger.Warn("db close", "err", err)
}
if srvErr != nil {
a.logger.Warn("shutdown timed out, forcing close", "err", srvErr)
_ = a.srv.Close()
return srvErr
}
a.logger.Info("shutdown completed cleanly")
return nil
}
Order matters: 1. HTTP server first so no new requests start. 2. Background workers second to drain queues. 3. DB last so in-flight queries can complete.
If you close DB first, in-flight handlers fail with "use of closed connection."
Hijacked connections¶
Server.Shutdown does not wait for hijacked connections (e.g., WebSockets). You need a separate registry. See the WebSocket pattern below.
Graceful shutdown with WebSockets¶
type wsReg struct {
mu sync.Mutex
conns map[net.Conn]func()
}
func (r *wsReg) add(c net.Conn, cancel func()) {
r.mu.Lock()
if r.conns == nil { r.conns = make(map[net.Conn]func()) }
r.conns[c] = cancel
r.mu.Unlock()
}
func (r *wsReg) remove(c net.Conn) {
r.mu.Lock(); delete(r.conns, c); r.mu.Unlock()
}
func (r *wsReg) shutdown(ctx context.Context) error {
r.mu.Lock()
conns := make([]net.Conn, 0, len(r.conns))
for c, cancel := range r.conns {
cancel() // signal handler to exit
conns = append(conns, c)
}
r.mu.Unlock()
done := make(chan struct{})
go func() {
// wait for handlers to actually close their conns
for {
r.mu.Lock(); n := len(r.conns); r.mu.Unlock()
if n == 0 { close(done); return }
time.Sleep(50 * time.Millisecond)
}
}()
select {
case <-done: return nil
case <-ctx.Done():
for _, c := range conns { c.Close() }
return ctx.Err()
}
}
In Shutdown, call wsReg.shutdown(ctx) after srv.Shutdown(ctx).
pprof on a busy server¶
net/http/pprof exposes runtime profiling endpoints. Never expose it on a public port.
import (
_ "net/http/pprof"
"net/http"
)
func startPprof() {
go func() { http.ListenAndServe("127.0.0.1:6060", nil) }()
}
Profile types¶
| Endpoint | What | When to use |
|---|---|---|
/debug/pprof/profile?seconds=30 | CPU profile | Find hot functions |
/debug/pprof/heap | Heap snapshot | Find memory hogs |
/debug/pprof/goroutine?debug=2 | Live goroutine dump (text) | Find leaked goroutines |
/debug/pprof/goroutine | Goroutine sample (pprof binary) | Profile-tooling goroutine counts |
/debug/pprof/block | Goroutines blocked on synchronisation | Find lock contention (need runtime.SetBlockProfileRate) |
/debug/pprof/mutex | Mutex contention | (need runtime.SetMutexProfileFraction) |
/debug/pprof/trace?seconds=5 | Scheduling trace | Investigate scheduling, GC pauses |
Workflow¶
# Capture goroutine dump while server is busy
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
less goroutines.txt
# CPU profile under load
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
(pprof) top
(pprof) list someHotFunction
(pprof) web
# Heap
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
(pprof) top
(pprof) list someAllocSite
Comparing two profiles¶
After a change, capture before/after profiles. pprof -base shows the diff:
Negative values in flat mean improvement.
Finding handler-leaked goroutines¶
A leaked handler goroutine is one that survives after ServeHTTP should have returned. Common cause: blocking on a channel, mutex, or syscall without observing r.Context().Done().
Symptom¶
runtime.NumGoroutine() grows over time, even at idle. Eventually OOM.
Diagnosis¶
- Capture
/debug/pprof/goroutine?debug=2. - Look for goroutines stuck in the same handler function across multiple snapshots taken minutes apart.
- Their stack trace points at the blocking call.
Example dump:
goroutine 12345 [chan receive, 5 minutes]:
main.slowHandler(0x..., 0x...)
/app/main.go:42 +0x80
net/http.HandlerFunc.ServeHTTP(...)
/usr/local/go/src/net/http/server.go:2136
net/http.(*ServeMux).ServeHTTP(0x..., ...)
/usr/local/go/src/net/http/server.go:2514 +0x...
net/http.serverHandler.ServeHTTP(...)
/usr/local/go/src/net/http/server.go:2938 +0x...
net/http.(*conn).serve(0x..., 0x...)
/usr/local/go/src/net/http/server.go:2009 +0x...
created by net/http.(*Server).Serve in goroutine 7
/usr/local/go/src/net/http/server.go:3086 +0x...
"chan receive, 5 minutes" tells you this goroutine has been stuck for 5 minutes. Its call at main.go:42 is the bug.
Fix patterns¶
Always select with ctx:
For network calls:
// BAD
resp, _ := http.Get(url)
// GOOD
req, _ := http.NewRequestWithContext(r.Context(), "GET", url, nil)
resp, _ := http.DefaultClient.Do(req)
For database:
// BAD
rows, _ := db.Query("SELECT ...")
// GOOD
rows, _ := db.QueryContext(r.Context(), "SELECT ...")
Programmatic detection¶
uber-go/goleak integrates with test packages:
Runs the test, then checks for unexpected goroutines. Fails if any handler-style goroutines remain.
In production, periodically:
go func() {
for range time.Tick(5 * time.Minute) {
n := runtime.NumGoroutine()
if n > goroutineHighWatermark {
// emit metric, alert, capture stack
buf := make([]byte, 1<<20)
buf = buf[:runtime.Stack(buf, true)]
logger.Warn("high goroutine count", "n", n, "stack", string(buf))
}
}
}()
Goroutine quotas and back-pressure¶
Out of the box *http.Server has no concurrency limit. Three approaches:
1. Connection-level: netutil.LimitListener¶
import "golang.org/x/net/netutil"
ln, _ := net.Listen("tcp", ":8080")
ln = netutil.LimitListener(ln, 10000)
srv.Serve(ln)
Beyond N accepted conns, Accept blocks. New clients see TCP refused.
2. Request-level: semaphore middleware¶
type limiter struct {
sem chan struct{}
h http.Handler
}
func newLimiter(n int, h http.Handler) *limiter {
return &limiter{sem: make(chan struct{}, n), h: h}
}
func (l *limiter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
select {
case l.sem <- struct{}{}:
defer func() { <-l.sem }()
l.h.ServeHTTP(w, r)
case <-r.Context().Done():
return
default:
http.Error(w, "server busy", http.StatusServiceUnavailable)
}
}
Excess requests get 503 immediately. Better UX than TCP refuse.
Variant: queue with bounded wait:
select {
case l.sem <- struct{}{}:
defer func() { <-l.sem }()
l.h.ServeHTTP(w, r)
case <-time.After(100 * time.Millisecond):
http.Error(w, "server busy", http.StatusServiceUnavailable)
case <-r.Context().Done():
return
}
3. HTTP/2 stream-level: MaxConcurrentStreams¶
Caps streams per connection. Doesn't cap total streams across all connections.
Combining¶
A typical production setup combines all three: - LimitListener caps total connections (memory bound). - Middleware semaphore caps in-flight handlers (CPU bound). - MaxConcurrentStreams caps per-h2-conn (prevent one client monopolising).
Custom net.Listener wrappers¶
You can wrap net.Listener.Accept to inject per-connection logic. Examples:
Per-IP connection limit¶
type ipLimiter struct {
net.Listener
mu sync.Mutex
counts map[string]int
maxPerIP int
}
func (l *ipLimiter) Accept() (net.Conn, error) {
for {
c, err := l.Listener.Accept()
if err != nil { return nil, err }
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
l.mu.Lock()
if l.counts[host] >= l.maxPerIP {
l.mu.Unlock()
c.Close()
continue
}
l.counts[host]++
l.mu.Unlock()
return &trackedConn{Conn: c, host: host, l: l}, nil
}
}
type trackedConn struct {
net.Conn
host string
l *ipLimiter
once sync.Once
}
func (c *trackedConn) Close() error {
c.once.Do(func() {
c.l.mu.Lock()
c.l.counts[c.host]--
if c.l.counts[c.host] <= 0 { delete(c.l.counts, c.host) }
c.l.mu.Unlock()
})
return c.Conn.Close()
}
sync.Once on Close prevents double-decrement; the server may call Close multiple times for hijacked conns and on graceful shutdown.
Logging listener¶
type loggingListener struct {
net.Listener
logger *slog.Logger
}
func (l *loggingListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
l.logger.Warn("accept failed", "err", err)
return nil, err
}
l.logger.Debug("accepted", "remote", c.RemoteAddr())
return c, nil
}
Wraps Accept for observability.
ConnState callback for connection metrics¶
Server.ConnState is called on every TCP connection state transition:
| State | Transition | When |
|---|---|---|
StateNew | First state after Accept | Connection created |
StateActive | After read of first byte (or after handler returns and a new request is being read) | Active request in flight |
StateIdle | After handler returns, before next request | Idle, awaiting next request on keep-alive |
StateHijacked | After Hijack() | Permanent state for hijacked conns |
StateClosed | Final state | Connection closed |
type connTracker struct {
new, active, idle, closed, hijacked atomic.Int64
}
func (t *connTracker) track(c net.Conn, state http.ConnState) {
switch state {
case http.StateNew: t.new.Add(1)
case http.StateActive: t.active.Add(1)
case http.StateIdle: t.idle.Add(1)
case http.StateHijacked: t.hijacked.Add(1)
case http.StateClosed: t.closed.Add(1)
}
}
func (t *connTracker) report() {
fmt.Printf("new=%d active=%d idle=%d hijacked=%d closed=%d\n",
t.new.Load(), t.active.Load(), t.idle.Load(), t.hijacked.Load(), t.closed.Load())
}
Note: these are cumulative counters. For instantaneous gauges, decrement on transition out:
type connGauge struct {
inFlight atomic.Int64
}
func (g *connGauge) track(c net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
g.inFlight.Add(1)
case http.StateClosed, http.StateHijacked:
g.inFlight.Add(-1)
}
}
StateHijacked decrements because the conn is no longer the server's responsibility; track hijacked conns separately if needed.
Custom ErrorLog¶
Server.ErrorLog *log.Logger receives: - TLS handshake errors. - HTTP request parsing errors (bad protocol, oversized headers). - Panics from handlers (after recovery). - Listener errors.
By default, these go to log.Default(). In a structured-logging app:
srv.ErrorLog = slog.NewLogLogger(
slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}).
WithAttrs([]slog.Attr{slog.String("source", "http")}),
slog.LevelWarn,
)
Now HTTP errors flow into your structured log with a source=http attribute.
Filtering noise¶
A common source of noise: TLS handshake errors from scanners hitting your port with garbage:
http: TLS handshake error from 198.51.100.1:54321: tls: first record does not look like a TLS handshake
These are typically harmless. Wrap your log to drop the most common patterns:
type filteredWriter struct {
inner io.Writer
}
func (w *filteredWriter) Write(p []byte) (int, error) {
if bytes.Contains(p, []byte("TLS handshake error")) {
return len(p), nil // silently drop
}
return w.inner.Write(p)
}
srv.ErrorLog = log.New(&filteredWriter{inner: os.Stderr}, "http: ", 0)
(In a structured-log world, prefer routing by attribute over substring filtering.)
Working with TLS handshake timeouts¶
TLS handshakes are per-connection. The handshake runs on the per-connection goroutine inside (*conn).serve (around server.go:1975).
If the handshake hangs (slow client, network issues), the goroutine is blocked. With Go 1.17+, the server uses tls.Conn.HandshakeContext and respects ReadHeaderTimeout as the handshake deadline.
For explicit control:
srv.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
// Server doesn't expose TLSHandshakeTimeout; use ReadHeaderTimeout for that.
Note: http.Transport (client) has TLSHandshakeTimeout. http.Server does not — use ReadHeaderTimeout.
Memory and GC pressure under load¶
A busy HTTP server's allocations come from: 1. Per-request headers parsing — bufio buffers, textproto.MIMEHeader. 2. Request body buffering — io.ReadAll if used, bufio otherwise. 3. Response body marshalling — json.Marshal, fmt.Sprintf, string concatenation. 4. Routing/middleware — context.WithValue, context.WithTimeout.
Tools¶
runtime/metrics(Go 1.16+): structured access to GC stats.runtime.ReadMemStats: snapshot of allocation counters.GODEBUG=gctrace=1: prints GC pauses to stderr.pprof heap.
Common wins¶
sync.Poolfor large response buffers.easyjsonorjsoniterinstead ofencoding/jsonfor hot endpoints.- Avoid
fmt.Sprintfin hot paths; usestrconv.AppendIntandappend. - Pre-allocate slices:
make([]byte, 0, expectedSize). - Reuse
*bytes.Buffervia pool.
Watch out for¶
context.WithValueallocates a small struct per call. Many middleware layers compound.r.URL.Query()parses on every call; cache the result if used multiple times.r.Header.Get("X-Foo")is O(N) by header count; for hot lookups considerr.Header.Values.
Coding patterns¶
Pattern 1 — Request-scoped logger¶
type loggerKey struct{}
func withLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := logger.With(
"method", r.Method,
"path", r.URL.Path,
"request_id", uuid.NewString(),
)
ctx := context.WithValue(r.Context(), loggerKey{}, l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func loggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { return l }
return slog.Default()
}
Pattern 2 — Recovery middleware¶
func recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rv := recover(); rv != nil {
stack := debug.Stack()
logger.Error("panic", "panic", rv, "stack", string(stack), "path", r.URL.Path)
if !headerWritten(w) {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
}()
next.ServeHTTP(w, r)
})
}
}
Pattern 3 — Per-handler timeout¶
func timeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.TimeoutHandler(next, d, "handler timed out")
}
}
http.TimeoutHandler runs the inner handler on a separate goroutine and returns 503 + body if it exceeds the deadline. Note: the inner goroutine is not killed; it continues until it returns naturally. So your handler still needs to observe r.Context().Done() to actually stop.
Pattern 4 — Access log¶
func accessLog(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wr := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(wr, r)
logger.Info("access",
"method", r.Method,
"path", r.URL.Path,
"status", wr.status,
"duration_ms", time.Since(start).Milliseconds(),
"bytes", wr.bytes,
)
})
}
}
type statusWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
Note: statusWriter is one goroutine per request — no concurrency concerns. But if you also need to expose http.Flusher / http.Hijacker, you must implement those interfaces on the wrapper too.
Common production mistakes¶
Mistake 1 — Defaulting to http.ListenAndServe¶
The convenience function uses http.DefaultServeMux (global mutable state) and has no timeouts. Always construct *http.Server explicitly.
Mistake 2 — Not calling Server.Close after Shutdown timeout¶
// BAD
srv.Shutdown(ctx) // hangs forever if handler blocks
// GOOD
if err := srv.Shutdown(ctx); err != nil {
srv.Close() // force
}
Mistake 3 — Ignoring ErrServerClosed¶
// BAD — treats normal shutdown as error
if err := srv.ListenAndServe(); err != nil { panic(err) }
// GOOD
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
Mistake 4 — r.Body read outside the handler¶
The body is only valid during the handler call. Reading it from a goroutine spawned by the handler after the handler returns is a race / use-after-free.
Mistake 5 — Forgetting to set MaxHeaderBytes¶
Default is 1 MiB. Combined with many connections, this is a memory amplification vector. Set to 16-64 KiB unless you have a specific need.
Mistake 6 — Calling r.Context() after handler returns¶
// BAD
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
<-r.Context().Done() // r.Context() is cancelled when handler returns
// anything we do here is on cancelled ctx — instant exit
}()
}
r.Context() is cancelled when ServeHTTP returns. Goroutines spawned from a handler that hold r.Context() will see cancellation almost immediately if the handler returns. Either capture a derived context with longer lifetime, or use context.Background() if the work outlives the request.
What you can build¶
After this file you should be able to: - Write a production-grade HTTP server from scratch with structured logging, recovery, graceful shutdown, and timeouts. - Use pprof to find leaks, hot paths, and lock contention. - Configure *http.Server for an upload service, a JSON API, or a streaming service. - Add per-IP rate limiting at the listener level. - Distinguish handler-level from connection-level concurrency limits.
Next, dive into senior.md for the HTTP/2 internals if you haven't, or specification.md for the normative references behind the patterns here.