HTTP: Protocol Theory and Go Implementation
"Imagine a postal service: you write a letter (request), send it to an address (server), and wait for a reply (response). HTTP is the protocol that defines how these letters are formatted, sent, and received over the internet."
What is HTTP?
- HTTP (Hypertext Transfer Protocol) is the foundation of data communication on the web.
- Request/Response Model: Clients (like browsers or Go programs) send requests; servers respond with data.
- Stateless: Each request is independent—servers don't remember previous requests by default.
- Analogy: Like sending a letter and getting a reply—each letter stands alone.
HTTP is just text over a connection: everything net/http does is convenience on top of that.
Anatomy of a Request and a Response
A raw HTTP request is plain text with a strict shape: a request line, a set of headers, a blank line, and an optional body. A response looks almost the same, but starts with a status line instead.
Methods describe the intent of a request:
| Method | Meaning | Safe? | Idempotent? |
|---|---|---|---|
| GET | Retrieve a resource | Yes | Yes |
| HEAD | Like GET, headers only, no body | Yes | Yes |
| POST | Create a resource / submit data | No | No |
| PUT | Replace a resource entirely | No | Yes |
| PATCH | Partially update a resource | No | No |
| DELETE | Remove a resource | No | Yes |
| OPTIONS | Ask what methods/headers are allowed | Yes | Yes |
- Safe means the method shouldn't change server state (GET should never delete data, even if a client calls it twice).
- Idempotent means calling it once or ten times leaves the server in the same state — useful for safe retries after a timeout.
Status codes are grouped by their first digit:
| Range | Class | Example |
|---|---|---|
| 1xx | Informational | 100 Continue |
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirection | 301 Moved Permanently, 304 Not Modified |
| 4xx | Client error | 400 Bad Request, 404 Not Found, 429 Too Many Requests |
| 5xx | Server error | 500 Internal Server Error, 503 Unavailable |
Headers carry metadata about the message: Content-Type and
Content-Length describe the body, Host identifies the target
virtual server, Authorization carries credentials, and countless
custom X--prefixed headers carry application-specific data.
HTTP Request/Response Lifecycle
sequenceDiagram
participant Client as HTTP Client (Browser/Go)
participant Server as HTTP Server
Client->>Server: Send HTTP Request (GET /)
Server-->>Client: Send HTTP Response (HTML, JSON, etc.)
Go in Action: Basic HTTP Server
Let's build a simple HTTP server in Go that responds with a greeting.
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world! Basic HTTP server in Go.")
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server listening at http://localhost:8080 ...")
http.ListenAndServe(":8080", nil)
}
http.ListenAndServe(":8080", nil) builds an http.Server with every timeout field left at its zero value, which means "wait forever." A client that opens a connection and trickles in one byte every few seconds (a Slowloris attack, or just a flaky mobile client) can tie up a goroutine and a socket indefinitely. Always construct an http.Server explicitly and set ReadTimeout, WriteTimeout, and IdleTimeout in production code.Extending the example with explicit timeouts:
package main
import (
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world! Basic HTTP server in Go.")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
fmt.Println("Server listening at http://localhost:8080 ...")
if err := server.ListenAndServe(); err != nil {
fmt.Println("Server stopped:", err)
}
}
Go in Action: Basic HTTP Client
Let's write a Go program that fetches a web page.
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("http://example.com")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("Server response:")
fmt.Println(string(body))
}
http.Get, http.Post, or client.Do returns a response whose Body must be closed, even if you don't read it. Skip Close() and the underlying TCP connection can't be returned to the client's connection pool — under load this leaks sockets and eventually exhausts file descriptors. Always pair the call with defer resp.Body.Close() right after checking the error.http.Get uses http.DefaultClient, which is fine for one-off scripts, but the zero-value client also has no timeout — a request to a server that never responds hangs forever. The fix isn't to allocate a fresh &http.Client{} on every call either: each http.Client owns its own connection pool (Transport), so creating one per request throws away keep-alive reuse and re-does a TCP/TLS handshake every time. Instead, create one http.Client with an explicit Timeout and reuse it across your program.Extending the example with a reusable, timeout-bound client:
package main
import (
"fmt"
"io"
"net/http"
"time"
)
// Package-level client: one connection pool, reused by every call.
var client = &http.Client{
Timeout: 10 * time.Second,
}
func main() {
resp, err := client.Get("http://example.com")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("Status:", resp.Status)
fmt.Println("Server response:")
fmt.Println(string(body))
}
Go in Action: Routing and Concurrent Servers
- Routing: Direct different URLs to different handlers.
- Concurrency: Go's HTTP server handles each request in its own goroutine.
Example: Custom Routes
package main
import (
"fmt"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from /hello!")
}
func byeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Goodbye from /bye!")
}
func main() {
http.HandleFunc("/hello", helloHandler)
http.HandleFunc("/bye", byeHandler)
fmt.Println("Server listening at http://localhost:8081 ...")
http.ListenAndServe(":8081", nil)
}
Example: Concurrent HTTP Server
package main
import (
"fmt"
"net/http"
"sync/atomic"
)
var counter int64
func handler(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt64(&counter, 1)
fmt.Fprintf(w, "Request #%d handled concurrently\n", n)
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Concurrent server at http://localhost:8082 ...")
http.ListenAndServe(":8082", nil)
}
Exercise: Concurrent HTTP Server
Go in Action: HTTP with Context and Cancellation
- Context: Allows canceling long-running requests if the client disconnects.
package main
import (
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fmt.Println("Request received, processing...")
select {
case <-time.After(5 * time.Second):
fmt.Fprintln(w, "Processing complete!")
case <-ctx.Done():
fmt.Fprintln(w, "Request canceled by client.")
}
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server with context at http://localhost:8083 ...")
http.ListenAndServe(":8083", nil)
}
Exercise: HTTP Server with Context
Go in Action: Graceful Shutdown
A production server shouldn't just die when it receives Ctrl+C or a
SIGTERM from an orchestrator — in-flight requests deserve a chance to
finish. Server.Shutdown(ctx) stops accepting new connections
immediately, then waits for active requests to complete (or for the
context to expire) before returning.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello! This server shuts down gracefully.")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
server := &http.Server{
Addr: ":8084",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
// Cancel ctx when the process receives an interrupt signal.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
fmt.Println("Server listening at http://localhost:8084 ...")
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Println("Server error:", err)
}
}()
<-ctx.Done() // Blocks here until Ctrl+C (or SIGTERM) arrives
fmt.Println("Shutdown signal received, draining connections...")
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
fmt.Println("Forced shutdown:", err)
} else {
fmt.Println("Server stopped cleanly.")
}
}
A server without a shutdown path doesn't stop — it just gets killed mid-sentence.
Try It Yourself: Timeouts and Graceful Shutdown
- Run the graceful shutdown server above and, in another terminal,
confirm it responds:
curl http://localhost:8084/. - Press
Ctrl+Cin the server's terminal. You should see "Shutdown signal received..." printed before the process exits, not an abrupt kill. - Temporarily change the handler to
time.Sleep(2 * time.Second)before writing its response, and lowerWriteTimeoutto500 * time.Millisecond. Hit the endpoint withcurl -vagain — instead of hanging for 2 seconds, the connection is now cut short by the server, and curl reports an error. - Start two slow requests in separate terminals, then
Ctrl+Cthe server. Confirm both requests still complete successfully before the process exits — that'sShutdowndraining in-flight work.
Frequently Asked Questions
My HTTP server hangs forever on a slow or misbehaving client — why doesn't Go stop it automatically?
Because http.ListenAndServe(":8080", nil) builds an http.Server with every timeout field left at its zero value, which literally means "wait forever" — nothing protects you from a Slowloris-style client trickling in one byte every few seconds. The fix is to construct the http.Server explicitly, as shown earlier in this chapter, and set ReadHeaderTimeout, ReadTimeout, WriteTimeout, and IdleTimeout rather than relying on the convenience function's defaults.
Do I need to close resp.Body even if I never read from it?
Yes, every time, with no exceptions — skipping resp.Body.Close() means the underlying TCP connection can never be returned to the client's connection pool, and under sustained load that quietly leaks sockets until you run out of file descriptors. Pair every successful http.Get/client.Do call with defer resp.Body.Close() immediately after checking the error, exactly like the basic HTTP client example does.
Is it better to create a fresh http.Client for each request so nothing gets shared between calls?
No, and this is a common overcorrection: each http.Client owns its own connection pool through its Transport, so a new client per request throws away keep-alive reuse and forces a fresh DNS lookup plus TCP (and TLS, for HTTPS) handshake every single time. Create one http.Client with an explicit Timeout at package level and reuse it across your whole program, the way the reusable-client example does.
What's the actual difference between a context deadline on a request and Server.Shutdown's context?
A request-scoped context (like r.Context() in the cancellation example) governs one in-flight request and fires when that specific client disconnects or its deadline passes. Server.Shutdown(ctx)'s context is different: it bounds how long the whole server will wait for every currently running handler to finish before giving up and returning, acting as a safety valve on the shutdown process itself rather than on any single request.
If I upgrade a connection to a WebSocket, will Server.Shutdown wait for it to close gracefully too?
No — Shutdown only tracks ordinary HTTP handlers; hijacked connections such as WebSockets fall outside its bookkeeping entirely. If your server upgrades connections, you're responsible for closing those yourself, typically by fanning your own cancellation signal out to each connection's goroutine alongside the server's shutdown signal.
Key Takeaways
- HTTP is the backbone of web communication: request/response, stateless, text-based.
- Go's
net/httppackage makes building servers and clients easy and concurrent. - Use handlers for routing, and context for cancellation.
- Each request is handled in its own goroutine—scalable by default!
- Always set explicit
Servertimeouts (ReadTimeout,WriteTimeout,IdleTimeout) — the zero-value server never times out. - Reuse a single
http.Clientwith aTimeoutset; never build one per request, and alwaysdefer resp.Body.Close(). - Prefer
Server.Shutdown(ctx)over letting the process die abruptly, so in-flight requests get a chance to finish.