McElfresh Blog

Go, PostgreSQL, MySQL, Ruby, Rails, Sinatra, etc.

Go HTTP server

Posted at — Jul 21, 2025

Overview

Where a Go HTTP server is started, its listener blocks until a client connects to the host + port the listener is bound to. The client connection is started up in its own goroutine, and runs until it returns either success or error. Because goroutines are very small, lightweight stacks, a Go HTTP server can start thousands or even millions of handlers, each in its own goroutine.

The usual practice with HTTP client - server connections is that connections should be short-lived. Ideally, where the client makes a request, if that request cannot be satisfied within a short timeframe, the connection is timed out, an error is returned to the client, the connection is destroyed, and resources are cleaned up on the server. If the client cancels a request, typically that cancellation is honored on the server, the connection is destroyed, and resources are cleaned up on the server.

The usual practice with a running server is that where a shutdown signal is sent to a server, active connections are given the opportunity, via a timer, to complete, before the server, and all related processes, applications, and services, are shut down gracefully. If the shutdown is part of a deploy, an updated server is started, and it handles all new connections. Existing connections to the existing server are permitted to complete or fail, then the existing server is shut down.

Therefore, at both the level of the server, and of each connection, we want to implement timeouts, a timer, and intentionally shut down the server, along with other applications and / or connections to those applications. For example, if our HTTP server is connected to a database, or other services that use persistent connections, we want to close the connections from our server to the database. If our HTTP server is connected to other HTTP services with short-lived connections, there may be nothing at all we have to do, as it is likely those connections will be cleaned up merely by waiting some short time period, and exiting our HTTP server.

Implementing a Go HTTP Server with Graceful Shutdown

The key points in starting, operating, and gracefully shutting down a Go HTTP server are:

Go’s http Package, Go, and the OS

The key component in a Go HTTP server is its net.Listener. A net.Listener includes a “listening” or “passive” socket whose job is to “block”, ie wait for the OS to push connections onto the OS’s “accept” queue. Where a Go HTTP server is started, its listener’s Accept method is immediately called and, if there is no data connection on the accept queue, the listener blocks, meaning that the OS puts the Go runtime thread that is running the listener into a paused state. Where a data connection is put onto the accept queue, the kernel wakes up the Go runtime thread, which dequeues the data connection, and passes the data connection to a handler, which the Go runtime starts in its own goroutine.

These completed data connections do more than a listening socket does. They typically pass data back and forth between client and server.

EPoll is the Linux syscall interface that manages sockets as described above. Its name is unfortunate; blocking does not work by polling at intervals. Epoll operates via “interrupt-driven I/O”. Where the state of a socket changes, and where this is a state that the kernel has registered interest in, an interrupt (either a hardware interrupt, in the case of a physical network, or a software interrupt, in the case of a virtual network, unix domain socket, or other non-hardware construct) is sent to the kernel, the kernel looks up the sleeping Go runtime thread that is associated with the registered event on that socket, the Go runtime thread wakes up the handler associated with that registered event / socket, and runs it.

At this point the completed connection is in an “ESTABLISHED” state, and the client may begin to send data over the connection. For a simple “Hello, world!” response, which is likely to be immediately available from the handler, the handler will not block, but will instead immediately send data to the client.

But suppose the handler must do more? Suppose the handler must retrieve data from a database, parse that data, and return it to the client? The same series of steps occurs with this data connection socket as occurred with the listener socket. Where the request is sent to the database, but the database does not respond immediately, the handler will block – ie, the OS kernel will park the Go runtime thread that is waiting on the database’s socket. When the database begins to send data over the connection, the kernel will wake up the Go runtime thread that it parked, the Go runtime thread will wake up the goroutine it assigned to this socket, and the goroutine will run the handler, which will parse the data it received from the database, and send that data to the client. When the handler has completed sending that data back to the client, the handler will close the socket, and eventually the memory represented by the completed goroutine that is running the handler will be garbage collected.

Code Overview

