Go is the language of infrastructure: APIs, proxies, workers, and CLIs compiled into a single static binary with no app server, no interpreter, and famously little ceremony. That minimalism cuts both ways for monitoring. There is no Actuator, no built-in health endpoint, no framework supervising your background jobs — net/http hands you primitives and trusts you with the rest. A Go binary also fails in characteristically Go ways: goroutine leaks that build for days, a default http.Server with no timeouts quietly accumulating dead connections, and one panicking goroutine taking down the entire process.

This guide walks through monitoring a Go application the way it actually breaks. We will write a proper /healthz handler, enumerate the Go-specific failure modes worth alerting on, cover background goroutines and scheduled work, and finish with a concrete CronAlert setup you can copy. CronAlert is agentless and runs on Cloudflare's edge, so everything here is plain HTTP checks, content assertions, and heartbeats — nothing to import, no sidecar to deploy.

Write a real /healthz handler — it's twenty lines

Go doesn't ship a health endpoint, and neither do Gin, Echo, or Chi — but the standard library makes a good one short. The key decisions are the same as in our health check endpoints guide: verify the dependencies the app is useless without, bound every check with a timeout so the health endpoint can't hang, and return 200 or 503 without leaking internals.

func healthzHandler(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 {
      // Log the detail; never send it to the caller
      log.Printf("healthz: db ping failed: %v", err)
      http.Error(w, `{"status":"down"}`, http.StatusServiceUnavailable)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"status":"ok"}`))
  }
}

Extend the same shape for Redis, an object store, or a downstream API — anything whose failure makes the app worthless. Two refinements pay for themselves: include runtime.NumGoroutine() in the payload (a cheap leak detector you can watch drift over weeks), and keep liveness separate from readiness if you run on Kubernetes — a dependency-heavy check wired into a liveness probe turns every database blip into a restart loop, as our Kubernetes monitoring guide explains. Point your external monitor at the dependency-aware endpoint; that's the one that tells the truth. The database health endpoint deep-dive covers what a good database check verifies beyond Ping.

The http.Server timeout gotcha

The single most common production landmine in Go services: http.ListenAndServe(addr, handler) uses a default http.Server with no timeouts whatsoever. ReadTimeout, WriteTimeout, and IdleTimeout are all zero, so a slow client — or a stalled one, or a deliberate slowloris — can hold a connection and its goroutine open forever. The failure builds invisibly: connections and file descriptors accumulate, memory grows, and one day the server stops accepting new connections entirely. Always configure the server explicitly:

srv := &http.Server{
  Addr:         ":8080",
  Handler:      mux,
  ReadTimeout:  5 * time.Second,
  WriteTimeout: 10 * time.Second,
  IdleTimeout:  120 * time.Second,
}
log.Fatal(srv.ListenAndServe())

From the outside, the buildup phase is visible as creeping latency long before the hard stop — which is exactly what a response-time threshold on your monitors is for.

What to monitor in a Go app

  • The API root or homepage. Whatever real clients hit first, checked for a 200 and for expected content — not just that something responded.
  • Your /healthz. The dependency-aware signal that catches database, cache, and downstream trouble.
  • Your most important endpoints. The two or three routes your product actually depends on, each with a keyword assertion on the JSON — a field name that only appears in a correct response. See monitoring API endpoints for assertion patterns.
  • gRPC gateways, if you expose them. A JSON/REST gateway in front of gRPC services deserves its own check; our gRPC monitoring guide covers the health-checking protocol side.
  • HTTPS and the SSL certificate. An expired certificate takes down every client no matter how healthy the binary is; see SSL certificate monitoring.

The HTTP and SSL checks here fit comfortably inside CronAlert's free plan — 25 monitors at a 3-minute interval. Pro ($5/mo) tightens the interval to 1 minute and unlocks the keyword and content assertions used below.

Go-specific failure modes

Generic uptime advice misses how Go binaries actually die. Design your checks around these:

  • Goroutine leaks. A goroutine blocked forever on a channel or mutex is never collected. Leak a few per request and memory climbs for days until the OOM killer arrives. The observable signature is a slow ramp — rising latency, then death — so a response-time threshold is your early-warning system, and runtime.NumGoroutine() in the health payload confirms the diagnosis.
  • Panics in background goroutines. net/http recovers panics in request handlers, but a panic in any goroutine you started crashes the whole process. One nil-map write in a worker takes the API down with it. If your process manager restarts it, you get a crash loop that looks "up" from a distance — flapping monitors are the tell.
  • Silently dead worker loops. The inverse problem: recover panics in a worker loop and a subtle bug can make the loop exit or deadlock without killing anything. The HTTP server stays green while emails, syncs, and exports quietly stop. Nothing in the request path will ever notice; only a heartbeat will.
  • database/sql pool exhaustion. Unclosed Rows, long transactions, or the default unlimited MaxOpenConns saturating the database itself. Queries block, latency spikes, the process stays alive. Cap the pool with SetMaxOpenConns, recycle with SetConnMaxLifetime, and let your health check's short database timeout surface the stall.
  • Missing client timeouts. http.Client also defaults to no timeout, so one hung downstream call can pin goroutines indefinitely — the upstream cousin of the server-timeout gotcha. Set Client.Timeout or use per-request contexts everywhere.
  • Container CPU throttling. Go sizes GOMAXPROCS from the node's CPU count, not the container's quota — a pod limited to 1 CPU on a 32-core node gets throttled hard, and tail latency explodes. Set GOMAXPROCS to match the quota (or use runtime/debug.SetMemoryLimit and automaxprocs-style tooling) and watch response times.
  • Deploy-time half-starts. A binary that starts but fails its first dependency connection can sit in a retry loop, port open, serving errors. A dependency-aware /healthz refuses to report healthy; a bare TCP check happily passes.

The recurring lesson across every framework we've covered: status codes lie. You need dependency-aware checks, content assertions, and response-time thresholds to see the truth — and in Go's case, the response-time threshold does extra duty, because nearly every failure above degrades before it kills.

Monitoring background goroutines and scheduled work

Go apps rarely use an external cron. The idiom is a time.Ticker loop, a robfig/cron schedule, or a worker pool draining a queue — all running as goroutines inside the same binary, all invisible to HTTP checks. When one stops, no request fails; work just stops happening, and you find out from a customer.

The fix is heartbeat monitoring: the worker reaches out instead of the monitor reaching in. Each cycle that completes successfully makes a quick request to a unique CronAlert heartbeat URL; you configure the expected interval plus a grace period, and if the ping stops arriving, CronAlert alerts you that the pipeline went silent — silence is the alarm.

func runNightlyExport(ctx context.Context) {
  if err := exportInvoices(ctx); err != nil {
    log.Printf("export failed: %v", err)
    return // no ping on failure — silence triggers the alert
  }

  // Ping CronAlert only after verified success
  req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
    "https://cronalert.com/api/heartbeat/TOKEN", nil)
  if resp, err := http.DefaultClient.Do(req); err == nil {
    resp.Body.Close()
  }
}

One heartbeat per critical job — nightly billing, hourly sync, the queue consumer's periodic "I'm alive" tick — and every failure mode above (dead loop, deadlock, crash-looping process, panic-killed scheduler) collapses into the same observable symptom: the ping stops. Our guides to cron job heartbeat monitoring and background worker monitoring cover the patterns in depth. Heartbeat monitors are available on every paid CronAlert plan, from $5/mo.

Catching "up but wrong" with content checks

A Go handler can return a perfect 200 wrapping the wrong answer: an empty slice because a swallowed error hid the real failure, a JSON error payload your middleware forgot to give a status code, or a proxy in front of the binary serving a cached error page. Assert on the response body: require a string that only a correct response contains — a known field name, an expected enum value, a version marker. Regex matching (Pro) handles dynamic payloads, and for endpoints that should be byte-stable, a SHA-256 content-hash check alerts the instant output drifts. See the keyword monitoring guide for building robust assertions.

A concrete CronAlert setup

Here is an end-to-end setup you can replicate in a few minutes after you create a free CronAlert account:

  • Ship a /healthz. Twenty lines with context timeouts, as above. Keep details out of the response body.
  • Create an HTTP monitor for it. Point CronAlert at https://api.yourapp.com/healthz, expect 200, set the interval (3 minutes free, 1 minute on Pro).
  • Set explicit server timeouts. Not a monitoring step, but the config change that makes the rest of your monitoring honest.
  • Monitor key endpoints with JSON keyword assertions (Pro). Require a field name only present in a correct response.
  • Add a heartbeat per background loop (Pro). Ticker jobs, cron schedules, and queue consumers ping their heartbeat URL only after verified success.
  • Turn on SSL and response-time checks. Certificate monitoring on the domain; a response-time threshold on /healthz and key routes — your goroutine-leak and pool-exhaustion early warning. Our timeout thresholds guide covers picking numbers.
  • Wire up alert channels. Email, Slack, Discord, Teams, Telegram, PagerDuty, Opsgenie, Splunk On-Call, webhooks, or PWA push.

Every plan includes the REST API (read-only on free, full read-write from Pro), so you can manage monitors from a Makefile target or CI pipeline — and CronAlert ships an MCP server so Claude Code, Cursor, or Windsurf can manage monitors for you. Teams needing multi-region coverage can move to the Team plan, which checks from five edge regions with quorum so a single regional blip never pages you.

Frequently asked questions

Does Go have a built-in health check endpoint?

No — net/http gives you primitives, and Gin, Echo, and Chi don't add one. Write a /healthz handler that pings dependencies with context timeouts and returns 200 or 503 without leaking detail; it's about twenty lines.

Why does my Go server hang or stop accepting connections?

Most often because http.Server has no default timeouts, so stalled clients hold goroutines and file descriptors forever until the server chokes. Set ReadTimeout, WriteTimeout, and IdleTimeout explicitly, and monitor response time to catch the buildup phase.

Why did my Go background goroutine silently stop?

Either a panic crashed the process (panics in goroutines you start are fatal), or a recovered worker loop exited or deadlocked without killing anything — leaving the HTTP server green while scheduled work stopped. Heartbeats catch both: the worker pings after each successful cycle, and silence triggers the alert.

How do I detect goroutine leaks in production?

Watch the ramp from outside: a response-time threshold fires as GC pressure grows, hours before the OOM. Confirm with runtime.NumGoroutine() in your health payload or net/http/pprof goroutine profiles diffed over time.

What causes database/sql connection pool exhaustion in Go?

Unclosed Rows, long transactions, or the default unlimited MaxOpenConns overwhelming the database. Cap the pool, set SetConnMaxLifetime, and rely on your health check's short database timeout plus response-time thresholds to surface the stall early.

Start monitoring your Go app

Go binaries fail quietly: goroutine leaks that ramp for days, worker loops that die without a sound, connection pools that stall while the process stays alive, and a default server config that accumulates dead connections until it stops answering. A hand-rolled /healthz plus keyword assertions on key routes, heartbeats on background goroutines, and SSL and response-time checks give you a signal you can trust — the HTTP and SSL checks start on CronAlert's free plan, and keyword assertions plus heartbeats come with Pro at $5/mo. Create a free CronAlert account and point your first check at /healthz in the next five minutes.

Related reading: HTTP health check endpoints, building a database health endpoint, gRPC endpoint monitoring, and our companion guides for Django, Rails, Laravel, FastAPI, Express/Node.js, and Spring Boot applications.