Go Dev — Building Real Applications
From HTTP servers to REST APIs, middleware, gRPC & production patterns — with real code from actual projects.
01 HTTP Client
*http.Client from the net/http standard library package. It lets your program send HTTP/1.1 and HTTP/2 requests to other servers and read the responses — the exact job that axios, fetch, requests, OkHttp, and HttpClient do in other ecosystems. A Go client is a thin, concurrency-safe wrapper around an http.RoundTripper (usually *http.Transport) that owns a connection pool, manages keep-alive, TLS, proxies, redirects, and cookie jars. The same http.Client value is safe to share across hundreds of goroutines.- Zero dependencies:
net/httpis in the standard library — nonpm install axios, nopip install requests. - Connection pooling: the default
http.Transportreuses TCP connections, giving you HTTP keep-alive for free. - Full HTTP/2 support: enabled automatically on TLS connections.
- Context cancellation: every request accepts a
context.Contextso upstream cancellations propagate down. - Configurable timeouts:
Client.Timeout, plus fine-grainedTransporttimeouts (DialContext,TLSHandshakeTimeout,ResponseHeaderTimeout). - Pluggable RoundTripper: wrap transport to add logging, retries, auth headers, tracing, circuit-breaking, or metrics.
- vs Python
requests: Go's client is synchronous by default but cheap to parallelize via goroutines — noasync/awaitceremony, no GIL. You build a*http.Requestexplicitly instead of passing keyword arguments. - vs Node.js
fetch/axios: Go returns(resp, err)tuples rather than rejecting Promises. Errors are values; you always check them. Response bodies areio.ReadCloserstreams, not buffered strings. - vs Java
HttpClient: Go has no builder pattern — you mutate struct fields directly. No fluent DSL, noCompletableFuture, just explicit method calls. - vs C#
HttpClient: same "reuse one client" principle, but Go avoids the infamousHttpClientsocket-exhaustion trap because the Transport manages pooling correctly by default. - vs Go third-party (
go-resty,hashicorp/go-retryablehttp): those add fluent APIs, retries, and JSON helpers on top of*http.Client, but the standard library is enough for 90% of production code.
http.Client whenever your Go program needs to talk to another HTTP service — a REST API, a webhook, an OAuth token endpoint, a cloud provider SDK, Prometheus, ElasticSearch, a microservice in your own cluster. It is the foundation for every SDK in the Go ecosystem: the AWS SDK, GitHub's go-github, Stripe's stripe-go, Kubernetes' client-go — all wrap *http.Client.- Forgetting
defer resp.Body.Close()— leaks sockets and file descriptors until the process dies. - Using
http.DefaultClientin production — it has no timeout, so a hung server can block your goroutine forever. Always create your own client withTimeout: 30 * time.Second. - Not draining the body — if you read part of the body then close it, the TCP connection can't be reused. Use
io.Copy(io.Discard, resp.Body)before closing to recycle the socket. - Creating a new client per request — defeats the connection pool. Create one
*http.Clientpackage-level variable and reuse it. - Ignoring status codes —
err == nilonly means the round trip succeeded; a500is still a valid response. You must checkresp.StatusCodeyourself.
client-go uses *http.Client with a custom transport for mTLS against the API server. Terraform providers use it to talk to AWS/GCP/Azure APIs. Prometheus scrapes metrics with a shared client across thousands of targets. Grafana Loki ships logs via a pooled client. Hugo uses it to fetch remote content for static sites. The Cloudflare Go libraries wrap it with retry logic for their edge API.Go's net/http package provides a production-grade HTTP client out of the box. Unlike most languages where you need a third-party library (axios, requests, etc.), Go's standard library is all you need. The http.Client struct gives you full control over timeouts, redirects, cookies, and transport-level settings.
The pattern is straightforward: create a client, make the request, check the error, and always close the response body with defer. Forgetting to close the body is a classic Go bug that causes resource leaks.
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
// Create an HTTP client (you can configure timeouts, redirects, etc.)
client := &http.Client{}
// Make a GET request to the Star Wars API
resp, err := client.Get("https://swapi.dev/api/people/1")
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close() // ALWAYS close the body to prevent resource leaks
// Read the entire response body into a byte slice
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Read error:", err)
return
}
fmt.Println(string(body)) // Convert []byte to string and print
}
HTTP responses hold open TCP connections. If you don't close the body, the connection stays open and eventually you exhaust the connection pool. defer ensures it runs when the function exits, even if an error occurs later.
Custom Requests with Headers
For anything beyond simple GET requests, use http.NewRequest to build the request manually. This gives you control over the HTTP method, headers, and body.
// Build a custom request with headers
req, err := http.NewRequest("GET", "https://api.example.com/data", nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer my-token")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
Always set a Timeout on your HTTP client. The default client has no timeout, meaning a slow server can hang your application forever. A 10-30 second timeout is a sensible default.
Go's http.Client maps directly to fetch (built-in) or axios (npm). Here's the same Star Wars API call in both:
// Node.js — using built-in fetch (Node 18+)
const response = await fetch('https://swapi.dev/api/people/1');
const data = await response.json(); // auto-parses JSON
console.log(data);
// Node.js — using axios
const { data } = await axios.get('https://swapi.dev/api/people/1');
console.log(data); // axios auto-parses JSON too
// Custom headers equivalent:
const resp = await fetch('https://api.example.com/data', {
headers: {
'Authorization': 'Bearer my-token',
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(10000), // 10s timeout (like Go's Timeout)
});
| Go | Node.js | Key Difference |
|---|---|---|
client.Get(url) | fetch(url) / axios.get(url) | Go returns (resp, err); Node returns a Promise |
io.ReadAll(resp.Body) | resp.json() / resp.text() | Go gives raw bytes; Node auto-decodes |
defer resp.Body.Close() | Nothing — GC handles it | Go MUST close body or leak sockets |
http.NewRequest("GET", url, nil) | new Request(url, { method: 'GET' }) | Almost identical pattern |
client.Timeout = 10 * time.Second | AbortSignal.timeout(10000) | Go sets once on client; Node per-request |
if err != nil { ... } | try/catch or .catch() | Go: explicit error value. Node: thrown exception |
Critical difference: In Go, you must close resp.Body or you leak TCP connections. In Node, the garbage collector handles cleanup. Also, Go errors are values you check explicitly — a 500 response is NOT an error in Go (you check resp.StatusCode), while axios throws on non-2xx by default.
02 HTTP Server Basics
http.Handler interface. The standard library ships a production-quality server (*http.Server) and two ways to register routes: the top-level http.HandleFunc helpers (which use http.DefaultServeMux) and your own *http.ServeMux. A handler is anything with a ServeHTTP(w http.ResponseWriter, r *http.Request) method — a pattern so simple that middleware, routers, and entire frameworks all compose through it. Go servers are natively concurrent: every incoming request runs on its own goroutine, so a single process can comfortably handle tens of thousands of simultaneous connections.- Goroutine-per-request: no thread pools to tune, no event loop to starve.
- HTTP/1.1 & HTTP/2 out of the box: HTTP/2 is automatic on TLS listeners.
- Graceful shutdown:
server.Shutdown(ctx)drains in-flight requests before exiting. - Interface-driven design:
http.Handleris a single-method interface — everything else composes from it. - Built-in timeouts:
ReadTimeout,WriteTimeout,IdleTimeout,ReadHeaderTimeoutprotect against slow-loris attacks. - Zero-allocation routing:
http.ServeMuxis a simple trie — no regex compilation on every request.
- vs Node.js Express: Express runs on a single-threaded event loop; one CPU-heavy request blocks everything. Go spawns a goroutine per request, so CPU work scales across all cores automatically.
- vs Python Flask/Django: Flask needs
gunicorn+geventoruWSGIto handle concurrency; Go's server is the production server. No WSGI, no ASGI, no separate reverse proxy required (though Nginx in front is still common). - vs Java Spring Boot: Spring Boot embeds Tomcat/Jetty/Undertow behind heavy annotations. Go has no annotations, no DI container — handlers are just functions.
- vs Rust
hyper/actix: Rust gives you more raw performance and zero-cost futures but forces you to reason about lifetimes and async runtimes. Go trades a little speed for dramatic simplicity. - vs Go frameworks (Gin, Echo, Fiber, Chi): those add ergonomic routers, middleware stacks, and param parsing on top of
net/http, but the raw standard library is completely usable for production APIs.
net/http when you want a production HTTP server with zero dependencies. It scales vertically without configuration tuning, deploys as a single binary, and exposes every knob you need for TLS, timeouts, graceful shutdown, and observability. For microservices, REST APIs, webhooks, health-check endpoints, and metrics exporters, it is the default choice.- Never use
http.ListenAndServein production without a custom*http.Server— the default has no timeouts, leaving you vulnerable to slow-loris DoS. - Panics crash only the current goroutine — but they still return a broken response to the client, so you need a recovery middleware.
- Writing to
http.ResponseWriterafter callingWriteHeader: headers must be set first; Go logs a warning but silently discards them. - Global
http.DefaultServeMuxis shared — any imported package can register handlers on it. Prefer your own*http.ServeMux. - Forgetting graceful shutdown —
server.Close()kills connections mid-response; useserver.Shutdown(ctx)instead.
net/http. Prometheus, Grafana, Consul, and Vault expose their HTTP APIs through it. Cloudflare uses it for edge workers. Dropbox migrated large parts of their Python backend to Go net/http and reduced server count by an order of magnitude.Go's standard library includes a full-featured HTTP server. No framework needed. This is one of Go's biggest strengths: you can build production-ready servers with zero external dependencies. The net/http package handles connection pooling, keep-alive, HTTP/1.1, and graceful shutdown out of the box.
The simplest server uses http.HandleFunc to register handler functions and http.ListenAndServe to start listening. Each incoming request runs in its own goroutine automatically, so your server is concurrent by default.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
// Register a handler function for the root path
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello Server!") // Write response to client
})
fmt.Println("Server on :8080")
// ListenAndServe blocks forever. log.Fatal logs the error if it returns.
log.Fatal(http.ListenAndServe(":8080", nil)) // nil = use DefaultServeMux
}
When you pass nil as the handler, Go uses http.DefaultServeMux, a global request multiplexer. Each call to http.HandleFunc registers a pattern on this default mux. When a request arrives, the mux finds the most specific matching pattern and calls its handler.
Understanding Every Line — What Each Function Does
Before learning multiple server methods, let's break down every piece of the basic server so nothing feels like magic.
package main
import (
"fmt" // Formatted I/O — Fprintln writes to any io.Writer (like http.ResponseWriter)
"log" // Logging with timestamps — log.Fatal = log.Print + os.Exit(1)
"net/http" // Full HTTP client + server, production-quality, zero dependencies
)
func main() {
// http.HandleFunc registers a function on the GLOBAL DefaultServeMux.
// Pattern "/" matches ALL paths (it's a catch-all in Go's mux).
// The function signature must be: func(http.ResponseWriter, *http.Request)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// w (ResponseWriter) — write your response here. It implements io.Writer.
// r (*Request) — contains everything about the incoming request:
// r.Method, r.URL, r.Header, r.Body, r.Context()
fmt.Fprintln(w, "Hello Server!")
})
fmt.Println("Server on :8080")
// log.Fatal does TWO things:
// 1. Logs the error with a timestamp (like log.Println)
// 2. Calls os.Exit(1) — terminates the program immediately
//
// http.ListenAndServe BLOCKS forever (it runs an infinite accept loop).
// It only returns if something goes wrong (port already in use, permission denied).
// So if it returns at all, it's an error — and log.Fatal prints it and exits.
log.Fatal(http.ListenAndServe(":8080", nil))
}
| Function | What it does | When to use |
|---|---|---|
fmt.Println("msg") | Prints to stdout, no timestamp | User-facing output, debug prints |
log.Println("msg") | Prints to stderr with timestamp, program continues | Non-fatal warnings, info logs |
log.Fatal("msg") | log.Println + os.Exit(1) — program dies | Startup failures where the program can't recover (port busy, missing config, cert not found) |
log.Fatalf("err: %v", err) | Same as Fatal but with format string | Same, but when you want to include the error value |
log.Panic("msg") | log.Println + panic() — triggers defer chains | Rarely used — when you want defers to run before crashing |
http.ListenAndServe is meant to run forever. If it returns, something is fatally wrong — the port is taken, permissions are denied, or the listener broke. There's nothing to "handle" — the server can't start. log.Fatal is the idiomatic Go pattern for "log it and crash" at startup. You'll see this in virtually every Go server example. Inside handlers, never use log.Fatal — use http.Error(w, msg, code) instead, because you don't want one bad request to kill the entire server.
All Server Creation Methods — Complete Reference
Go gives you 5 ways to start an HTTP server, from simplest to most control. Each TLS method is the secure equivalent of a plain HTTP method. Here's every one of them with when and why you'd pick it.
Method 1: http.ListenAndServe — Quick Start
A package-level convenience function. Creates an http.Server internally, binds to the address, and blocks forever. You get zero configuration — no timeouts, no TLS, no graceful shutdown.
// Signature: func ListenAndServe(addr string, handler http.Handler) error
http.HandleFunc("/", homeHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
// │ │
// │ └─ nil = use DefaultServeMux
// └─ ":8080" = listen on all interfaces, port 8080
// "localhost:8080" = only local connections
// ":0" = OS picks a random free port
Method 2: http.ListenAndServeTLS — Quick Start with HTTPS
Same as above but wraps the connection in TLS. You pass cert and key file paths. Still no timeouts or shutdown control.
// Signature: func ListenAndServeTLS(addr, certFile, keyFile string, handler http.Handler) error
log.Fatal(http.ListenAndServeTLS(
":443", // HTTPS standard port
"cert.pem", // Public certificate file — sent to clients
"key.pem", // Private key file — NEVER share this, chmod 0600
nil, // Handler (nil = DefaultServeMux)
))
// Internally: creates *http.Server, loads cert+key via tls.LoadX509KeyPair,
// wraps the TCP listener in a TLS listener, then calls server.Serve()
Method 3: server.ListenAndServe — Production HTTP
Create your own *http.Server struct with full control over timeouts, handler, header limits, and graceful shutdown.
mux := http.NewServeMux() // Your own mux — isolated from global state
mux.HandleFunc("GET /", homeHandler)
server := &http.Server{
Addr: ":8080", // Listen address
Handler: mux, // Your mux (NOT nil — avoid DefaultServeMux)
ReadTimeout: 5 * time.Second, // Max time to read full request
ReadHeaderTimeout: 2 * time.Second, // Max time to read just headers
WriteTimeout: 10 * time.Second, // Max time to write the response
IdleTimeout: 120 * time.Second, // How long keep-alive connections idle
MaxHeaderBytes: 1 << 20, // 1 MB max header size
}
// ListenAndServe on a *http.Server: creates a TCP listener on server.Addr,
// then calls server.Serve(listener). Blocks until error.
log.Fatal(server.ListenAndServe())
Method 4: server.ListenAndServeTLS — Production HTTPS
Same as above but with TLS. This is the most common production setup. HTTP/2 is automatically enabled over TLS connections.
server := &http.Server{
Addr: ":443",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12, // Reject TLS 1.0 and 1.1 (insecure)
},
}
// Loads cert+key, wraps listener in TLS, enables HTTP/2 via ALPN
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
// If certs are already in TLSConfig.Certificates, pass empty strings:
// log.Fatal(server.ListenAndServeTLS("", ""))
Method 5: server.Serve(listener) — Maximum Control (HTTP)
You create the TCP listener yourself and pass it in. Use this for Unix sockets, dynamic port assignment, or custom connection handling.
// Create a TCP listener manually
ln, err := net.Listen("tcp", ":0") // :0 = OS picks a free port
if err != nil {
log.Fatal(err)
}
log.Printf("Listening on %s", ln.Addr()) // Print the actual assigned port
server := &http.Server{Handler: mux}
log.Fatal(server.Serve(ln)) // Uses YOUR listener instead of creating one
// Unix socket example — no TCP, communicates via filesystem:
// ln, _ := net.Listen("unix", "/var/run/myapp.sock")
// server.Serve(ln)
Method 6: server.ServeTLS(listener, cert, key) — Maximum Control (HTTPS)
Same as Serve but wraps your listener in TLS. Used for custom TLS listeners, mTLS setups, or when you need to control the listener lifecycle.
ln, err := net.Listen("tcp", ":443")
if err != nil {
log.Fatal(err)
}
server := &http.Server{
Handler: mux,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
ClientAuth: tls.RequireAndVerifyClientCert, // mTLS — require client cert
},
}
// ServeTLS wraps ln in a TLS listener, then calls server.Serve(tlsListener)
log.Fatal(server.ServeTLS(ln, "cert.pem", "key.pem"))
| Method | Creates listener? | TLS? | Timeouts? | Use case |
|---|---|---|---|---|
http.ListenAndServe(addr, h) | Yes (internal) | No | No | Prototyping, scripts |
http.ListenAndServeTLS(addr, cert, key, h) | Yes (internal) | Yes | No | Quick HTTPS prototype |
server.ListenAndServe() | Yes (from Addr) | No | Yes | Production HTTP |
server.ListenAndServeTLS(cert, key) | Yes (from Addr) | Yes | Yes | Production HTTPS |
server.Serve(ln) | No (you provide) | No | Yes | Unix sockets, dynamic ports |
server.ServeTLS(ln, cert, key) | No (you provide) | Yes | Yes | Custom TLS, mTLS |
http.ListenAndServe(addr, handler)
└─ creates &http.Server{Addr: addr, Handler: handler}
└─ server.ListenAndServe()
└─ net.Listen("tcp", server.Addr) ← creates TCP listener
└─ server.Serve(listener) ← accept loop (blocks forever)
└─ for each connection: go conn.serve() ← goroutine per request
http.ListenAndServeTLS(addr, cert, key, handler)
└─ creates &http.Server{Addr: addr, Handler: handler}
└─ server.ListenAndServeTLS(cert, key)
└─ tls.LoadX509KeyPair(cert, key) ← loads certificate + private key
└─ net.Listen("tcp", server.Addr)
└─ tls.NewListener(ln, config) ← wraps TCP in TLS
└─ server.Serve(tlsListener) ← same accept loop, encrypted
log.Fatal calls os.Exit(1) which kills the entire process. Inside a handler, one bad request would crash the whole server. Use it only at startup. Inside handlers, return errors to the client:
// ❌ WRONG — kills the entire server for one bad request
func handler(w http.ResponseWriter, r *http.Request) {
data, err := db.Query("SELECT ...")
if err != nil {
log.Fatal(err) // DON'T — this calls os.Exit(1)!
}
}
// ✅ CORRECT — returns error to client, server keeps running
func handler(w http.ResponseWriter, r *http.Request) {
data, err := db.Query("SELECT ...")
if err != nil {
log.Printf("DB error: %v", err) // Log it (non-fatal)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
Handler vs HandlerFunc
Go has two ways to define handlers. http.HandlerFunc is for quick inline functions. http.Handler is an interface that any struct can implement, giving you more structure for complex handlers.
// Method 1: HandlerFunc — quick and simple
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"status": "ok"}`)
})
// Method 2: Handler interface — for structured handlers
type HomeHandler struct{}
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Welcome home")
}
// Register the struct handler
http.Handle("/home", &HomeHandler{})
Different Ways to Create an HTTP Server
Go gives you several ways to spin up an HTTP server, each adding more control. Understanding when to use which is critical for writing production code.
1. http.ListenAndServe — The Simplest Way
This is a convenience wrapper that creates a default http.Server internally. It's great for prototyping but never use it in production because you get no control over timeouts, TLS, or graceful shutdown.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello!")
})
// nil handler = use http.DefaultServeMux (the global mux)
// This is essentially:
// server := &http.Server{Addr: ":8080", Handler: nil}
// server.ListenAndServe()
log.Fatal(http.ListenAndServe(":8080", nil))
}
http.ListenAndServe creates a server with zero timeouts. A slow client can hold a connection open forever (slow-loris attack). It also uses the global DefaultServeMux, which any imported package can register handlers on — a security risk. And you can't call Shutdown() on it for graceful termination.
2. Custom http.Server — The Production Way
Creating your own *http.Server gives you full control over timeouts, TLS configuration, error logging, connection limits, and graceful shutdown. This is what every production Go service should use.
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from production server!")
})
server := &http.Server{
Addr: ":8080", // Listen address
Handler: mux, // Your own mux, NOT nil/DefaultServeMux
ReadTimeout: 5 * time.Second, // Max time to read entire request (headers + body)
ReadHeaderTimeout: 2 * time.Second, // Max time to read just the headers
WriteTimeout: 10 * time.Second, // Max time to write the response
IdleTimeout: 120 * time.Second, // Max time for keep-alive connections to idle
MaxHeaderBytes: 1 << 20, // 1 MB max header size
}
// Start server in a goroutine so we can handle shutdown
go func() {
log.Printf("Server starting on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Wait for interrupt signal (Ctrl+C)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
log.Println("Shutting down server...")
// Give in-flight requests 30 seconds to finish
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown stops accepting new connections, waits for existing ones to finish
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Forced shutdown: %v", err)
}
log.Println("Server stopped gracefully")
}
Client connects
├── ReadHeaderTimeout ──→ headers must arrive within this window
├── ReadTimeout ─────────→ entire request (headers + body) must arrive
├── Handler executes...
├── WriteTimeout ────────→ response must be fully written
└── IdleTimeout ─────────→ if keep-alive, how long to wait for next request
Without these timeouts, a malicious client can open thousands of connections and never close them, exhausting your server's file descriptors.
3. DefaultServeMux vs Custom ServeMux
Understanding the difference between these two is important for both security and code organization.
// ❌ DefaultServeMux — global, shared, risky
// When you call http.HandleFunc() or http.Handle(), you're registering
// on the GLOBAL http.DefaultServeMux. Any imported package can also
// register on it. If you pass nil to ListenAndServe, it uses this.
http.HandleFunc("/secret", secretHandler) // Goes to DefaultServeMux
http.ListenAndServe(":8080", nil) // nil = DefaultServeMux
// Imagine an imported package does this in its init():
// func init() {
// http.HandleFunc("/debug/pprof/", pprof.Index) // Now exposed!
// }
// You just accidentally exposed profiling data to the internet.
// ✅ Custom ServeMux — isolated, explicit, safe
// You control exactly what's registered. Nothing else can touch it.
mux := http.NewServeMux()
mux.HandleFunc("GET /users", usersHandler) // Only what YOU register
mux.HandleFunc("POST /users", createUser) // Method-based routing (Go 1.22+)
server := &http.Server{
Addr: ":8080",
Handler: mux, // Explicit — no surprises
}
server.ListenAndServe()
| Feature | DefaultServeMux | Custom ServeMux |
|---|---|---|
| Scope | Global singleton — shared across entire process | Local variable — scoped to your code |
| Registration | http.HandleFunc() | mux.HandleFunc() |
| Safety | Any package can register routes via init() | Only code with access to the variable can register |
| Testing | Harder — global state leaks between tests | Easy — create a new mux per test |
| Multiple servers | One mux for everything | Different mux per server (e.g., public vs admin) |
| Use case | Quick scripts, prototyping | Production services, anything non-trivial |
4. Running Multiple Servers (Public + Admin)
A common pattern is running two servers in the same process: one public-facing and one for internal admin/metrics. Each gets its own mux and can run on different ports.
func main() {
// Public API server
publicMux := http.NewServeMux()
publicMux.HandleFunc("GET /api/products", listProducts)
publicMux.HandleFunc("GET /api/products/{id}", getProduct)
publicServer := &http.Server{
Addr: ":8080",
Handler: publicMux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
// Internal admin/metrics server (not exposed to internet)
adminMux := http.NewServeMux()
adminMux.HandleFunc("GET /healthz", healthCheck)
adminMux.HandleFunc("GET /metrics", metricsHandler)
adminMux.HandleFunc("GET /debug/pprof/", pprofHandler)
adminServer := &http.Server{
Addr: ":9090", // Different port, firewalled from public
Handler: adminMux,
}
// Run both servers concurrently
go func() {
log.Println("Public server on :8080")
log.Fatal(publicServer.ListenAndServe())
}()
go func() {
log.Println("Admin server on :9090")
log.Fatal(adminServer.ListenAndServe())
}()
// Block forever (or use signal handling for graceful shutdown)
select {}
}
5. Custom net.Listener — Maximum Control
For advanced use cases, you can create your own TCP listener and pass it to server.Serve(). This gives you control over the listener itself — useful for Unix sockets, custom connection limits, or dynamic port assignment.
import (
"net"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", homeHandler)
// Create a TCP listener manually
ln, err := net.Listen("tcp", ":0") // :0 = OS picks a free port
if err != nil {
log.Fatal(err)
}
log.Printf("Listening on %s", ln.Addr()) // Print the actual port
server := &http.Server{Handler: mux}
server.Serve(ln) // Use Serve() instead of ListenAndServe()
// Unix socket example:
// ln, _ := net.Listen("unix", "/var/run/myapp.sock")
// server.Serve(ln)
}
| Method | What it does | When to use |
|---|---|---|
http.ListenAndServe(addr, handler) | Creates server + listener internally, blocks | Prototyping, scripts, learning |
server.ListenAndServe() | Creates listener from server.Addr, blocks | Production HTTP servers |
server.ListenAndServeTLS(cert, key) | Same but with TLS from cert/key files | Production HTTPS servers |
server.Serve(listener) | Uses YOUR listener, blocks | Custom listeners, Unix sockets, dynamic ports |
server.ServeTLS(listener, cert, key) | Uses YOUR listener + TLS | Custom TLS listeners, mTLS setups |
6. http.ListenAndServe vs http.Server Summary
// These two are EQUIVALENT:
// Version 1: The shortcut (what http.ListenAndServe does internally)
http.ListenAndServe(":8080", mux)
// Version 2: What it actually expands to
server := &http.Server{
Addr: ":8080",
Handler: mux,
// ReadTimeout: 0 ← DANGER: no timeouts!
// WriteTimeout: 0 ← DANGER!
// IdleTimeout: 0 ← DANGER!
}
server.ListenAndServe()
// The ONLY difference: with http.Server{} you can set
// timeouts, TLS config, error logger, max header bytes,
// base context, and call Shutdown() for graceful termination.
// http.ListenAndServe gives you NONE of that.
Go's HTTP server maps to Node's http.createServer() or Express's app.listen(). Here's how the concepts translate:
// --- BASIC SERVER (like Go's http.ListenAndServe) ---
const http = require('http');
const server = http.createServer((req, res) => {
res.end('Hello Server!');
});
server.listen(8080, () => console.log('Server on :8080'));
// --- PRODUCTION SERVER (like Go's custom *http.Server) ---
const server = http.createServer(app);
server.timeout = 10000; // Like Go's WriteTimeout (10s)
server.headersTimeout = 5000; // Like Go's ReadHeaderTimeout (5s)
server.keepAliveTimeout = 120000; // Like Go's IdleTimeout (120s)
server.maxHeadersCount = 100; // Limit header count
server.listen(8080);
// --- GRACEFUL SHUTDOWN (like Go's server.Shutdown(ctx)) ---
process.on('SIGINT', () => {
console.log('Shutting down...');
server.close(() => { // Waits for in-flight requests
console.log('Server stopped gracefully');
process.exit(0);
});
// Force kill after 30s
setTimeout(() => process.exit(1), 30000);
});
| Go | Node.js | Key Difference |
|---|---|---|
http.HandleFunc("/", handler) | app.get("/", handler) | Go registers on a mux; Express uses chained methods |
http.ListenAndServe(":8080", nil) | app.listen(8080) | Go blocks forever; Node is non-blocking (event loop) |
log.Fatal(err) | server.on('error', cb) | Go exits on startup error; Node emits an error event |
server.Shutdown(ctx) | server.close(callback) | Go uses context for timeout; Node uses callback |
| Goroutine per request (auto) | Single-threaded event loop | Fundamental: Go handles CPU-heavy requests concurrently; Node blocks on them |
http.NewServeMux() | express.Router() | Go's mux is a simple trie; Express router is middleware-based |
w.Header().Set(...) | res.setHeader(...) | Same concept, different API |
fmt.Fprintln(w, "text") | res.send("text") | Go writes to io.Writer; Express has convenience methods |
The biggest difference: Go spawns a goroutine per request automatically — 10,000 concurrent connections = 10,000 goroutines (~20KB each). Node processes ALL requests on a single thread. One slow database query in Node.js doesn't block (it's async), but one CPU-heavy computation blocks everything. In Go, CPU-heavy work in one goroutine doesn't affect others.
03 Modern Routing (Go 1.22+)
http.ServeMux that finally added HTTP method matching and path wildcards to the standard library. Before 1.22 you had to use gorilla/mux, chi, or gin for any non-trivial API because the built-in mux could only match literal prefixes. Now you can write mux.HandleFunc("GET /users/{id}", handler), extract parameters with r.PathValue("id"), and even match a trailing wildcard {path...} for catch-all routes — all without any third-party dependency.- Method-scoped patterns:
"GET /posts","POST /posts","DELETE /posts/{id}"all coexist on the same path. - Named path parameters:
{id},{slug}— accessed viar.PathValue("id"). - Wildcard suffix:
{path...}captures everything remaining, useful for static file servers or proxies. - Host-based routing:
"api.example.com/v1/users"matches only that host. - Precedence rules: more-specific patterns beat less-specific ones automatically — no ordering hacks.
- 405 Method Not Allowed: automatically returned when a path exists but the method doesn't match.
- vs Go pre-1.22 stdlib: the old
http.ServeMuxhad no method matching, no params — you had to parse URLs yourself or pull in a router library. - vs
gorilla/mux: gorilla supports regex constraints and has been archived; stdlib routing is now a drop-in replacement for 90% of its use cases without the dependency. - vs
chi: chi still offers better middleware chaining and sub-router mounting, but stdlib now covers basic method+param routing. - vs Gin/Echo/Fiber: those frameworks bundle routing, middleware, validation, and JSON helpers. Stdlib routing is leaner and doesn't lock you into a framework ecosystem.
- vs Express.js: Express uses middleware chains and
req.params.id; Go 1.22 usesr.PathValue("id")and a flat registration style. - vs Rails routes.rb: no DSL, no convention-over-configuration — you register each route explicitly.
- Your
go.modmust declarego 1.22or later — otherwise patterns silently fall back to the old behavior. - No regex constraints: you can't limit
{id}to digits; you must validate inside the handler. - No middleware stack — you wrap handlers manually (
Logger(Recovery(mux))), which gets verbose. - No sub-router mounting like
chi.Mount— you have to usehttp.StripPrefixor register prefixed routes yourself. - PathValue returns empty string if not found — not an error — so typos silently produce
"".
net/http router is also the backbone of Go's own godoc and pkg.go.dev site.Before Go 1.22, the standard library router was limited: no method-based routing, no path parameters. You had to use third-party routers like gorilla/mux or chi. Go 1.22 changed everything by adding method-based routing, path parameters, and wildcards directly into the standard library.
This means for most applications, you no longer need an external router. The syntax is clean: prefix your pattern with the HTTP method, use {name} for parameters, and {name...} for catch-all wildcards.
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Method-based routing — only matches the specified HTTP method
mux.HandleFunc("POST /items/create", createItem)
mux.HandleFunc("DELETE /items/create", deleteItem)
// Path parameters with {id} — extracted via r.PathValue()
mux.HandleFunc("GET /teachers/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Teacher ID: %s", r.PathValue("id"))
})
// Wildcard catch-all with {path...} — matches any remaining path
mux.HandleFunc("/files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Path: %s", r.PathValue("path"))
})
http.ListenAndServe(":8080", mux)
}
"GET /users"— matches only GET requests to /users"/users"— matches ALL methods to /users (like pre-1.22 behavior)"GET /users/{id}"— matches GET with a single path segment captured as "id""/files/{path...}"— matches any method, captures everything after /files/ as "path"- More specific patterns take priority over general ones
PathValue vs Query Parameters
Don't confuse path parameters with query parameters. Path values are part of the URL structure (/users/42), while query parameters come after ? (/users?id=42).
// Path parameter: /users/42
id := r.PathValue("id") // "42"
// Query parameter: /users?page=2&limit=10
page := r.URL.Query().Get("page") // "2"
limit := r.URL.Query().Get("limit") // "10"
Go 1.22's routing now matches what Express has offered since day one — method-based routing and path parameters. The syntax is different but the concepts map 1:1:
// Express.js — the same routing patterns
const express = require('express');
const app = express();
// Method-based routing (same as Go's "POST /items/create")
app.post('/items/create', createItem);
app.delete('/items/create', deleteItem);
// Path parameters (same as Go's /teachers/{id})
app.get('/teachers/:id', (req, res) => {
res.send(`Teacher ID: ${req.params.id}`);
// ^^^^^^^^^^^^^^
// Express: req.params.id
// Go: r.PathValue("id")
});
// Wildcard catch-all (same as Go's /files/{path...})
app.get('/files/*', (req, res) => {
res.send(`Path: ${req.params[0]}`);
// Express uses * for catch-all; Go uses {name...}
});
// Query parameters — identical concept
app.get('/users', (req, res) => {
const page = req.query.page; // Express: req.query.page
const limit = req.query.limit; // Go: r.URL.Query().Get("limit")
});
| Go (1.22+) | Express.js |
|---|---|
mux.HandleFunc("GET /users/{id}", h) | app.get('/users/:id', h) |
r.PathValue("id") | req.params.id |
r.URL.Query().Get("page") | req.query.page |
"/files/{path...}" | '/files/*' |
| Auto 405 Method Not Allowed | Need app.all() or custom handler |
| More-specific pattern wins automatically | First registered route wins (order matters!) |
Key difference: Express matches routes in registration order (first match wins), so route order matters. Go 1.22 uses specificity (most-specific pattern wins), so /users/new always beats /users/{id} regardless of order.
04 Sub-Routing & Route Groups
/api/v1 shares a JSON-response middleware, and every route under /api/v1/admin additionally requires an auth middleware. Go has no native router.Group() API in net/http, so you build sub-routing by combining multiple *http.ServeMux instances, the http.StripPrefix helper, and handler wrapping — or you use a framework like Chi or Gin that has this built in.- Prefix mounting:
mainMux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))delegates everything under/api/v1to a separate mux. - Shared middleware per group: wrap the sub-mux once (
authMiddleware(adminMux)) instead of every handler. - Version isolation: run
v1andv2routers side-by-side during API migrations. - Independent testing: each sub-mux can be tested in isolation with
httptest.NewRecorder. - Composable ownership: different teams can maintain different sub-routers in different files/packages.
- vs Express.js
Router: Express gives youapp.use('/api', router), a direct equivalent. Go requires the extraStripPrefixstep because paths are matched verbatim. - vs Rails
namespace: Rails nests routes declaratively; Go does it imperatively by wiring muxes together. - vs Spring Boot
@RequestMapping: Spring uses class-level annotations for prefixes; Go uses explicit handler registration. - vs Chi's
r.Route("/api", ...): Chi has first-class sub-routers with inherited middleware; stdlib doesn't, so the wiring is more verbose but completely transparent. - vs Gin's
router.Group("/api"): Gin groups inherit middleware automatically; with stdlib you manually re-wrap.
main.go clean, enables per-group middleware (auth only on /admin, CORS only on /public), and supports API versioning (/v1, /v2) without duplication. Without it, adding auth means repeating the wrapper on every handler — a recipe for security bugs when someone forgets one.- Forgetting
http.StripPrefix— the sub-mux sees the full path/api/v1/usersinstead of/usersand nothing matches. - Trailing slash mismatch:
"/api/v1"and"/api/v1/"are different patterns inhttp.ServeMux. - Middleware order confusion: wrapping a sub-mux after mounting has no effect; wrap before.
- Method matching inside a group: remember Go 1.22 patterns in the sub-mux still need the
"GET /"prefix.
/api/v1, /apis/apps/v1, /healthz etc. Gitea (Go-based Git hosting) mounts its API router under /api/v1 and admin under /-/admin. Grafana composes dozens of sub-routers for its datasource, alerting, and user-management subsystems. Caddy's admin API uses prefix-based route groups.As your application grows, you'll want to organize routes into logical groups. Go's http.ServeMux supports this through sub-routing using http.StripPrefix. This pattern lets you build modular routers and mount them under prefixes, similar to Express.js app.use("/api", router).
The key insight is that http.StripPrefix removes the prefix before passing the request to the inner handler, so your sub-router's patterns don't need to include the prefix.
func main() {
// Create a sub-router with its own routes
mux := http.NewServeMux()
mux.HandleFunc("GET /users", usersHandler)
mux.HandleFunc("GET /items", itemsHandler)
// Mount under /app prefix
app := http.NewServeMux()
app.Handle("/app/", http.StripPrefix("/app", mux))
// Now these routes work:
// /app/users -> usersHandler
// /app/items -> itemsHandler
http.ListenAndServe(":8080", app)
}
Multiple API Versions
This pattern is especially useful for API versioning. You can maintain separate routers for /v1 and /v2, each with their own handlers.
// Version 1 routes
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", getUsersV1)
// Version 2 routes (different implementation)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", getUsersV2)
// Mount both under /api
api := http.NewServeMux()
api.Handle("/api/v1/", http.StripPrefix("/api/v1", v1))
api.Handle("/api/v2/", http.StripPrefix("/api/v2", v2))
Express has first-class sub-routing with express.Router() and app.use(prefix, router) — no StripPrefix needed because Express handles prefix stripping automatically:
// Express.js — Sub-routing with express.Router()
const express = require('express');
const app = express();
// Create sub-routers (like Go's http.NewServeMux())
const usersRouter = express.Router();
usersRouter.get('/', getUsers); // Handles /app/users
usersRouter.get('/:id', getUser); // Handles /app/users/:id
const itemsRouter = express.Router();
itemsRouter.get('/', getItems); // Handles /app/items
// Mount under /app prefix — Express strips the prefix automatically!
app.use('/app/users', usersRouter);
app.use('/app/items', itemsRouter);
// --- API Versioning ---
const v1Router = express.Router();
v1Router.get('/users', getUsersV1);
const v2Router = express.Router();
v2Router.get('/users', getUsersV2);
app.use('/api/v1', v1Router); // No StripPrefix needed!
app.use('/api/v2', v2Router); // Express handles it automatically
| Go | Express.js |
|---|---|
mux := http.NewServeMux() | const router = express.Router() |
app.Handle("/api/", http.StripPrefix("/api", mux)) | app.use('/api', router) |
Must use http.StripPrefix manually | Express strips prefix automatically |
Trailing slash matters: "/api/" vs "/api" | Express normalizes both |
Key difference: Express's app.use('/prefix', router) is a one-liner. Go requires the http.StripPrefix wrapper because the sub-mux sees the full path otherwise. Express handles this automatically, which is more convenient but also more magical.
05 TLS, HTTPS & HTTP/2
crypto/tls package and plugs directly into net/http: you call server.ListenAndServeTLS(certFile, keyFile) and Go takes care of the handshake, certificate presentation, ALPN negotiation, and session resumption. Whenever TLS is enabled, Go automatically upgrades the connection to HTTP/2 via ALPN — no configuration required. For zero-touch certificates, the golang.org/x/crypto/acme/autocert package integrates with Let's Encrypt to provision and renew certs on demand.- Automatic HTTP/2: TLS + ALPN = HTTP/2 with no code changes.
- Modern cipher suites: TLS 1.3 is the default on Go 1.12+; weak ciphers are disabled.
- Mutual TLS (mTLS): set
tls.Config.ClientAuth = RequireAndVerifyClientCertand you have client-certificate auth. - SNI support: a single listener can serve multiple hostnames with different certificates via
GetCertificate. - autocert: one-line Let's Encrypt integration with on-disk caching.
- HTTP/3 (experimental): via
quic-go, though not in the stdlib yet.
- vs Nginx/Apache: you don't need a reverse proxy for TLS termination — Go serves HTTPS directly at line rate. Caddy, a pure-Go server, actually is your reverse proxy.
- vs Node.js
httpsmodule: same idea, but Go's HTTP/2 is transparent; Node needs the separatehttp2module and manual ALPN setup. - vs Python with
ssl: Python's SSL context is more verbose and HTTP/2 requireshypercornor similar. - vs Java
SSLContext: Java's JKS/PKCS12 keystores are painful; Go just reads PEM files. - vs Rust
rustls: comparable in safety and speed; Go wins on ergonomics (no lifetimes to manage), Rust wins on compile-time guarantees.
- Self-signed certs in dev leak into production — use real certs (or Let's Encrypt) from day one.
- Certificate key file permissions: must be
0600ortls.LoadX509KeyPairmay still load but security scanners flag it. - Forgetting to redirect HTTP to HTTPS: run a second listener on port 80 that 301s to
https://. - autocert requires port 80 or DNS-01: behind a load balancer you need
HostPolicyconfigured correctly. - HTTP/2 "PRI method" panic: caused by clients trying HTTP/2 cleartext (h2c) without configuration — use
h2c.NewHandlerif you need it.
crypto/tls + autocert. Kubernetes uses mTLS for internal API server communication. Consul and Vault use mTLS for all service-to-service traffic. Cloudflare's Go services terminate millions of TLS handshakes per second. Let's Encrypt's Boulder CA backend is itself written in Go.Production servers must use TLS (Transport Layer Security) to encrypt traffic. Go makes this straightforward with ListenAndServeTLS. Here's a complete breakdown of how TLS, certificates, HTTPS, and HTTP/2 work in Go.
How TLS/HTTPS Works (The Big Picture)
Browser Go Server (:443)
│ │
│──── 1. ClientHello ────────────────────→│ "I support TLS 1.3, these ciphers"
│ │
│←─── 2. ServerHello + Certificate ──────│ "Let's use TLS 1.3. Here's my cert"
│ │
│──── 3. Verify cert against CA ─────────│ Browser checks cert chain
│ │
│──── 4. Key Exchange ───────────────────→│ Establish shared secret
│ │
│←──→ 5. Encrypted HTTP traffic ────────→│ All data encrypted from here
│ │
│ (ALPN negotiation happens at step 2:
│ both sides agree on h2 = HTTP/2)
Generating Certificates
Before you can serve HTTPS, you need a certificate (public) and a private key. There are three ways to get them:
1. Self-Signed Certificate (Development Only)
# Generate a self-signed cert valid for 365 days
# -x509: output a self-signed cert instead of a CSR
# -nodes: don't encrypt the private key with a passphrase
# -newkey rsa:4096: generate a new 4096-bit RSA key
openssl req -x509 -newkey rsa:4096 \
-keyout key.pem \
-out cert.pem \
-days 365 \
-nodes \
-subj "/CN=localhost"
# For testing with multiple domains (Subject Alternative Names):
openssl req -x509 -newkey rsa:4096 \
-keyout key.pem -out cert.pem -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1"
Browsers show "Your connection is not private" because no trusted CA signed the cert. This is fine for local dev but never use self-signed certs in production. Clients using http.Client will also reject them unless you set InsecureSkipVerify: true (which defeats the purpose of TLS).
2. Generate Certs Programmatically in Go (Tests & Dev)
Go can generate certificates at runtime — useful for test servers and development tools that need TLS without external files.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log"
"math/big"
"net"
"net/http"
"time"
)
func generateSelfSignedCert() (tls.Certificate, error) {
// Generate ECDSA private key (faster than RSA, smaller keys)
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
// Create certificate template
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{Organization: []string{"Dev"}},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour), // 1 day validity
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
}
// Self-sign: certificate signs itself (template == parent)
certDER, err := x509.CreateCertificate(rand.Reader, template, template,
&privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, err
}
// Encode to PEM format in memory (no files needed)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, _ := x509.MarshalECPrivateKey(privateKey)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return tls.X509KeyPair(certPEM, keyPEM)
}
func main() {
cert, err := generateSelfSignedCert()
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "TLS: %s, Protocol: %s", r.TLS.Version, r.Proto)
})
server := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert}, // Use the in-memory cert
MinVersion: tls.VersionTLS12,
},
}
log.Println("HTTPS server on :8443")
// Pass empty strings — certs are already in TLSConfig
log.Fatal(server.ListenAndServeTLS("", ""))
}
3. Let's Encrypt with autocert (Production)
The autocert package automatically provisions and renews TLS certificates from Let's Encrypt. Zero manual cert management.
import (
"crypto/tls"
"net/http"
"golang.org/x/crypto/acme/autocert"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", homeHandler)
// autocert manager handles everything:
// - Requests certs from Let's Encrypt on first connection
// - Caches them in /var/www/.cache
// - Auto-renews before expiry (certs last 90 days)
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS, // Accept Let's Encrypt TOS
HostPolicy: autocert.HostWhitelist("example.com", "www.example.com"), // Only these domains
Cache: autocert.DirCache("/var/www/.cache"), // Where to store certs
}
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: &tls.Config{
GetCertificate: certManager.GetCertificate, // Dynamic cert lookup
MinVersion: tls.VersionTLS12,
},
}
// IMPORTANT: Also run HTTP on port 80 for two reasons:
// 1. Let's Encrypt needs port 80 for HTTP-01 challenge verification
// 2. Redirect all HTTP traffic to HTTPS
go http.ListenAndServe(":80", certManager.HTTPHandler(nil))
log.Fatal(server.ListenAndServeTLS("", "")) // Certs come from certManager
}
HTTP-01 challenge: Let's Encrypt asks your server to prove it owns the domain by serving a token on http://yourdomain.com/.well-known/acme-challenge/TOKEN. That's why certManager.HTTPHandler(nil) must run on port 80. If port 80 is blocked, you can use DNS-01 challenge instead (requires DNS API integration).
Basic HTTPS Server with TLS Config
The most common production setup: load cert files, configure TLS, serve HTTPS.
import (
"crypto/tls"
"fmt"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Protocol: %s, TLS: %v\n", r.Proto, r.TLS != nil)
})
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12, // Minimum TLS 1.2
MaxVersion: tls.VersionTLS13, // Allow TLS 1.3 (default anyway)
PreferServerCipherSuites: true, // Server chooses the cipher
CurvePreferences: []tls.CurveID{
tls.X25519, // Fastest, most secure
tls.CurveP256, // Widely supported fallback
},
CipherSuites: []uint16{
// TLS 1.3 ciphers (always used for TLS 1.3, this list is for TLS 1.2)
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
}
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: tlsConfig,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
}
log.Println("HTTPS server starting on :443")
// cert.pem = your certificate (public), key.pem = your private key
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}
HTTP to HTTPS Redirect
In production, you should always redirect HTTP traffic to HTTPS. Run a small server on port 80 that 301-redirects everything.
// Run this alongside your HTTPS server
go func() {
redirectServer := &http.Server{
Addr: ":80",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Build HTTPS URL from the original request
target := "https://" + r.Host + r.RequestURI
http.Redirect(w, r, target, http.StatusMovedPermanently) // 301
}),
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
log.Fatal(redirectServer.ListenAndServe())
}()
HTTP/2 — Automatic with TLS
Go enables HTTP/2 automatically when you serve over TLS. No configuration needed — the ALPN (Application-Layer Protocol Negotiation) during the TLS handshake handles it. Both client and server agree on "h2" (HTTP/2) during step 2 of the TLS handshake.
// HTTP/2 is AUTOMATIC with TLS. Just use ListenAndServeTLS and you get:
// - Multiplexed streams (many requests on one connection)
// - Header compression (HPACK)
// - Stream prioritization
// - Binary framing (faster parsing than HTTP/1.1 text)
// You can verify the protocol in your handler:
mux.HandleFunc("GET /protocol", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Protocol: %s\n", r.Proto)
// With TLS: "Protocol: HTTP/2.0"
// Without TLS: "Protocol: HTTP/1.1"
})
Explicit HTTP/2 Configuration
For advanced control (max concurrent streams, upload limits), use golang.org/x/net/http2:
import "golang.org/x/net/http2"
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: tlsConfig,
}
// Configure HTTP/2 settings
http2.ConfigureServer(server, &http2.Server{
MaxConcurrentStreams: 250, // Max simultaneous streams per connection
MaxUploadBufferPerConnection: 1 << 20, // 1 MB flow control window
MaxUploadBufferPerStream: 1 << 20, // 1 MB per stream
})
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
HTTP/2 Cleartext (h2c) — Without TLS
Sometimes you need HTTP/2 without TLS — typically behind a reverse proxy (Nginx, Envoy) that handles TLS termination. Go supports this via h2c:
import (
"net/http"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Protocol: %s (no TLS!)\n", r.Proto)
})
// h2c.NewHandler wraps your mux to handle HTTP/2 cleartext upgrades
h2s := &http2.Server{}
handler := h2c.NewHandler(mux, h2s)
server := &http.Server{
Addr: ":8080",
Handler: handler, // Supports both HTTP/1.1 and HTTP/2 without TLS
}
// No TLS, but HTTP/2 works via cleartext upgrade
log.Fatal(server.ListenAndServe())
// Test with: curl --http2-prior-knowledge http://localhost:8080/
}
Mutual TLS (mTLS) — Both Sides Verify
In mTLS, both the server AND the client present certificates. The server verifies the client's cert before allowing the connection. This is used in service-to-service communication (Kubernetes, service meshes, internal APIs).
import (
"crypto/tls"
"crypto/x509"
"log"
"net/http"
"os"
)
func main() {
// Load the CA certificate that signed client certificates
caCert, err := os.ReadFile("ca-cert.pem")
if err != nil {
log.Fatal(err)
}
// Create a cert pool with the CA cert
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
// RequireAndVerifyClientCert = client MUST present a valid cert
// Other options:
// NoClientCert — don't ask for client cert (default)
// RequestClientCert — ask but don't verify
// RequireAnyClientCert — require but don't verify against CA
// VerifyClientCertIfGiven — verify only if client sends one
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caCertPool, // Only accept client certs signed by this CA
MinVersion: tls.VersionTLS12,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
// Access client certificate info
if len(r.TLS.PeerCertificates) > 0 {
clientCert := r.TLS.PeerCertificates[0]
fmt.Fprintf(w, "Hello %s (verified client)\n", clientCert.Subject.CommonName)
}
})
server := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: tlsConfig,
}
// Server's own cert and key
log.Fatal(server.ListenAndServeTLS("server-cert.pem", "server-key.pem"))
}
// mTLS Client — must present its own certificate:
// cert, _ := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
// client := &http.Client{
// Transport: &http.Transport{
// TLSClientConfig: &tls.Config{
// Certificates: []tls.Certificate{cert},
// RootCAs: caCertPool, // Trust the server's CA
// },
// },
// }
// resp, err := client.Get("https://localhost:8443/")
SNI — Multiple Domains on One Server
Server Name Indication (SNI) lets one server serve different certificates for different hostnames. Go supports this via the GetCertificate callback.
// Load certificates for different domains
certExample, _ := tls.LoadX509KeyPair("example.com.pem", "example.com-key.pem")
certAPI, _ := tls.LoadX509KeyPair("api.example.com.pem", "api.example.com-key.pem")
certs := map[string]tls.Certificate{
"example.com": certExample,
"api.example.com": certAPI,
}
tlsConfig := &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
// hello.ServerName contains the hostname the client requested
if cert, ok := certs[hello.ServerName]; ok {
return &cert, nil
}
return &certExample, nil // Default fallback
},
MinVersion: tls.VersionTLS12,
}
HTTP/1.1 vs HTTP/2 vs HTTP/3
| Feature | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| Transport | TCP | TCP | QUIC (over UDP) |
| Connections | One request per connection (or pipelining) | Multiplexed: many requests on one connection | Multiplexed, no head-of-line blocking |
| Headers | Plain text, sent every time | HPACK compressed, binary | QPACK compressed, binary |
| Server Push | Not supported | Server can push resources | Supported (rarely used) |
| Protocol | Text-based | Binary framing layer | Binary over QUIC |
| Head-of-line | Blocking per connection | Solved at HTTP level, not TCP | Fully solved (streams independent) |
| Connection setup | TCP handshake + TLS handshake | TCP + TLS handshake | 0-RTT or 1-RTT (TLS built into QUIC) |
| Go support | Built-in | Built-in (auto with TLS) | quic-go library (not in stdlib) |
| TLS required | No (but should be) | Technically no, practically yes | Always (built into QUIC) |
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", healthHandler)
mux.HandleFunc("GET /api/users/{id}", getUserHandler)
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}
httpsServer := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: tlsConfig,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
}
// HTTP/2 is automatic with TLS — this line is optional for tuning
http2.ConfigureServer(httpsServer, &http2.Server{})
// HTTP → HTTPS redirect on port 80
go http.ListenAndServe(":80", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently)
}))
// Graceful shutdown
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
httpsServer.Shutdown(ctx)
}()
log.Println("HTTPS on :443, HTTP redirect on :80")
log.Fatal(httpsServer.ListenAndServeTLS("cert.pem", "key.pem"))
}
Node.js has a separate https module for TLS. Unlike Go (where TLS + HTTP/2 is automatic), Node needs extra setup for HTTP/2:
// Node.js — HTTPS server (like Go's ListenAndServeTLS)
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('key.pem'), // Go: passed to ListenAndServeTLS
cert: fs.readFileSync('cert.pem'), // Go: passed to ListenAndServeTLS
minVersion: 'TLSv1.2', // Go: tls.Config{MinVersion: tls.VersionTLS12}
};
const server = https.createServer(options, app);
server.listen(443);
// HTTP → HTTPS redirect (same concept as Go)
const http = require('http');
http.createServer((req, res) => {
res.writeHead(301, { Location: `https://${req.headers.host}${req.url}` });
res.end();
}).listen(80);
// Node.js HTTP/2 — requires separate module (NOT automatic like Go!)
const http2 = require('http2');
const h2Server = http2.createSecureServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
allowHTTP1: true, // Fallback to HTTP/1.1
});
// Let's Encrypt (like Go's autocert)
// npm install greenlock-express
require('greenlock-express').init({
packageRoot: __dirname,
maintainerEmail: 'admin@example.com',
configDir: './greenlock.d',
cluster: false,
}).serve(app);
| Go | Node.js | Key Difference |
|---|---|---|
server.ListenAndServeTLS("cert", "key") | https.createServer({key, cert}, app) | Go: one method. Node: separate module |
| HTTP/2 automatic with TLS | Requires http2 module separately | Go wins here — zero config for HTTP/2 |
autocert.Manager{} | greenlock-express / certbot | Go has stdlib-adjacent support; Node needs npm |
tls.Config{ClientAuth: RequireAndVerifyClientCert} | {requestCert: true, rejectUnauthorized: true} | Same mTLS concept, different syntax |
Key difference: Go enables HTTP/2 automatically when you use TLS — zero configuration. Node requires importing the separate http2 module and changing your server creation code. Also, Go's crypto/tls can generate certificates programmatically at runtime, which is very convenient for tests.
06 REST API Project Structure
cmd/), private application code (internal/), reusable libraries (pkg/), and configuration. Inside internal/ you typically split by responsibility: handlers/ (HTTP layer), services/ (business logic), repository/ or store/ (database), models/ (domain types), and middleware/. Go's package-per-directory rule and its strict import cycle enforcement naturally push you toward clean layering.cmd/<binary>/main.go: a thin main function per binary; each subdirectory becomes an executable.internal/: Go enforces that packages here can only be imported by code in the parent module — a compile-time privacy check.pkg/(optional): truly reusable libraries that you're willing to let other modules import.api/: OpenAPI specs, Protobuf files, JSON schemas.migrations/: SQL migration files managed bygoose,golang-migrate, oratlas.configs/: YAML/TOML config templates (not secrets).Dockerfile,Makefile,go.mod,go.sum: at the repo root.
- vs Node.js: Node has no enforced privacy beyond
package.jsonexports. Go'sinternal/is a compile error if violated — far stronger. - vs Python: Python uses
__init__.pyand relative imports; Go uses directory names as import paths, avoiding circular-import headaches. - vs Java/Maven: Maven's
src/main/java/com/company/...is deeply nested; Go layouts are usually 2–3 levels deep. - vs Rails: Rails imposes convention (
app/controllers,app/models); Go lets you choose, buthandlers/services/repositoryis the de facto standard. - vs Clean Architecture / Hexagonal: Go's layout maps naturally —
handlers= delivery,services= use-cases,repository= gateways,models= entities.
internal/ prevents accidental API leakage. Splitting by responsibility makes testing trivial — you can mock the repository in service tests and mock the service in handler tests. It also enables multi-binary repos: one module with cmd/api, cmd/worker, and cmd/migrate sharing the same internal code.- Over-nesting:
internal/app/services/user/v1/handler/is too deep — Go favors flat. - Putting everything in
pkg/: if no external consumer needs it, it belongs ininternal/. - Cyclic imports: Go refuses to compile, so you have to refactor — often a sign that two packages should merge or a third interface package is needed.
- Global state in
main.go: instead, build a*Appstruct with dependencies injected inmain. - Mixing
cmd/and library code:cmd/should be thin — parse flags, wire dependencies, call intointernal/.
cmd/kube-apiserver, cmd/kubelet, cmd/kubectl — dozens of binaries sharing pkg/ and staging/. Hashicorp tools (Vault, Consul, Nomad) follow the same pattern. Grafana, Loki, and Cortex use cmd/ + pkg/. The unofficial but widely referenced golang-standards/project-layout GitHub repo codifies these conventions.Go projects follow a conventional layout using cmd/, internal/, and pkg/ directories. This structure comes from the Go community standard and is used by most production Go applications. The key idea: internal/ packages cannot be imported by external projects (enforced by the Go compiler), while pkg/ packages are shareable.
- cmd/ — Each subdirectory is a separate binary.
cmd/api/builds the API server,cmd/worker/could build a background worker. - internal/ — Go enforces that these packages can only be imported by code within this module. This prevents external projects from depending on your internal implementation details.
- pkg/ — Optional. Contains utilities that could theoretically be used by other projects. Some teams skip this and put everything in
internal/. - handlers/ — HTTP handlers that parse requests and call business logic. Thin layer.
- repository/ — Database access layer. Isolates SQL queries from business logic.
Node.js/Express projects have a similar layered structure but with different conventions. Here's how they map:
# Go Project Structure → Node.js/Express Equivalent
───────────────────────────────────────────────────────────
rest_api_go/ my-express-app/
├── cmd/api/server.go ├── src/index.js # entry point
├── internal/ ├── src/ # no enforced privacy
│ ├── models/student.go │ ├── models/Student.js # Mongoose/Prisma schema
│ ├── api/handlers/ │ ├── controllers/ # request handlers
│ ├── api/router/ │ ├── routes/ # route definitions
│ ├── api/middlewares/ │ ├── middleware/ # Express middleware
│ └── repository/sqlconnect/ │ ├── services/ # business logic
├── pkg/utils/ │ └── utils/ # shared utilities
├── go.mod ├── package.json # like go.mod
├── go.sum ├── package-lock.json # like go.sum
└── Makefile └── node_modules/ # 500MB+ of deps
| Go Concept | Node.js Equivalent | Key Difference |
|---|---|---|
internal/ — compiler-enforced privacy | No equivalent — Node has no import restrictions | Go wins: internal/ is a compile error if violated |
cmd/ — multiple binaries | Multiple scripts in package.json | Go compiles each to a separate binary |
go.mod + go.sum | package.json + package-lock.json | Same concept: deps + lockfile |
| Single binary deployment (~10MB) | Copy entire node_modules/ + source | Go's deployment is dramatically simpler |
handlers/ | controllers/ | Same layer, different name convention |
repository/ | services/ or db/ | Same purpose: isolate DB queries |
07 Models
User, Order, Product — usually mirroring a database row or a JSON payload. Models live in their own package (internal/models) and use struct tags to describe how fields serialize to JSON (json:"email"), map to SQL columns (db:"email_address"), or participate in validation (validate:"required,email"). Go has no ORM-style annotations or metaclasses; everything is just struct fields and tags that libraries read via the reflect package.- Struct tags drive everything:
json,db,bson,validate,form,xml— one struct can serve many layers. - Zero-value friendly: an uninitialized
User{}is a valid, usable value with all fields at their type's zero value. - Embedded structs:
type User struct { Base; Name string }promotesBase's fields for free (composition instead of inheritance). - Unexported fields: lowercase fields are private to the package and invisible to JSON, useful for internal state.
- Pointer vs value: use
*stringfor nullable fields, plainstringwhen empty means empty. - Custom marshaling: implement
MarshalJSON/UnmarshalJSONto control serialization of complex types (dates, money, enums).
- vs Java JPA/Hibernate entities: no
@Entity, no proxy classes, no lazy loading surprises — Go models are plain data. - vs Python
dataclass/Pydantic: Go structs pre-date dataclasses and are a core language feature. Pydantic-like validation comes fromgo-playground/validator. - vs TypeScript interfaces: TS interfaces exist only at compile time; Go structs are real types at runtime with reflection access.
- vs Rails ActiveRecord: Rails models carry their own DB connection; Go models are dumb data and separate repositories handle persistence.
- vs Rust structs: similar philosophy but Go tags are string-based; Rust uses
#[derive]macros for stronger type safety.
request, response, and domain types — Go lets you scale either way.- Forgetting
json:"-"on sensitive fields — password hashes leak into API responses. - Zero values vs missing fields:
json.Unmarshalcan't distinguish{"age": 0}from{}unless you use*int. - Time zones:
time.Timemarshals to RFC3339 but DB drivers may return UTC vs local inconsistently. - Using the same struct for request and response — leaks server-side fields like
CreatedAtorInternalNotes. - Embedding and name collisions: two embedded structs with a
Namefield cause an ambiguous selector error.
k8s.io/api/core/v1) are generated Go structs with JSON tags — Pod, Deployment, Service are all just structs. Terraform providers define resource schemas as Go structs with hcl tags. Stripe's stripe-go, GitHub's go-github, and AWS SDK v2 all model their APIs as tagged Go structs.Models define the shape of your data. In Go, we use struct tags to control how data is serialized to JSON and mapped to database columns. The json tag controls JSON field names, and the db tag (used by libraries like sqlx) maps to database columns.
Student Model
package models
type Student struct {
ID int `json:"id,omitempty" db:"id,omitempty"`
FirstName string `json:"first_name,omitempty" db:"first_name,omitempty"`
LastName string `json:"last_name,omitempty" db:"last_name,omitempty"`
Email string `json:"email,omitempty" db:"email,omitempty"`
Class string `json:"class,omitempty" db:"class,omitempty"`
}
omitempty tells the JSON encoder to skip fields that have zero values (empty string, 0, nil, false). This is useful for PATCH requests where you only want to send changed fields, and for responses where you don't want to expose empty data.
Exec Model (with nullable fields)
Real databases have nullable columns. Go's sql.NullString handles this by wrapping a string with a Valid boolean. When the database value is NULL, Valid is false and String is empty.
package models
import "database/sql"
type Exec struct {
ID int `json:"id,omitempty" db:"id,omitempty"`
FirstName sql.NullString `json:"first_name,omitempty" db:"first_name,omitempty"`
LastName sql.NullString `json:"last_name,omitempty" db:"last_name,omitempty"`
Email sql.NullString `json:"email,omitempty" db:"email,omitempty"`
Department sql.NullString `json:"department,omitempty" db:"department,omitempty"`
}
// Usage: checking if a NullString has a value
if exec.Email.Valid {
fmt.Println("Email:", exec.Email.String)
} else {
fmt.Println("Email is NULL")
}
sql.NullString serializes to JSON as {"String": "...", "Valid": true} — not what you want! You need a custom MarshalJSON method or use a library like guregu/null for clean JSON output.
Go structs with tags map to Mongoose schemas, Prisma models, TypeScript interfaces, or Sequelize models:
// --- Mongoose Schema (like Go struct with tags) ---
const studentSchema = new mongoose.Schema({
firstName: { type: String, required: true }, // Go: `json:"first_name" validate:"required"`
lastName: { type: String },
email: { type: String, required: true },
class: { type: String },
}, {
toJSON: { // Like Go's json:"..." tags
transform: (doc, ret) => {
ret.id = ret._id; // Like Go's json:"id"
delete ret._id;
delete ret.__v;
}
}
});
// --- Prisma Model (schema-first, like Go structs) ---
// In prisma/schema.prisma:
// model Student {
// id Int @id @default(autoincrement())
// firstName String @map("first_name") // Like Go's db:"first_name"
// lastName String @map("last_name")
// email String
// class String? // ? = nullable (like sql.NullString)
// }
// --- TypeScript Interface (type-safe but no runtime) ---
interface Student {
id?: number; // ? = optional (like omitempty)
first_name: string;
last_name?: string;
email: string;
class?: string;
}
| Go | Node.js/TypeScript |
|---|---|
`json:"first_name,omitempty"` | toJSON transform or serialization library |
`db:"first_name"` | @map("first_name") (Prisma) / field name (Mongoose) |
sql.NullString | String? (Prisma) / string | null (TS) |
omitempty — skip zero values in JSON | No built-in equivalent; use JSON.stringify replacer |
Struct tags read via reflect | Decorators (class-validator) or schema objects |
Key difference: Go struct tags are a single source of truth — one struct controls JSON serialization, DB mapping, AND validation. In Node, you often have separate definitions: a Mongoose schema for DB, a TypeScript interface for types, and a Joi/Zod schema for validation. Prisma comes closest to Go's approach with its unified schema file.
08 Database Connection (MySQL)
database/sql package, which defines a driver-agnostic API: you open a connection with sql.Open(driverName, dsn) and receive a *sql.DB — a long-lived, goroutine-safe connection pool, not a single connection. For MySQL you import a driver like github.com/go-sql-driver/mysql for its side effects (it registers itself with database/sql via init()). You then run queries with db.Query, db.QueryRow, db.Exec, or use db.Begin for transactions. The pool handles connection reuse, idle timeouts, and concurrent access automatically.- Driver-independent API: swap MySQL for Postgres by changing the import and the DSN.
- Connection pooling built in: tune with
SetMaxOpenConns,SetMaxIdleConns,SetConnMaxLifetime,SetConnMaxIdleTime. - Context-aware:
QueryContext,ExecContext,BeginTxaccept acontext.Contextfor timeouts and cancellation. - Prepared statements:
db.Prepareor driver-level auto-prepare prevents SQL injection. - Lazy connection:
sql.Opendoesn't actually connect until you calldb.Ping(). - Transactions:
tx.Commit()/tx.Rollback()with defer-based rollback patterns.
- vs Node
mysql2/pg: Node drivers expose promises; Go uses synchronous calls and lets you parallelize with goroutines. - vs Python
psycopg2/SQLAlchemy: SQLAlchemy is a full ORM;database/sqlis lower-level. Go ORMs (GORM, Ent, Bun) sit on top. - vs Java JDBC: very similar model (driver + pool + prepared statements) but Go's error handling is more explicit.
- vs Rust
sqlx: sqlx verifies SQL at compile time against your schema;database/sqlis runtime-only (thoughsqlcin Go compiles SQL to typed Go functions at build time). - vs ActiveRecord/Hibernate: no lazy loading, no change tracking, no magic — you write SQL and scan rows.
database/sql + MySQL driver for any service that needs a relational store: user accounts, orders, inventory, audit logs. The stdlib API gives you predictable performance, explicit SQL (no N+1 surprises), and full transaction control. For teams that prefer writing SQL over learning ORM DSLs, it's the standard choice. For heavy query needs, sqlx (thin helper) or sqlc (SQL-to-Go codegen) are common upgrades.- Opening a new
*sql.DBper request — kills performance. It's a pool; open once and reuse. - Forgetting
rows.Close()— leaks connections back to the pool slowly until the pool exhausts. - Not checking
rows.Err()after iteration — silent data corruption. - Parsing time values: MySQL driver requires
?parseTime=truein the DSN ortime.Timescanning panics. - Unlimited connections: default
SetMaxOpenConns(0)= unlimited; MySQL runs out of file descriptors under load. - Transactions and connection leaks: a
txholds a connection until Commit/Rollback — forgetting either deadlocks the pool.
database/sql. Gitea supports MySQL, Postgres, SQLite, and MSSQL via the same driver interface.Go's database/sql package provides a generic interface for SQL databases. The actual driver (MySQL, PostgreSQL, SQLite) is imported as a side effect using the blank identifier _. The connection pool is managed automatically — sql.Open doesn't actually connect; it prepares the pool. The first real query triggers the connection.
package sqlconnect
import (
"database/sql"
"fmt"
"os"
_ "github.com/go-sql-driver/mysql" // Import driver as side effect
)
const (
host = "localhost"
dbport = "3306"
)
func ConnectDb() (*sql.DB, error) {
// Read credentials from environment variables (never hardcode!)
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
dbname := os.Getenv("DB_NAME")
// Build the MySQL connection string: user:password@tcp(host:port)/dbname
connectionString := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
user, password, host, dbport, dbname)
// sql.Open doesn't connect — it prepares the connection pool
db, err := sql.Open("mysql", connectionString)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Ping to verify the connection actually works
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
// Configure the connection pool
db.SetMaxOpenConns(25) // Max open connections
db.SetMaxIdleConns(5) // Max idle connections in pool
db.SetConnMaxLifetime(5 * time.Minute) // Max lifetime of a connection
return db, nil
}
Always read database credentials from environment variables or a secrets manager. Hardcoded passwords in source code will end up in version control and eventually get leaked.
Go's database/sql maps to Node's mysql2, pg, or Prisma/Knex. The key difference: Go's pool is built into the stdlib; Node needs a library:
// --- mysql2 (closest to Go's database/sql) ---
const mysql = require('mysql2/promise');
// Create a connection pool (like Go's sql.Open + SetMaxOpenConns)
const pool = mysql.createPool({
host: 'localhost',
port: 3306,
user: process.env.DB_USER, // Like Go's os.Getenv("DB_USER")
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 25, // Like Go's SetMaxOpenConns(25)
idleTimeout: 300000, // Like Go's SetConnMaxIdleTime
waitForConnections: true,
});
// Test connection (like Go's db.Ping())
await pool.query('SELECT 1');
// --- Prisma (ORM approach — no direct Go stdlib equivalent) ---
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); // Pool managed internally
// --- Knex.js (query builder, similar to sqlx) ---
const knex = require('knex')({
client: 'mysql2',
connection: { host: 'localhost', user: 'root', database: 'mydb' },
pool: { min: 5, max: 25 }, // Like Go's SetMaxIdleConns/SetMaxOpenConns
});
Go (database/sql) | Node.js |
|---|---|
sql.Open("mysql", dsn) | mysql.createPool({...}) |
db.Ping() | pool.query('SELECT 1') |
db.SetMaxOpenConns(25) | connectionLimit: 25 |
db.SetMaxIdleConns(5) | pool.min: 5 (Knex) |
_ "github.com/go-sql-driver/mysql" | require('mysql2') |
Driver registers via init() | Driver is imported directly |
db.Query() is synchronous per goroutine | pool.query() returns a Promise |
Key difference: Go's sql.Open doesn't actually connect — it just prepares the pool. The first real query triggers the connection. Node's createPool also doesn't connect eagerly, but Go's blank import pattern (_ "github.com/go-sql-driver/mysql") is unique — the driver registers itself via an init() function, which feels magical if you're coming from Node.
09 CRUD Operations
handlers/ package and delegate to a repository or service layer. Each HTTP verb maps to a database operation: POST /users → INSERT, GET /users/:id → SELECT, PUT/PATCH /users/:id → UPDATE, DELETE /users/:id → DELETE. Go's explicit error handling and lack of ORM magic means CRUD code is verbose but transparent: every query is visible, every error is handled.- HTTP verb semantics: safe GET, idempotent PUT/DELETE, non-idempotent POST.
- Prepared statements with
?placeholders: prevent SQL injection by default. - Row scanning:
rows.Scan(&user.ID, &user.Name)— explicit, type-safe, no reflection overhead. - LastInsertId & RowsAffected: returned from
sql.ResultafterExec. - Status code discipline: 201 Created, 200 OK, 204 No Content, 404 Not Found, 409 Conflict.
- JSON in / JSON out:
json.NewDecoder(r.Body).Decodeandjson.NewEncoder(w).Encode.
- vs Rails scaffold: Rails generates CRUD with one command via ActiveRecord; Go expects you to write each handler explicitly. More code, fewer surprises.
- vs Django REST Framework: DRF has
ModelViewSetthat auto-generates CRUD; Go has no equivalent in stdlib (though code generators likeentandsqlccome close). - vs Express + Sequelize: Sequelize hides SQL; Go favors writing SQL explicitly so query plans are visible.
- vs Spring Data JPA: JPA derives queries from method names; Go requires you to write them, which avoids "magic query" debugging nightmares.
- vs Go frameworks (Gin, Echo): those simplify binding and response writing but the underlying CRUD logic is identical.
- Concatenating user input into SQL strings — classic injection vulnerability. Always use
?placeholders. - Returning DB errors directly to clients — leaks table names and schema. Wrap errors with domain-level messages.
- Not handling
sql.ErrNoRows— it's not really an error for "not found" — map it to a 404. - PATCH vs PUT: PATCH expects partial updates; you need to handle missing fields without overwriting with zero values (use pointers or a map).
- Soft delete vs hard delete: production apps usually flag rows with
deleted_atinstead of issuingDELETE. - Missing pagination:
SELECT * FROM userson a million-row table will OOM your process.
CRUD (Create, Read, Update, Delete) operations form the backbone of any REST API. Each operation maps to an HTTP method: POST (Create), GET (Read), PUT/PATCH (Update), DELETE (Delete). Let's build real handlers that talk to a MySQL database.
GET with Pagination
Always paginate list endpoints. Without pagination, a table with millions of rows will crash your server. Use page and limit query parameters with sensible defaults.
func GetStudents(w http.ResponseWriter, r *http.Request) {
// Parse pagination parameters with defaults
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if page < 1 { page = 1 }
if limit < 1 || limit > 100 { limit = 10 }
offset := (page - 1) * limit
// Query the database with LIMIT and OFFSET
query := "SELECT id, first_name, last_name, email, class FROM students LIMIT ? OFFSET ?"
rows, err := db.Query(query, limit, offset)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var students []models.Student
for rows.Next() {
var s models.Student
if err := rows.Scan(&s.ID, &s.FirstName, &s.LastName, &s.Email, &s.Class); err != nil {
http.Error(w, "Scan error", http.StatusInternalServerError)
return
}
students = append(students, s)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(students)
}
POST with Validation
func CreateStudent(w http.ResponseWriter, r *http.Request) {
var s models.Student
// Decode JSON body into struct
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validate required fields
if s.FirstName == "" || s.Email == "" {
http.Error(w, "first_name and email are required", http.StatusBadRequest)
return
}
// Insert into database
result, err := db.Exec(
"INSERT INTO students (first_name, last_name, email, class) VALUES (?, ?, ?, ?)",
s.FirstName, s.LastName, s.Email, s.Class,
)
if err != nil {
http.Error(w, "Insert failed", http.StatusInternalServerError)
return
}
// Get the auto-generated ID
id, _ := result.LastInsertId()
s.ID = int(id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // 201 for resource creation
json.NewEncoder(w).Encode(s)
}
PUT — Full Update
PUT replaces the entire resource. All fields must be provided. If a field is missing, it gets set to its zero value.
func UpdateStudent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var s models.Student
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Update ALL fields — this is what makes it PUT, not PATCH
_, err := db.Exec(
"UPDATE students SET first_name=?, last_name=?, email=?, class=? WHERE id=?",
s.FirstName, s.LastName, s.Email, s.Class, id,
)
if err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "updated"})
}
PATCH — Partial Update (using Reflection)
PATCH is trickier than PUT. You only update the fields that were sent in the request. Using Go's reflect package, you can dynamically build the SQL SET clause based on which fields have non-zero values.
func PatchStudent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var s models.Student
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Use reflection to build dynamic SET clause
v := reflect.ValueOf(s)
t := v.Type()
var setClauses []string
var values []interface{}
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
// Skip zero-value fields (they weren't in the JSON body)
if !field.IsZero() {
dbTag := t.Field(i).Tag.Get("db")
column := strings.Split(dbTag, ",")[0]
if column != "" && column != "id" {
setClauses = append(setClauses, column+"=?")
values = append(values, field.Interface())
}
}
}
if len(setClauses) == 0 {
http.Error(w, "No fields to update", http.StatusBadRequest)
return
}
values = append(values, id)
query := fmt.Sprintf("UPDATE students SET %s WHERE id=?",
strings.Join(setClauses, ", "))
db.Exec(query, values...)
}
DELETE with Transaction
For destructive operations, use transactions. This ensures either all operations succeed or none do. If deleting a student also requires cleaning up related records, a transaction keeps your data consistent.
func DeleteStudent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Begin a transaction
tx, err := db.Begin()
if err != nil {
http.Error(w, "Transaction start failed", http.StatusInternalServerError)
return
}
// Delete related records first (foreign key constraints)
_, err = tx.Exec("DELETE FROM enrollments WHERE student_id = ?", id)
if err != nil {
tx.Rollback() // Undo everything if this fails
http.Error(w, "Failed to delete enrollments", http.StatusInternalServerError)
return
}
// Delete the student
result, err := tx.Exec("DELETE FROM students WHERE id = ?", id)
if err != nil {
tx.Rollback()
http.Error(w, "Failed to delete student", http.StatusInternalServerError)
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
tx.Rollback()
http.Error(w, "Student not found", http.StatusNotFound)
return
}
// Commit the transaction — all operations succeed together
tx.Commit()
w.WriteHeader(http.StatusNoContent) // 204 — deleted, no body
}
Go's explicit SQL + json.NewDecoder maps to Express's req.body + mysql2/Prisma/Knex queries. Here's the same CRUD in Express:
// --- GET with Pagination (Express + mysql2) ---
app.get('/students', async (req, res) => {
let page = parseInt(req.query.page) || 1; // Go: strconv.Atoi(r.URL.Query().Get("page"))
let limit = parseInt(req.query.limit) || 10;
const offset = (page - 1) * limit;
const [rows] = await pool.query(
'SELECT * FROM students LIMIT ? OFFSET ?', // Same SQL!
[limit, offset] // Same parameterized query
);
res.json(rows); // Go: json.NewEncoder(w).Encode(students)
});
// --- POST (Express + mysql2) ---
app.post('/students', async (req, res) => {
const { first_name, email, last_name, class: cls } = req.body;
// Go: json.NewDecoder(r.Body).Decode(&s)
if (!first_name || !email) {
return res.status(400).json({ error: 'first_name and email required' });
}
const [result] = await pool.query(
'INSERT INTO students (first_name, last_name, email, class) VALUES (?, ?, ?, ?)',
[first_name, last_name, email, cls]
);
res.status(201).json({ id: result.insertId, first_name, email });
// Go: w.WriteHeader(http.StatusCreated)
});
// --- DELETE with Transaction ---
app.delete('/students/:id', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction(); // Go: db.Begin()
await conn.query('DELETE FROM enrollments WHERE student_id = ?', [req.params.id]);
const [result] = await conn.query('DELETE FROM students WHERE id = ?', [req.params.id]);
if (result.affectedRows === 0) {
await conn.rollback(); // Go: tx.Rollback()
return res.status(404).json({ error: 'Not found' });
}
await conn.commit(); // Go: tx.Commit()
res.status(204).end();
} catch (err) {
await conn.rollback();
res.status(500).json({ error: 'Delete failed' });
} finally {
conn.release(); // Go: automatic with pool
}
});
Key differences: (1) Express auto-parses JSON body with express.json() middleware — Go requires json.NewDecoder. (2) Node uses try/catch for errors — Go checks err != nil at every step. (3) Go's row scanning (rows.Scan(&s.ID, ...)) is manual and explicit — Node's mysql2 returns plain JS objects automatically. (4) Go's reflect-based PATCH is way more verbose than Node's dynamic object merging.
10 Routing & Router Setup
internal/routes/routes.go and exposes a function such as func NewRouter(app *App) http.Handler that returns the fully configured *http.ServeMux (or chi.Router, gin.Engine, etc.). Centralizing routing makes it trivial to see every endpoint at a glance, keeps main.go clean, and makes the router itself testable in isolation.- Single registration point: all routes visible in one file.
- Dependency injection: the router function receives the
*App(or individual services) and closes over them in handlers. - Middleware composition: wrap the final
http.Handlerwith logging, recovery, CORS, auth in a clear outer-to-inner order. - Version prefixing: register
/api/v1routes on one sub-mux,/api/v2on another. - Static file serving:
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./public")))). - Health and metrics endpoints:
/healthz,/readyz,/metricsregistered alongside business routes.
- vs Express
app.use: Express interleaves middleware and routes freely; Go keeps them separate for clarity. - vs Flask blueprints: Flask splits by blueprint module; Go often keeps one router file per binary but delegates handler logic into packages.
- vs Rails
routes.rb: Rails has a DSL; Go uses plain function calls — no meta-programming. - vs Spring
@RestController: Spring scatters routes across annotated classes; Go's explicit router file avoids the "where is this route defined?" problem. - vs Chi/Gin: Chi offers
r.Route,r.Group, and per-route middleware; stdlib is more verbose but dependency-free.
httptest. It also decouples the HTTP layer from main, so you can reuse the same router for production, integration tests, and dev-time mock servers.- Middleware applied in the wrong order: recovery must be outermost or a panic inside logging crashes everything.
- Closure capture bugs: registering handlers in a loop with a shared variable — classic pre-Go-1.22 gotcha.
- Global muxes:
http.HandleFuncmodifiesDefaultServeMux, which is shared — prefer a local*http.ServeMux. - Forgetting trailing slash conventions:
"/users"and"/users/"behave differently. - Route conflicts:
"GET /users/{id}"and"GET /users/new"— Go 1.22 picks the more specific one, but older routers may behave unpredictably.
/api/v1/query, /api/v1/series, /-/healthy etc. in one router-setup function.In a real project, you don't define all routes in main(). Instead, you create router functions for each domain (students, teachers, execs) and compose them together. This keeps each file focused and makes it easy to add new route groups without touching existing code.
package router
import "net/http"
// MainRouter composes all sub-routers into one
func MainRouter() *http.ServeMux {
tRouter := teachersRouter() // handles /teachers/*
sRouter := studentsRouter() // handles /students/*
// Mount execs router under students router
sRouter.Handle("/", execsRouter())
// Mount students router (with execs) under teachers router
tRouter.Handle("/", sRouter)
return tRouter
}
// Each sub-router defines its own routes
func studentsRouter() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /students", handlers.GetStudents)
mux.HandleFunc("GET /students/{id}", handlers.GetStudent)
mux.HandleFunc("POST /students", handlers.CreateStudent)
mux.HandleFunc("PUT /students/{id}", handlers.UpdateStudent)
mux.HandleFunc("PATCH /students/{id}", handlers.PatchStudent)
mux.HandleFunc("DELETE /students/{id}", handlers.DeleteStudent)
return mux
}
func teachersRouter() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /teachers", handlers.GetTeachers)
mux.HandleFunc("GET /teachers/{id}", handlers.GetTeacher)
return mux
}
When you call tRouter.Handle("/", sRouter), any request that doesn't match a teachers route falls through to the students router. This creates a priority chain: teachers routes > students routes > execs routes. The "/" catch-all pattern acts as a fallback.
Go's router composition maps to Express's modular routing pattern where each domain has its own file:
// routes/students.js (like Go's studentsRouter function)
const router = require('express').Router();
router.get('/', handlers.getStudents);
router.get('/:id', handlers.getStudent);
router.post('/', handlers.createStudent);
router.put('/:id', handlers.updateStudent);
router.patch('/:id', handlers.patchStudent);
router.delete('/:id', handlers.deleteStudent);
module.exports = router;
// routes/teachers.js
const router = require('express').Router();
router.get('/', handlers.getTeachers);
router.get('/:id', handlers.getTeacher);
module.exports = router;
// app.js — main router composition (like Go's MainRouter)
const app = require('express')();
app.use('/students', require('./routes/students'));
app.use('/teachers', require('./routes/teachers'));
app.use('/execs', require('./routes/execs'));
Key difference: Express's app.use('/students', router) is cleaner than Go's mux composition with Handle("/") fallthrough. In Go, you explicitly wire routers together; in Express, each app.use() call is independent. Go's approach is more verbose but makes the routing hierarchy completely transparent — no hidden behavior.
11 Middleware Pattern
http.Handler and returns a new http.Handler, wrapping it with cross-cutting behavior: logging, authentication, compression, rate limiting, request-ID tagging, panic recovery, and more. The canonical Go signature is func(next http.Handler) http.Handler. Because http.Handler is a single-method interface, middleware composes naturally: Logger(Recovery(Auth(handler))). The result is a clean chain where each layer can inspect or mutate the request, call (or skip) next.ServeHTTP, and post-process the response.- Functional composition: middleware are just higher-order functions — no framework lock-in.
- Before/after hooks: do work before calling
next, after, or both (e.g., timing a request). - Short-circuiting: return early without calling
nextto reject auth-failed requests. - Context propagation: attach values (user ID, request ID, tenant) via
r.WithContext(ctx)and read them in downstream handlers. - ResponseWriter wrapping: wrap
http.ResponseWriterto capture status code, body size, or buffer the response. - Global or per-route: apply to the whole router or to individual sub-routes.
- vs Express middleware: Express uses
(req, res, next) => next(), a callback-style chain. Go wraps handlers instead, which is stack-based and easier to reason about. - vs Python Django middleware: Django middleware is class-based with
process_request/process_responsemethods. Go is simpler — one function. - vs Java Servlet Filters: filters are registered in XML or via annotations; Go composes middleware programmatically.
- vs Rails
around_action: Rails uses method decorators inside controllers; Go middleware lives outside handlers entirely. - vs Gin
Use: Gin middleware has a similar shape but uses*gin.Contextandc.Next(), which is more framework-coupled.
- Wrong ordering: recovery must be outermost; auth before handler; logging outermost or innermost depending on whether you want to log panics.
- Writing headers after calling
next: the response may already be sent; you can't change headers post hoc. - Reading
r.Bodyin middleware consumes the stream — handlers downstream see an empty body unless you restore it. - Panicking in middleware itself — the recovery middleware must be outside it to catch anything.
- Goroutines started in middleware outlive the request — don't pass
r.Context()to them without care.
chi/middleware) used by thousands of services. OpenTelemetry's Go instrumentation provides a middleware that wraps any http.Handler to emit spans automatically. Goa and go-kit both encourage middleware-based service design.Middleware is the backbone of any production Go server. A middleware is simply a function that wraps an http.Handler, does something before/after the request, and calls the next handler in the chain. This pattern is composable: you stack middlewares like layers, and each request passes through all of them in order.
The signature is always the same: take a handler, return a handler. This makes every middleware interchangeable and stackable.
package utils
import "net/http"
// Middleware is a function that wraps an http.Handler
type Middleware func(http.Handler) http.Handler
// ApplyMiddlewares chains multiple middlewares around a handler.
// Middlewares are applied in reverse order so they execute left-to-right.
func ApplyMiddlewares(handler http.Handler, middlewares ...Middleware) http.Handler {
for _, mw := range middlewares {
handler = mw(handler)
}
return handler
}
Using the Middleware Chain
func main() {
router := router.MainRouter()
// Stack middlewares — order matters!
// Outermost (first listed) runs first on request, last on response
secureMux := utils.ApplyMiddlewares(router,
mw.SecurityHeaders, // Set security headers on every response
mw.Compression, // Gzip compress responses
mw.XSSMiddleware, // Sanitize input
jwtMiddleware, // Authenticate requests
mw.ResponseTimeMiddleware, // Track response times
mw.Cors, // Handle CORS preflight
)
http.ListenAndServe(":8080", secureMux)
}
Because each middleware wraps the previous one, the first middleware in the list is the outermost layer. It runs first on the request and last on the response. Think of it like an onion: CORS -> ResponseTime -> JWT -> XSS -> Compression -> SecurityHeaders -> [handler] -> SecurityHeaders -> Compression -> XSS -> JWT -> ResponseTime -> CORS.
Express vs Go Middleware — Side by Side
If you're coming from Node/Express, Go middleware looks unfamiliar at first because it uses a wrapper pattern instead of a callback chain. Both accomplish the same goal — run code before/after handlers — but the mental model is different.
Express: Callback-style Chain
In Express, middleware is a function that receives (req, res, next). You call next() to hand control to the next middleware. The framework keeps an internal array of middleware and calls them in registration order — you never see the chain itself.
// Express: middleware is a function you register
app.use((req, res, next) => {
console.log('before');
next(); // hand off to the next middleware
console.log('after');
});
app.get('/hello', (req, res) => res.send('hi'));
Go: Wrapper-style (Decorator Pattern)
In Go, middleware is a function that takes a handler and returns a new handler. You build the chain explicitly by wrapping: logger(auth(mux)). There's no hidden array — the stack is literally the code you wrote.
// Go: middleware wraps a handler, returns a handler
func logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("before")
next.ServeHTTP(w, r) // call the wrapped handler
log.Println("after")
})
}
mux := http.NewServeMux()
mux.HandleFunc("/hello", helloHandler)
// You explicitly build the stack by wrapping:
http.ListenAndServe(":8080", logger(auth(mux)))
Concept-by-Concept Mapping
| Concept | Express | Go |
|---|---|---|
| Pass control forward | next() | next.ServeHTTP(w, r) |
| Register middleware | app.use(fn) | handler = mw(handler) |
| Chain order | Registration order via app.use | Wrap order: a(b(c(h))) → a runs first |
| Short-circuit | Don't call next(), send response | Don't call next.ServeHTTP, write response |
| Who owns the chain | Framework (hidden array) | You (explicit wrapping) |
| Signature | (req, res, next) => ... | func(http.Handler) http.Handler |
| Attach request data | Mutate req.user = ... | r.WithContext(ctx) — immutable |
| Error handling | next(err) → error middleware | Write error response directly; no special error chain |
- Express: "I'm a link in a chain. Someone calls me, I call
next." - Go: "I'm a box wrapped around another handler. I decide when (or if) to call what's inside me."
That's why Go feels stack-based: logger(auth(mux)) literally reads as a stack you built by hand. In Express the stack exists but the framework builds it for you from app.use calls.
Short-circuiting: Auth Rejection Example
Both frameworks support early return — just skip calling the "next" step.
// Express: don't call next(), just respond
app.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).send('unauthorized');
}
next();
});
// Go: don't call next.ServeHTTP, just write
func auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
12 Security Headers Middleware
http.Handler wrapper that sets these headers on every response before delegating to the next handler. Key headers include Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Referrer-Policy, and Permissions-Policy.- CSP (Content-Security-Policy): controls which scripts, styles, images, and iframes can load — the single most effective XSS mitigation.
- HSTS (Strict-Transport-Security): forces browsers to use HTTPS for a given duration, preventing downgrade attacks.
- X-Frame-Options: DENY: blocks clickjacking by disallowing iframing.
- X-Content-Type-Options: nosniff: stops browsers from guessing MIME types and running mislabeled JS.
- Referrer-Policy: controls how much of the referring URL is sent to third parties.
- Permissions-Policy: disables browser features (camera, mic, geolocation) your app doesn't need.
- vs Node
helmet: Helmet is a collection of small middlewares that set these headers — Go equivalents includeunrolled/secureor a handwritten 20-line middleware. - vs Django
SecurityMiddleware: Django sets a subset by default; Go stdlib sets nothing — you must opt in. - vs Rails
default_headers: Rails configures a default set inconfig/application.rb; Go is explicit about every header. - vs Nginx
add_header: Nginx can set headers at the proxy layer, but doing it in-app means headers survive all proxies and are version-controlled with the code.
securityheaders.com. They cost nothing and ship in every production Go web service that does browser-facing work.- Overly strict CSP breaks your own app — inline styles, third-party scripts, or Google Analytics all need explicit allowances.
- HSTS on localhost — if you browse
http://localhostand receive HSTS, your browser remembers and you can't use HTTP dev servers. - Setting headers after
WriteHeader— they're silently dropped. - Forgetting API-only services: CSP is irrelevant for JSON APIs but other headers (HSTS, nosniff) are still valuable.
- Preload list: HSTS preload requires the
includeSubDomainsandpreloaddirectives — easy to misconfigure.
Security headers tell the browser how to behave when handling your site's content. Without these headers, your application is vulnerable to clickjacking, XSS, MIME sniffing, and other attacks. This middleware sets all recommended headers on every response.
package middlewares
import "net/http"
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent the page from being embedded in an iframe (clickjacking)
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
// Prevent MIME type sniffing — browser must trust Content-Type
w.Header().Set("X-Content-Type-Options", "nosniff")
// Enable browser's built-in XSS filter
w.Header().Set("X-XSS-Protection", "1; mode=block")
// HSTS — force HTTPS for 1 year, include subdomains
w.Header().Set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload")
// Content Security Policy — restrict resource origins
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'")
// Control how much referrer info is sent with requests
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features (camera, microphone, etc.)
w.Header().Set("Permissions-Policy",
"camera=(), microphone=(), geolocation=()")
// Remove server identification header
w.Header().Del("Server")
next.ServeHTTP(w, r) // Pass to next handler
})
}
Go's security headers middleware maps directly to helmet — the most popular Express security middleware (npm install helmet):
// Express + Helmet — same headers, one line
const helmet = require('helmet');
app.use(helmet()); // Sets ALL the same headers Go sets manually!
// What helmet() does internally (same as Go's SecurityHeaders):
app.use(helmet({
frameguard: { action: 'sameorigin' }, // X-Frame-Options: SAMEORIGIN
noSniff: true, // X-Content-Type-Options: nosniff
xssFilter: true, // X-XSS-Protection: 1; mode=block
hsts: { maxAge: 31536000, includeSubDomains: true },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
}
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
permittedCrossDomainPolicies: false,
}));
// Without helmet (manual, like Go's approach):
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.removeHeader('X-Powered-By'); // Like Go's w.Header().Del("Server")
next();
});
Key difference: In Node, helmet gives you all security headers with one app.use(helmet()) call. In Go, you write the 20-line middleware yourself (or use unrolled/secure). Go's approach is more transparent — you see exactly what headers are set. Helmet is more convenient but hides the details.
| Header | Purpose |
|---|---|
X-Frame-Options | Prevents your page from being loaded in an iframe on another site (clickjacking defense) |
X-Content-Type-Options | Stops browsers from guessing the MIME type — prevents treating a text file as JavaScript |
X-XSS-Protection | Activates the browser's XSS auditor (legacy but still useful) |
Strict-Transport-Security | Tells browsers to always use HTTPS, even if the user types http:// |
Content-Security-Policy | Whitelist which domains can serve scripts, styles, images, etc. |
Referrer-Policy | Controls how much URL info is sent in the Referer header when navigating |
Permissions-Policy | Disables browser features like camera, microphone, geolocation per origin |
What it prevents. Clickjacking: an attacker loads your page inside a transparent <iframe> on evil.com, overlays a fake button on top of your real "Transfer $1000" button, and tricks a logged-in user into clicking through. With X-Frame-Options: DENY (or SAMEORIGIN), the browser refuses to render the iframe at all.
Example attack the header blocks:
<!-- evil.com -->
<iframe src="https://bank.example.com/transfer"
style="opacity:0.01;position:absolute;top:0;left:0;"></iframe>
<button style="position:absolute;top:50px;left:50px">Claim prize</button>
Bypasses & limits:
- Only two useful values —
DENYandSAMEORIGIN. The oldALLOW-FROM uriwas never supported in Chrome/Safari and is dead. If you need "allow a list of partners to embed", you must use CSPframe-ancestorsinstead. - Old browsers ignore it — IE < 8, ancient Android browsers. Mitigation: pair with a JavaScript frame-buster (
if (top !== self) top.location = self.location), but those can themselves be defeated with the iframesandboxattribute. - Double-framing trick — nesting the target in an attacker-controlled iframe used to confuse the
SAMEORIGINcheck on very old browsers. Modern browsers walk the full ancestor chain, so this is fixed.
How it breaks legitimate features: payment widgets, Zendesk/Intercom chat embeds, Stripe Elements, OAuth "sign in with …" popups that fall back to iframes, preview-in-iframe dashboards (Notion, Confluence), and your own marketing site embedding a live demo.
How to prevent breakage: drop X-Frame-Options entirely on routes that must be embeddable and replace with a targeted CSP: Content-Security-Policy: frame-ancestors 'self' https://partner.example.com https://*.trusted-cdn.com. CSP frame-ancestors supersedes X-Frame-Options when both are present and supports multiple origins.
What it prevents. Browsers historically tried to be "helpful" and inspect response bodies to guess the real type. If you served a user-uploaded avatar.jpg whose bytes actually started with <script>alert(1)</script>, IE/old Chrome would sniff it as HTML and execute it. nosniff tells the browser: trust my Content-Type, don't second-guess.
Example attack the header blocks:
# User uploads "profile.png" whose bytes begin with:
<html><script>fetch('/api/me').then(r=>r.json()).then(send)</script>
# Server replies: Content-Type: image/png
# Without nosniff: browser sniffs "looks like HTML", renders & runs the script
# With nosniff: browser treats it as a broken image. Safe.
Bypasses & limits:
- Only
scriptandstylecontexts are strictly enforced —nosniffblocks a<script src=/upload/evil.jpg>with a non-JS MIME type. It does not fix an attacker who gets you to open the file directly in a new tab withContent-Disposition: inline. - Still vulnerable if you set the wrong
Content-Typeyourself — the header only disables guessing; it trusts whatever you declared. Serving user content withtext/htmlis still XSS. - Download endpoints — attacker uploads
report.pdfthat is actually HTML; if your endpoint setsContent-Type: application/pdf, nosniff keeps the browser from rendering it as HTML. Without the header, some browsers still sniffed and rendered.
How it breaks legitimate features: misconfigured static servers that serve .js as text/plain will suddenly fail to load (the browser refuses to execute). Same for stylesheets served as application/octet-stream, ES modules served without the right MIME, or WASM files served as generic binary.
How to prevent breakage: audit your static server / CDN MIME map — ensure .js → application/javascript, .css → text/css, .mjs → application/javascript, .wasm → application/wasm. Use Content-Disposition: attachment for any user-uploaded downloads so the browser never tries to render them inline.
What it did. Older browsers (IE 8+, old Chrome/WebKit) had a heuristic "XSS auditor" that scanned request parameters against the response body; if it saw the same script in both, it blanked the script out. X-XSS-Protection: 1; mode=block forced a full block instead of just filtering.
Why it's legacy. The auditor was itself a security hole. Researchers (Masato Kinugawa and others) showed you could use the filter to remove legitimate defensive scripts, revealing data that would otherwise be hidden — effectively a "script-based CSRF leak." Chrome removed it in 2019, Edge in 2020, Firefox never shipped it. Only old Safari respects it today.
Example of the auditor being weaponized:
# Target page has:
<script>if (!isAdmin) document.body.innerHTML = '';</script>
<div>secret admin data</div>
# Attacker links to: /page?q=<script>if (!isAdmin)
# Auditor sees matching script in URL + body → strips the defensive script
# Secret div remains visible. Leak.
Current best practice: set X-XSS-Protection: 0 to explicitly disable the auditor on any browser that still has it, and rely on a strong CSP for XSS defense. Some hardening libraries still emit 1; mode=block by default — override it.
What it prevents. SSL-stripping / downgrade attacks. A user on hotel wifi types bank.com — the browser sends http://bank.com first, the attacker intercepts, and proxies an HTTPS connection on the user's behalf while feeding them plaintext. HSTS tells the browser "for the next N seconds, never speak plaintext HTTP to this host — upgrade every request to HTTPS locally, before leaving the machine."
Canonical directive:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Bypasses & limits:
- Trust-on-first-use (TOFU) — HSTS only kicks in after the first successful HTTPS response. The very first visit on a brand-new device is still hijackable. Fix: submit your domain to the Chrome HSTS preload list, which ships with the browser.
- NTP / clock-skew attacks — if an attacker can push the victim's clock forward past
max-age, the HSTS entry expires and the downgrade becomes possible again (Delorean attack). - Only applies to the host that sent it — without
includeSubDomains, an attacker can downgradeinsecure-sub.example.comand steal cookies scoped to.example.com.
How it breaks things (and these are real outages):
- You enable HSTS with
max-age=31536000, then your TLS cert expires or misrenews. Users get a hard-fail browser error with no "proceed anyway" button — for a year. You cannot revoke HSTS from the server side; the header is cached client-side. - Internal dev hostnames (
admin.example.com) that run over plain HTTP for local tooling suddenly refuse to load after you shipincludeSubDomains. - Preload-list entries are effectively permanent. Removal requests take months and still won't reach users who haven't updated their browser.
How to prevent breakage: roll out in stages — start with max-age=300 (5 min), bump to an hour, a day, a week, then a year once you're confident every subdomain is HTTPS-only. Only add preload after the 1-year directive has been live for weeks without incident. Keep a monitored cert-renewal pipeline and alerts on "days until cert expiry < 21."
What it prevents. Cross-site scripting (XSS), inline-script injection, data exfiltration to attacker servers, and unauthorized framing. You declare an allow-list of sources per resource type, and the browser refuses to load or execute anything outside it.
Example attack CSP blocks: an attacker finds a stored-XSS in a comment field and injects <script src="https://evil.com/steal.js"></script>. Without CSP, the browser fetches and runs it. With script-src 'self', the browser blocks the load and logs a CSP violation.
Common bypass patterns (and why they exist):
'unsafe-inline'— any page that uses inline<script>or inline event handlers (onclick=) needs this, but it re-opens XSS completely. Fix: switch to nonces (script-src 'self' 'nonce-r4nd0m') or hashes.- Whitelisted CDNs with JSONP endpoints — if you allow
https://ajax.googleapis.com, an attacker can use its JSONP APIs to load arbitrary JS as "your" code. Google's own researchers published that 94% of real-world CSPs were bypassable this way. Fix: use strict-dynamic:script-src 'nonce-xyz' 'strict-dynamic', which ignores host allow-lists entirely and trusts only scripts started by a nonce'd root. - Dangling
<base>injection — if an attacker injects<base href="//evil.com/">, subsequent relative script URLs resolve to the attacker. Addbase-uri 'none'orbase-uri 'self'. - Object / embed fallthroughs — forgotten
object-srcdefaults allow Flash/PDF XSS. Always setobject-src 'none'. - Path-based whitelists don't actually restrict paths —
script-src example.com/safe/still allowsexample.com/unsafe/evil.jsin most browsers (redirects bypass path checks). Treat the host as the smallest unit.
How it breaks legitimate features: analytics snippets (GA, Segment, Mixpanel) that inject more scripts, Stripe.js, Intercom/Drift chat widgets, Sentry's error reporter, inline styles emitted by styled-components/emotion, Google Maps, YouTube embeds, Cloudflare Turnstile, eval() inside a bundler dev-mode, and browser extensions that try to inject helpers. The first deploy of a strict CSP almost always takes something down.
How to prevent breakage:
- Ship in report-only mode first:
Content-Security-Policy-Report-Only: ...; report-uri /csp-report. Collect violations for a week or two, fix the real ones. - Use nonces +
strict-dynamicinstead of host allow-lists — resilient to new third-party subresources. - Keep an allow-list document for marketing/analytics; every new vendor request goes through a CSP review.
- Per-route policies: your SPA can have a strict CSP, while your
/embeds/*route (if any) has a looser one — don't globalize the loosest policy.
What it prevents. Accidental leakage of sensitive URL data to third-party sites via the Referer header. Tokens, session IDs, and password-reset links in query strings all leak to every third-party script, image, or link the page loads. The classic incident: a password-reset URL shared over chat, the chat client prefetches the link, and the Referer sent to the target's analytics provider contains the reset token.
Common values, strongest to weakest:
no-referrer— never sendReferer. Safest; breaks some analytics.strict-origin-when-cross-origin(modern browser default) — send full URL to same-origin, only the origin to cross-origin HTTPS, nothing on HTTPS→HTTP downgrade. Good default.same-origin— send full URL only to same-origin requests; cross-origin gets nothing.unsafe-url— send full URL everywhere, including query strings, even HTTPS→HTTP. Never use.
Example leak the header prevents:
# User opens: https://app.example.com/reset?token=abc123
# Page loads: https://cdn.ads.com/pixel.gif
# Without Referrer-Policy (old default):
# Referer: https://app.example.com/reset?token=abc123 ← leaked to ad network
# With strict-origin-when-cross-origin:
# Referer: https://app.example.com ← token stripped
How it breaks things:
- Payment processors and identity providers sometimes require a full
Refererfor CSRF/anti-fraud checks. Going stricter than they expect causes opaque "Invalid referrer" errors during redirects. - Analytics attribution — a strict policy means GA/Adobe see traffic as "direct / none" instead of attributing to the referring campaign.
- Federated login (OAuth) often verifies the
Refereron callback;no-referrercan break the flow.
How to prevent breakage: default to strict-origin-when-cross-origin globally (it's already the browser default, but explicit is better), then per-page override with <meta name="referrer" content="origin"> or the referrerpolicy attribute on specific <a>/<img>/<iframe> tags that need a different value. Never put secrets in URL query strings — fix the leak at the source.
What it prevents. An XSS or compromised third-party script silently activating powerful browser APIs — camera, microphone, geolocation, clipboard read, payment request, USB, Bluetooth, fullscreen trickery, motion sensors. If your app doesn't need those features, Permissions-Policy denies them at the page level so even a successful injection can't turn on the webcam.
Example directive:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(),
usb=(), accelerometer=(), gyroscope=(), clipboard-read=()
Empty parentheses () mean "no origin". self means "this origin only". (self "https://widget.example.com") allows the feature in this page and in iframes from that specific origin.
Bypasses & limits:
- Older name
Feature-Policyis still respected by some browsers. For max compatibility, emit both — the syntax differs:Feature-Policy: camera 'none'; microphone 'none'vs the newercamera=(), microphone=(). - Per-iframe
allowattribute — a parent can grant a feature to an iframe even when its own policy forbids it for the top-level document. Audit your<iframe allow="...">attributes as carefully as the header. - Not all APIs are covered — WebSocket, localStorage, indexeddb, service workers are not gated by Permissions-Policy. It's a sharp tool, not a universal kill switch.
How it breaks legitimate features: video-call widgets (Daily, LiveKit, Zoom Web SDK) need camera/microphone/display-capture. Map/ride-share pages need geolocation. Apple Pay / Google Pay need payment. Fitness trackers and VR demos need accelerometer/gyroscope/xr-spatial-tracking. Forgetting to allow these produces a confusing silent failure — the API just returns "permission denied" with no hint that the policy is the cause.
How to prevent breakage: start with an allow-list mindset — deny everything, then grant the minimum set per-route. Use the browser devtools "Application → Permissions Policy" panel to see which features the current page is allowed to use. For third-party embeds (e.g. a LiveKit iframe), explicitly grant via the iframe's allow="camera; microphone" attribute and the parent's Permissions-Policy header — both must agree.
13 CORS Middleware
app.example.com) from calling an API on another origin (api.example.com) unless the API explicitly opts in via response headers. A Go CORS middleware inspects the Origin request header, validates it against an allow-list, and adds headers like Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Allow-Credentials. It also handles browser preflight OPTIONS requests by short-circuiting the chain with a 204 response.- Origin allow-listing: exact-match or regex/wildcard support for subdomains.
- Preflight handling: respond to
OPTIONSrequests with the allowed methods and headers. - Credentials flag: enable cookies/Authorization header with
Access-Control-Allow-Credentials: true(cannot be combined withAllow-Origin: *). - Max-Age caching: tell browsers how long to cache preflight results (reduces OPTIONS traffic).
- Exposed headers: allow frontend code to read custom response headers like
X-Total-Count.
- vs Express
cors: Express has a singlecors()middleware. Go equivalents:rs/cors,gorilla/handlers, or a 30-line handwritten middleware. - vs Django
django-cors-headers: Django's package uses settings.py config; Go uses struct-based config. - vs Spring
@CrossOrigin: Spring allows per-controller annotations; Go applies CORS globally or per-route via handler wrapping. - vs Nginx CORS config: doing it in-app means your CORS policy is version-controlled with the code instead of hidden in config files.
Access-Control-Allow-Origin: *+ credentials = blocked — the spec forbids it; you must echo the specific origin.- Forgetting preflight for PUT/DELETE/custom headers — the browser sends
OPTIONSfirst, and if you don't respond 2xx it aborts. - Wildcard subdomains:
*.example.comis not valid; you must parse theOriginheader and echo it if it matches a regex. - Caching CORS responses behind a proxy without a
Vary: Originheader returns wrong headers to other origins. - Setting CORS headers on 4xx/5xx responses: if your error handler runs before CORS middleware, errors won't get CORS headers and the browser hides the real response.
rs/cors package is used by thousands of Go services including many at Uber and Dropbox.CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When your frontend at localhost:3000 calls your API at localhost:8080, the browser blocks the request unless your API explicitly allows it via CORS headers. Without proper CORS, your SPA cannot talk to your API.
The browser sends a preflight request (OPTIONS) before the actual request to check if the origin is allowed. Your middleware must handle this preflight and respond with the appropriate headers.
package middlewares
import "net/http"
func Cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Define allowed origins
allowedOrigins := map[string]bool{
"http://localhost:3000": true,
"https://myapp.com": true,
}
if allowedOrigins[origin] {
// Echo the allowed origin (not "*" — needed for credentials)
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400") // Cache preflight for 24h
}
// Handle preflight (OPTIONS) — return immediately, don't hit handler
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) // 204
return
}
next.ServeHTTP(w, r)
})
}
Using * as the allowed origin blocks cookies and credentials. If your API uses cookies for authentication (JWT in httpOnly cookie), you must echo the specific origin and set Access-Control-Allow-Credentials: true.
Go's handwritten CORS middleware maps to the cors npm package — Express's standard CORS solution:
// Express + cors package (npm install cors)
const cors = require('cors');
app.use(cors({
origin: ['http://localhost:3000', 'https://myapp.com'], // Like Go's allowedOrigins map
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow-Credentials: true
maxAge: 86400, // Cache preflight 24h
}));
// Without cors package (manual, like Go's approach):
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowed = ['http://localhost:3000', 'https://myapp.com'];
if (allowed.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
Key difference: Express's cors() package handles everything in one config object. Go's approach is manual but identical in logic. Both must handle preflight OPTIONS requests, both must echo the specific origin (not *) when using credentials.
14 JWT Authentication Middleware
Authorization: Bearer ... header, verifies its signature (usually HMAC-SHA256 or RSA/ECDSA), validates standard claims (exp, iat, iss, aud), and if everything passes, attaches the user identity to r.Context() so downstream handlers can read it. The most popular Go library is github.com/golang-jwt/jwt/v5.- Stateless auth: the server doesn't need a session store — the token carries identity.
- Signature verification: HS256 (shared secret) or RS256/ES256 (public/private key pairs).
- Standard claims:
exp(expiry),iat(issued-at),nbf(not-before),iss,aud,sub. - Custom claims: role, tenant ID, scopes — anything you serialize into the payload.
- Context propagation: inject user ID into
context.Contextfor logs and DB queries. - Refresh token pattern: short-lived access token + long-lived refresh token stored server-side.
- vs session cookies: sessions require a server-side store (Redis, DB); JWTs are stateless but harder to revoke.
- vs OAuth access tokens: OAuth access tokens are often opaque strings backed by a server database; JWTs are self-contained and verifiable without a lookup.
- vs Node
jsonwebtoken: very similar API; Go's version uses strongly typed claim structs via generics. - vs Java
jjwt: same standards, different ergonomics — Go is more concise. - vs PASETO: PASETO is a safer alternative designed to avoid JWT's algorithm-confusion pitfalls;
o1egl/pasetois the Go implementation.
- Algorithm confusion attack: accepting
alg: noneor switching HS256 to RS256 with the public key as the secret — always pin the algorithm. - Storing JWTs in localStorage exposes them to XSS. Use httpOnly cookies for browser clients.
- No easy revocation: once issued, a JWT is valid until
exp. Short lifetimes + refresh tokens are the workaround. - Clock skew: distributed servers with slight clock drift reject valid tokens — allow a small leeway.
- Putting sensitive data in claims: JWT payloads are base64-encoded, not encrypted — anyone can decode them.
- Leaking the HS256 secret means anyone can forge tokens; prefer RS256 if the secret has to be shared.
JWT (JSON Web Token) is the standard for stateless authentication in REST APIs. The token is signed by the server and contains claims (user ID, role, expiration). The middleware reads the token from an httpOnly cookie (more secure than localStorage), validates the signature, checks expiration, and passes the claims to downstream handlers via context.
package middlewares
import (
"context"
"net/http"
"os"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const UserClaimsKey contextKey = "userClaims"
func JWTMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read token from httpOnly cookie (not from Authorization header)
cookie, err := r.Cookie("auth_token")
if err != nil {
http.Error(w, "Unauthorized: no token", http.StatusUnauthorized)
return
}
// Parse and validate the JWT
secret := []byte(os.Getenv("JWT_SECRET"))
token, err := jwt.Parse(cookie.Value, func(t *jwt.Token) (interface{}, error) {
// Ensure the signing method is HMAC (prevent algorithm switching attack)
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return secret, nil
})
if err != nil || !token.Valid {
http.Error(w, "Unauthorized: invalid token", http.StatusUnauthorized)
return
}
// Extract claims and add to request context
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Unauthorized: invalid claims", http.StatusUnauthorized)
return
}
// Pass claims downstream via context
ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// In a handler, retrieve the claims:
func ProfileHandler(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(UserClaimsKey).(jwt.MapClaims)
userID := claims["user_id"] // extracted from the JWT
fmt.Fprintf(w, "Hello user %v", userID)
}
Storing JWT in localStorage is vulnerable to XSS attacks: any injected script can read localStorage.getItem("token"). An httpOnly cookie is invisible to JavaScript, so even if XSS occurs, the attacker cannot steal the token. The browser automatically sends the cookie with every request.
Go's golang-jwt/jwt maps to Node's jsonwebtoken package. The JWT logic is almost identical:
// Express JWT Middleware (npm install jsonwebtoken)
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
// Read from httpOnly cookie (like Go's r.Cookie("auth_token"))
const token = req.cookies?.auth_token;
if (!token) return res.status(401).json({ error: 'No token' });
try {
// Verify signature + expiry (like Go's jwt.Parse with keyfunc)
const claims = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Pin algorithm (like Go's SigningMethodHMAC check)
});
req.user = claims; // Go: context.WithValue(ctx, UserClaimsKey, claims)
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Usage in handler:
app.get('/profile', authMiddleware, (req, res) => {
const userId = req.user.user_id; // Go: r.Context().Value(UserClaimsKey)
res.json({ userId });
});
// Creating a JWT (sign):
const token = jwt.sign(
{ user_id: 42, role: 'admin' },
process.env.JWT_SECRET,
{ expiresIn: '24h', algorithm: 'HS256' }
);
| Go | Node.js |
|---|---|
jwt.Parse(tokenStr, keyFunc) | jwt.verify(token, secret, opts) |
t.Method.(*jwt.SigningMethodHMAC) | algorithms: ['HS256'] |
context.WithValue(ctx, key, claims) | req.user = claims |
r.Context().Value(key) | req.user |
r.Cookie("auth_token") | req.cookies.auth_token (needs cookie-parser) |
Key difference: Node.js mutates req.user directly (mutable). Go uses context.WithValue which creates a new immutable context — safer for concurrent access. Also, Go forces you to use a typed context key (type contextKey string) to avoid collisions, while Node just adds a property to the request object.
15 Password Hashing (Argon2)
golang.org/x/crypto/argon2 package. You hash a password with a random salt, tunable memory cost (typically 64 MB), iterations (3), and parallelism (2–4 threads), then store the salt + parameters + digest together in a single encoded string. At login, you re-hash the candidate password with the same parameters and compare results in constant time.- Argon2id variant: hybrid of Argon2i (side-channel resistant) and Argon2d (GPU resistant) — the recommended default.
- Memory-hard: attackers need huge RAM per guess, defeating GPU parallelism.
- Tunable cost: memory, time, and parallelism parameters adjustable as hardware improves.
- Random per-user salt: prevents rainbow-table attacks; you generate 16 bytes from
crypto/rand. - Constant-time compare: use
crypto/subtle.ConstantTimeCompareto prevent timing attacks. - Encoded format: stores algorithm, version, parameters, salt, and hash in one string for forward compatibility.
- vs bcrypt (
golang.org/x/crypto/bcrypt): bcrypt is older, still acceptable, but not memory-hard — GPU attacks are faster against it. - vs scrypt: scrypt was the previous state-of-the-art; Argon2 improves on it in both security and tunability.
- vs PBKDF2: PBKDF2 is FIPS-compliant but lacks memory hardness — still used where FIPS matters.
- vs MD5/SHA-1/SHA-256 for passwords: catastrophically insecure — these are cryptographic digests, not password hashers. Billions of guesses per second on modern GPUs.
- vs Node
argon2package: same algorithm, different API. Go's is closer to the specification; Node wraps a native binding.
- Using the same salt for everyone — defeats the entire purpose. Generate a fresh 16-byte salt per password.
- Insufficient memory cost: 4 MB is too low; 64 MB is the current OWASP baseline.
- Using
bytes.Equalinstead ofsubtle.ConstantTimeCompare— opens timing side-channels. - Hard-coding parameters: you'll need to rotate cost parameters as hardware improves — store them in the encoded hash.
- Memory exhaustion attacks: 64 MB × concurrent login attempts can OOM your server under a login flood — rate-limit logins.
- Not migrating from bcrypt: you can re-hash with Argon2 on next successful login for a seamless upgrade.
conduit) uses bcrypt/Argon2. Gitea supports Argon2id for user passwords. Bitwarden's Go services and Authelia (Go auth gateway) both use Argon2id. Hashicorp Vault's userpass auth backend hashes credentials with bcrypt but Argon2 is recommended for new deployments.Never store passwords in plain text. Argon2 is the winner of the 2015 Password Hashing Competition and is the recommended algorithm for password hashing. It's resistant to GPU/ASIC attacks because it requires significant memory, unlike bcrypt which is compute-bound only.
The pattern: generate a random salt, hash the password with Argon2id, store the salt+hash together. To verify, extract the salt, re-hash the input, and compare using constant-time comparison (to prevent timing attacks).
package utils
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"golang.org/x/crypto/argon2"
)
const (
argonTime = 1 // Number of iterations
argonMemory = 64 * 1024 // 64 MB memory cost
argonThreads = 4 // Parallelism factor
argonKeyLen = 32 // Output hash length
saltLen = 16 // Salt length in bytes
)
// HashPassword generates a salt and returns salt$hash (both base64)
func HashPassword(password string) (string, error) {
// Generate a random salt
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
// Hash with Argon2id (recommended variant)
hash := argon2.IDKey([]byte(password), salt,
argonTime, argonMemory, argonThreads, argonKeyLen)
// Encode both as base64 and combine with $ separator
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
hashB64 := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("%s$%s", saltB64, hashB64), nil
}
// VerifyPassword checks a password against a stored hash
func VerifyPassword(password, stored string) bool {
// Split stored value into salt and hash
parts := strings.SplitN(stored, "$", 2)
if len(parts) != 2 {
return false
}
salt, _ := base64.RawStdEncoding.DecodeString(parts[0])
expectedHash, _ := base64.RawStdEncoding.DecodeString(parts[1])
// Re-hash the input with the same salt
computedHash := argon2.IDKey([]byte(password), salt,
argonTime, argonMemory, argonThreads, argonKeyLen)
// Constant-time comparison prevents timing attacks
return subtle.ConstantTimeCompare(expectedHash, computedHash) == 1
}
Regular == comparison exits early on the first mismatched byte. An attacker can measure the time difference to determine how many bytes matched, gradually guessing the hash. subtle.ConstantTimeCompare always takes the same time regardless of input, eliminating this side-channel attack.
Go's Argon2 maps to Node's argon2 or bcrypt npm packages:
// Node.js — argon2 (npm install argon2)
const argon2 = require('argon2');
// Hash — argon2 handles salt generation internally
const hash = await argon2.hash(password, {
type: argon2.argon2id, // Like Go's argon2.IDKey (recommended variant)
memoryCost: 65536, // 64 MB (like Go's argonMemory = 64 * 1024)
timeCost: 1, // Like Go's argonTime = 1
parallelism: 4, // Like Go's argonThreads = 4
});
// Returns: "$argon2id$v=19$m=65536,t=1,p=4$salt$hash"
// Verify — constant-time comparison built in
const isValid = await argon2.verify(hash, password);
// Go: subtle.ConstantTimeCompare(expectedHash, computedHash) == 1
// --- bcrypt alternative (simpler, older, still acceptable) ---
const bcrypt = require('bcrypt');
const bcryptHash = await bcrypt.hash(password, 12); // 12 rounds
const valid = await bcrypt.compare(password, bcryptHash);
Key difference: Node's argon2 package returns a self-describing string ($argon2id$v=19$m=65536...) that includes all parameters. Go's implementation here uses a custom salt$hash format. Node also handles salt generation and constant-time comparison internally — Go requires you to do both manually with crypto/rand and subtle.ConstantTimeCompare.
16 Rate Limiting Middleware
429 Too Many Requests if the quota is exceeded. The standard library-adjacent golang.org/x/time/rate package provides a battle-tested token bucket implementation via rate.Limiter and rate.NewLimiter(r rate.Limit, b int). For distributed rate limiting across many app instances, a Redis-backed limiter (e.g., go-redis/redis_rate) is used.- Token bucket algorithm: smooths bursts — tokens refill at a fixed rate, requests consume tokens.
- Per-client keying: IP, user ID, API key, tenant, or combinations.
- Per-route limits: stricter limits on
/loginand/signupthan on/products. - Headers: return
X-RateLimit-Limit,X-RateLimit-Remaining,Retry-Afterso clients can back off. - Distributed enforcement: Redis or memcache shared state across app replicas.
- Sliding window: alternative to token bucket using sorted sets in Redis.
- vs Express
express-rate-limit: Node's limiter is in-memory by default; Go'sgolang.org/x/time/rateis also in-memory but nicely typed. - vs Nginx
limit_req: Nginx rate limiting is at the proxy edge and applies globally; app-level limiting can be per-user or per-API-key. - vs API Gateway (Kong, Tyk, Envoy): gateways offer advanced policies (quotas, plans) but you may want app-level limits on top for fine-grained control.
- vs Redis
INCR-based limiting: the token bucket is smoother; a naiveINCR + EXPIREis a fixed window that suffers from boundary bursts. - vs Java Bucket4j: Bucket4j supports distributed buckets via Hazelcast; Go libraries like
ulule/limitercover the same ground.
- Keying by
r.RemoteAddrbehind a proxy rate-limits the proxy itself; readX-Forwarded-Forinstead (carefully, to avoid spoofing). - In-memory limiter with multiple replicas means clients get N× the allowed rate — use Redis for shared state.
- Limiting by IP punishes users behind NAT/CGNAT; key by user ID or API key when possible.
- Missing
Retry-Afterheader — clients don't know when to retry, leading to thundering-herd loops. - Limiter map grows unbounded: evict stale entries or use an LRU cache.
- Applying globally to
/healthzcauses Kubernetes probes to fail under load.
ratelimit-service gRPC API that a Go backend (envoyproxy/ratelimit) implements.Rate limiting prevents abuse by restricting how many requests a client can make in a given time window. This implementation tracks requests per IP address using a map protected by a mutex. A background goroutine periodically resets the counters.
package middlewares
import (
"net/http"
"sync"
"time"
)
type visitor struct {
count int
lastSeen time.Time
}
var (
visitors = make(map[string]*visitor)
mu sync.Mutex
rateLimit = 100 // Max requests per window
)
// Background goroutine: clean up stale entries every minute
func init() {
go func() {
for {
time.Sleep(1 * time.Minute)
mu.Lock()
for ip, v := range visitors {
if time.Since(v.lastSeen) > 3*time.Minute {
delete(visitors, ip)
}
}
mu.Unlock()
}
}()
}
func RateLimiter(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
mu.Lock()
v, exists := visitors[ip]
if !exists {
visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
mu.Unlock()
next.ServeHTTP(w, r)
return
}
// Reset count if window has passed
if time.Since(v.lastSeen) > 1*time.Minute {
v.count = 0
v.lastSeen = time.Now()
}
v.count++
if v.count > rateLimit {
mu.Unlock()
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
mu.Unlock()
next.ServeHTTP(w, r)
})
}
- Use
golang.org/x/time/ratefor a token-bucket rate limiter - Use Redis for distributed rate limiting across multiple server instances
- Extract IP from
X-Forwarded-FororX-Real-IPheaders when behind a reverse proxy - Return
Retry-Afterheader to tell clients when they can retry
Go's rate limiter maps to express-rate-limit — one of the most used Express middleware packages:
// Express + express-rate-limit (npm install express-rate-limit)
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window (like Go's 1*time.Minute)
max: 100, // 100 requests per window (like Go's rateLimit = 100)
message: 'Rate limit exceeded',
standardHeaders: true, // Returns X-RateLimit-* headers
legacyHeaders: false,
keyGenerator: (req) => req.ip, // Like Go's r.RemoteAddr
});
app.use(limiter);
// Stricter limit for login (like Go's per-route limiting):
const loginLimiter = rateLimit({ windowMs: 900000, max: 5 });
app.post('/login', loginLimiter, loginHandler);
Key difference: Express's express-rate-limit gives you per-route limiting, automatic headers, and Redis store support out of the box. Go's manual implementation requires you to build the visitor map, mutex locking, and cleanup goroutine yourself. Both are in-memory by default — for multiple server instances, both need Redis.
17 Compression Middleware
Accept-Encoding header, wraps the http.ResponseWriter with a compressing writer (e.g., gzip.NewWriter), and sets Content-Encoding: gzip on the response. Go's standard library provides compress/gzip, compress/flate, and compress/zlib. Third-party packages like klauspost/compress deliver faster implementations and brotli/zstd support.- Content negotiation: pick the best encoding the client supports.
- Level tuning: 1 (fastest) to 9 (smallest); 5–6 is the usual sweet spot.
- Min-size threshold: skip tiny responses where compression overhead isn't worth it.
- Content-type filtering: don't compress already-compressed formats (JPEG, PNG, gzip, video).
- Pooled writers: use
sync.Poolto reusegzip.Writerinstances and avoid allocations. - Vary: Accept-Encoding: crucial for caches/CDNs so they don't serve gzipped responses to non-gzip clients.
- vs Express
compression: same idea, similar defaults; Go gives more explicit control over pooling. - vs Nginx
gzip on: doing it at the edge offloads CPU from your Go process; doing it in-app means it works even without a proxy. - vs HTTP/2 header compression (HPACK): HPACK compresses headers only; body compression is still your job.
- vs brotli/zstd: brotli is ~15% smaller than gzip for text; zstd is faster and decent ratio — both are supported via
andybalholm/brotliandklauspost/compress/zstd.
- Compressing already-compressed content (images, videos) wastes CPU for no benefit.
- Forgetting
Vary: Accept-Encoding— CDN caches poison themselves and return gzipped bodies to clients that asked for identity. - Not closing the
gzip.Writerbefore flushing the response — produces truncated output. - BREACH attack: compressing responses that include user secrets can leak them if an attacker controls part of the body — mitigate by masking CSRF tokens.
- Streaming responses and compression: flushing too often destroys the compression ratio.
- Allocation pressure: a new
gzip.Writerper request is expensive — pool them.
/metrics endpoint, which is essential when scraping thousands of time series. Grafana Loki uses zstd for log chunks. Chi's middleware package ships a Compress middleware used across the ecosystem.Gzip compression can reduce response sizes by 70-90% for text-based content (JSON, HTML, CSS). The middleware checks if the client supports gzip (via Accept-Encoding header), wraps the response writer with a gzip writer, and sets the appropriate headers.
The key challenge is that you need a custom ResponseWriter that writes to the gzip stream instead of the raw connection.
package middlewares
import (
"compress/gzip"
"net/http"
"strings"
)
// gzipResponseWriter wraps http.ResponseWriter to write through gzip
type gzipResponseWriter struct {
http.ResponseWriter
Writer *gzip.Writer
}
// Write sends data through the gzip compressor
func (grw *gzipResponseWriter) Write(b []byte) (int, error) {
return grw.Writer.Write(b)
}
func Compression(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if client accepts gzip
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
// Set headers BEFORE writing any response
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
// Create gzip writer and wrap the ResponseWriter
gz := gzip.NewWriter(w)
defer gz.Close() // Flush and close when handler returns
grw := &gzipResponseWriter{ResponseWriter: w, Writer: gz}
next.ServeHTTP(grw, r) // Downstream writes go through gzip
})
}
Go's compression middleware maps to Express's compression package:
// Express + compression (npm install compression)
const compression = require('compression');
app.use(compression({
level: 6, // Compression level (1=fast, 9=smallest)
threshold: 1024, // Don't compress below 1KB
filter: (req, res) => { // Skip already-compressed types
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
}));
// That's it! Handles Accept-Encoding, Vary header, Content-Encoding automatically
Key difference: Express's compression() handles everything — content-type filtering, minimum size threshold, Vary header — in one function call. Go requires you to build a custom ResponseWriter wrapper, manage the gzip writer lifecycle with defer gz.Close(), and set headers manually. Go gives you more control; Node gives you more convenience.
18 XSS Protection Middleware
Content-Security-Policy, X-XSS-Protection, X-Content-Type-Options) that instruct the browser to block or contain malicious scripts, and (2) optionally sanitizes incoming JSON/form data to strip dangerous HTML. The Go stdlib's html/template package auto-escapes by default, but for rich user input you use microcosm-cc/bluemonday — the canonical Go HTML sanitizer.- Context-aware escaping:
html/templateknows whether a variable is in an attribute, a script, or a URL and escapes accordingly. - HTML sanitization:
bluemonday.UGCPolicy()allows safe tags (p,b,a) while strippingscript,iframe, event handlers. - Strict CSP: disallow inline scripts (
script-src 'self') — the single most effective XSS mitigation. - X-Content-Type-Options: nosniff: prevents browsers from executing a
.txtfile as JavaScript. - Input validation: reject unexpected characters in user-supplied IDs, emails, URLs.
- Output encoding: always encode at the point of output, never store pre-escaped data.
- vs Node
xss/DOMPurify: DOMPurify runs in the browser; bluemonday runs on the server. Both use allow-lists. - vs Python
bleach: bleach is bluemonday's conceptual sibling — allow-list HTML sanitizer. - vs Rails' default escaping: ERB auto-escapes; Go's
html/templatedoes the same. Both packages are safer than raw string interpolation. - vs PHP
htmlspecialchars: PHP's function is basic text escaping; bluemonday is context-aware HTML parsing. - vs WAF rules (Cloudflare, ModSecurity): WAFs block known attack patterns at the edge; in-app sanitization catches what the WAF misses.
- Using
text/templatefor HTML — it doesn't escape; usehtml/template. - Trusting client-side sanitization only — attackers bypass JS; always sanitize server-side.
- Allowing
javascript:URLs in link attributes — bluemonday's default URL schemes block these. - Loose CSP with
'unsafe-inline'— defeats most of CSP's value; use nonces or hashes. - Double-escaping: sanitizing then escaping produces
&lt;mess. - Stored XSS in JSON APIs: when the frontend injects a value into the DOM without escaping, it's still your server's responsibility to validate input.
html/template's context-aware escaping. Grafana applies strict CSP on its dashboard HTML. Caddy ships security headers by default on any site it serves.XSS (Cross-Site Scripting) attacks inject malicious scripts into your application. This middleware sanitizes all JSON request bodies by recursively walking the parsed JSON structure and stripping dangerous HTML/JavaScript using the bluemonday library.
The key idea: parse the JSON body, recursively sanitize every string value, then re-encode the sanitized body and replace the original request body.
package middlewares
import (
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/microcosm-cc/bluemonday"
)
var policy = bluemonday.UGCPolicy() // User-Generated Content policy
// sanitizeValue recursively sanitizes all strings in a JSON structure
func sanitizeValue(v interface{}) interface{} {
switch val := v.(type) {
case string:
return policy.Sanitize(val) // Strip dangerous HTML/JS
case map[string]interface{}:
for k, v := range val {
val[k] = sanitizeValue(v) // Recurse into objects
}
return val
case []interface{}:
for i, v := range val {
val[i] = sanitizeValue(v) // Recurse into arrays
}
return val
default:
return v // Numbers, booleans, null — pass through
}
}
func XSSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only process requests with JSON bodies
if r.Body != nil && r.Header.Get("Content-Type") == "application/json" {
body, err := io.ReadAll(r.Body)
if err == nil && len(body) > 0 {
var data interface{}
if json.Unmarshal(body, &data) == nil {
sanitized := sanitizeValue(data)
clean, _ := json.Marshal(sanitized)
r.Body = io.NopCloser(bytes.NewReader(clean))
} else {
// Not valid JSON — restore original body
r.Body = io.NopCloser(bytes.NewReader(body))
}
}
}
next.ServeHTTP(w, r)
})
}
Go's XSS middleware with bluemonday maps to Node's xss, sanitize-html, or DOMPurify:
// Express + xss-clean or sanitize-html
const sanitizeHtml = require('sanitize-html'); // Like Go's bluemonday
// Middleware to sanitize all string fields in req.body
app.use((req, res, next) => {
if (req.body && typeof req.body === 'object') {
const sanitize = (obj) => {
for (const key in obj) {
if (typeof obj[key] === 'string') {
obj[key] = sanitizeHtml(obj[key], {
allowedTags: ['b', 'i', 'em', 'strong', 'a'], // Like bluemonday.UGCPolicy()
allowedAttributes: { a: ['href'] },
});
} else if (typeof obj[key] === 'object') {
sanitize(obj[key]); // Recurse (like Go's sanitizeValue)
}
}
};
sanitize(req.body);
}
next();
});
| Go | Node.js |
|---|---|
bluemonday.UGCPolicy() | sanitize-html with UGC allow-list |
html/template (auto-escapes) | EJS/Pug don't auto-escape by default! |
| Server-side only | DOMPurify can run client-side too |
Key difference: Go's html/template auto-escapes output by default (safe). Node's template engines (EJS, Handlebars) don't always auto-escape — you must use <%= %> (escaped) not <%- %> (unescaped). This is a common source of XSS in Node apps.
19 HPP Protection
?role=user&role=admin) hoping the server behaves inconsistently — for example, a WAF sees the first value while the application trusts the last. Different frameworks and languages pick different winners: PHP takes the last, Express takes an array, ASP.NET concatenates with commas. An HPP-protection middleware normalizes this behavior by collapsing duplicates to the last (or first) value, or by rejecting the request entirely when duplicates are unexpected.- Query parameter deduplication: ensure
r.URL.Query().Get("key")returns a consistent value. - Form data normalization: collapse repeated form fields in
POSTbodies. - Allow-list for genuinely repeated params:
?tags=go&tags=backendis legitimate; other params should be singular. - Explicit rejection: return 400 Bad Request when duplicates appear on forbidden keys.
- Consistent behavior across middleware chain: eliminates discrepancies between WAFs and app logic.
- vs Node
hpppackage: Express'shppmiddleware is the direct inspiration; Go equivalents are smaller since Go'surl.Valuesis already amap[string][]string. - vs Spring Boot: Java servlets return the first value by default, which itself is inconsistent with PHP/ASP.NET — mixed stacks are vulnerable.
- vs raw
net/http: Go'sr.URL.Query().Get(key)returns the first value, butr.FormValue(key)may return a different one — calling both without care creates inconsistency. - vs WAF rules: WAFs can detect HPP patterns but differ in interpretation — app-level normalization is the source of truth.
- Breaking legitimate multi-value params (checkboxes, tag lists) by blindly deduplicating — use an allow-list.
- Calling
r.FormValueandr.URL.Query().Getfor the same key — they may return different values after body parsing. - Middleware runs after body parsing — ensure you run HPP normalization before handlers read params.
- JSON body pollution: HPP traditionally applies to query/form; JSON has its own "duplicate key" issue that
encoding/jsonhandles by taking the last value. - Overwriting
r.URLcan confuse logging and metrics — create a copy if needed.
hpp logic directly. The OWASP Testing Guide documents HPP and recommends app-layer mitigation.HTTP Parameter Pollution (HPP) is an attack where the attacker sends duplicate query parameters to confuse your application. For example: /search?category=books&category=admin. Depending on how the server handles duplicates, this can bypass security filters or alter query logic.
This middleware keeps only the last value for each query parameter, preventing pollution. You can also configure a whitelist of parameters that are allowed to have multiple values (like tags or ids).
package middlewares
import (
"net/http"
"net/url"
)
// Parameters that are allowed to have multiple values
var allowedDuplicates = map[string]bool{
"tags": true,
"ids": true,
}
func HPPProtection(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
clean := url.Values{}
for key, values := range query {
if allowedDuplicates[key] {
clean[key] = values // Keep all values for whitelisted params
} else {
// Keep only the last value for non-whitelisted params
clean.Set(key, values[len(values)-1])
}
}
r.URL.RawQuery = clean.Encode()
next.ServeHTTP(w, r)
})
}
Taking the last value is a common convention that matches PHP and Express.js behavior. Some frameworks take the first value. The important thing is to be consistent and prevent attackers from injecting unexpected values through duplicates.
Go's HPP middleware maps directly to the hpp npm package:
// Express + hpp (npm install hpp)
const hpp = require('hpp');
app.use(hpp({
whitelist: ['tags', 'ids'], // Like Go's allowedDuplicates map
}));
// ?role=user&role=admin → req.query.role = "admin" (last value)
// ?tags=go&tags=node → req.query.tags = ["go", "node"] (whitelisted)
// Without hpp — Express behavior by default:
// ?role=user&role=admin → req.query.role = ["user", "admin"] (array!)
// This is DIFFERENT from Go, where Query().Get() returns the FIRST value
Key difference: Express turns duplicate params into arrays by default. Go's r.URL.Query().Get() returns the first value. Both behaviors can be exploited. The hpp package normalizes Express to use the last value, similar to Go's HPP middleware.
20 Response Time Middleware
time.Since(start), sets an X-Response-Time header on the response, emits metrics to Prometheus or statsd, and logs slow requests. Because middleware wraps http.Handler, it's trivial to insert a stopwatch around the next call — Go's elegance shines here: start := time.Now(); next.ServeHTTP(w, r); log(time.Since(start)).- Per-request timing:
time.Now()is monotonic and nanosecond-precision. - Status-code capture: wrap
http.ResponseWriterto record the written status for labels. - Prometheus histograms:
http_request_duration_secondswith labels for method, path, status. - Slow-query logging: log a warning when duration exceeds a threshold.
- X-Response-Time header: useful for client-side debugging and synthetic monitoring.
- Distributed tracing: integrate with OpenTelemetry to correlate traces with HTTP latency.
- vs Express morgan / response-time: Node's response-time middleware does the same job; Go's version is usually handwritten in 15 lines.
- vs Spring Boot Actuator: Actuator auto-emits HTTP metrics via Micrometer; Go requires opt-in middleware but the data model is the same.
- vs Python
prometheus_flask_exporter: drop-in for Flask; Go equivalents includepromhttp+ a handwritten middleware orchi-prometheus. - vs APM agents (Datadog, New Relic): those auto-instrument via bytecode/runtime hooks; in Go you install their SDK which wraps your router.
P99 > 500ms for 5 minutes). Every production Go service should have it from day one.- High-cardinality path labels:
/users/42as a label creates a time series per user ID — use the route pattern (/users/{id}) instead. - Timer not including response flush: if you measure before
WriteHeaderyou miss the body-write latency — measure around the fullnext.ServeHTTP. - ResponseWriter wrapping breaks Hijacker/Flusher: if your wrapper doesn't also implement those interfaces, WebSockets and SSE break.
- Using
log.Printfwith a giant fmt string adds measurable overhead on hot paths — use structured logging. - Measuring with
time.Now().Sub(time.Now()): always usetime.Sincewhich is monotonic.
promhttp.InstrumentHandlerDuration. Kubernetes components emit duration histograms for every API call. Grafana Mimir and Loki expose detailed latency histograms. OpenTelemetry's otelhttp package instruments any http.Handler with both traces and metrics.Tracking response time is essential for monitoring and debugging. This middleware records when the request started, lets the handler run, then calculates the elapsed time and adds it as a response header. You can also log slow requests for alerting.
package middlewares
import (
"fmt"
"log"
"net/http"
"time"
)
func ResponseTimeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Run the actual handler
next.ServeHTTP(w, r)
// Calculate elapsed time
duration := time.Since(start)
// Add response time header (useful for debugging)
w.Header().Set("X-Response-Time",
fmt.Sprintf("%dms", duration.Milliseconds()))
// Log slow requests (> 500ms)
if duration > 500*time.Millisecond {
log.Printf("SLOW REQUEST: %s %s took %v",
r.Method, r.URL.Path, duration)
}
})
}
Note that w.Header().Set() after next.ServeHTTP(w, r) only works if the handler hasn't already called w.WriteHeader() or w.Write(). Once headers are sent, they can't be modified. For guaranteed header delivery, use a custom ResponseWriter wrapper to capture the status code.
Go's response time middleware maps to response-time and morgan npm packages:
// Express + response-time (npm install response-time)
const responseTime = require('response-time');
app.use(responseTime());
// Automatically sets X-Response-Time: 12.345ms header
// Express + morgan for logging (npm install morgan)
const morgan = require('morgan');
app.use(morgan('dev')); // Logs: GET /api/users 200 12.345 ms
// Manual version (same as Go's approach):
app.use((req, res, next) => {
const start = Date.now(); // Go: time.Now()
res.on('finish', () => { // Go: code after next.ServeHTTP()
const duration = Date.now() - start;
if (duration > 500) {
console.warn(`SLOW: ${req.method} ${req.url} ${duration}ms`);
}
});
next();
});
Key difference: Node uses res.on('finish', ...) event to run code after the response is sent. Go runs code after next.ServeHTTP(w, r) returns. Both achieve the same result but Go's model is stack-based (code before/after the call), while Node uses events.
21 Middleware Route Exclusion
auth middleware should run on every /api route except /api/login and /api/register, and your ratelimit middleware should skip /healthz. Because Go's standard router doesn't support per-route middleware natively, you either (1) wrap a sub-mux with the middleware and register only the protected routes on it, or (2) write a small helper that checks the request path against an exclusion list before calling next.- Path allow/deny lists: exact matches or prefix matches.
- Sub-mux composition: public and private routes on separate muxes merged at the top.
- Per-route wrappers: wrap individual handlers with
authMiddleware(handlerFunc). - Method-aware exclusion: skip
GET /usersbut enforce onPOST /users. - Fallthrough on match: call
next.ServeHTTPdirectly without running middleware logic.
- vs Express
app.use('/api', middleware): Express supports prefix-scoped middleware natively; Go requires sub-mux or wrapper patterns. - vs Chi
r.Group: Chi has per-group middleware that naturally excludes other groups — a cleaner approach than path-based exclusion. - vs Gin
router.Use: Gin supports middleware per route group; stdlib doesn't. - vs Java Servlet Filter url-pattern: servlets declare path patterns in
web.xmlor annotations; Go does it imperatively. - vs Spring Security's
requestMatchers: Spring has a fluent DSL for which URLs require auth; Go just uses functions.
- Accidentally excluding too broadly: skipping auth on
/apiinstead of/api/loginopens the whole API. - Prefix matches that are too permissive:
/adminmatches/administrative-toolsunless you check a trailing slash. - Exclusion lists in global variables are hard to test — inject them into the middleware instead.
- Mixing sub-mux exclusion with wrapper exclusion creates two sources of truth for "is this route protected?".
- Skipping logging/metrics middleware to "save CPU" on health checks also hides outages — keep those instrumented.
/api, /apis, /healthz). Traefik uses middleware chains scoped by router rules — its config lets you attach middleware to specific routes only. Caddy's matcher system lets middleware target specific paths. Chi's router ships Middlewares() and Group() for exactly this use case.Not every route needs every middleware. For example, your login and register endpoints shouldn't require JWT authentication, and your health check endpoint probably doesn't need rate limiting. This helper wraps a middleware and skips it for specified paths.
package utils
import (
"net/http"
"strings"
)
// MiddlewaresExcludePaths wraps a middleware to skip it for certain paths
func MiddlewaresExcludePaths(mw Middleware, excludePaths ...string) Middleware {
return func(next http.Handler) http.Handler {
// Apply the middleware to the handler
wrapped := mw(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if current path should be excluded
for _, path := range excludePaths {
if strings.HasPrefix(r.URL.Path, path) {
next.ServeHTTP(w, r) // Skip middleware
return
}
}
wrapped.ServeHTTP(w, r) // Apply middleware
})
}
}
// Usage: JWT middleware skips login and register routes
jwtMiddleware := utils.MiddlewaresExcludePaths(
mw.JWTMiddleware,
"/auth/login",
"/auth/register",
"/health",
)
Express handles route exclusion more naturally through its route-group and per-route middleware model:
// Express — Method 1: express-unless (npm install express-unless)
const unless = require('express-unless');
const authMiddleware = (req, res, next) => { /* JWT logic */ };
authMiddleware.unless = unless;
app.use(authMiddleware.unless({
path: ['/auth/login', '/auth/register', '/health'],
// Same paths Go's MiddlewaresExcludePaths skips!
}));
// Express — Method 2: Per-route middleware (cleaner, no package needed)
// Public routes — no auth
app.post('/auth/login', loginHandler);
app.post('/auth/register', registerHandler);
app.get('/health', healthHandler);
// Protected routes — auth middleware applied per-route
app.get('/api/users', authMiddleware, usersHandler);
app.get('/api/profile', authMiddleware, profileHandler);
// Express — Method 3: Route groups (most common in Express)
const protectedRouter = express.Router();
protectedRouter.use(authMiddleware); // Applied to all routes in this group
protectedRouter.get('/profile', profileHandler);
protectedRouter.get('/settings', settingsHandler);
app.use('/api', protectedRouter);
const publicRouter = express.Router(); // No auth middleware
publicRouter.post('/login', loginHandler);
app.use('/auth', publicRouter);
Key difference: Express has per-route middleware built in — you can apply auth to individual routes without a helper function. Go's stdlib doesn't support per-route middleware natively, which is why the MiddlewaresExcludePaths helper exists. Express's router-group pattern (Method 3) is the cleanest approach and has no Go stdlib equivalent.
22 Utility Functions
internal/utils (or internal/httputil) package and encapsulate common boilerplate: writing JSON responses, decoding JSON request bodies, sending standardized error replies, reading path parameters, generating IDs, parsing pagination. Good utilities turn ten-line handlers into two-line handlers while keeping error handling explicit. The canonical pair in Go REST code is WriteJSON(w, status, data) and DecodeJSON(r, &target), plus an Error(w, status, message) helper for consistent error shapes.- JSON response writer: sets content-type, status, and encodes in one call.
- JSON body decoder: with
DisallowUnknownFields()to reject typos. - Error envelope:
{"error": {"code": "invalid_input", "message": "..."}}. - Pagination parser: extract
limitandoffsetfrom query params with defaults and caps. - Request ID extractor: pull the request-ID from context for logging.
- Input validators: email, UUID, non-empty string helpers.
- vs Express
res.json(): Express bundles JSON helpers into the response object; Go uses standalone functions. - vs Django REST Framework Response: DRF has a rich
Responseclass with content negotiation; Go is more minimal. - vs Gin
c.JSON(status, obj): Gin provides the same shape inside its context; stdlib users handwrite it once. - vs Spring
ResponseEntity: Spring has a builder pattern; Go is simpler but less discoverable. - vs Rails render: Rails auto-serializes; Go is explicit.
httputil package unifies the error shape across the whole API, enforces JSON content types, and makes it easy to add cross-cutting changes (like switching to application/problem+json) in one place.- Utility functions that panic instead of returning errors — make the caller's error handling awkward.
- Writing the status after the body — must call
WriteHeaderbeforeWrite. - Encoding directly into the
ResponseWritermeans you can't change your mind if encoding fails partway through — buffer first if that matters. - Global logger in utilities — inject the logger so tests can assert on output.
- Inconsistent error codes:
"invalid_input"vs"INVALID_INPUT"vs"invalidInput"across handlers — pick one style and enforce it.
httputil or transport package with these helpers. Kubernetes' apimachinery has extensive utility packages for writing status responses and encoding. go-kit provides transport/http with helpers for the same purpose. Chi's render package (go-chi/render) is a popular external library filling exactly this role.These are the helper functions that keep your handlers clean. Instead of repeating SQL generation, error handling, and reflection logic in every handler, extract them into reusable utilities.
GenerateInsertQuery
Dynamically builds an INSERT SQL query from a struct, using reflection to read struct tags.
// GenerateInsertQuery builds "INSERT INTO table (col1, col2) VALUES (?, ?)"
// from any struct using its `db` tags
func GenerateInsertQuery(table string, model interface{}) string {
t := reflect.TypeOf(model)
v := reflect.ValueOf(model)
var columns []string
var placeholders []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)
// Skip zero-value fields and ID (auto-increment)
if value.IsZero() { continue }
dbTag := field.Tag.Get("db")
column := strings.Split(dbTag, ",")[0]
if column == "id" { continue }
columns = append(columns, column)
placeholders = append(placeholders, "?")
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(columns, ", "),
strings.Join(placeholders, ", "),
)
}
GetStructValues (Reflection-based)
// GetStructValues extracts non-zero field values from a struct
// Returns values in the same order as GenerateInsertQuery columns
func GetStructValues(model interface{}) []interface{} {
v := reflect.ValueOf(model)
t := reflect.TypeOf(model)
var values []interface{}
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
dbTag := t.Field(i).Tag.Get("db")
column := strings.Split(dbTag, ",")[0]
if !field.IsZero() && column != "id" {
values = append(values, field.Interface())
}
}
return values
}
AddSorting & AddFilters
// AddSorting appends ORDER BY to a query from ?sort=name&order=desc
func AddSorting(query string, r *http.Request, allowedColumns map[string]bool) string {
sortBy := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
// Whitelist columns to prevent SQL injection
if allowedColumns[sortBy] {
if order != "desc" { order = "asc" }
query += fmt.Sprintf(" ORDER BY %s %s", sortBy, order)
}
return query
}
// AddFilters appends WHERE clauses from query parameters
func AddFilters(query string, r *http.Request,
allowedFilters map[string]string) (string, []interface{}) {
var conditions []string
var values []interface{}
for param, column := range allowedFilters {
if val := r.URL.Query().Get(param); val != "" {
conditions = append(conditions, column+" = ?")
values = append(values, val)
}
}
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
return query, values
}
ErrorHandler
// ErrorHandler sends a consistent JSON error response
func ErrorHandler(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{
"error": http.StatusText(status),
"message": message,
})
}
// AuthorizeUser checks if the JWT user matches the requested resource
func AuthorizeUser(r *http.Request, resourceOwnerID int) bool {
claims := r.Context().Value(UserClaimsKey).(jwt.MapClaims)
userID := int(claims["user_id"].(float64))
return userID == resourceOwnerID
}
Go's utility functions map to Express helper patterns. The biggest difference: Go needs reflection for dynamic queries, Node uses objects natively:
// --- JSON Response Helper (like Go's ErrorHandler) ---
// In Express, res.json() and res.status() are built in!
const sendError = (res, status, message) => {
res.status(status).json({
error: http.STATUS_CODES[status], // Like Go's http.StatusText(status)
message,
});
};
// --- Dynamic INSERT (Go uses reflect; Node uses Object.keys!) ---
const generateInsert = (table, model) => {
const keys = Object.keys(model).filter(k => k !== 'id' && model[k] != null);
const columns = keys.join(', ');
const placeholders = keys.map(() => '?').join(', ');
const values = keys.map(k => model[k]);
return { sql: `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`, values };
// No reflect needed! JS objects are introspectable by default
};
// --- Sorting (like Go's AddSorting) ---
const addSorting = (query, reqQuery, allowedColumns) => {
const { sort, order = 'asc' } = reqQuery;
if (sort && allowedColumns.includes(sort)) {
query += ` ORDER BY ${sort} ${order === 'desc' ? 'desc' : 'asc'}`;
}
return query;
};
Key difference: Go needs the reflect package to introspect struct fields at runtime (verbose, slow). JavaScript objects are inherently dynamic — Object.keys(), bracket notation, and for...in make dynamic query building trivial. This is one area where Node's dynamic typing is a genuine advantage over Go's static types.
23 Protocol Buffers
.proto file, then run protoc with the Go plugin to generate strongly typed Go structs, marshal/unmarshal methods, and (with protoc-gen-go-grpc) gRPC client and server stubs. Protobuf wire format is binary, compact, and forward/backward compatible: adding a new field with a new tag number doesn't break old clients.- Schema-first: one
.protofile is the source of truth for all languages. - Binary wire format: 3–10× smaller than JSON and much faster to parse.
- Strong typing: generated Go types are exact — no reflection, no schema validation at runtime.
- Field numbers for compatibility: adding/removing fields is safe if tag numbers never reuse.
- Well-known types:
Timestamp,Duration,Any,Struct,FieldMask. - Language interop: Go service can talk to Java/Python/C++/Rust clients with zero manual translation.
- vs JSON: protobuf is ~5× smaller and ~10× faster to parse, but not human-readable and requires a schema.
- vs MessagePack/BSON: those are schemaless binary formats; protobuf requires a schema but gives you strong typing and codegen.
- vs Avro: Avro embeds the schema in the data; protobuf separates them. Avro is more popular in Hadoop/Kafka; protobuf in microservices.
- vs FlatBuffers / Cap'n Proto: those allow zero-copy access; protobuf always parses into heap structs but has far broader ecosystem support.
- vs gRPC over JSON (grpc-gateway): you can serve the same protobuf service as both gRPC (binary) and REST (JSON) — best of both worlds.
- Reusing field numbers: catastrophic compatibility break — once a number is deprecated, never reuse it; add a new one.
- Proto2 vs proto3: proto3 dropped required fields and default values, changing the null semantics dramatically.
- Code generation setup:
protocis not a Go tool — install the binary and the Go plugins separately. - Generated code in version control: either commit the generated files or make codegen part of every build; pick one and stick to it.
- Large messages: protobuf wire format handles them but gRPC has a default 4 MB message limit you'll hit fast.
- Enum zero values: proto3 enums start at zero, which must be
UNSPECIFIEDby convention to distinguish unset from first real value.
Protocol Buffers (protobuf) is Google's language-neutral, platform-neutral mechanism for serializing structured data. Think of it as JSON, but binary, smaller, faster, and with a strict schema. You define your data structures in .proto files, then the protoc compiler generates Go code (structs and methods) automatically.
Protobuf is the foundation of gRPC. While REST APIs use JSON over HTTP, gRPC uses protobuf over HTTP/2 for much better performance.
.proto File Syntax
// calculator.proto
syntax = "proto3"; // Use proto3 syntax (latest)
package calculator; // Package namespace
option go_package = "/proto/gen;mainpb"; // Go package path
// Service defines the RPC methods
service Calculate {
rpc Add (AddRequest) returns (AddResponse);
}
// Messages define the data structures
message AddRequest {
int32 a = 1; // Field number 1 (not value — used for binary encoding)
int32 b = 2; // Field number 2
}
message AddResponse {
int32 sum = 1;
}
The numbers (1, 2, etc.) are not default values — they're field identifiers used in the binary encoding. Once a field number is assigned and deployed, it must never change. Numbers 1-15 use 1 byte in the encoding, 16-2047 use 2 bytes. Put your most frequently used fields in 1-15.
Generating Go Code
# Install the protoc compiler and Go plugins
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# Generate Go code from .proto file
protoc --go_out=. --go-grpc_out=. calculator.proto
# This generates:
# calculator.pb.go — message structs (AddRequest, AddResponse)
# calculator_grpc.pb.go — service interface & client stub
Common Protobuf Types
| Protobuf Type | Go Type | Notes |
|---|---|---|
int32 | int32 | Variable-length encoding |
int64 | int64 | Variable-length encoding |
string | string | Must be valid UTF-8 |
bool | bool | |
bytes | []byte | Arbitrary byte sequences |
float / double | float32 / float64 | |
repeated T | []T | Lists/arrays |
map<K,V> | map[K]V | Key-value maps |
Protobuf works in Node too! The same .proto file generates JavaScript/TypeScript code. Here's how it compares:
// Node.js — protobuf with @grpc/proto-loader (dynamic loading)
const protoLoader = require('@grpc/proto-loader');
const grpc = require('@grpc/grpc-js');
const packageDef = protoLoader.loadSync('calculator.proto');
const proto = grpc.loadPackageDefinition(packageDef);
// proto.calculator.Calculate — service definition
// Node.js — protobuf with protobufjs (static generation)
// npx pbjs -t static-module -w commonjs -o proto.js calculator.proto
const { calculator } = require('./proto.js');
const msg = calculator.AddRequest.create({ a: 10, b: 20 });
const buffer = calculator.AddRequest.encode(msg).finish();
// buffer is the same binary wire format as Go's proto.Marshal()
// JSON equivalent for comparison:
// JSON: {"a": 10, "b": 20} → ~18 bytes (text)
// Protobuf: 0x08 0x0A 0x10 0x14 → 4 bytes (binary!)
| Go | Node.js |
|---|---|
protoc --go_out=. (compile-time codegen) | @grpc/proto-loader (runtime loading) or pbjs (codegen) |
| Generated Go structs (strongly typed) | Dynamic objects or generated TS interfaces |
proto.Marshal(msg) | Type.encode(msg).finish() |
proto.Unmarshal(data, msg) | Type.decode(buffer) |
Key difference: Go always uses compile-time code generation — you run protoc and get typed structs. Node can either load .proto files at runtime (convenient but no type safety) or generate code with pbjs. Go's approach catches schema errors at compile time; Node's dynamic approach discovers them at runtime.
24 gRPC Server
grpc.NewServer() from google.golang.org/grpc; you register an implementation of the auto-generated service interface (e.g., pb.RegisterUserServiceServer) and call server.Serve(listener). The framework handles connection multiplexing, message framing, context propagation, metadata (gRPC's equivalent of HTTP headers), status codes, and streaming out of the box.- HTTP/2 multiplexing: many concurrent streams over one TCP connection.
- Four RPC modes: unary, server-streaming, client-streaming, bidirectional-streaming.
- Interceptors: gRPC's equivalent of HTTP middleware — wrap calls with logging, auth, tracing.
- Rich status codes:
codes.NotFound,codes.PermissionDenied,codes.ResourceExhausted— richer than HTTP's. - Deadline propagation: a
context.Contextwith a deadline flows through every call. - Bidirectional TLS: first-class mTLS for service-to-service auth.
- Reflection: clients can discover the service schema at runtime via the reflection API.
- vs REST/JSON: gRPC is binary, strongly typed, streaming-capable, and faster, but not browser-native (needs grpc-web or grpc-gateway).
- vs GraphQL: GraphQL is query-oriented and schemaless on the wire; gRPC is RPC-oriented and schema-first. GraphQL shines for front-end aggregation; gRPC shines for service-to-service.
- vs Thrift (Facebook): similar concept, older IDL-based RPC framework — still used at Meta but gRPC has overtaken it elsewhere.
- vs Finagle (Twitter, Scala): Finagle is an asynchronous RPC framework predating gRPC; gRPC has the open-source momentum.
- vs JSON-RPC: JSON-RPC is a simple text protocol; gRPC is dramatically more powerful and faster.
.proto file and get matched client/server code automatically.- Not propagating deadlines: every call should inherit the incoming context so upstream cancellation cascades.
- Unbounded message size: default limits are 4 MB; large payloads need
grpc.MaxRecvMsgSize. - Blocking in streaming handlers: a slow client blocks the stream; always
selectonctx.Done(). - Using HTTP status codes — gRPC uses its own codes from the
codespackage. - Forgetting to close the listener on shutdown — causes orphaned goroutines on reload.
- Mixing protobuf versions: proto2 and proto3 generated code can conflict on the same types.
kubelet talks to the container runtime via CRI (gRPC). etcd's client protocol is gRPC. Envoy's xDS control plane is gRPC. CockroachDB and TiDB use gRPC for inter-node communication. Dapr, Dgraph, Pilosa, and Temporal all expose gRPC APIs. Google Cloud SDK's preferred protocol is gRPC.gRPC is a high-performance RPC framework that uses HTTP/2 for transport and Protocol Buffers for serialization. Unlike REST where you manually serialize/deserialize JSON and define URL patterns, gRPC generates the entire client-server boilerplate from your .proto file. You just implement the business logic.
package main
import (
"context"
"crypto/tls"
"log"
"net"
pb "myapp/proto/gen" // Import generated protobuf code
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Server implements the Calculate service interface
type Server struct {
pb.UnimplementedCalculateServer // Embed for forward compatibility
}
// Add implements the Add RPC method
func (s *Server) Add(ctx context.Context, req *pb.AddRequest) (*pb.AddResponse, error) {
sum := req.GetA() + req.GetB()
return &pb.AddResponse{Sum: sum}, nil
}
func main() {
// Load TLS certificate
creds, err := credentials.NewServerTLSFromFile("cert.pem", "key.pem")
if err != nil {
log.Fatalf("Failed to load TLS: %v", err)
}
// Create gRPC server with TLS
grpcServer := grpc.NewServer(grpc.Creds(creds))
// Register our service implementation
pb.RegisterCalculateServer(grpcServer, &Server{})
// Listen on TCP port
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("gRPC server listening on :50051")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
Embedding UnimplementedCalculateServer ensures forward compatibility. If you add new RPC methods to your .proto file but haven't implemented them yet, the server will return an "unimplemented" error instead of failing to compile. This lets you evolve your API incrementally.
Go's gRPC server maps to Node's @grpc/grpc-js package. The same .proto file, different implementation:
// Node.js gRPC Server (@grpc/grpc-js + @grpc/proto-loader)
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDef = protoLoader.loadSync('calculator.proto');
const proto = grpc.loadPackageDefinition(packageDef);
// Implement the Add RPC (like Go's func (s *Server) Add(...))
const server = new grpc.Server();
server.addService(proto.calculator.Calculate.service, {
Add: (call, callback) => {
const sum = call.request.a + call.request.b;
callback(null, { sum });
// Go: return &pb.AddResponse{Sum: sum}, nil
},
});
// Start server (like Go's grpcServer.Serve(lis))
server.bindAsync(
'0.0.0.0:50051',
grpc.ServerCredentials.createInsecure(),
(err, port) => {
console.log(`gRPC server on :${port}`);
}
);
| Go | Node.js |
|---|---|
pb.RegisterCalculateServer(srv, &Server{}) | server.addService(proto.service, impl) |
return &pb.AddResponse{Sum: sum}, nil | callback(null, { sum }) |
grpc.Creds(creds) | grpc.ServerCredentials.createSsl(...) |
| Goroutine per RPC (concurrent) | Single-threaded event loop |
UnimplementedServer embedding | No equivalent — just don't implement the method |
Key difference: Go's gRPC server handles concurrent RPCs via goroutines — each call runs on its own goroutine. Node's gRPC server runs on the single-threaded event loop, so CPU-heavy RPC handlers block all other calls. For I/O-heavy RPCs (database queries), Node is fine. For CPU-heavy work, Go is dramatically better.
25 gRPC Client
*grpc.ClientConn. You dial the server with grpc.NewClient(target) (or the older grpc.Dial), create a typed client with pb.NewUserServiceClient(conn), and then call methods like any Go function: client.GetUser(ctx, &pb.GetUserRequest{Id: 42}). The stub handles serialization, HTTP/2 framing, metadata, deadlines, retries, and load balancing. One ClientConn is safe to share across thousands of goroutines.- Typed stubs: generated from the
.protofile — compile-time guarantees that request and response types match. - Connection multiplexing: one HTTP/2 connection handles many concurrent streams.
- Name resolution: built-in support for DNS, xDS, and custom resolvers for service discovery.
- Load balancing: pick_first, round_robin, xDS-driven — configured via service config or dial options.
- Retry policy: declarative retry config via service config JSON.
- Interceptors: client-side middleware for logging, tracing, auth, metrics.
- vs an HTTP client wrapper: a gRPC stub is strongly typed; an HTTP client wrapper requires manual request/response encoding.
- vs Apollo Client (GraphQL): Apollo is browser-oriented with caching; gRPC is for backend-to-backend with no automatic caching.
- vs OpenAPI-generated clients: OpenAPI generates REST clients in many languages; gRPC stubs are binary and typically faster.
- vs Java gRPC stubs: same semantics, different ergonomics — Go stubs return
(resp, err); Java stubs can be blocking, async, or future-based. - vs Connect-Go: Connect is a newer gRPC-compatible framework that also speaks plain HTTP — easier to integrate with browsers.
.proto yields consistent clients in every language.- Creating a new
ClientConnper call — defeats connection reuse and HTTP/2 multiplexing. Create one per target and share. - Not deferring
conn.Close()in long-lived programs leaks TCP connections on shutdown. - Missing context deadlines: a hung server blocks the goroutine forever unless you set a timeout on the context.
- Insecure credentials in production:
grpc.WithInsecure()(orinsecure.NewCredentials()) is fine for dev but must be replaced with TLS in prod. - Dial errors are silent by default —
grpc.Dialreturns immediately and errors surface only on the first call unless you usegrpc.WithBlock. - Load balancer confusion:
dns:///myservicewithround_robinis necessary to load-balance across pods in Kubernetes.
kubectl is largely a REST client but talks to CRI via gRPC. Dapr sidecars provide gRPC clients for service invocation. Envoy's xDS API is consumed by gRPC clients in control planes. Temporal workers connect to Temporal's server via gRPC. Vitess query serving and TiDB's PD client are gRPC-based.The gRPC client is auto-generated from the same .proto file. You create a connection, get a typed client stub, and call methods as if they were local function calls. The serialization, HTTP/2 framing, and error handling are all handled by the framework.
package main
import (
"context"
"log"
"time"
pb "myapp/proto/gen"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
func main() {
// Load TLS credentials
creds, err := credentials.NewClientTLSFromFile("cert.pem", "")
if err != nil {
log.Fatalf("Failed to load TLS: %v", err)
}
// Connect to gRPC server
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Create a typed client stub
client := pb.NewCalculateClient(conn)
// Add metadata (like HTTP headers)
md := metadata.New(map[string]string{
"authorization": "Bearer my-token",
})
ctx := metadata.NewOutgoingContext(context.Background(), md)
// Set a timeout
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Call the Add RPC — looks like a local function call!
resp, err := client.Add(ctx, &pb.AddRequest{A: 10, B: 20})
if err != nil {
log.Fatalf("RPC failed: %v", err)
}
log.Printf("10 + 20 = %d", resp.GetSum()) // Output: 10 + 20 = 30
}
Go's typed gRPC client maps to Node's dynamically loaded client stub:
// Node.js gRPC Client
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDef = protoLoader.loadSync('calculator.proto');
const proto = grpc.loadPackageDefinition(packageDef);
// Create client (like Go's grpc.Dial + pb.NewCalculateClient)
const client = new proto.calculator.Calculate(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Call the RPC — callback-style (traditional Node)
client.Add({ a: 10, b: 20 }, (err, response) => {
if (err) throw err;
console.log(`10 + 20 = ${response.sum}`);
});
// With promisify (modern async/await style):
const { promisify } = require('util');
const addAsync = promisify(client.Add).bind(client);
const resp = await addAsync({ a: 10, b: 20 });
// Go: resp, err := client.Add(ctx, &pb.AddRequest{A: 10, B: 20})
// Metadata (like Go's gRPC metadata)
const metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer my-token');
client.Add({ a: 10, b: 20 }, metadata, callback);
Key difference: Go's gRPC client returns typed structs generated from the proto — resp.GetSum() is compile-time checked. Node's client returns plain JS objects — response.sum has no type checking (unless using TypeScript with generated types). Also, Go uses context.Context for deadlines/cancellation; Node must manually set deadlines via metadata.
26 gRPC Streaming
Send, Recv, and CloseSend methods that you call in goroutines or loops.- HTTP/2 frames: messages are chunked into frames on a single TCP stream — no head-of-line blocking between calls.
- Flow control: HTTP/2 provides automatic back-pressure; fast senders don't swamp slow receivers.
- Deadline + cancellation: the same
context.Contextcancels in-flight streams. - Metadata on open: headers and trailers let you attach authentication and status to the stream.
- Natural mapping to Go: loops with
stream.Recv()returningio.EOFfeel exactly like reading from anio.Reader.
- vs WebSockets: WebSockets are great for browsers but lack strong typing, deadline propagation, and richer status codes. gRPC streaming is superior for backend-to-backend.
- vs Server-Sent Events (SSE): SSE is one-way and text-based; gRPC streaming is binary and bidirectional.
- vs Kafka / NATS / RabbitMQ: message brokers decouple producers and consumers; gRPC streaming is point-to-point with no persistence.
- vs long-polling: long-polling fakes streaming over short-lived HTTP calls; gRPC uses persistent HTTP/2 streams.
- vs GraphQL subscriptions: GraphQL subscriptions run over WebSockets in browsers; gRPC is the backend equivalent.
- Forgetting to call
CloseSendin client-streaming — the server waits forever. - Blocking on
Recvwithout checkingctx.Done()— cancelled streams leak goroutines. - Handling
io.EOFas an error — it's the normal end-of-stream marker. - Flow control misunderstanding: streams have per-stream windows; a slow reader blocks the sender until the window refills.
- Running Send and Recv in the same goroutine in bidi streaming can deadlock — split into two goroutines.
- Intermediate proxies that buffer: some load balancers don't support HTTP/2 streaming properly — use a gRPC-aware LB (Envoy, Linkerd).
kubectl logs -f uses server streaming via the API server. Envoy's xDS uses bidirectional streaming to push config updates. etcd's watch API streams change events. Temporal workflows use bidirectional streams for task polling. Google Cloud's Pub/Sub pull subscribers use streaming for low-latency message delivery. Cockroach's changefeed streams row changes to consumers.gRPC supports four communication patterns. Unary (request-response) is the simplest. But gRPC's real power comes from its three streaming modes: server-side, client-side, and bidirectional. Streaming is ideal for real-time data, large file transfers, and chat applications.
Proto Definitions for Streaming
service StreamService {
// Server-side streaming: client sends one request, server sends many responses
rpc Fibonacci (FibRequest) returns (stream FibResponse);
// Client-side streaming: client sends many requests, server sends one response
rpc SendNumbers (stream NumberRequest) returns (SumResponse);
// Bidirectional streaming: both sides send streams simultaneously
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message FibRequest { int32 count = 1; }
message FibResponse { int64 value = 1; }
message NumberRequest { int32 number = 1; }
message SumResponse { int64 total = 1; }
message ChatMessage { string user = 1; string text = 2; }
Server-Side Streaming (Fibonacci)
The server sends multiple responses for a single request. The client reads from the stream until it receives an EOF (End of File) signal.
// Server implementation: sends N Fibonacci numbers
func (s *Server) Fibonacci(req *pb.FibRequest, stream pb.StreamService_FibonacciServer) error {
a, b := int64(0), int64(1)
for i := int32(0); i < req.GetCount(); i++ {
// Send each number as a separate message
if err := stream.Send(&pb.FibResponse{Value: a}); err != nil {
return err
}
a, b = b, a+b
}
return nil // Returning nil closes the stream
}
// Client: reads the stream
stream, err := client.Fibonacci(ctx, &pb.FibRequest{Count: 10})
for {
resp, err := stream.Recv()
if err == io.EOF {
break // Server closed the stream
}
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.GetValue())
}
Client-Side Streaming (SendNumbers)
The client sends multiple messages, and the server responds once after the client finishes.
// Server: receives numbers and returns the sum
func (s *Server) SendNumbers(stream pb.StreamService_SendNumbersServer) error {
var total int64
for {
req, err := stream.Recv()
if err == io.EOF {
// Client finished sending — return the result
return stream.SendAndClose(&pb.SumResponse{Total: total})
}
if err != nil {
return err
}
total += int64(req.GetNumber())
}
}
// Client: sends numbers one by one
stream, _ := client.SendNumbers(ctx)
for _, n := range []int32{1, 2, 3, 4, 5} {
stream.Send(&pb.NumberRequest{Number: n})
}
resp, _ := stream.CloseAndRecv()
fmt.Println("Sum:", resp.GetTotal()) // 15
Bidirectional Streaming (Chat)
Both sides send and receive simultaneously. This is ideal for chat, gaming, and real-time collaboration.
// Server: echoes messages back with a prefix
func (s *Server) Chat(stream pb.StreamService_ChatServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// Echo back with modification
reply := &pb.ChatMessage{
User: "server",
Text: fmt.Sprintf("Echo: %s", msg.GetText()),
}
if err := stream.Send(reply); err != nil {
return err
}
}
}
// Client: send and receive concurrently using goroutines
stream, _ := client.Chat(ctx)
// Goroutine to receive messages
go func() {
for {
msg, err := stream.Recv()
if err == io.EOF { return }
fmt.Printf("[%s]: %s\n", msg.GetUser(), msg.GetText())
}
}()
// Send messages from main goroutine
messages := []string{"Hello", "How are you?", "Goodbye"}
for _, text := range messages {
stream.Send(&pb.ChatMessage{User: "client", Text: text})
}
stream.CloseSend()
| Pattern | Use Case |
|---|---|
| Unary | Simple request-response (CRUD, auth, calculations) |
| Server streaming | Real-time feeds, log tailing, large result sets |
| Client streaming | File uploads, sensor data batching, bulk operations |
| Bidirectional | Chat, gaming, collaborative editing, live dashboards |
gRPC streaming in Node works similarly but uses Node.js event-style APIs instead of Go's loop-based approach:
// --- Node.js Server-Side Streaming ---
// Server implementation:
Fibonacci: (call) => {
let a = 0, b = 1;
for (let i = 0; i < call.request.count; i++) {
call.write({ value: a }); // Go: stream.Send(&pb.FibResponse{...})
[a, b] = [b, a + b];
}
call.end(); // Go: return nil (closes stream)
}
// Client reads stream:
const call = client.Fibonacci({ count: 10 });
call.on('data', (resp) => console.log(resp.value));
call.on('end', () => console.log('done'));
// Go: for { resp, err := stream.Recv(); if err == io.EOF { break } }
// --- Node.js Bidirectional Streaming (Chat) ---
const call = client.Chat();
call.on('data', (msg) => {
console.log(`[${msg.user}]: ${msg.text}`);
});
call.write({ user: 'client', text: 'Hello' });
call.write({ user: 'client', text: 'Goodbye' });
call.end(); // Go: stream.CloseSend()
| Go | Node.js |
|---|---|
stream.Send(&msg) | call.write(msg) |
stream.Recv() in a loop | call.on('data', cb) event |
io.EOF signals end | call.on('end', cb) |
stream.CloseSend() | call.end() |
| Goroutines for concurrent send/recv | Events handle concurrency naturally |
Key difference: Go uses loops with goroutines for streaming (imperative). Node uses event emitters (reactive). For bidirectional streaming, Go needs two goroutines (one for send, one for recv). Node handles both directions through events on a single thread — actually a case where the event loop model fits naturally.
27 gRPC Gateway
protoc-gen-grpc-gateway) that reads your .proto file along with HTTP annotations from google.api.http and produces a reverse-proxy server that translates RESTful JSON requests into gRPC calls. It lets you write your service once in protobuf and serve both gRPC (binary, internal) and REST+JSON (public, browser-friendly) from the same code. The gateway runs as a separate HTTP handler (or on the same process) that forwards calls to the gRPC backend.- Single source of truth: one proto defines REST paths, methods, bodies, and query params via
option (google.api.http). - Automatic JSON <-> protobuf translation: camelCase/snake_case, enums as strings, timestamps as RFC3339.
- OpenAPI generation:
protoc-gen-openapiv2produces Swagger specs from the same proto. - Streaming support: server-streaming maps to SSE or chunked JSON responses.
- Custom marshalers: per-content-type (e.g., protobuf over HTTP for Envoy ext_authz).
- Header/metadata mapping: propagate HTTP headers to gRPC metadata and vice versa.
- vs Connect-Go (
connectrpc.com/connect): Connect is a newer approach — one server speaks gRPC, gRPC-Web, and Connect protocol over plain HTTP; grpc-gateway is a translation layer. - vs Twirp (Twitch): Twirp is a protobuf-based RPC framework that uses plain HTTP+JSON or HTTP+protobuf; simpler than gRPC but lacks streaming.
- vs writing REST handlers manually: manual handlers drift from the proto schema over time; the gateway stays in sync automatically.
- vs Envoy gRPC-JSON transcoder: Envoy does the same translation at the proxy layer instead of in a Go process — good for polyglot backends.
- vs gRPC-Web: gRPC-Web lets browsers speak gRPC directly (binary); grpc-gateway exposes idiomatic REST+JSON for broader interop.
curl. It saves you from maintaining parallel implementations and guarantees the two APIs never drift. It's especially popular for public product APIs that need to support both gRPC clients and JavaScript/Python clients that don't want to install gRPC runtimes.- Setup complexity: requires
protoc, the Go plugin, the grpc-gateway plugin, and the openapiv2 plugin — abuf.gen.yamlconfig is strongly recommended. - Error mapping: gRPC status codes must map to HTTP status codes — the defaults are usually right but edge cases need custom mappers.
- Large binary fields:
bytestypes in proto become base64 strings in JSON, which is bulky. - Streaming over REST is awkward: server-streaming becomes chunked JSON; client-streaming is not really supported.
- Two ports to open if you serve gRPC and gateway separately — or use
cmuxto multiplex on one port.
Sometimes you need both gRPC and REST on the same server. The grpc-gateway project generates a reverse proxy that translates RESTful JSON API calls into gRPC calls. This lets you serve gRPC clients (internal microservices) and REST clients (web browsers, mobile apps) from the same backend.
How It Works
You annotate your .proto file with HTTP rules, then protoc generates a REST proxy that forwards requests to your gRPC server.
import "google/api/annotations.proto";
service Calculate {
rpc Add (AddRequest) returns (AddResponse) {
option (google.api.http) = {
post: "/v1/add"
body: "*"
};
}
}
Running gRPC + REST Together
package main
import (
"context"
"log"
"net"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "myapp/proto/gen"
)
func main() {
// 1. Start gRPC server on port 50051
go func() {
lis, _ := net.Listen("tcp", ":50051")
grpcServer := grpc.NewServer()
pb.RegisterCalculateServer(grpcServer, &Server{})
log.Println("gRPC server on :50051")
grpcServer.Serve(lis)
}()
// 2. Start REST gateway on port 8080
ctx := context.Background()
mux := runtime.NewServeMux()
// Connect the gateway to the gRPC server
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
err := pb.RegisterCalculateHandlerFromEndpoint(ctx, mux, "localhost:50051", opts)
if err != nil {
log.Fatal(err)
}
log.Println("REST gateway on :8080")
http.ListenAndServe(":8080", mux)
// Now both work:
// gRPC: client.Add(ctx, &AddRequest{A: 10, B: 20})
// REST: POST http://localhost:8080/v1/add {"a": 10, "b": 20}
}
REST Client -> JSON/HTTP -> grpc-gateway (reverse proxy) -> Protobuf/HTTP2 -> gRPC Server. The gateway handles JSON <-> protobuf conversion automatically. Your gRPC server code stays the same.
The concept of serving both gRPC and REST from one codebase exists in Node too, but with different tools:
// Node.js alternatives to grpc-gateway:
// 1. grpc-web — browser-compatible gRPC (Envoy proxy translates)
// npm install @improbable-eng/grpc-web
// Browser → Envoy (HTTP/1.1→HTTP/2 proxy) → gRPC Server
// 2. Connect-Web (connectrpc.com) — modern, browser-native gRPC
// Works directly from browser without Envoy proxy
// Server speaks gRPC + Connect + gRPC-Web all on one port
// 3. Manual REST wrapper (most common in Node):
const express = require('express');
const app = express();
// REST endpoint that calls the gRPC service internally
app.post('/v1/add', async (req, res) => {
const { a, b } = req.body;
// Call gRPC service
grpcClient.Add({ a, b }, (err, response) => {
if (err) return res.status(500).json({ error: err.message });
res.json(response); // {sum: 30}
});
});
// 4. NestJS with gRPC — framework that supports both natively:
// @GrpcMethod('Calculate', 'Add')
// add(data: AddRequest): AddResponse {
// return { sum: data.a + data.b };
// }
Key difference: Go's grpc-gateway generates the REST proxy from the proto file — zero manual wiring. In Node, you typically write the REST wrapper manually or use a framework like NestJS that supports both. Go's approach guarantees REST and gRPC stay in sync; Node's manual approach can drift over time.
28 Go vs Node.js
- Concurrency model: Go uses goroutines (M:N scheduled, pre-emptive) so CPU-heavy work scales across all cores automatically. Node uses a single-threaded event loop — CPU-bound tasks block everything. Node
worker_threadsand clustering help but are not transparent. - Type system: Go is statically typed with generics (1.18+). JavaScript is dynamically typed; TypeScript adds compile-time types but still compiles to untyped JS at runtime.
- Performance: Go is typically 2–5× faster than Node on CPU-bound work and uses ~3–10× less memory under comparable load.
- Startup time: Go: <10 ms. Node: 100–500 ms (plus JIT warm-up). Matters for serverless and CLI tools.
- Deployment: Go compiles to a single static binary. Node needs
node_modules(often 500 MB+) plus the Node runtime. - Ecosystem: npm is the largest package registry in the world. Go's ecosystem is smaller but more curated, with fewer transitive dependencies.
- Error handling: Go returns
(value, error)pairs explicitly. Node usestry/catch, Promise rejections, and callback errors — easier to miss. - Tooling: Go ships
go fmt,go test,go vet,go build,pprof. Node uses a patchwork: eslint, prettier, jest, webpack, ts-node, nodemon, etc.
gorilla/websocket is also excellent), SSR frameworks (Next.js, Remix), GraphQL services with Apollo's mature tooling, and I/O-bound services where the event loop is a natural fit.- Treating "Node is fast" as universal: Node is fast for non-blocking I/O; it stalls hard on CPU-bound work.
- Assuming Go has "no packages": its standard library is so rich you rarely need third-party code for stdlib-level tasks.
- Mixing async and sync in Node: one forgotten
awaitand behavior changes silently. Go's explicit error returns catch mistakes at compile time. - Hiring pool: JavaScript devs are easier to hire than Go devs — an organizational factor, not technical.
- Memory footprint: a Go service at 30 MB resident is routine; the equivalent Node service often sits at 150+ MB.
Both Go and Node.js are popular for building web servers, but they take fundamentally different approaches. Go compiles to a single binary with built-in concurrency. Node.js runs on V8 with an event loop and requires external packages for most functionality. Let's compare them head-to-head.
HTTP Server Comparison
Go — net/http (standard library)
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from Go!")
})
http.ListenAndServe(":8080", nil)
}
// Build: go build -o server . (single binary, ~6MB)
// Deploy: copy binary to server, run it. Done.
Node.js — Native http module
const http = require('http');
http.createServer((req, res) => {
res.end('Hello from Node!');
}).listen(8080);
// Requires: Node.js runtime installed
// No routing, no middleware — need Express/Fastify
Node.js — Express (most popular framework)
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello from Express!');
});
app.listen(8080);
// Requires: npm install express + node_modules (~30MB)
Key Differences
| Aspect | Go (net/http) | Node.js (Express/Fastify) |
|---|---|---|
| Concurrency | Goroutines — lightweight threads managed by Go runtime. Thousands of concurrent connections with minimal memory | Single-threaded event loop. CPU-heavy tasks block everything. Use clustering for multi-core |
| Deployment | Single static binary. No runtime needed. Copy and run. | Requires Node.js runtime, node_modules, package.json |
| Routing | Built into stdlib (Go 1.22+). Method routing, path params, wildcards. | Need Express/Fastify/Koa. Native http has nothing. |
| Type Safety | Static typing, compile-time checks | Dynamic typing (unless using TypeScript) |
| Memory | ~10MB for a typical server | ~50-100MB for Express with middleware |
| CPU-bound Tasks | Excellent — goroutines spread across all CPU cores | Poor — blocks event loop. Need worker threads or child processes |
| Error Handling | Explicit if err != nil checks | try/catch, callbacks, .catch() chains |
| Ecosystem | Smaller but high-quality. Stdlib covers most needs. | Massive npm ecosystem. Package for everything (and its problems) |
Concurrency: Goroutines vs Event Loop
This is the fundamental difference. Go creates a new goroutine for each request automatically. Node.js processes all requests on a single thread with callbacks.
// Go: Each request runs in its own goroutine automatically
// 10,000 concurrent requests = 10,000 goroutines (~20KB each = ~200MB)
// CPU-heavy work in one goroutine doesn't block others
http.HandleFunc("/heavy", func(w http.ResponseWriter, r *http.Request) {
// This runs in its own goroutine — doesn't block other requests
result := heavyComputation()
fmt.Fprintln(w, result)
})
// Node.js: Single thread — heavy computation blocks EVERYONE
app.get('/heavy', (req, res) => {
// This blocks the entire event loop — all other requests wait
const result = heavyComputation();
res.send(result);
});
// Fix: Use clustering to run on multiple cores
const cluster = require('cluster');
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork(); // One process per CPU core
}
} else {
app.listen(8080); // Each process is still single-threaded
}
- Choose Go when: building microservices, CLI tools, systems programming, high-concurrency APIs, CPU-intensive backends, or when you want simple deployment (single binary)
- Choose Node.js when: building full-stack JavaScript apps, real-time apps with Socket.io, rapid prototyping, or when your team already knows JavaScript and you need the npm ecosystem