net/http is Go’s standard, production-grade HTTP implementation.
It provides:
- An HTTP server
- An HTTP client
- A routing + handler model
- Middleware-like composition (without calling it middleware)
- TLS, cookies, headers, streaming, HTTP/2, etc.
In Go, HTTP is not a framework. It’s a library + philosophy: small interfaces, explicit wiring, no magic.
Before APIs, understand why it looks “simple” but feels different from Express.
- Interfaces over classes
- Functions are first-class
- Composition over inheritance
- Concurrency is built-in
- Explicit is better than implicit
That’s why:
- No controllers
- No middleware keyword
- No request lifecycle hooks
- No router by default
You build everything explicitly, but in a clean, testable way.
Everything in net/http revolves around one interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}If something can:
- Receive a request
- Write a response
…it is an HTTP handler.
Go lets functions implement interfaces.
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}So this works:
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World"))
}And Go treats it as a Handler.
Express equivalent:
(req, res) => res.send("Hello")
But in Go, this is an interface implementation, not magic.
ResponseWriter is how we send responses.
It’s an interface, not a struct.
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}- Headers must be set before writing
Write()implicitly sends status200 OK- Once headers are sent, they are locked
Example:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"ok": true}`))If we call Write() first → status becomes 200.
*http.Request represents the entire incoming request.
It is immutable in practice (you shouldn’t mutate it casually).
Key fields:
type Request struct {
Method string
URL *url.URL
Header Header
Body io.ReadCloser
Context context.Context
}r.Method // "GET", "POST", etc.r.URL.Path
r.URL.Query().Get("id")r.Header.Get("Authorization")bodyBytes, _ := io.ReadAll(r.Body)- Body is a stream
- Can be read only once
- Large bodies are streamed (memory-safe)
The simplest server:
http.ListenAndServe(":8080", nil)It means:
“Use the DefaultServeMux”
http.ServeMux is Go’s default router.
mux := http.NewServeMux()Routes are registered like this:
mux.HandleFunc("/", homeHandler)
mux.Handle("/api", apiHandler)/→ matches everything/api/→ prefix match/api/users→ exact or deeper
:id) built-in.
That’s why people use:
chigorilla/muxhttprouter
But ServeMux is extremely fast and simple.
When we do:
http.HandleFunc("/", handler)We are registering routes on a global router.
Internally:
var DefaultServeMux = NewServeMux()This is okay for:
- Small apps
- Learning
- Examples
For real apps → always create your own mux.
For production, we usually create a server explicitly:
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
server.ListenAndServe()This allows:
- Timeouts
- TLS config
- Graceful shutdown
Example:
&http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}This is mandatory for production.
Go does not have “middleware” as a keyword.
Instead:
Middleware = a function that wraps a handler and returns a handler
func middleware(next http.Handler) http.HandlerExample:
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println(r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}Usage:
handler := logging(finalHandler)handler := auth(logging(finalHandler))This is functional programming, not framework magic.
Every request has a context:
ctx := r.Context()Used for:
- Cancellation
- Deadlines
- Request-scoped values
When client disconnects → context is canceled.
Example:
select {
case <-ctx.Done():
return
case result := <-dbQuery:
// respond
}This is huge for scalable systems.
Go also provides an HTTP client.
Basic request:
resp, err := http.Get("https://api.example.com")Better way:
client := &http.Client{
Timeout: 10 * time.Second,
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer token")
resp, err := client.Do(req)defer resp.Body.Close()cookie, err := r.Cookie("session")http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "abc123",
HttpOnly: true,
})r.ParseForm()
r.FormValue("email")r.ParseMultipartForm(10 << 20) // 10MB
file, header, err := r.FormFile("avatar")Go excels at streaming.
w.Write([]byte("chunk 1"))
w.(http.Flusher).Flush()Used for:
- SSE
- Large files
- Real-time responses
Every incoming request is handled in its own goroutine.
You do NOT create threads manually.
This means:
- Handlers must be thread-safe
- Shared state must be protected (
sync.Mutex) - Blocking = expensive
Compared to Node:
- Node → event loop
- Go → goroutine per request
Because it is.
But the tradeoff:
- 🔥 Extremely fast
- 🔥 Explicit control
- 🔥 Easy to test
- 🔥 Zero framework lock-in
Frameworks like Gin, Fiber, Echo are thin layers on top of net/http.
Step 1 Master:
HandlerServeMux- Middleware pattern
- Context
Step 2 Build:
- Auth middleware
- JSON API
- Graceful shutdown
Step 3
Then use a router like chi—not before.