Go Practice #1 First HTTP Server

5 min read

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.

  1. First HTTP Server (this post) — net/http, ListenAndServe, graceful shutdown
  2. Routing — Go 1.22+ ServeMux pattern matching
  3. JSON I/O and input validation
  4. DB integrationdatabase/sql, transactions
  5. Middleware patterns
  6. Testing and deploymenthttptest, 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 #

hello.go
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)
}
run
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 #

net/http core
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 handler
  • ResponseWriter — write the response
  • Request — request information

These three are the building blocks of Go HTTP code.

HandleFunc vs Handler #

two ways to register
// 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 #

response write order
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().Set must come before WriteHeader or Write
  • Header changes after that have no effect

If you don’t call WriteHeader — the first Write defaults to 200.

URL/Query parameters #

query string
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 2

Query() returns a url.Values (map[string][]string). Note that the same key can appear multiple times.

Method dispatch #

per-method handling
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 #

POST 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 #

MaxBytesReader
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   // 500

Use 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 #

explicit Server config
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.

graceful shutdown
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:

  1. A separate goroutine runs ListenAndServe — main waits on a signal
  2. signal.NotifyContext — the standard pattern from Go 1.16+. Handles signals via context
  3. srv.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 #

serving static files
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))
  • http.Dir("public") — a directory on disk
  • http.FileServer — a handler that serves that directory
  • http.StripPrefix — strip the URL prefix (without it the server would look for public/static/...)

Small static sites — done in one line.

http.HandlerFunc adapter #

function as Handler
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.

common pitfall
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 #

common pitfall
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/http alone is enough for a production server
  • Three core types — Handler, ResponseWriter, Request
  • HandleFunc is common; for stateful handlers use struct + ServeHTTP
  • Response write order — Header → WriteHeader → Write
  • Build http.Server yourself and always set timeouts
  • graceful shutdownsignal.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.).

X