← Knowledge hub
Tutorialby Uptime Room· September 24, 2026· 6 min read

Go Health Check: Handler, Context and Drain Window

A go health check is code you write yourself, so the deadline, the dependency list and the drain window are yours to get right. Here is the working shape.

A go health check is a handler you write yourself. The standard library ships no health route and Go has no framework convention to inherit, so the response shape, the timeout and the dependency list are all your decisions. That freedom is why so many Go services end up serving a cheerful 200 while their database is unreachable.

What should a go health check actually test?

Three separate claims, and confusing them is the root of most bad implementations. That the process is alive. That its dependencies answer. That it is ready to take traffic right now. Kubernetes gives each claim its own probe, and the probe reference is worth reading before you write any Go.

QuestionProbeCorrect response when Postgres is down
Is the process alive?liveness200, the process is fine
Can it serve a real request?readiness503, take it out of rotation
Has it finished booting?startup503 until the first success

Read the right-hand column twice. Putting a database call in the liveness handler means one slow query restarts every pod at once, which turns a degraded dependency into a full outage. Liveness answers from memory. Readiness is where the dependencies belong.

The handler, and the context nobody passes

The default database ping in Go has no deadline. db.Ping() calls PingContext with a background context, so a connection attempt to a wedged host blocks until the driver gives up, which can be far longer than your probe timeout. Pass a context with a deadline and the check fails fast instead.

Here is the minimum readiness handler, with the deadline derived from the request so a cancelled probe cancels the query.

func readyz(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
		defer cancel()

		if err := db.PingContext(ctx); err != nil {
			http.Error(w, `{"status":"unavailable"}`, http.StatusServiceUnavailable)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status":"ok"}`))
	}
}

DB.PingContext reuses a pooled connection or opens a new one, so it exercises the pool rather than a cached handle. Keep your deadline below the orchestrator's own timeout. If the probe is configured with timeoutSeconds: 1 and your handler waits two seconds, the kubelet gives up first and the body you carefully wrote is never read.

Checking dependencies in parallel

Serial checks add up. Four dependencies at 500 milliseconds each is a two-second handler, which is already past most probe timeouts. Run them concurrently under one shared deadline and collect every result, because a health response that stops at the first failure hides the other three.

type result struct {
	Name string `json:"name"`
	OK   bool   `json:"ok"`
	Err  string `json:"error,omitempty"`
}

func runChecks(ctx context.Context, checks map[string]func(context.Context) error) []result {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	out := make([]result, 0, len(checks))
	var mu sync.Mutex
	var wg sync.WaitGroup

	for name, fn := range checks {
		wg.Add(1)
		go func(name string, fn func(context.Context) error) {
			defer wg.Done()
			res := result{Name: name, OK: true}
			if err := fn(ctx); err != nil {
				res.OK, res.Err = false, err.Error()
			}
			mu.Lock()
			out = append(out, res)
			mu.Unlock()
		}(name, fn)
	}
	wg.Wait()
	return out
}

Loop variables have been per-iteration since Go 1.22, so passing name and fn as arguments is belt and braces on a modern toolchain. It still reads better and it still compiles correctly on older ones. If you would rather cancel the rest on first failure, errgroup does that in four lines, but for a health endpoint the full picture is usually worth more.

One caching note, because a go health check gets called far more often than people expect. Twenty pods probing every five seconds is 240 database round trips a minute that do no user work. Cache the aggregate result for two or three seconds behind a mutex. The probe stays honest and your dependencies stop paying for it.

Readiness, shutdown and the drain window

This is the part Go makes easy and almost everyone skips. http.Server.Shutdown stops accepting new connections and waits for in-flight requests to finish. It does not tell the load balancer anything. For a few seconds after SIGTERM the proxy is still routing to a socket that has stopped accepting, and those requests become 502s.

The fix is to flip readiness first, wait for the routing layer to notice, and only then shut down.

var ready atomic.Bool

func main() {
	ready.Store(true)
	srv := &http.Server{Addr: ":8080", Handler: mux}
	go srv.ListenAndServe()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGTERM, os.Interrupt)
	<-stop

	ready.Store(false)
	time.Sleep(5 * time.Second)

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	_ = srv.Shutdown(ctx)
}

We tested this against a rolling Deployment with a three-second readiness period. The five-second gap was the difference between a clean rollout and a handful of 502s every single time. Make sure terminationGracePeriodSeconds comfortably exceeds the sleep plus the shutdown timeout, or the kubelet kills the process mid-drain.

Do you need a library?

For one or two dependencies, no. The handler above is thirty lines and you can read all of it. Past three or four, a registry stops the copy and paste. Nearly every golang health check library on GitHub is a variation on that one idea.

