Go Practice #1 First HTTP Server
The Go Practice series begins. We’ll spend 6 posts on how the tools from Basics/Intermediate/Advanced fit together when actually building a backend.
- First HTTP Server (this post) — net/http, ListenAndServe, graceful shutdown
- Routing — Go 1.22+ ServeMux pattern matching
- JSON I/O and input validation
- DB integration —
database/sql, transactions - Middleware patterns
- Testing and deployment —
httptest, Docker
The biggest reason Go is strong for backends — a production server is possible with just the standard net/http. This post is that starting point.
The smallest server #
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world!")
})
http.ListenAndServe(":8080", nil)
}go run hello.go
curl http://localhost:8080
# Hello, world!19 lines (7 excluding blanks). Zero external dependencies. No help from frameworks like Express or FastAPI — just the standard library.
Three core types #
type Handler interface {
ServeHTTP(w ResponseWriter, r *Request)
}
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
type Request struct {
Method, URL, Header, Body, ...
}Handler— the common interface for every request handlerResponseWriter— write the responseRequest— request information
These three are the building blocks of Go HTTP code.
HandleFunc vs Handler
#
// register a function
http.HandleFunc("/a", func(w, r) { ... })
// register via the Handler interface
type myHandler struct{}
func (h *myHandler) ServeHTTP(w, r) { ... }
http.Handle("/b", &myHandler{})Most of the time, HandleFunc is the concise choice. Handlers that need state (fields) — use a struct + ServeHTTP.
HandleFunc — internally turns a function into a Handler via the http.HandlerFunc adapter (a textbook adapter pattern).
Writing the response #
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") // 1) headers first
w.WriteHeader(http.StatusOK) // 2) status code
fmt.Fprintln(w, `{"ok":true}`) // 3) body
}Order matters:
Header().Setmust come beforeWriteHeaderorWrite- Header changes after that have no effect
If you don’t call WriteHeader — the first Write defaults to 200.
URL/Query parameters #
func handler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
name := q.Get("name") // ?name=...
page := q.Get("page")
fmt.Fprintf(w, "Hello %s on page %s", name, page)
}curl 'http://localhost:8080/?name=lee&page=2'
# Hello lee on page 2Query() returns a url.Values (map[string][]string). Note that the same key can appear multiple times.
Method dispatch #
func handler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// read
case http.MethodPost:
// create
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}From Go 1.22+ — ServeMux itself supports method matching. Detailed in the next post.
Reading the request body #
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
defer r.Body.Close()
fmt.Fprintf(w, "got %d bytes", len(body))
}r.Body is — an io.ReadCloser. Structured input like JSON is detailed in #3 JSON.
Limit body size #
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
body, err := io.ReadAll(r.Body)Always set a limit to guard against malicious oversized requests.
Status codes — standard constants #
http.StatusOK // 200
http.StatusCreated // 201
http.StatusBadRequest // 400
http.StatusUnauthorized // 401
http.StatusNotFound // 404
http.StatusInternalServerError // 500Use constants instead of magic numbers — both IDE autocomplete and readability improve.
http.Error helper
#
http.Error(w, "not found", http.StatusNotFound)Internally, it sets the headers, status code, and message in one shot. Used often for simple error responses.
Server struct for explicit configuration #
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Fatal(srv.ListenAndServe())http.ListenAndServe(":8080", nil) is convenient — but timeout defaults are infinite. In production, always create your own http.Server and set timeouts (defends against slowloris attacks, etc.).
Graceful shutdown #
When you stop the server with SIGINT and want to let in-flight requests finish before shutting down.
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080", Handler: handler()}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
// wait for SIGINT/SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
log.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatal("shutdown:", err)
}
log.Println("bye")
}The flow:
- A separate goroutine runs ListenAndServe — main waits on a signal
signal.NotifyContext— the standard pattern from Go 1.16+. Handles signals via contextsrv.Shutdown(ctx)— waits for in-flight requests to finish (or until ctx times out)
Essential for safe rollouts in environments like Kubernetes or ECS.
Static files — http.FileServer
#
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))http.Dir("public")— a directory on diskhttp.FileServer— a handler that serves that directoryhttp.StripPrefix— strip the URL prefix (without it the server would look forpublic/static/...)
Small static sites — done in one line.
http.HandlerFunc adapter
#
func myFunc(w http.ResponseWriter, r *http.Request) { ... }
var h http.Handler = http.HandlerFunc(myFunc)Converts a function to satisfy the interface’s single method. The core technique for middleware (#5).
Pitfall — handlers run concurrently #
The same handler is called from many requests concurrently. If a handler touches shared state, you need the tools from Advanced #2 sync.
var counter int // shared variable
func handler(w http.ResponseWriter, r *http.Request) {
counter++ // ✗ race
}Solution: atomic.Int64 or Mutex.
Pitfall — forgetting to return after writing a response #
if err != nil {
http.Error(w, "...", 400)
// missing return → code below also runs → header conflict
}
fmt.Fprintln(w, "...")After an error response, always exit with return.
Wrap-up #
What we covered:
net/httpalone is enough for a production server- Three core types —
Handler,ResponseWriter,Request HandleFuncis common; for stateful handlers use struct + ServeHTTP- Response write order — Header → WriteHeader → Write
- Build
http.Serveryourself and always set timeouts - graceful shutdown —
signal.NotifyContext+srv.Shutdown(ctx) - Handlers run concurrently — synchronize shared state
In the next post (#2 Routing) — how Go 1.22+ ServeMux supports pattern/method matching, and where you’d reach for an external router (chi, gorilla/mux, etc.).