Framework

HTTP routing

The pkg/http package provides HTTP routing, middleware, request/response handling, and server lifecycle management. It wraps Go's standard net/http with a clean API for building JSON APIs and serving SPAs.

import "github.com/stanza-go/framework/pkg/http"

Router

Create a router and register handlers using Go 1.22+ pattern syntax:

router := http.NewRouter()

router.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
    http.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})

router.HandleFunc("POST /api/users", createUser)
router.HandleFunc("GET /api/users/{id}", getUser)
router.HandleFunc("DELETE /api/users/{id}", deleteUser)

Path parameters

Extract path parameters with http.PathParam:

router.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := http.PathParam(r, "id")
    // ...
})

For integer path parameters, PathParamInt64 parses the value and writes a 400 error if invalid:

router.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id, ok := http.PathParamInt64(w, r, "id")
    if !ok {
        return // 400 response already written
    }
    // id is int64
})

Query parameters

// Simple string parameter
name := http.QueryParam(r, "name")

// With fallback value
sort := http.QueryParamOr(r, "sort", "created_at")

// Integer with fallback
page := http.QueryParamInt(r, "page", 1)
perPage := http.QueryParamInt(r, "per_page", 20)

Reading request bodies

Use BindJSON for the common case — it reads the JSON body and writes a 400 error on failure:

var req struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}
if !http.BindJSON(w, r, &req) {
    return
}

BindJSON returns false if the body is missing, malformed, or exceeds 1 MB. The caller should return immediately — the error response is already written.

For custom error handling or larger payloads, use the lower-level functions:

// ReadJSON — handle the error yourself
if err := http.ReadJSON(r, &input); err != nil {
    http.WriteError(w, http.StatusBadRequest, "invalid JSON")
    return
}

// ReadJSONLimit — allow up to 10MB
if err := http.ReadJSONLimit(r, &input, 10<<20); err != nil {
    http.WriteError(w, http.StatusBadRequest, "invalid JSON")
    return
}

Writing responses

// JSON response with status code
http.WriteJSON(w, http.StatusOK, map[string]any{
    "user": user,
    "total": count,
})

// Created response
http.WriteJSON(w, http.StatusCreated, user)

// Error response — writes {"error": "message"}
http.WriteError(w, http.StatusNotFound, "user not found")
http.WriteError(w, http.StatusUnauthorized, "invalid credentials")

WriteServerError

WriteServerError handles internal server errors by logging the real error and writing a generic 500 response in one call:

func WriteServerError(w ResponseWriter, r *Request, message string, err error)

It does two things:

  1. Logs the error via the request-scoped logger (from RequestLogger middleware), so the log entry automatically includes request_id, path, and other request context.
  2. Writes a 500 JSON response with the generic message — {"error": "message"}.

Use WriteServerError instead of WriteError for internal server errors whenever you have an err variable. It replaces the manual log-then-write pattern:

// Before — two steps, easy to forget the log
l := log.FromContext(r.Context())
l.Error("failed to list users", log.String("error", err.Error()))
http.WriteError(w, http.StatusInternalServerError, "failed to list users")
return

// After — one call, error is always logged
http.WriteServerError(w, r, "failed to list users", err)
return

For non-500 errors (400, 404, 409, etc.), continue using WriteError.


CSV export

Write CSV file responses with automatic Content-Type and Content-Disposition headers:

rows, err := db.Query(sql, args...)
if err != nil {
    http.WriteError(w, http.StatusInternalServerError, "failed to export")
    return
}
defer rows.Close()

http.WriteCSV(w, "users", []string{"ID", "Email", "Name"}, func() []string {
    if !rows.Next() {
        return nil
    }
    var id int64
    var email, name string
    if err := rows.Scan(&id, &email, &name); err != nil {
        return nil
    }
    return []string{sqlite.FormatID(id), email, name}
})

The entity parameter controls the filename: users produces users-20260322.csv. The callback is called repeatedly until it returns nil.


Bulk ID validation

Validate ID slices for bulk operations (bulk delete, bulk update):