hellofresh/health-go registers named checks and returns an aggregate status. alexliesenfeld/health mixes synchronous and asynchronous checks with built-in caching. go-sundheit runs checks on a background schedule, so the endpoint only reads a cached verdict. That last shape suits a dependency that is slow but rarely broken.

We prefer the plain handler until the check list outgrows a screen. Every library here is a thin wrapper over what you already wrote. The wrapper earns its place when you want per-check timeouts, caching and a stable JSON shape without maintaining all three yourself. If you use Fiber, its healthcheck middleware already mounts /livez, /readyz and /startupz.

Scratch and distroless images have no curl

A Go binary usually ships in scratch or a distroless base, which means no shell, no curl and no wget. A Dockerfile HEALTHCHECK needs something to execute, so the usual trick is to compile the go health check into the same binary behind a flag.

FROM scratch
COPY --from=build /out/app /app
HEALTHCHECK --interval=10s --timeout=2s --start-period=5s \
  CMD ["/app", "-healthcheck"]
ENTRYPOINT ["/app"]

The flag path makes one request to http://127.0.0.1:8080/readyz and exits 0 or 1. Use the exec form, since there is no shell to parse the string form. On Kubernetes you can skip all of this: an httpGet probe is performed by the kubelet from outside the container, so nothing needs to exist inside the image. The container-level trade-offs are worked through in our guide to container health checks, and the probe timing arithmetic is in the Kubernetes probes walkthrough.

Watching the endpoint from outside the binary

Every check above runs in the same process, or on the same node, as the thing it judges. Consider a wedged runtime, an expired certificate on the ingress, or a DNS record pointing at a retired address. In each case the handler is unreachable or never consulted, and the pod reports itself healthy to an audience of nobody.

An external probe closes that gap because it makes the same request a user makes.

curl -X POST https://uptimeroom.com/api/v1/monitors \
  -H "Authorization: Bearer urm_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "go api",
    "type": "http",
    "target": "https://api.example.com/readyz",
    "intervalSec": 60,
    "regions": ["london", "singapore"],
    "expectedStatus": [200]
  }'

Checks run from six probe regions as of September 2026, and a failure from one region is confirmed against a second before anyone is paged. Teams that filter at the edge can find each region's egress address on the probe IPs page. What belongs in the response body, as opposed to the status code, is covered in our piece on designing a health endpoint.

Common mistakes

Putting the database in the liveness probe. A slow query then restarts every replica simultaneously. Liveness should answer from memory and nothing else.

Calling db.Ping() with no context. Without a deadline the handler outlives the probe timeout, so the orchestrator records a timeout and you never see the error the driver returned.

Returning 200 with a body that says "degraded". Load balancers and kubelets read the status code and discard the body. If the service cannot serve traffic, say 503 and put the detail in the JSON for humans.

Getting a go health check right takes about an hour. You want a liveness route that answers from memory, a readiness route with a deadline and parallel dependency checks, and a drain window before shutdown. Then point something outside the cluster at the public hostname, because none of that code is visible from in there. Two monitors per service fit easily inside the ten that the free tier allows as of September 2026.

FAQ

Does Go have a built-in health check endpoint?

No. The standard library gives you net/http and nothing else, so there is no default route the way Rails ships /up or Spring Boot ships /actuator/health. You register your own handler on a mux. Some frameworks fill the gap: Fiber's healthcheck middleware mounts /livez, /readyz and /startupz for you.

Which Go health check library should I use?

For one or two dependencies, none. Write the handler. Past three, hellofresh/health-go registers named checks and returns an aggregate status, alexliesenfeld/health adds caching and async checks, and go-sundheit runs checks on a background schedule so the endpoint reads a cached verdict. All three wrap the same pattern.

Should a Go health check ping the database?

Readiness should, liveness should not. A database call in the liveness handler means one slow query restarts every replica at once, turning a degraded dependency into a full outage. Use db.PingContext with a deadline of a second or two in the readiness handler, and return 503 when it fails.

How do you add a health check to a Go Docker image?

Scratch and distroless images have no shell, curl or wget, so HEALTHCHECK needs something executable. Compile the check into your own binary behind a flag and call it with the exec form, for example CMD ["/app", "-healthcheck"]. On Kubernetes you can skip this entirely, because httpGet probes run from the kubelet, not inside the container.

What is the difference between liveness and readiness in Go?

Liveness answers whether the process should be restarted and should return from memory with no I/O. Readiness answers whether this instance should receive traffic right now, so it checks dependencies and flips to false during shutdown. An atomic.Bool read by the readiness handler is the usual way to drain before calling Server.Shutdown.

Related reading