Dev Skills

An HTTP server with no framework

Go 1.22 routes by method and path in the standard library. You do not need gin, echo, or chi.

Topic 47 of 98 · 10 min to read · 50 min to build
After this topic you can
  • Build a JSON API with Go 1.22 method-and-path routing and r.PathValue.
  • Write handlers that set headers, then the status, then the body, and return after an error.
  • Explain why a bare http.ListenAndServe is unsafe and configure http.Server timeouts.
  • Embed a file or a static folder into the binary with //go:embed and serve it with http.FileServerFS.

Most Go tutorials reach for a web framework on page one. Since Go 1.22 that advice is out of date. net/http routes on method and path parameters by itself, and that was the main reason most projects added one.

Starting without a framework is not stubbornness. You learn what an HTTP handler is, and frameworks you meet later are easy to read because most of them wrap this.

The whole server

main.go
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Go 1.22: method and {param} routing, built in.
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
writeJSON(w, 200, map[string]string{"id": id})
})
mux.HandleFunc("POST /users", createUser)
log.Println("listening on :8080")
// Simple version: no timeouts. The next sections swap this for an
// http.Server with timeouts, then add graceful shutdown.
// It blocks on purpose: it serves until the process is stopped.
log.Fatal(http.ListenAndServe(":8080", mux))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func createUser(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid json"})
return
}
if in.Name == "" || in.Email == "" {
writeJSON(w, 422, map[string]string{"error": "name and email are required"})
return
}
writeJSON(w, 201, in)
}

A handler is one function shape

Everything in net/http is built on this signature. w is where you write the response; r is everything the client sent.

the two handlers from main.go, annotated
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") // headers FIRST
w.WriteHeader(status) // then the status
json.NewEncoder(w).Encode(v) // then the body
}
func createUser(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid json"})
return // ALWAYS return after writing an error
}
if in.Name == "" || in.Email == "" {
writeJSON(w, 422, map[string]string{"error": "name and email are required"})
return
}
writeJSON(w, 201, in)
}
Watch out: The order matters. Set headers, then call WriteHeader, then write the body. Headers set after WriteHeader are ignored. WriteHeader takes effect only once, and writing a body without it sends a 200. If you write an error after a response has started, you get "superfluous response.WriteHeader call" in your logs, the client keeps the first status, and the error JSON is glued onto the first body. Always return right after writing an error.

Test it

terminal
go run .
curl -s localhost:8080/health | jq
curl -s localhost:8080/users/42 | jq
curl -s -X POST localhost:8080/users -d '{"name":"a","email":"a@b.c"}' | jq
curl -s -X POST localhost:8080/users -d 'not json' -i

The production settings, now rather than later

http.ListenAndServe(":8080", mux) is fine for a tutorial and wrong for anything you deploy. A zero-value http.Server has no timeouts at all, so a slow or idle client can hold a connection open forever.

server.go
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // the Slowloris defence people forget
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(srv.ListenAndServe())

Shipping files inside the binary with //go:embed

Sometimes the server needs a file of its own: a welcome page, a SQL schema, a folder of CSS and images. //go:embed copies those files into the binary at build time, so you deploy one file and nothing can go missing on the server.

Put the comment directly above a package-level variable. For one file, use a string or []byte and add import _ "embed" (the blank import switches the feature on). For many files, use embed.FS, which behaves like a small read-only folder, and hand it to http.FileServerFS (Go 1.22+; on older Go, http.FileServer(http.FS(static))).

The path is relative to the folder of the package containing the .go file, cannot use .., and the files must exist when you build or go build fails. Files starting with . or _ are skipped unless you write all:static.

internal/web/static.go (excerpt — needs real files next to it, so it will not run on the playground)
package web
import (
"embed"
"net/http"
)
// one file, as a string: welcome.txt sits next to this file, in internal/web/
//
//go:embed welcome.txt
var welcome string
// a whole folder: static/index.html, static/app.css, ...
//
//go:embed static
var static embed.FS
func routes(mux *http.ServeMux) {
mux.HandleFunc("GET /welcome", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(welcome))
})
// GET /static/app.css serves the embedded static/app.css
mux.Handle("GET /static/", http.FileServerFS(static))
}
Using only a string or []byte? Then you import the package for its side effect alone: import _ "embed". Here embed.FS is used by name, so a normal import is enough.
The short version
  • Go 1.22 net/http routes on "GET /users/{id}" and reads params with r.PathValue. No framework needed.
  • A handler is func(http.ResponseWriter, *http.Request). Everything else is a wrapper over that.
  • Headers, then WriteHeader, then body. Always return after writing an error.
  • A bare http.ListenAndServe has no timeouts. Configure http.Server yourself.
Remember: A handler reads the request and writes the response. Everything a framework adds is convenience on top of that one function shape.
Build this before moving on
Build an in-memory user API: GET, POST, GET by id, DELETE. Keep the users in a map behind a mutex, because net/http runs each request in its own goroutine. Test every route with curl, including the failure cases.
Coding challenge · checked by tests
Write the handler GetUser(w, r) for the route GET /users/{id}. When id is a positive whole number, reply 200 with Content-Type: application/json and the body {"id":7,"name":"user-7"} (for id 7). Otherwise reply 400 with Content-Type: application/json and {"error":"bad id"}. Read the id with r.PathValue("id") and convert it with strconv.Atoi. The hidden tests call it through a ServeMux with httptest.NewRecorder and httptest.NewRequest, no real server needed.

Tab indents. Press Esc, then Tab, to leave the editor.

Then try these in your own editor
  1. 1Add GET /users/{id} and read the parameter with r.PathValue. Try the wrong HTTP method and see the 405.
  2. 2Write a handler that sets a header after WriteHeader and confirm the header is silently dropped.
  3. 3Write an error path that forgets to return after writing, then find "superfluous WriteHeader" in the log.
  4. 4Swap http.ListenAndServe for a configured http.Server with all four timeouts.
Check yourselfTake the Web services test