Writing HTTP services in Go: is the standard library enough?
I explore what you get — and what you don't — when building a small HTTP service with Go's net/http package, and where the framework threshold sits.
One rule I set for myself while learning Go was this: I wouldn’t pull in an external library until I’d tried to build the thing with the standard library first. That rule was occasionally frustrating, but it taught me a lot. HTTP services turned out to be the area that really put it to the test.
In PHP, some framework is always running underneath everything — Laravel, Slim, whatever. Writing an HTTP server in raw PHP is theoretically possible, but nobody does it. In Go, on the other hand, the net/http package is genuinely usable; production services get written with the standard library every day. That difference forces a shift in mindset.
A basic server with net/http
Getting a Go HTTP server running takes just a few lines:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"status":"ok"}`)
})
http.ListenAndServe(":8080", nil)
}
It runs, responds on port 8080, zero dependencies. That simplicity feels satisfying at first — but as soon as you start building a real API, the limitations surface.
What the standard library is missing
The HandleFunc-based registration mechanism in net/http isn’t flexible enough. Route parameters — dynamic path segments like /users/123 — don’t exist in the standard library. You have to parse them by hand:
http.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
// manually extract everything after "/users/"
id := r.URL.Path[len("/users/"):]
if id == "" {
// return list
return
}
// return single record
})
This approach is tolerable for two or three routes, but it becomes unmanageable at ten. HTTP method dispatch (GET/POST/PUT/DELETE) also has to be done by hand:
switch r.Method {
case http.MethodGet:
// handle GET
case http.MethodPost:
// handle POST
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
Copy-pasting that switch block into every handler inflates the code quickly. The standard library doesn’t abstract this common pattern away — you either write it yourself or delegate it to a library.
Where is the framework threshold?
Before reaching for a library, I ask myself: how generic is the need that the standard library isn’t covering? Route parameters and HTTP method routing are generic and universal — every service needs them.
That’s when I look at third-party packages. In the Go ecosystem, gorilla/mux and chi are lightweight routers that fill this gap. They’re not frameworks; they simply add route parameters and method matching on top of net/http, and they conform to the standard http.Handler interface.
import "github.com/go-chi/chi/v5"
r := chi.NewRouter()
r.Get("/users/{id}", func(w http.ResponseWriter, req *http.Request) {
id := chi.URLParam(req, "id")
// work with id
})
http.ListenAndServe(":8080", r)
The core philosophy is preserved: a plugin that conforms to the standard library’s interfaces and doesn’t conflict with other packages. Not a large dependency pile like Laravel.
What convinced me about chi is its full compliance with the standard http.Handler interface. When evaluating a library, I pay close attention to this; a router that breaks the standard interface makes it harder to work with middleware or utilities that expect it. A handler written with chi can be used with any other net/http-compatible setup.
JSON responses and error handling
The standard library ships encoding/json for serialization, and it works well:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
This small helper eliminates repetition; all handlers use it.
Comparison with PHP
Writing an HTTP service in Go is different from writing one in PHP without a framework: the standard library is genuinely production-ready, well-maintained, and fast. But the layers Laravel provides — authentication, ORM, queues, email — aren’t present in Go, nor do they need to be. Go services typically do one narrower responsibility very well; broader concerns bring in other languages.
The bottom line: Go’s standard library covers a great deal, but not everything. Reaching for a lightweight library for route parameters and method routing is a reasonable trade-off. Jumping to a full framework is a premature decision in most cases — something you can do when the need genuinely grows to that scale.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.