Introduction to Web Development in Go

Updated

September 8, 2026

Introduction to Web Development in Go

Overview

Go is a strong fit for web backends: one binary, a solid standard library HTTP stack, clear concurrency, and boring reliability. This chapter sets the mental model—how a request moves through your process—and gets a minimal server running.

Why Go for web services

Strength What it means in practice
net/http in stdlib Ship APIs without a framework tax
Goroutines Handle many concurrent connections simply
Static typing + go test Refactor routes and handlers with confidence
Single binary Deploy the same artifact to VM, container, or bare metal
Explicit errors Surface failures at the boundary instead of hidden exceptions

You still need good structure, timeouts, and tests. Go does not replace design; it keeps the implementation honest.

Request lifecycle

  Client                Server process
    │                        │
    │  TCP + HTTP request    │
    │───────────────────────►│  ListenAndServe accepts connection
    │                        │  ServeMux matches method + path
    │                        │  Middleware (log, auth, recover)
    │                        │  Handler reads body / query
    │                        │  Domain / DB / external calls
    │  status + headers+body │
    │◄───────────────────────│  Response written, connection reuse

Rule: Handlers should be thin. Parse input → call application code → write response. Keep SQL, payment calls, and business rules out of the raw http.Handler body when the app grows.

First server

mkdir -p /tmp/bookstore-intro && cd /tmp/bookstore-intro
go mod init example.com/bookstore

Save as main.go:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", healthz)
    mux.HandleFunc("GET /api/hello", hello)

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    log.Println("listening on :8080")
    log.Fatal(srv.ListenAndServe())
}

func healthz(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte("ok"))
}

func hello(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(map[string]string{
        "service": "bookstore",
        "message": "welcome",
    })
}

Run and probe:

go run .
# other terminal
curl -s localhost:8080/healthz
curl -s localhost:8080/api/hello

What to notice

  • Method-aware patterns (GET /path) need Go 1.22+. Older Go used path-only matching.
  • Timeouts on http.Server are not optional for production; they bound stuck clients.
  • JSON via encoding/json is enough for most REST bodies.

Status codes you will use constantly

Code When
200 Success with body
201 Created resource
204 Success, no body
400 Client sent bad input
401 Not authenticated
403 Authenticated but not allowed
404 Missing resource
409 Conflict (duplicate ISBN, etc.)
500 Unexpected server failure

Prefer specific 4xx over a generic 500 when the client can fix the request.

Logging from day one

import "log/slog"

func withRequestLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

Wire it as Handler: withRequestLog(mux) on the server. Chapter 279 expands correlation and tests.

Error shape for APIs

Agree early on a small JSON error envelope:

type APIError struct {
    Error string `json:"error"`
    Code  string `json:"code,omitempty"`
}

func writeError(w http.ResponseWriter, status int, code, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(APIError{Error: msg, Code: code})
}

Clients and frontends can branch on code without scraping free-text messages.

Bookstore north star

By the end of this part the same process will expose:

  • GET /api/books — list catalog
  • GET /api/books/{id} — book detail
  • POST /api/books — create (admin)
  • POST /api/auth/login — session cookie
  • HTML pages that reuse the same domain layer

Rules of thumb

Do Don’t
Set server timeouts Use bare http.ListenAndServe in production forever
Return structured JSON errors Mix ad-hoc string bodies
Keep handlers thin Put SQL and payment logic only in handlers
Log method, path, duration Log secrets, cookies, full credit cards

Try next

  1. Add GET /api/version returning a build string.
  2. Return 404 JSON for unknown paths via a catch-all or custom NotFoundHandler.
  3. Hit the server with concurrent curl loops and watch logs stay readable.