Go No Dependencies: zero dependency http server
Go 1.22 brought enhancements to the net/http
package’s router. Enabling method matching and wildcard patterns for HTTP routes. The standard library now provides a robust router that can handle most use cases, reducing the need for third-party routing libraries.
Zero dependencies or minimal dependencies are a common requirement for many projects. In this post we will explore how we can remove one more dependency from our Go projects by implementing a zero dependency HTTP server.
Prior to Go 1.22 #
Before Go 1.22, the standard library’s net/http
package did not provide a router. Developers often reached for third-party libraries such as gorilla/mux
or chi
to handle routing in their applications. These libraries provide additional features such as middleware, sub-routers, and more. For example, with chi you can define routes with method verbs mux.Get("/users", handleUsers)
.
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func handleUser(w http.ResponseWriter, r *http.Request) {
// Handler implementation
}
func createUser(w http.ResponseWriter, r *http.Request) {
// Handler implementation
}
func main() {
r := chi.NewRouter()
r.Get("/users/{id}", handleUser)
r.Post("/users", createUser)
http.ListenAndServe(":8080", r)
}
Go 1.22 introduced HTTP method verbs in routes for the standard library HTTP package e.g. http.HandleFunc("GET /users", handleUsers)
.
A key difference between the standard library and third-party libraries is the lack of middleware support. Middleware is a common pattern in web applications to add additional functionality to HTTP handlers. For example, logging, authentication, and error handling. Third party libraries such as gorilla/mux
and chi
provide middleware support out of the box.
Building a Zero Dependency HTTP Server #
To build our zero dependency HTTP server we will use the standard library’s net/http
package. We will create a simple HTTP server that listens on port 8080
and responds with a Hello, World!
message.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"time"
)
func main() {
// Create new mux for routing
mux := http.NewServeMux()
// Add routes
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("Hello, World!")
})
// Create the HTTP server
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start server in a goroutine to allow for graceful shutdown
go func() {
log.Printf("server starting on :8080")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// Wait for interrupt signal to gracefully shut down the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
// Create a deadline for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("server forced to shutdown:", err)
}
}
In the above code snippet we have defined a simple HTTP server that listens on port 8080
. So far it looks very similar to our previous third party solution using Chi. The key difference is that we are now using the standard library’s http.ServeMux
to handle routing.
The server implementation includes graceful shutdown handling, which is important for production services. When the server receives an interrupt signal (Ctrl+C), it:
- Stops accepting new requests
- Allows in-flight requests to complete
- Times out after 10 seconds if requests haven’t completed
This ensures that requests are handled properly even when the server is shutting down, rather than abruptly terminating connections.
Adding Routes #
To add routes to our server we can use the http.ServeMux
’s HandleFunc
method. For example, to add a route that responds with a Hello, World!
message:
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("Hello, World!")
})
In our example we created a route that responds to GET /
requests with a Hello, World!
message. The HandleFunc
method takes a string that defines the HTTP method and path. The string is split by a space to separate the method and path. The second argument is a function that takes an http.ResponseWriter
and http.Request
.
The router also supports path parameters. For example, we can capture dynamic values from the URL:
type User struct {
ID string `json:"id"`
}
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
// Extract the id parameter from the URL
userID := r.PathValue("id")
// Create a user object with the ID from the URL
user := User{
ID: userID,
}
// Set response headers and encode JSON response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
})
In the above example, we’re using Go 1.22’s new path parameter syntax {id}
to capture a variable part of the URL. When a request is made to /users/123
, the PathValue()
method makes it easy to extract the id
value without any string manipulation or regular expressions. This is a significant improvement over the old router where you’d need to parse URL segments manually or rely on third-party libraries.
The PathValue()
method returns an empty string if the parameter isn’t found, making it safe to use without additional error checking for parameter existence. This gives us a clean way to handle dynamic routes while keeping our handler code simple and readable.
For more complex routing patterns, you can use wildcard patterns to match multiple segments in a URL. For example, to match all requests to /static/...
:
// Example wildcard pattern
mux.HandleFunc("GET /static/...", func(w http.ResponseWriter, r *http.Request) {
// Handle static files
})
The ...
wildcard at the end of a pattern matches the remainder of the URL path, allowing you to create flexible routing patterns. For example, /static/...
will match any path under /static/
, making it perfect for serving static files or handling nested routes. Wildcard patterns can only appear at the end of a pattern.
Middleware #
Middleware is a common pattern in web applications to add functionality to HTTP handlers. Common use cases for middleware include logging, authentication, and error handling among others. In the absence of a third-party router, we will need to implement our own set of helper functions to provide middleware functionality.
// Middleware is a function that wraps an http.HandlerFunc
type Middleware func(http.HandlerFunc) http.HandlerFunc
// WithLogging logs the request method and path along with the time taken
func WithLogging(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next(w, r)
log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
}
}
// WithRecovery catches panics in request handling
func WithRecovery(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next(w, r)
}
}
In this snippet we define a Middleware
type that wraps an http.HandlerFunc
. We have also defined two common middlewares WithLogging
and WithRecovery
. WithLogging
logs the request method and path along with the time taken to process the request. WithRecovery
catches panics in the request handling and returns an Internal Server Error
response.
Using the middleware is simple, we just need to wrap our handler functions with the middleware we want to apply:
mux.HandleFunc("GET /", WithLogging(WithRecovery(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("Hello, World!")
})))
This demonstrates how you can achieve the common middleware pattern without having to depend on external third-party router libraries. The middleware functions are simple to test and compose, allowing us to add cross-cutting concerns to our handlers while keeping the core request handling logic clean.
A common helper function for chaining middleware is:
func Chain(h http.HandlerFunc, m ...Middleware) http.HandlerFunc {
for i := len(m) - 1; i >= 0; i-- {
h = m[i](h)
}
return h
}
This function takes a handler function and a list of middleware functions and chains them together. This allows you to apply multiple middleware functions to a single handler function.
// Usage example
mux.HandleFunc("GET /users/{id}", Chain(
handleUser,
WithLogging,
WithRecovery,
))
When defining a chain of middleware functions, the order in which they are applied matters. Middleware functions are applied in the order they are passed to the Chain
function. For example, in our usage example above, logging will be performed before recovery.
Conclusion #
Go 1.22’s enhanced HTTP router brings many features we previously relied on third-party libraries for directly into the standard library. With method-based routing, path parameters, and wildcard support, we can now build robust HTTP servers with zero external dependencies.
While libraries like Chi and Gorilla Mux offer additional features, the standard library’s router is now capable of handling many common use cases. This means:
- Fewer dependencies to manage
- No version compatibility concerns
- Smaller binary sizes
- Code that’s more likely to remain stable over time
For simple to medium complexity HTTP servers, the standard library solution demonstrated in this post might be all you need. However, if your application requires more advanced routing features like subrouters, regular expression matching, or extensive middleware chains, then a third-party router might still be the better choice.
Consider starting with the standard library approach and only reaching for external dependencies when you have specific requirements that aren’t met by net/http
.