In the below code:

  1package server
  2
  3import (
  4	"context"
  5	"errors"
  6	"github.com/charliemcelfresh/charlie-go/internal/model"
  7	"github.com/go-chi/chi/v5"
  8	"github.com/go-chi/chi/v5/middleware"
  9	"net"
 10	"net/http"
 11	_ "net/http/pprof"
 12	"os/signal"
 13	"sync"
 14	"syscall"
 15	"time"
 16)
 17
 18type Tokener interface {
 19	Parse(token string) (Claims, error)
 20}
 21
 22type Logger interface {
 23	Error(msg string, args ...any)
 24	Info(msg string, args ...any)
 25}
 26
 27type Repo interface {
 28	GetItems(ctx context.Context, perPage, page int) ([]model.Item, error)
 29	CreateItem(ctx context.Context, item model.Item) (model.Item, error)
 30	Close() error
 31}
 32
 33type Server interface {
 34	Serve(l net.Listener) error
 35	Shutdown(ctx context.Context) error
 36}
 37
 38type Listener interface {
 39	Close() error
 40	Accept() (net.Conn, error)
 41	Addr() net.Addr
 42}
 43
 44type provider struct {
 45	jwt        Tokener
 46	repository Repo
 47	logger     Logger
 48	server     Server
 49	listener   Listener
 50}
 51
 52func NewProvider(repo Repo, listener Listener, logger Logger, token Tokener) provider {
 53	provider := provider{
 54		jwt:        token,
 55		repository: repo,
 56		logger:     logger,
 57	}
 58
 59	router := chi.NewRouter()
 60	router.Use(middleware.Logger)
 61	router.Use(middleware.Recoverer)
 62	router.Use(provider.AddContentTypeToResponse)
 63	router.Group(
 64		func(r chi.Router) {
 65			r.Use(provider.Authorize)
 66			r.Get("/api/v1/items", provider.GetItems)
 67			r.Post("/api/v1/item", provider.CreateItem)
 68		},
 69	)
 70
 71	provider.listener = listener
 72
 73	httpServer := &http.Server{
 74		ReadHeaderTimeout: 5 * time.Second,
 75		ReadTimeout:       10 * time.Second,
 76		WriteTimeout:      10 * time.Second,
 77		IdleTimeout:       10 * time.Second,
 78		Handler:           router,
 79	}
 80
 81	provider.server = httpServer
 82
 83	return provider
 84}
 85
 86func (p *provider) Serve(parentCtx context.Context) {
 87	// Set up graceful shutdown using os.Signal, via signal.NotifyContext
 88	signalCtx, cleanupSignals := signal.NotifyContext(parentCtx, syscall.SIGINT, syscall.SIGTERM)
 89	// clean up signal handling when function exits
 90	defer cleanupSignals()
 91
 92	errCh := make(chan error, 1)
 93
 94	var wg sync.WaitGroup
 95	wg.Add(1)
 96	go func() {
 97		defer wg.Done()
 98		if err := p.server.Serve(p.listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
 99			p.logger.Error("error starting Server, exiting", "err", err)
100			errCh <- err
101		}
102	}()
103
104	shutdown := func(p *provider) {
105		timeoutCtx, timeoutCancelFunc := context.WithTimeout(context.Background(), 10*time.Second)
106		defer timeoutCancelFunc()
107
108		if err := p.server.Shutdown(timeoutCtx); err != nil {
109			p.logger.Error("httpServer.Shutdown error", "err", err)
110		}
111
112		rErr := p.repository.Close()
113		if rErr != nil {
114			p.logger.Error("error closing p.repository, returning anyway", "rErr", rErr)
115		} else {
116			p.logger.Info("p.repository.Close complete")
117		}
118		p.logger.Info("graceful shutdown complete")
119	}
120
121	select {
122	case <-signalCtx.Done():
123		p.logger.Info("received shutdown signal, exiting", "signalErr", signalCtx.Err())
124		shutdown(p)
125	case err := <-errCh:
126		p.logger.Error("httpServer.Serve encountered error, exiting", "err", err)
127		shutdown(p)
128	}
129	wg.Wait()
130}

Conclusion

The above demonstrates one way to start, operate, and gracefully shut down a Go HTTP server. We could start our server in the main goroutine, and put cancellation into a separate goroutine. We could have returned errors to Serve’s calling function. We could avoid allocations at critical performance points, by avoiding the use of Listener and Repo interfaces. We could think about goroutine pools for our handlers. We could add instrumentation both at the network level, and at the code level, in order to uncover any helpful optimizations.

The point is that the Go http package provides, out of the box, a high performance HTTP server, that adheres to typical HTTP practices, and uses the power of Go and also of the OS.