Go’s net/http package is powerful enough for production use without external dependencies. This sets Go apart from most languages where you need a framework (Express, Flask, Spring) for anything beyond a toy server. Companies like Cloudflare, Dropbox, and Netflix run production services using just the standard library’s HTTP server, sometimes with a lightweight router on top. This chapter covers building robust web services from the ground up.
package mainimport ( "fmt" "log" "net/http")func main() { http.HandleFunc("/", homeHandler) http.HandleFunc("/api/users", usersHandler) log.Println("Server starting on :8080") // ListenAndServe blocks forever; log.Fatal prints the error and exits // if the server fails to start (port in use, permissions, etc.) log.Fatal(http.ListenAndServe(":8080", nil))}func homeHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Welcome to the API!")}func usersHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: getUsers(w, r) case http.MethodPost: createUser(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) }}
Middleware are functions that wrap handlers to add functionality. Think of middleware as layers in an onion: each request passes through the outer layers (logging, auth, CORS) before reaching the core handler, and the response passes back out through the same layers. This is the Go version of the decorator pattern, and it is the standard way to add cross-cutting concerns like authentication, logging, rate limiting, and panic recovery.
Pitfall — Using http.DefaultClient in Production: The default http.Get, http.Post, and http.DefaultClient have no timeout. A slow or unresponsive server will cause your goroutine to hang indefinitely, eventually exhausting resources. Always create a custom client with explicit timeouts:
// WRONG: no timeout, hangs forever on slow serversresp, err := http.Get("https://api.example.com/users/1")// RIGHT: always set timeoutsclient := &http.Client{Timeout: 10 * time.Second}resp, err := client.Get("https://api.example.com/users/1")
func fetchUser(id string) (*User, error) { resp, err := http.Get("https://api.example.com/users/" + id) if err != nil { return nil, err } defer resp.Body.Close() // Always close: returns connection to pool for reuse if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) } var user User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, err } return &user, nil}
A production HTTP client is like a well-tuned car engine: it needs proper connection pooling (how many connections to keep warm), timeouts (when to give up), and transport settings (how to manage the underlying TCP connections). The http.Transport is the engine; the http.Client is the driver interface.
type APIClient struct { baseURL string httpClient *http.Client apiKey string}func NewAPIClient(baseURL, apiKey string) *APIClient { return &APIClient{ baseURL: baseURL, apiKey: apiKey, httpClient: &http.Client{ Timeout: 30 * time.Second, // Overall request timeout Transport: &http.Transport{ MaxIdleConns: 100, // Total idle connections across all hosts MaxIdleConnsPerHost: 10, // Idle connections per host (tune for your traffic) IdleConnTimeout: 90 * time.Second, // How long idle connections survive }, }, }}func (c *APIClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { var bodyReader io.Reader if body != nil { jsonBody, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("User-Agent", "MyApp/1.0") return c.httpClient.Do(req)}func (c *APIClient) GetUser(ctx context.Context, id string) (*User, error) { resp, err := c.do(ctx, http.MethodGet, "/users/"+id, nil) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, ErrNotFound } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error: %d - %s", resp.StatusCode, string(body)) } var user User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, err } return &user, nil}func (c *APIClient) CreateUser(ctx context.Context, user *CreateUserRequest) (*User, error) { resp, err := c.do(ctx, http.MethodPost, "/users", user) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return nil, parseAPIError(resp) } var created User if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return nil, err } return &created, nil}
Pitfall — Not Draining Response Bodies: If you read only part of an HTTP response body (or skip reading it entirely) without closing it, the underlying TCP connection cannot be reused. Always drain and close the body, even for error responses:
resp, err := client.Do(req)if err != nil { return err}defer func() { // Drain any unread body so the connection can be reused io.Copy(io.Discard, resp.Body) resp.Body.Close()}()
Pitfall — Goroutine Leak in HTTP Handlers: If your handler spawns a goroutine that outlives the request (e.g., for background processing) and that goroutine holds a reference to the request context or response writer, you get undefined behavior. The ResponseWriter is invalid after ServeHTTP returns. If you need background work, copy the data you need and use a fresh context.Background().
Go's net/http package is considered production-ready without frameworks. What specifically makes it sufficient, and when would you reach for Chi, Gin, or Echo instead?
Strong Answer:
The net/http standard library provides: a production-grade HTTP server with configurable timeouts, keep-alive, TLS, and graceful shutdown; the http.Handler interface (one method: ServeHTTP) which is the foundation of Go’s composable middleware ecosystem; built-in connection pooling in the HTTP client; and since Go 1.22, method-based routing with path parameters (GET /users/{id}).
What it lacks: Go 1.22 routing covers most cases, but before that, the default mux only did prefix matching without method dispatch, which was insufficient for REST APIs. Other missing pieces: route groups with shared middleware, built-in request validation/binding, and automatic OPTIONS/CORS handling.
When to use a framework: Chi is my default choice when I need route groups, middleware chaining, and URL parameters on Go versions before 1.22. Chi is just a router that wraps the standard http.Handler interface, so your handlers are portable. Gin is appropriate when you want performance-optimized routing (radix tree), built-in JSON binding/validation, and your team is comfortable with Gin’s non-standard *gin.Context instead of http.ResponseWriter + *http.Request. Echo is similar to Gin in philosophy.
My recommendation for new projects: start with the standard library plus Go 1.22 routing. Add Chi if you need route groups or are on an older Go version. Only reach for Gin/Echo if your team already knows them or you need their specific middleware ecosystem.
Follow-up: Explain the middleware pattern in Go. How does func(http.Handler) http.Handler enable composable middleware?The type func(http.Handler) http.Handler is a function that takes a handler and returns a new handler that wraps the original. This is the decorator pattern applied to HTTP handling. Each middleware adds behavior (logging, auth, CORS, rate limiting) before or after calling the inner handler. Because every middleware has the same signature, they compose naturally. A Chain helper reverses this nesting for readability. The beauty is that any middleware works with any handler from any library, because they all speak the same http.Handler interface. This is why Go does not need a monolithic framework — the interface IS the framework.
You are building a JSON API in Go. Walk me through the security considerations for handling request bodies, and show me the production-grade pattern.
Strong Answer:
First, limit the request body size. An attacker can send a multi-gigabyte body to exhaust memory. Use http.MaxBytesReader(w, r.Body, 1<<20) to cap at 1MB (or whatever is appropriate). This replaces r.Body with a reader that returns an error when the limit is exceeded.
Second, use json.NewDecoder(r.Body).Decode(&req) instead of io.ReadAll + json.Unmarshal. The decoder streams the JSON instead of loading the entire body into memory, and with MaxBytesReader, it fails early on oversized payloads.
Third, call decoder.DisallowUnknownFields() to reject JSON with fields not in your struct. This catches typos and prevents unexpected data from silently being accepted.
Fourth, validate the decoded struct. Use a validation library like go-playground/validator with struct tags (validate:"required,email"), or write explicit validation logic. Never trust client input.
Fifth, prevent JSON injection: always set Content-Type: application/json on responses, never interpolate user input into JSON strings manually, and sanitize any data that might be rendered in HTML contexts.
Sixth, handle the error from json.NewEncoder(w).Encode(response). If the client disconnects mid-response, this write can fail, and you want to log it rather than crash.
Follow-up: What are the critical timeout settings on http.Server and what happens if you do not set them?Without timeouts, a slow client can hold a connection open indefinitely, exhausting your server’s file descriptors and goroutines. ReadTimeout limits how long the server waits to read the full request. WriteTimeout limits how long the server allows for writing the response. IdleTimeout controls how long keep-alive connections stay open between requests. ReadHeaderTimeout (often overlooked) limits reading just the headers, protecting against slowloris attacks. In production, I set ReadTimeout: 5s, WriteTimeout: 10s, IdleTimeout: 120s, ReadHeaderTimeout: 2s. Without these, a single slow or malicious client can consume a goroutine and connection slot forever.
The default HTTP client in Go has no timeout. Explain why this is dangerous and show me the production-grade HTTP client configuration.
Strong Answer:
http.DefaultClient has Timeout: 0, which means requests can block indefinitely. If the target server is slow or unresponsive, your goroutine hangs forever. In a service handling concurrent requests, this quickly leads to goroutine exhaustion.
The production configuration includes: Timeout: 30 * time.Second on the client (overall request timeout), and detailed transport settings: MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second.
Additionally, always use http.NewRequestWithContext(ctx, method, url, body) to create requests with context-based cancellation. This ties the request to the caller’s context, so if the caller times out or cancels, the HTTP request is aborted immediately.
Critical detail: always read and close the response body, even on error responses. If you do not drain the body, the underlying TCP connection cannot be reused. The pattern is: defer resp.Body.Close() and for error cases: io.Copy(io.Discard, resp.Body).
Follow-up: How would you implement retry with exponential backoff for HTTP requests, and what requests should you NOT retry?Retry only idempotent requests: GET, PUT, DELETE. Never retry POST unless the API supports idempotency keys. Retry on server errors (5xx) and network errors, but not on client errors (4xx). The backoff formula is baseDelay * 2^attempt + jitter, where jitter prevents thundering herd. Always check ctx.Done() between retries so cancellation is respected. And always close the response body of failed attempts before retrying, or you leak connections.