The problem with copy-pasting handlers
You are building an API. You add a /users endpoint. Then /posts. Then /comments. The code works. Suddenly the team needs request logging. You add a log line to three handlers. Then they need API key validation. You copy-paste the check. Now you have duplication. If the validation logic changes, you update three places. One slip and a security hole opens.
Middleware solves this by letting you define behavior once and apply it everywhere. You write a function that wraps your handlers. That function runs before the handler, after the handler, or both. It can modify the request, modify the response, or stop the request entirely. You register the middleware, and every route gets the behavior automatically.
Middleware is a chain of wrappers
Middleware is a function that takes a handler and returns a handler. The returned handler is a wrapper. It runs your custom code, calls the original handler, then runs more code. Think of it like a series of security checkpoints. The request passes through each checkpoint. Each checkpoint can inspect the package, stamp it, or reject it before it reaches the final destination.
Echo builds a chain of these wrappers. When a request arrives, Echo calls the first middleware. That middleware calls the next one. Eventually the chain reaches your route handler. The handler returns a result. The result bubbles back up through the chain. Each middleware gets a chance to act on the result before it reaches the client.
The signature that makes it work
Every Echo middleware follows the same signature:
func(next echo.HandlerFunc) echo.HandlerFunc
The function receives next, which is the rest of the chain. It returns a new echo.HandlerFunc. That returned function is the wrapper. Inside the wrapper, you call next(c) to pass control to the next link. If you never call next(c), the chain stops. The request short-circuits.
This pattern uses closures. The wrapper captures next and keeps it alive. When the wrapper runs, it has access to next even though next was passed as an argument. This is how middleware composes. Each layer wraps the layer below it.
Minimal example: timing a request
Here is the core pattern. A middleware that measures how long the request takes and logs it.
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
// Middleware wraps the next handler.
// It receives the next handler and returns a new handler that adds timing logic.
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
// Call the next handler in the chain.
// This executes the route handler or the next middleware.
err := next(c)
// Log after the handler finishes.
// The duration includes the handler execution time.
c.Logger().Infof("%s %s %v", c.Request().Method, c.Request().RequestURI, time.Since(start))
return err
}
})
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
e.Start(":1323")
}
How the chain executes
When a request hits the server, Echo invokes the middleware registered with e.Use(). The middleware captures the start time. It calls next(c). That call pauses the middleware and runs the rest of the chain. In this example, the rest of the chain is the GET / handler. The handler returns a 200 response. Control returns to the middleware. The middleware calculates the duration and logs it. Finally, the middleware returns the error from next(c). Echo sends the response to the client.
If the handler returns an error, the middleware still runs the code after next(c). The log line executes even on failure. This is useful for auditing. You can log every request regardless of outcome. If you want to skip logging on errors, check err before logging.
Middleware is a chain. Break the chain and the request dies.
Convention: use the context logger
Echo provides c.Logger(). This logger is attached to the context. It automatically includes metadata like the request ID, IP address, and timestamp. The community standard is to use c.Logger() instead of fmt.Println or a global logger. Using the context logger ensures logs are correlated with the request.
If you use fmt.Println, the output goes to stdout but loses request context. Debugging becomes harder because you cannot trace a log line back to a specific request. Stick to c.Logger(). It follows the Go convention of passing context through the call stack.
Realistic pattern: request IDs and short-circuiting
A common requirement is generating a unique request ID. This ID helps trace requests across services. Another requirement is authentication. If the request lacks credentials, you want to stop it immediately.
Here is a middleware that adds a request ID. It stores the ID in the context so downstream handlers can access it.
// GenerateRequestID adds a unique ID to the context and response headers.
// It ensures every request can be traced across logs and services.
func GenerateRequestID(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Generate a simple ID based on time.
// In production, use a UUID library for collision resistance.
id := time.Now().UnixNano()
// Set header so the client can echo it back in retries.
c.Response().Header().Set("X-Request-ID", fmt.Sprintf("%d", id))
// Store in context data store for handlers to read.
c.Set("requestID", id)
return next(c)
}
}
Here is a middleware that short-circuits when authentication fails. It checks for an API key. If the key is missing, it returns an error without calling next(c).
// CheckAuth stops the request if the API key is missing.
// Returning an error here prevents next(c) from running.
func CheckAuth(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
key := c.QueryParam("api_key")
if key == "" {
// Short-circuit: return error immediately.
// The route handler never runs.
return echo.NewHTTPError(http.StatusUnauthorized, "missing api key")
}
return next(c)
}
}
Short-circuiting is powerful. Use it to fail fast. Don't let invalid requests reach your business logic. If the request is malformed or unauthorized, reject it at the middleware layer. This keeps your handlers clean and focused on success cases.
Modifying the response after the handler
Middleware can modify the response after the handler runs. This is useful for adding headers like X-Response-Time or wrapping JSON responses in a standard envelope.
Here is a middleware that adds a response time header. It runs code after next(c) to inspect the response.
// AddResponseTimeHeader adds the duration to the response headers.
// It runs after the handler to measure total processing time.
func AddResponseTimeHeader(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
err := next(c)
// Calculate duration after handler returns.
duration := time.Since(start)
// Add header to the response.
// This runs even if the handler returns an error.
c.Response().Header().Set("X-Response-Time", duration.String())
return err
}
}
The handler writes the body. The middleware adds the header. The order matters. If you modify the response before next(c), the handler might overwrite your changes. If you modify after next(c), your changes persist. Echo buffers the response headers until the handler returns, so adding headers after next(c) is safe.
Pitfalls: the request body trap
The request body is an io.ReadCloser. You can read it once. If middleware reads the body, the handler gets an empty stream. This is a common bug.
If you need to inspect the body in middleware, you must read it, store it, and reset the body for the handler. Use io.NopCloser with a bytes.Buffer.
// ReadBody reads the request body and stores it in context.
// It resets the body so downstream handlers can read it too.
func ReadBody(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Read the body into a buffer.
body, err := io.ReadAll(c.Request().Body)
if err != nil {
return err
}
// Reset the body using NopCloser.
// This allows the handler to read the body again.
c.Request().Body = io.NopCloser(bytes.NewBuffer(body))
// Store body in context for handlers to access.
c.Set("body", string(body))
return next(c)
}
}
If you forget to reset the body, the handler sees EOF. The compiler won't catch this. It's a runtime logic error. The request body is a stream. Read it once, or you lose it.
Another pitfall is forgetting to call next(c). If you return without calling next(c), the request hangs or returns a 404. The compiler rejects this with not enough arguments in call to next if you try to call next without the context. If you return a value that isn't an error, you get cannot use ... as error value in return argument. Always return the error from next(c) unless you are short-circuiting.
Decision: when to use middleware
Middleware is for cross-cutting concerns. It handles behavior that applies across multiple routes. Don't use middleware for business logic specific to one endpoint. That belongs in the handler.
Use global middleware with e.Use() when the logic applies to every route, like logging or request ID generation. Use route-specific middleware by passing it as the second argument to e.GET() when only certain endpoints need the behavior, like authentication for admin routes. Use group middleware with e.Group().Use() when a subset of routes shares a prefix and common requirements, like all /api/v1 routes needing version checks. Use a custom struct with a ServeHTTP method when you need to maintain state across requests, like a rate limiter with a shared counter. Use plain handler logic when the behavior is specific to one endpoint and doesn't warrant extraction; middleware adds indirection that isn't always worth it.