Every Go API I ship starts life with the same block of code.
Not the handlers, not the routing. The server setup. Timeouts, signal handling, the shutdown dance. I'd copy it from the last project, change a number or two, and move on. After the fifth or sixth time I stopped pretending it was project code. It's infrastructure, so I extracted it.
It's called graceful. Zero dependencies, about 200 lines of code you can read in one sitting. Here's what it does and why.
The stdlib defaults are a footgun
http.Server ships with every timeout set to zero. Zero doesn't mean "sensible default". It means no timeout at all.
A client that connects and sends its headers one byte at a time will hold that connection open forever. That's slowloris, and it's not exotic — it's the default behavior of the server most Go APIs run on. Same story on the write side: a handler writing to a dead connection can block indefinitely, because nothing bounds it.
Every production server needs five things, and the standard library leaves all five at zero values:
- Real timeouts on reads and writes
- Signal handling for SIGINT/SIGTERM
- Draining in-flight requests instead of dropping them
- Cleanup that runs in a sane order (DB pools, telemetry flush)
- A deadline on shutdown itself, so it can't hang forever
None of this is hard. It's just boilerplate you have to remember, every time, in exactly the right order.
The setup
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := graceful.New(mux,
graceful.WithAddr(":8080"),
graceful.WithOnShutdown(func(ctx context.Context) error {
return db.Close()
}),
)
if err := srv.Run(context.Background()); err != nil {
log.Fatal(err)
}
That's the whole thing. Run blocks until the context is cancelled or a signal arrives, drains in-flight requests, runs your hooks, and returns. ErrServerClosed never surfaces as an error, because it isn't one.
The defaults I landed on after running this shape in production:
| Setting | Default | Why |
|---|---|---|
| ReadHeaderTimeout | 5s | Headers must arrive fast. This is the slowloris guard. |
| ReadTimeout | 10s | Bounds the full request read, body included. |
| WriteTimeout | 30s | Bounds the full response write, handler time included. |
| IdleTimeout | 120s | Keep-alive connections don't linger forever. |
| ShutdownTimeout | 20s | Requests and hooks finish inside this, or not at all. |
Every one of them is overridable with a With... option. WriteTimeout is the one people get wrong most often — if you serve large exports or SSE, raise it first and ask questions later.
Shutdown hooks, in order
When Kubernetes sends SIGTERM, the order matters. Stop accepting new connections first. Let in-flight requests finish. Then close things, in the order you registered them.
Hooks in graceful run sequentially after the listener drains. Each one gets the shutdown context, which is cancelled at the ShutdownTimeout deadline. If a hook fails, it's logged and the rest still run — one misbehaving cleanup shouldn't block closing the database pool.
What it deliberately doesn't do
No router. No middleware chain. No config files, no framework, no opinions about your handlers. It's a server lifecycle wrapper and nothing else, which is exactly why it's 200 lines and has zero dependencies. If you need more than this, you don't need a bigger version of graceful — you need a framework, and that's a different decision.
The full *http.Server is exposed via HTTPServer() if you need ConnState hooks or HTTP/2 tuning, and WithTLS takes a cert and key (passing only one is a config error, caught at startup, not at 3am).
Try it
go get github.com/parsaaftabi/graceful
Repo: https://github.com/parsaaftabi/graceful — issues and PRs welcome. If your timeout defaults differ from mine, I'm genuinely curious what you run and why. That's the part of this setup that never stops being project-specific.
Top comments (0)