The silent failure in production
Your deployment pipeline stalls at the final step. The orchestrator keeps killing your container because it never sees a successful response from the health probe. Traffic never reaches your service, and the logs show nothing but repeated connection timeouts. The application is actually running, but the load balancer assumes it is broken and routes requests elsewhere. You are left staring at a healthy process that the infrastructure refuses to trust.
What a health check actually does
A health check endpoint is a machine-to-machine handshake. It does not measure whether your code is elegant or whether your database schema is normalized. It answers a single binary question for external systems: can I send you work right now. Load balancers, Kubernetes controllers, and monitoring agents poll this URL on a fixed interval. A 200 status code means the process is alive and ready. Any other code tells the infrastructure to stop sending traffic or restart the container.
The distinction between liveness and readiness matters in production. A liveness probe checks if the process is stuck in a deadlock or consuming too much memory. A readiness probe checks if dependencies like databases or message queues are connected and if the application has finished loading its configuration. Go does not enforce this split at the language level. You implement it by returning different status codes or exposing separate paths. The infrastructure reads the HTTP status line and acts accordingly.
Health checks are infrastructure plumbing. Keep them fast, keep them honest, and never let them block the main request loop.
The minimal implementation
Here is the simplest possible health check. It registers a handler on the default multiplexer and returns a plain text response.
package main
import (
"log"
"net/http"
)
func main() {
// Attach the anonymous function to the /health path
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
// 200 tells the load balancer the process is alive
w.WriteHeader(http.StatusOK)
// Plain text body is enough for basic probes
w.Write([]byte("OK"))
})
// nil uses the default ServeMux that HandleFunc modifies
log.Fatal(http.ListenAndServe(":8080", nil))
}
How the standard library routes the request
When the program starts, http.HandleFunc attaches the anonymous function to the /health path on http.DefaultServeMux. The nil argument to ListenAndServe tells the server to use that same default multiplexer. When a request arrives, the standard library matches the URL path, calls your function, and passes the response writer and request objects.
w.WriteHeader(http.StatusOK) sends the HTTP status line and headers. If you skip this call and write to the body first, Go automatically sends a 200 status code. Explicitly calling it makes the intent clear and prevents accidental header mismatches. The w.Write call sends the response body and flushes it to the network. The load balancer receives the 200, marks the instance as healthy, and begins routing traffic.
The http.HandleFunc function is a convenience wrapper. It converts your function signature into an http.Handler interface. Go follows the convention of accepting interfaces and returning structs. The standard library defines http.Handler with a single ServeHTTP method. By wrapping your function, the library spares you from writing a boilerplate struct type just to satisfy the interface.
Trust the standard library routing. Let DefaultServeMux handle path matching unless you need custom middleware.
Production-grade dependency checking
Production services rarely run in isolation. They depend on databases, caches, or external APIs. A health check that only verifies the Go process is running will return 200 even when the database is down, causing your application to fail silently on every request. A realistic handler verifies critical dependencies and returns structured data.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
)
// HealthResponse holds the status of each dependency
type HealthResponse struct {
Status string `json:"status"`
Components map[string]string `json:"components"`
}
// healthHandler returns a handler that checks external dependencies
func healthHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Limit probe duration to prevent slow dependencies from blocking
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
components := make(map[string]string)
overallStatus := "healthy"
// Ping the database with a short timeout
if err := db.PingContext(ctx); err != nil {
components["database"] = "unhealthy"
overallStatus = "degraded"
} else {
components["database"] = "healthy"
}
resp := HealthResponse{
Status: overallStatus,
Components: components,
}
// Set content type before writing the body
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
}
The handler accepts a database connection and returns an http.HandlerFunc. This pattern keeps the handler testable and decouples it from global state. The context.WithTimeout call ensures the probe never hangs indefinitely. If the database is slow to respond, the context cancels after two seconds, and db.PingContext returns an error instead of blocking the load balancer.
The response structure uses JSON because monitoring tools and orchestrators expect machine-readable payloads. The encoding/json package marshals the struct using the struct tags. Setting Content-Type before WriteHeader or Write is required. If you write the body first, Go locks the headers and you get a http: Headers already written panic. The handler always returns 200 here, but marks the overall status as degraded. Some orchestrators treat anything other than 200 as a failure, so returning 200 with a degraded status lets the service stay online while alerting systems trigger notifications.
Convention matters here. The receiver name for methods should be one or two letters matching the type. Handlers are usually functions, but if you wrap them in a struct, name the receiver h or hs. Public names start with a capital letter. Private start lowercase. The HealthResponse struct is exported so external packages can unmarshal it, while the handler function remains unexported unless you need to share it across packages.
Do not leak dependency state into the probe. Check connections, do not run migrations.
Why context matters for probes
Load balancers and Kubernetes probes often cancel requests if they take too long. If your handler ignores r.Context() and continues running a long query, the goroutine stays alive after the client disconnects. That goroutine leak accumulates over time and eventually exhausts memory. Always pass the request context to blocking calls and respect cancellation.
The context.Context type always goes as the first parameter in Go functions, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. When ctx.Done() returns a closed channel, your code should stop working and return an error. The db.PingContext call does this automatically. If you write your own check, select on ctx.Done() alongside your blocking operation.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Health check handlers run in their own goroutine per request. If you spawn additional goroutines inside the handler, you must track them or use a context to stop them. The standard library does not clean up leaked goroutines for you.
Context is plumbing. Run it through every long-lived call site.
Common pitfalls and compiler feedback
Health checks become problematic when they block or leak resources. A common mistake is running a full database migration or a heavy cache warm-up inside the probe handler. The load balancer times out, marks the instance unhealthy, and removes it from the pool. Keep probes fast and idempotent. They should read state, not modify it.
Forgetting to import a package triggers a straightforward compiler error. If you reference json without importing encoding/json, the compiler rejects the program with undefined: json. If you import a package but never use it, Go stops compilation with imported and not used. The language enforces clean imports to prevent dead code from bloating binaries. Run gofmt on save. It fixes indentation and import grouping automatically. Most editors run it in the background.
Another pitfall is ignoring the request context. Load balancers and Kubernetes probes often cancel requests if they take too long. If your handler ignores r.Context() and continues running a long query, the goroutine stays alive after the client disconnects. That goroutine leak accumulates over time and eventually exhausts memory. Always pass the request context to blocking calls and respect cancellation.
The community convention for error handling applies here too. Check every dependency call immediately. The verbose if err != nil pattern makes the failure path visible. Do not swallow errors or return early without updating the status map. A health check that silently fails is worse than one that returns 503. If you need to discard a return value intentionally, use _. The underscore tells the compiler you considered the value and chose to drop it. Use it sparingly with errors.
The worst goroutine bug is the one that never logs. Add a timeout to every probe and fail fast.
Choosing the right probe strategy
Use a bare 200 response when your service has no external dependencies and only needs to prove the process is running. Use a dependency-aware JSON endpoint when orchestrators need to know which subsystems are failing. Use separate liveness and readiness paths when your application takes time to initialize caches or compile templates. Use a 503 status code when the service is alive but cannot safely handle traffic yet. Use a dedicated external probe service when your health checks require network calls that should not run inside the application process.
Health checks are not business logic. Keep them isolated, keep them fast, and let the infrastructure do its job.