DEV Community

Cover image for The http.Server Setup I Use in Every Go Project — Now Open Source
Parsa Aftabi
Parsa Aftabi

Posted on

The http.Server Setup I Use in Every Go Project — Now Open Source

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:

  1. Real timeouts on reads and writes
  2. Signal handling for SIGINT/SIGTERM
  3. Draining in-flight requests instead of dropping them
  4. Cleanup that runs in a sane order (DB pools, telemetry flush)
  5. 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)
}
Enter fullscreen mode Exit fullscreen mode

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

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)