A Go server can start successfully and still be unable to handle real requests.
The process may be running while PostgreSQL is down. The API is alive, but it cannot do its main job.
That led me to a simple question:
If the server is running, is the application truly healthy?
Here is what I learned while building a useful health endpoint in Go.
What is a health endpoint?
A health endpoint is a small HTTP endpoint, usually something like:
GET /health
It is mainly used by Docker, Kubernetes, load balancers, and monitoring tools. These systems call the endpoint and decide whether the application should receive traffic.
Health checks are commonly split into three types:
- Liveness: Is the application process running?
- Readiness: Can the application handle traffic right now?
- Startup: Has the application finished starting?
A liveness check should be simple. Restarting the application will not fix a database outage.
The endpoint in this post is closer to a readiness check because it checks PostgreSQL. If the database is unavailable, the application keeps running but reports that it is not ready for traffic.
Decide what the response should look like
I wanted the response to answer three questions:
- Is the whole application healthy?
- Which dependency failed?
- How long did each check take?
That gave me these two structs:
type Response struct {
Status string `json:"status"`
Checks map[string]CheckResult `json:"checks"`
Timestamp time.Time `json:"timestamp"`
}
type CheckResult struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
DurationMS int64 `json:"duration_ms"`
}
func (r Response) Healthy() bool {
return r.Status == "healthy"
}
The JSON tags control the field names in the response. omitempty hides the error field when there is no error. The small Healthy method keeps the handler easy to read.
When PostgreSQL is available, /health returns 200 OK:
{
"status": "healthy",
"checks": {
"postgres": {
"status": "up",
"duration_ms": 2
}
},
"timestamp": "2026-07-20T10:30:00Z"
}
When PostgreSQL is unavailable, it returns 503 Service Unavailable:
{
"status": "unhealthy",
"checks": {
"postgres": {
"status": "down",
"error": "ping postgres: connection refused",
"duration_ms": 8
}
},
"timestamp": "2026-07-20T10:31:00Z"
}
The HTTP status is important. A tool should not need to read the JSON just to learn whether the application is ready.
Give every dependency the same shape
PostgreSQL may not be the only dependency forever. An API might later need Redis, Kafka, or another internal service.
A small interface gives every health check the same shape:
type Checker interface {
Name() string
Check(ctx context.Context) error
}
Each checker provides a name for the response and returns an error when its dependency is unavailable.
The PostgreSQL checker is small:
type PostgresChecker struct {
pool *pgxpool.Pool
}
func NewPostgresChecker(pool *pgxpool.Pool) *PostgresChecker {
return &PostgresChecker{pool: pool}
}
func (c *PostgresChecker) Name() string {
return "postgres"
}
func (c *PostgresChecker) Check(ctx context.Context) error {
if err := c.pool.Ping(ctx); err != nil {
return fmt.Errorf("ping postgres: %w", err)
}
return nil
}
Ping asks the connection pool to contact PostgreSQL. We do not need a large SQL query. A health check should be fast, cheap, and safe to run often.
Wrapping the error with ping postgres adds useful context when something fails.
Run the checks safely
The service has one job: run every checker and combine the results.
type Service struct {
checkers []Checker
}
func NewService(checkers ...Checker) *Service {
return &Service{
checkers: append([]Checker(nil), checkers...),
}
}
The constructor accepts any number of checkers and stores a copy of the list. The service begins each request with a healthy response and an empty map for the individual results:
response := Response{
Status: "healthy",
Checks: make(map[string]CheckResult, len(s.checkers)),
Timestamp: time.Now().UTC(),
}
For each checker, it:
- Creates a two-second timeout.
- Records the start time.
- Runs the check.
- Saves its status, error, and duration.
- Marks the whole response unhealthy if any check fails.
The important part looks like this:
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
started := time.Now()
err := checker.Check(checkCtx)
result := CheckResult{
Status: "up",
DurationMS: time.Since(started).Milliseconds(),
}
if err != nil {
result.Status = "down"
result.Error = err.Error()
}
Without a timeout, one stuck dependency could make /health wait forever.
The timeout is based on the incoming request context. If the caller disconnects, Go can cancel the remaining work instead of continuing a request nobody is waiting for.
Why run checks concurrently?
Imagine three dependencies:
PostgreSQL: 100 ms
Redis: 200 ms
Kafka: 300 ms
Running them one after another takes about 600 ms. Running them together takes about 300 ms, which is close to the slowest check.
The service starts one goroutine for each checker and uses a sync.WaitGroup to wait for all of them:
for _, checker := range s.checkers {
wg.Add(1)
go func(checker Checker) {
defer wg.Done()
// Run the check and build its result.
}(checker)
}
wg.Wait()
The goroutines write to the same response map, so a sync.Mutex protects those writes:
mu.Lock()
response.Checks[checker.Name()] = result
if err != nil {
response.Status = "unhealthy"
}
mu.Unlock()
With only one dependency, concurrency does not make the endpoint faster. Its value appears when more checks are registered.
Turn the result into HTTP
The handler runs the service, selects the status code, and writes JSON:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
response := h.service.Check(r.Context())
statusCode := http.StatusOK
if !response.Healthy() {
statusCode = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(response)
}
Finally, the checker, service, and handler are connected in main.go:
postgresChecker := health.NewPostgresChecker(pool)
healthService := health.NewService(postgresChecker)
mux.Handle("GET /health", health.NewHandler(healthService))
One request now follows a clear path:
GET /health
-> handler
-> health service
-> dependency checkers
-> JSON response
A few practical rules
Health checks should stay lightweight. Avoid large queries, writes, or slow calls to external services.
Health is also not the same as monitoring. A health endpoint answers, "Can this instance receive traffic?" Metrics, logs, and traces explain how the application performs and why failures happen.
Be careful with error messages too. Detailed errors are helpful during development, but a public endpoint should not reveal passwords, connection strings, or other private information.
For a small codebase, all of this can live in one health.go file. Split it later when the file becomes harder to navigate, not simply because it might grow one day.
What I learned
A useful health endpoint is more than a hard-coded {"status":"ok"} response.
It needs clear output, correct HTTP status codes, fast dependency checks, timeouts, and safe handling of multiple checks. It should also know its limit: health tells us whether the application is ready, while monitoring tells us what is happening over time.
It is a small feature, but it makes an API much easier to operate and trust.
Top comments (0)