var req struct {
    IDs []int64 `json:"ids"`
}
if !http.BindJSON(w, r, &req) {
    return
}
if !http.CheckBulkIDs(w, req.IDs, 100) {
    return // 400 response already written
}

CheckBulkIDs writes a 400 error and returns false if the slice is empty or exceeds the maximum count.


Route groups

Groups share a path prefix and middleware. They can be nested:

api := router.Group("/api")

// Public endpoints
api.HandleFunc("GET /health", healthHandler)

// Protected admin group
admin := api.Group("/admin")
admin.Use(auth.RequireAuth())
admin.Use(auth.RequireScope("admin"))
admin.HandleFunc("GET /dashboard", dashboardHandler)
admin.HandleFunc("GET /users", listUsersHandler)
admin.HandleFunc("POST /users", createUserHandler)

// Protected user group
user := api.Group("/user")
user.Use(auth.RequireAuth())
user.Use(auth.RequireScope("user"))
user.HandleFunc("GET /profile", profileHandler)

Middleware applied to a group runs for all handlers in that group and its sub-groups, after any router-level middleware.


Middleware

Middleware wraps handlers to add behavior. The type signature is:

type Middleware func(Handler) Handler

Apply middleware to the router (global) or to groups:

// Global middleware — runs for every request
router.Use(http.RequestLogger(logger))
router.Use(http.CORS(corsConfig))
router.Use(http.Recovery(onPanic))

// Group middleware — runs for routes in the group
admin.Use(auth.RequireAuth())

The middleware chain should be ordered so that each middleware can access context set by earlier ones:

router.Use(http.RequestID(http.RequestIDConfig{}))        // 1. assign request ID
router.Use(http.RequestLogger(logger))                     // 2. log with request ID
router.Use(http.Compress(http.CompressConfig{}))           // 3. gzip compression
router.Use(http.ETag(http.ETagConfig{}))                   // 4. conditional requests
router.Use(http.SecureHeaders(http.SecureHeadersConfig{})) // 5. security headers
router.Use(http.MaxBody(2 << 20))                          // 6. request body limit (2 MB)
router.Use(http.CORS(corsConfig))                          // 7. CORS
router.Use(http.Recovery(onPanic))                         // 8. panic recovery

Built-in middleware

Request ID — assigns a unique identifier to every request:

router.Use(http.RequestID(http.RequestIDConfig{}))

Generates a UUID v4 per request and sets it as the X-Request-ID response header. If the incoming request already carries X-Request-ID, that value is reused (for distributed tracing with upstream proxies).

Access the ID in handlers:

id := http.GetRequestID(r)

Configuration:

FieldDefaultDescription
HeaderX-Request-IDHeader name to read/write
GeneratorUUID v4Custom ID generator function
router.Use(http.RequestID(http.RequestIDConfig{
    Header:    "X-Trace-ID",
    Generator: func() string { return myCustomID() },
}))

Request logging — logs method, path, status, duration, and response size:

router.Use(http.RequestLogger(logger))

5xx responses are logged at Error level, everything else at Info. When RequestID middleware runs earlier in the chain, the request_id field is automatically included in each log entry.

RequestLogger also stores a request-scoped child logger (with request_id pre-set) in the request context. Handlers retrieve it with log.FromContext so that every log entry from a handler is correlated with the HTTP request:

func myHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        l := log.FromContext(r.Context())
        // l.Error(...) automatically includes request_id
    }
}

Compression — gzip-compresses responses to reduce transfer size:

router.Use(http.Compress(http.CompressConfig{}))

Buffers the response body until it exceeds a minimum size threshold (default 1 KB), then checks the Content-Type to decide whether to compress. Only text-based content types are compressed — binary formats like images, video, and archives are already compressed and gain nothing from gzip.

The client must advertise Accept-Encoding: gzip for compression to activate. When active, the middleware sets Content-Encoding: gzip and Vary: Accept-Encoding, and removes Content-Length since the final size is unknown until gzip finishes.

Uses sync.Pool to reuse gzip writers — zero allocations in steady state.

Configuration:

FieldDefaultDescription
Level6 (default compression)Gzip level 1–9. Higher = smaller output, more CPU
MinSize1024Minimum body size in bytes before compressing
ContentTypesSee belowMIME type prefixes eligible for compression

