DEV Community

Cover image for Your Go API Is Running—But Is It Healthy?
Harsh Mangalam
Harsh Mangalam

Posted on

Your Go API Is Running—But Is It Healthy?

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Is the whole application healthy?
  2. Which dependency failed?
  3. 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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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...),
    }
}
Enter fullscreen mode Exit fullscreen mode

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(),
}
Enter fullscreen mode Exit fullscreen mode

For each checker, it:

  1. Creates a two-second timeout.
  2. Records the start time.
  3. Runs the check.
  4. Saves its status, error, and duration.
  5. 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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

One request now follows a clear path:

GET /health
    -> handler
    -> health service
    -> dependency checkers
    -> JSON response
Enter fullscreen mode Exit fullscreen mode

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)