So You Think You Know HTTP in Go? Think Again
Why Go’s HTTP model is actually pretty nice
If you’ve written web code in other languages, you might be used to frameworks doing everything for you. Go takes a different route. The net/http package is small, honest, and a bit opinionated. You get:
- A simple interface:
func(w http.ResponseWriter, r *http.Request) - A built‑in HTTP server that’s perfectly fine for real production use
- First‑class support for concurrency via goroutines
Take this tiny server:
package main
import (
"fmt"
"log"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, Go HTTP!")
}
func main() {
http.HandleFunc("/hello", helloHandler)
log.Println("Listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
This looks almost too simple. But under the hood, Go is spinning up a goroutine per connection, parsing HTTP, managing keep‑alives, and dealing with all the boring protocol details so you don’t have to.
The catch? Because it’s so easy to get started, people often stop there. And that’s where the weird bugs creep in.
What’s really hiding in *http.Request?
The *http.Request struct is a goldmine. Most folks hit r.URL.Path and r.Method and move on. There’s a lot more you can (and should) use:
r.Context()for deadlines and cancelationr.Headerfor content negotiation, auth, and tracingr.Bodyfor request payloads (JSON, form data, etc.)r.Formandr.PostFormfor URL and form values
Here’s a handler that actually pays attention to what the client is sending:
func echoJSON(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "unsupported media type", http.StatusUnsupportedMediaType)
return
}
type payload struct {
Message string `json:"message"`
}
var p payload
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(p); err != nil {
log.Printf("write error: %v", err)
}
}
Notice what’s going on here:
- We enforce the HTTP method instead of accepting everything.
- We check
Content-Typeinstead of optimistically decoding. - We use
DisallowUnknownFieldsto catch client bugs early.
Is this more code than the typical blog snippet? Yes. Is it closer to what you actually want in production? Also yes.
Why ResponseWriter is more than “something you write to”
http.ResponseWriter looks like an io.Writer with delusions of grandeur. In reality, it controls:
- Status code (via
WriteHeader) - Headers (via
Header()) - Streaming behavior (flush, hijack in some cases)
The key rule: headers and status must be set before you write the body. Once you call w.Write the first time, Go sends the headers. If you try this:
func badHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Oops")
w.WriteHeader(http.StatusInternalServerError) // too late
}
The client will see 200 OK with body Oops, not a 500. Go even logs a warning if you do this, but in production logs it’s easy to miss.
A more disciplined pattern looks like this:
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("writeJSON error: %v", err)
}
}
Then in handlers you call:
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
This keeps your response logic consistent and boring in the best possible way.
Context, timeouts, and not hanging forever
Here’s where reality hits. Imagine Mia, who built a small internal API in Go. It worked fine… until one backend dependency started timing out. Suddenly, requests piled up, goroutines exploded, and the service felt sluggish.
The missing piece? Timeouts and context.
Go’s http.Request carries a Context that gets canceled when the client disconnects or when a server timeout kicks in. You can (and should) pass this context down to anything that might block: database calls, HTTP clients, long computations.
func userHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := loadUser(ctx, userID)
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, user)
}
On the server side, you also want to configure timeouts instead of relying on defaults:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("Listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
This is the difference between “works on my machine” and “survives production traffic.”
For more background on why timeouts matter in network services, it’s worth reading general networking guidance from places like NIST or reliability discussions in university systems courses (for example, materials from MIT OpenCourseWare). They’re not Go‑specific, but the principles carry over directly.
Parsing query params and forms without losing your mind
Query parameters and form values are where a lot of subtle bugs sneak in. Types, defaults, required vs optional… it adds up.
Go gives you a couple of helpers:
r.URL.Query()for query parametersr.ParseForm()plusr.Form/r.PostFormfor form data
Here’s a handler that reads a couple of query params and doesn’t pretend everything is a string forever:
func searchHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
http.Error(w, "missing q", http.StatusBadRequest)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 20 // default
if limitStr != "" {
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 100 {
limit = v
}
}
results := search(q, limit)
writeJSON(w, http.StatusOK, results)
}
Nothing fancy, just sensible defaults and validation. The kind of thing you’ll thank yourself for later.
JSON: where most Go APIs live
Let’s be honest: most Go HTTP services today are JSON APIs. That means your real job is:
- Safely decoding request JSON into structs
- Validating input
- Encoding responses in a predictable shape
A common pattern is to have small helper functions for reading and writing JSON, so every handler doesn’t reinvent the wheel.
func decodeJSON(r *http.Request, dst any) error {
if r.Body == nil {
return errors.New("empty body")
}
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) // 1MB limit
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
// Ensure there is only one JSON value
if dec.More() {
return errors.New("multiple JSON values")
}
return nil
}
Then a handler becomes:
func createUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
type createUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
var req createUserRequest
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if req.Email == "" || req.Name == "" {
http.Error(w, "missing fields", http.StatusBadRequest)
return
}
user, err := createUser(r.Context(), req.Email, req.Name)
if err != nil {
http.Error(w, "could not create user", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, user)
}
Is this boilerplate? A bit. But it’s the kind of boilerplate that prevents malformed requests from quietly corrupting your data.
For secure coding practices around input validation and serialization, resources like the OWASP Foundation are worth bookmarking, even if they’re not Go‑specific.
Streaming, large responses, and not blowing up memory
Not every response is a tiny JSON blob. Sometimes you’re returning logs, reports, or file downloads that are way bigger than you want to keep in memory.
This is where Go’s streaming model shines. Because ResponseWriter is an io.Writer, you can stream directly from a source to the client:
func downloadHandler(w http.ResponseWriter, r *http.Request) {
f, err := os.Open("./bigfile.dat")
if err != nil {
http.Error(w, "file not found", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=bigfile.dat")
if _, err := io.Copy(w, f); err != nil {
log.Printf("download error: %v", err)
}
}
No buffering the whole file, no guessing sizes. Just a stream from disk to network.
On the flip side, when reading request bodies, you want to be a bit more paranoid. Use http.MaxBytesReader or similar patterns to cap how much you’re willing to read from a client. Otherwise, one overly enthusiastic request can chew through your RAM.
Middleware: the glue between requests and business logic
At some point, you’ll want to do things like logging, authentication, or rate limiting for many handlers. You could sprinkle that logic everywhere, or you can wrap handlers with middleware.
In Go, middleware is just a function that takes and returns an http.Handler:
func loggingMiddleware(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 in %v", r.Method, r.URL.Path, time.Since(start))
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
Then you compose them when wiring your routes:
mux := http.NewServeMux()
protected := loggingMiddleware(authMiddleware(http.HandlerFunc(userHandler)))
mux.Handle("/user", protected)
It’s not magic, just functions. But it keeps your handlers focused on actual business logic instead of boilerplate.
Common HTTP mistakes Go developers keep making
After seeing a lot of Go HTTP code in the wild, some patterns repeat themselves. A few that are worth fixing early:
- Ignoring
Contextand then wondering why shutdowns are messy. - Writing to the response after calling
http.Error(which already writes headers and a body). - Forgetting to set
Content-Type, leaving clients to guess. - Not closing
r.Bodyin custom servers or when proxying requests. - Using global
http.DefaultClientwithout timeouts for outbound calls.
None of these are dramatic on their own. But they add up to flaky behavior under load.
If you care about reliability and performance, it’s worth reading general guidance on building networked services, even if the examples aren’t in Go. University systems courses (for example, from Stanford or similar programs) often publish free material on this that maps nicely onto real‑world Go servers.
FAQ: the questions people actually ask about HTTP in Go
Do I need a framework on top of net/http?
Not necessarily. For many services, net/http plus a router (like chi or gorilla/mux) is more than enough. Frameworks can help with structure and batteries‑included features, but they also add their own learning curve and constraints. If you understand net/http well, you can make an informed decision instead of reaching for a framework by habit.
Is Go’s built‑in HTTP server safe for production?
Yes, it is used in plenty of production systems. The bigger question is how you configure it: set timeouts, handle graceful shutdown, and think about TLS termination (often via a reverse proxy like nginx or a cloud load balancer). The defaults are okay for experiments, not for serious traffic.
How do I properly shut down an HTTP server in Go?
Use http.Server with Shutdown and a context. That lets you stop accepting new connections and give in‑flight requests a grace period to finish. Something like:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown: %v", err)
}
This is much nicer than killing the process and hoping for the best.
Should I always return JSON from my API?
If you’re building a public or internal API, JSON is a reasonable default because tooling and humans both handle it well. But don’t force JSON where it doesn’t fit. File downloads, streaming logs, or metrics endpoints might use different content types. The key is to be consistent and honest about what you return via Content-Type.
How can I learn more about HTTP itself, not just Go’s API?
Understanding HTTP the protocol makes Go’s API feel a lot less magical. The MDN Web Docs are a solid, practical reference. For security aspects (headers, TLS, auth patterns), the OWASP Cheat Sheet Series is also worth exploring.
If you take nothing else from this: Go’s HTTP stack gives you a lot of power for very little code, but that doesn’t mean you should stop at hello world. A bit of care around requests, responses, and context turns “it works” into “it keeps working when things get weird.”
Related Topics
Practical examples of Go command-line application examples for 2025
Practical examples of 3 examples of working with Go maps for real projects
Practical examples of simple HTTP server examples in Go
Practical examples of defining and using structs in Go
Practical examples of creating and using Go interfaces
So You Think You Know HTTP in Go? Think Again
Explore More Go Code Snippets
Discover more examples and insights in this category.
View All Go Code Snippets