Default content types compressed:

  • text/* (HTML, CSS, plain text)
  • application/json
  • application/javascript
  • application/xml
  • application/xhtml+xml
  • image/svg+xml

Custom configuration example:

router.Use(http.Compress(http.CompressConfig{
    Level:   gzip.BestSpeed,       // level 1 — fastest
    MinSize: 512,                  // compress responses > 512 bytes
    ContentTypes: []string{        // only compress JSON
        "application/json",
    },
}))

ETag — enables conditional requests with 304 Not Modified:

router.Use(http.ETag(http.ETagConfig{}))

Computes a CRC32 hash of the response body and sets it as the ETag header. When a client sends If-None-Match with a matching ETag, the middleware returns 304 Not Modified with no body, saving bandwidth.

Only applies to GET and HEAD requests with 2xx responses that have a body. Responses that already carry an ETag header (e.g., from net/http's file server) are passed through unchanged.

Configuration:

FieldDefaultDescription
WeakfalseProduce weak ETags (W/"...") instead of strong ETags

Weak ETags indicate semantic equivalence rather than byte-for-byte identity. Use them when responses may vary slightly (e.g., different whitespace) but are logically the same:

router.Use(http.ETag(http.ETagConfig{Weak: true}))

ETag matching follows RFC 7232 §3.2 — weak comparison is used for If-None-Match, so W/"abc" matches "abc". Comma-separated lists and the * wildcard are supported.

Chain position: ETag should be placed after Compress so the hash is computed on uncompressed content. This ensures the ETag remains stable regardless of whether the client accepts gzip.

Security headers — sets common security headers on all responses:

router.Use(http.SecureHeaders(http.SecureHeadersConfig{}))

With zero-value config, it applies safe defaults:

HeaderDefault Value
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
X-XSS-Protection0 (disabled — CSP replaces it)
Permissions-Policycamera=(), microphone=(), geolocation=()

Optional headers enabled via config:

router.Use(http.SecureHeaders(http.SecureHeadersConfig{
    HSTSMaxAge:            63072000,                      // 2 years, HTTPS only
    ContentSecurityPolicy: "default-src 'self'",          // app-specific CSP
    FrameOptions:          "SAMEORIGIN",                  // allow same-origin framing
}))

CORS — handles cross-origin requests and preflight:

router.Use(http.CORS(http.CORSConfig{
    AllowOrigins:     []string{"http://localhost:23706", "http://localhost:23700"},
    AllowCredentials: true,
    MaxAge:           86400,
}))

Default allowed methods: GET, POST, PUT, DELETE, PATCH, OPTIONS. Default allowed headers: Origin, Content-Type, Accept, Authorization.

Recovery — catches panics and returns 500:

router.Use(http.Recovery(func(recovered any, stack []byte) {
    logger.Error("panic", log.Any("error", recovered), log.String("stack", string(stack)))
}))

The callback is optional — pass nil to recover silently.

Request body limit — caps how much data handlers can read:

router.Use(http.MaxBody(2 << 20)) // 2 MB

Wraps request bodies with MaxBytesReader. When a handler reads beyond the limit, the read returns an error and the server closes the connection. This protects JSON and form endpoints from abuse without affecting file uploads.

Multipart requests (Content-Type: multipart/*) are exempt — upload handlers should enforce their own limits directly. This lets you set a tight global limit (e.g., 2 MB) while allowing specific upload endpoints to accept larger payloads (e.g., 50 MB).

When the limit is exceeded, ReadJSON returns ErrBodyTooLarge which is automatically translated to a 400 Bad Request response.

Chain position: Place after SecureHeaders and before CORS. This ensures security headers are always set, even on oversized requests.


Rate limiting

The RateLimit middleware limits requests per key (default: client IP) using a fixed-window counter:

authGroup.Use(http.RateLimit(http.RateLimitConfig{
    Limit:  20,
    Window: time.Minute,
}))

When the limit is exceeded, it returns 429 Too Many Requests with a Retry-After header.

Configuration

FieldDefaultDescription
Limit60Max requests per window
Window1 minuteTime window duration
KeyFuncClient IPFunction to extract the rate limit key
Message"rate limit exceeded"Error message in 429 response

Response headers

Every response includes rate limit headers:

HeaderDescription
X-RateLimit-LimitConfigured limit
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetUnix timestamp when the window expires
Retry-AfterSeconds until retry (only on 429)

Custom key function

Rate limit by API key instead of IP:

router.Use(http.RateLimit(http.RateLimitConfig{
    Limit:   100,
    Window:  time.Minute,
    KeyFunc: func(r *http.Request) string { return r.Header.Get("X-API-Key") },
}))

Client IP extraction

The ClientIP helper (used by default) extracts the real client IP behind proxies:

ip := http.ClientIP(r)  // checks X-Forwarded-For, X-Real-IP, then RemoteAddr

Group-level rate limiting

Apply rate limits to specific route groups (e.g., auth endpoints):

// Rate limit auth endpoints at 20 req/min per IP
authGroup := api.Group("")
authGroup.Use(http.RateLimit(http.RateLimitConfig{
    Limit:  20,
    Window: time.Minute,
}))
authGroup.HandleFunc("POST /auth/login", loginHandler)
authGroup.HandleFunc("POST /auth/register", registerHandler)
authGroup.HandleFunc("POST /auth/forgot-password", forgotHandler)

Pagination

ParsePagination extracts limit and offset query parameters from the request with validation and clamping:

// Parse with default limit 50, max limit 100
pg := http.ParsePagination(r, 50, 100)

// Use with query builder
sql, args := sqlite.Select("id", "name", "email").
    From("users").
    Limit(pg.Limit).
    Offset(pg.Offset).
    Build()

The limit is clamped between 1 and maxLimit. The offset is clamped to non-negative. Invalid or missing values fall back to the defaults.

Paginated response

PaginatedResponse writes a standardized JSON response with items and total count:

http.PaginatedResponse(w, "users", users, total)
// Response: {"users": [...], "total": 42}

The items are written under the given key. For endpoints that need additional fields (e.g., unread counts), use ParsePagination for input and write the response manually with WriteJSON.

Full example

func listHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        pg := http.ParsePagination(r, 50, 100)

        selectQ := sqlite.Select("id", "name", "email").
            From("users").
            WhereNull("deleted_at").
            Limit(pg.Limit).
            Offset(pg.Offset)

        countQ := sqlite.CountFrom(selectQ)

        // ... execute queries, scan rows ...

        http.PaginatedResponse(w, "users", users, total)
    }
}

Sorting

QueryParamSort reads sort and order query parameters and validates them against a whitelist of allowed columns:

col, dir := http.QueryParamSort(r,
    []string{"id", "email", "name", "created_at"},
    "id", "DESC",  // defaults
)

selectQ.OrderBy(col, dir)
  • The sort parameter is matched case-insensitively against the allowed list
  • The order parameter accepts "asc" or "desc" (case-insensitive), normalized to uppercase
  • If sort is missing or not in the allowed list, the default column is used
  • If order is invalid, the default direction is used

This prevents SQL injection by only allowing pre-approved column names.

Combined with pagination

func listHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        pg := http.ParsePagination(r, 50, 100)
        col, dir := http.QueryParamSort(r,
            []string{"id", "email", "name", "created_at"},
            "id", "DESC",
        )

        selectQ := sqlite.Select("id", "name", "email", "created_at").
            From("users").
            WhereNull("deleted_at").
            OrderBy(col, dir).
            Limit(pg.Limit).
            Offset(pg.Offset)

        countQ := sqlite.CountFrom(selectQ)

        // ... execute and respond ...
    }
}

The client requests: GET /api/admin/users?sort=name&order=asc&limit=20&offset=40


Static file serving

Serve embedded SPAs with client-side routing support:

//go:embed ui/dist
var uiFS embed.FS

//go:embed admin/dist
var adminFS embed.FS

router.Handle("GET /admin/{path...}", http.Static(adminFS))
router.Handle("GET /{path...}", http.Static(uiFS))

Static serves files that exist in the filesystem. For paths without an extension that don't match a file, it serves index.html (SPA fallback). Paths with an extension that don't match return 404.


Server

Wrap the router in a server with lifecycle management:

srv := http.NewServer(router,
    http.WithAddr(":23710"),
    http.WithReadTimeout(15 * time.Second),
    http.WithWriteTimeout(15 * time.Second),
    http.WithIdleTimeout(60 * time.Second),
)

// Start serving (non-blocking)
srv.Start(ctx)

// Graceful shutdown
srv.Stop(ctx)

In a Stanza app, the server is wired through the lifecycle:

func provideServer(lc *lifecycle.Lifecycle, router *http.Router) *http.Server {
    srv := http.NewServer(router, http.WithAddr(":23710"))

    lc.Append(lifecycle.Hook{
        OnStart: srv.Start,
        OnStop:  srv.Stop,
    })

    return srv
}

Route introspection

List all registered routes with Routes(). Routes are sorted by path then method:

for _, rt := range router.Routes() {
    fmt.Printf("%-6s %s\n", rt.Method, rt.Path)
}

Each Route has two fields:

FieldTypeDescription
MethodstringHTTP method (GET, POST, etc.) or empty for catch-all patterns
PathstringURL path pattern including parameters (e.g. /users/{id})

Routes registered through groups include the full resolved path:

api := r.Group("/api")
api.HandleFunc("GET /users", listUsers) // Route.Path = "/api/users"

admin := api.Group("/admin")
admin.HandleFunc("GET /roles", listRoles) // Route.Path = "/api/admin/roles"

Exposing routes as an API

Use Routes() to build a route listing endpoint for debugging or documentation:

admin.HandleFunc("GET /routes", func(w http.ResponseWriter, r *http.Request) {
    routes := router.Routes()
    type entry struct {
        Method string `json:"method"`
        Path   string `json:"path"`
    }
    out := make([]entry, 0, len(routes))
    for _, rt := range routes {
        out = append(out, entry{Method: rt.Method, Path: rt.Path})
    }
    http.WriteJSON(w, http.StatusOK, out)
})

Request metrics

Track request counts, status code distribution, and average latency with Metrics:

m := http.NewMetrics()
router.Use(m.Middleware())

// In a handler (e.g. dashboard):
stats := m.Stats()

Add the middleware early in the chain — after RequestID but before RequestLogger — so it captures the full request lifecycle.

Stats() returns a MetricsStats snapshot:

FieldTypeDescription
TotalRequestsint64Cumulative requests processed
ActiveRequestsint64Currently in-flight requests
Status2xxint64Successful responses
Status3xxint64Redirects
Status4xxint64Client errors
Status5xxint64Server errors
BytesWrittenint64Total response bytes
AvgDurationMsfloat64Mean request duration (ms)

All counters are atomic — safe to read from any goroutine without synchronization.


Prometheus metrics

PrometheusHandler returns a handler that renders metrics in Prometheus text exposition format. Pass a collector function that gathers metrics on each scrape:

router.HandleFunc("GET /metrics", http.PrometheusHandler(func() []http.PrometheusMetric {
    dbStats := db.Stats()
    httpStats := metrics.Stats()
    return []http.PrometheusMetric{
        {Name: "myapp_db_reads_total", Help: "Total read queries", Type: "counter", Value: float64(dbStats.TotalReads)},
        {Name: "myapp_db_writes_total", Help: "Total write queries", Type: "counter", Value: float64(dbStats.TotalWrites)},
        {Name: "myapp_http_requests_total", Help: "Total HTTP requests", Type: "counter", Value: float64(httpStats.TotalRequests)},
        {Name: "myapp_http_requests_active", Help: "In-flight requests", Type: "gauge", Value: float64(httpStats.ActiveRequests)},
    }
}))

Each PrometheusMetric has four fields:

FieldTypeDescription
NamestringMetric name (e.g. myapp_http_requests_total)
HelpstringOne-line description
Typestring"counter" (monotonically increasing) or "gauge" (point-in-time)
Valuefloat64Current value

The handler sets Content-Type: text/plain; version=0.0.4 as required by the Prometheus scrape protocol. See the observability recipe for a complete example wiring all framework Stats() into a single /api/metrics endpoint.

RuntimeMetrics

RuntimeMetrics returns standard Go runtime metrics using the same names as the official Go Prometheus client library. This makes them compatible with standard Go Grafana dashboards.

out = append(out, http.RuntimeMetrics()...)
MetricTypeDescription
go_goroutinesgaugeNumber of goroutines that currently exist
go_memstats_alloc_bytesgaugeBytes of allocated heap objects
go_memstats_sys_bytesgaugeTotal bytes obtained from the OS
go_memstats_heap_inuse_bytesgaugeHeap bytes in in-use spans
go_memstats_stack_inuse_bytesgaugeStack bytes in use
go_memstats_heap_objectsgaugeNumber of allocated heap objects
go_gc_completed_totalcounterNumber of completed GC cycles
go_gc_pause_total_secondscounterCumulative GC pause duration

RuntimeMetrics calls runtime.ReadMemStats, which briefly stops the world. This is negligible at typical Prometheus scrape intervals (15–60 seconds).


Status codes

The package re-exports common HTTP status codes as constants:

ConstantValue
StatusOK200
StatusCreated201
StatusNoContent204
StatusMovedPermanently301
StatusFound302
StatusSeeOther303
StatusNotModified304
StatusTemporaryRedirect307
StatusPermanentRedirect308
StatusBadRequest400
StatusUnauthorized401
StatusForbidden403
StatusNotFound404
StatusMethodNotAllowed405
StatusConflict409
StatusGone410
StatusRequestEntityTooLarge413
StatusUnprocessableEntity422
StatusTooManyRequests429
StatusInternalServerError500
StatusBadGateway502
StatusServiceUnavailable503

WebSocket

The pkg/http package includes a zero-dependency RFC 6455 WebSocket implementation for building real-time features.

Upgrading a connection

Use Upgrader to upgrade an HTTP connection to WebSocket:

upgrader := http.Upgrader{}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r)
    if err != nil {
        return // Upgrade writes the error response
    }
    defer conn.Close()

    for {
        msgType, data, err := conn.ReadMessage()
        if err != nil {
            break // Client disconnected or error
        }
        // Echo back
        conn.WriteMessage(msgType, data)
    }
}

The upgrader validates the handshake (method, headers, Sec-WebSocket-Key) and writes the 101 Switching Protocols response. By default, it checks that the Origin header matches the Host header. Non-browser clients that omit Origin are allowed.

Upgrader configuration

FieldDefaultDescription
ReadBufferSize4096Read buffer size in bytes
WriteBufferSize4096Write buffer size in bytes
CheckOriginOrigin == HostFunction to validate the request origin
upgrader := http.Upgrader{
    ReadBufferSize:  8192,
    WriteBufferSize: 8192,
    CheckOrigin: func(r *http.Request) bool {
        return true // Allow all origins
    },
}

Message types

ConstantValueDescription
TextMessage1UTF-8 text data
BinaryMessage2Binary data
// Send a JSON message
data, _ := json.Marshal(map[string]string{"status": "ok"})
conn.WriteMessage(http.TextMessage, data)

// Read a message
msgType, payload, err := conn.ReadMessage()
if msgType == http.TextMessage {
    // Handle text
}

Control frames

Ping/pong frames are handled automatically — incoming pings are replied with pongs. You can also send them explicitly:

conn.WritePing([]byte("heartbeat"))
conn.WritePong([]byte("heartbeat"))

Custom handlers:

conn.SetPingHandler(func(data []byte) error {
    fmt.Println("ping received:", string(data))
    return conn.WritePong(data)
})

Connection settings

// Max incoming message size (default: 16 MB)
conn.SetMaxMessageSize(1 << 20) // 1 MB

// Timeouts
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))

// Peer address
addr := conn.RemoteAddr()

Closing

// Simple close
conn.Close()

// Close with status code and message
conn.CloseWithMessage(http.CloseNormalClosure, "goodbye")
ConstantCodeDescription
CloseNormalClosure1000Normal closure
CloseGoingAway1001Server shutting down
CloseProtocolError1002Protocol error
CloseUnsupportedData1003Unsupported data type
CloseNoStatusReceived1005No close code in frame (not sent by application)
CloseAbnormalClosure1006Connection dropped without close frame (not sent by application)
CloseInvalidPayload1007Invalid UTF-8 in text message
ClosePolicyViolation1008Message violates server policy
CloseMessageTooBig1009Message exceeds size limit

When the peer sends a close frame, ReadMessage returns a *CloseError with the code and text:

_, _, err := conn.ReadMessage()
if err != nil {
    var closeErr *http.CloseError
    if errors.As(err, &closeErr) {
        fmt.Printf("closed with code %d: %s\n", closeErr.Code, closeErr.Text)
    }
}

Middleware compatibility

WebSocket connections work through the middleware stack. Each middleware wrapper (responseRecorder, compressWriter, etagWriter) implements Unwrap() ResponseWriter, allowing the upgrader to find the underlying net/http.Hijacker interface automatically. No special middleware ordering is needed.

Concurrency model

One reader goroutine and one writer goroutine can operate on the same Conn concurrently. All writes (including control frames) are protected by a mutex. A typical pattern:

conn, _ := upgrader.Upgrade(w, r)
defer conn.Close()

done := make(chan struct{})

// Reader goroutine — detects disconnection
go func() {
    defer close(done)
    for {
        _, _, err := conn.ReadMessage()
        if err != nil {
            return
        }
    }
}()

// Writer — sends events until client disconnects
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()

for {
    select {
    case <-done:
        return
    case event := <-events:
        data, _ := json.Marshal(event)
        conn.WriteMessage(http.TextMessage, data)
    case <-ticker.C:
        conn.WritePing(nil)
    }
}

Server-Sent Events (SSE)

SSEWriter provides a simple API for streaming events from server to client using the Server-Sent Events protocol. SSE is simpler than WebSocket when you only need server-to-client push — the browser's EventSource API handles reconnection automatically.

Basic usage

func streamHandler(w http.ResponseWriter, r *http.Request) {
    sse := http.NewSSEWriter(w)

    for {
        select {
        case <-r.Context().Done():
            return
        case msg := <-updates:
            sse.Event("message", msg)
        case <-time.After(30 * time.Second):
            sse.Comment("keepalive")
        }
    }
}

NewSSEWriter sets Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive, then flushes the headers immediately.

Methods

MethodDescription
Event(name, data string)Send a named event. The client receives it via addEventListener(name, ...)
Data(data string)Send an unnamed event. The client receives it via onmessage
Comment(text string)Send a comment (invisible to EventSource). Use as a keep-alive heartbeat
Retry(ms int)Tell the client to wait ms milliseconds before reconnecting

All methods flush automatically. Multiline data is split across multiple data: fields per the SSE specification.

Sending JSON

type Update struct {
    Count int    `json:"count"`
    Label string `json:"label"`
}

data, _ := json.Marshal(Update{Count: 42, Label: "active"})
sse.Event("stats", string(data))

Client-side

const source = new EventSource('/api/stream');

source.addEventListener('stats', (e) => {
    const data = JSON.parse(e.data);
    console.log(data.count, data.label);
});

source.addEventListener('message', (e) => {
    console.log('unnamed event:', e.data);
});

source.onerror = () => {
    // EventSource reconnects automatically using the retry interval
};

Middleware compatibility

SSE works correctly through the full middleware chain:

  • Write timeout: NewSSEWriter automatically clears the server's WriteTimeout deadline (typically 15 seconds) using ResponseController. Without this, SSE connections would be killed after the timeout expires. WebSocket avoids this by hijacking the connection; SSE needs an explicit deadline reset.
  • Compression: The Compress middleware excludes text/event-stream from gzip automatically.
  • ETag / Flush: Both Compress and ETag middleware implement http.Flusher — when SSE calls Flush(), each wrapper propagates it to the underlying ResponseWriter, ensuring events reach the client immediately.

When to use SSE vs WebSocket

SSEWebSocket
DirectionServer → ClientBidirectional
ProtocolHTTPUpgrade to WS
ReconnectionAutomatic (EventSource)Manual
Data formatText (UTF-8)Text or binary
Use casesLogs, notifications, dashboardsChat, interactive features
Previous
Configuration