Echo #
Echo is a Go web framework that positions itself between developer productivity and performance — more opinionated than the standard net/http but closer to Go idioms than Fiber. Echo is built on net/http, so it’s fully compatible with the existing Go middleware and library ecosystem. Echo’s main strengths lie in three areas: a flexible binding and validation system, strong custom context support for extending functionality without global state, and mature HTTP/2 and WebSocket support. This article covers all of Echo’s main features, from installation to production-ready code organization patterns.
Installation #
go get github.com/labstack/echo/v4
A minimal server:
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
})
e.Logger.Fatal(e.Start(":8080"))
}
Echo usesnet/httpas its transport layer, so all middleware written forhttp.Handlercan be used viaecho.WrapMiddleware(). This is Echo’s advantage over Fiber, which isn’t compatible with thenet/httpecosystem.
How Requests Work in Echo #
Echo introduces the echo.Context concept, which wraps the standard http.Request and http.ResponseWriter while adding expressive methods. Understanding the request flow is important before writing middleware.
flowchart TD
A([HTTP Request]) --> B["net/http Server"]
B --> C["echo.Context created<br/>(wraps Request + ResponseWriter)"]
C --> D["Router — radix tree matching"]
D --> E{Route found?}
E -- No --> F["HTTPErrorHandler<br/>404 Not Found"]
E -- Yes --> G["Middleware Chain<br/>(Pre + Group + Route level)"]
G --> H["Main Handler"]
H --> I["Response written<br/>to the ResponseWriter"]
F --> I
I --> J([Response to the Client])Input Parsing Methods on the Context #
To read request input, Echo provides several parser methods on echo.Context:
| Parsing Method | Data Source | Format / Result Type | Type Handling | Main Use Cases |
|---|---|---|---|---|
c.Param("id") | URL Path Parameter (e.g. /users/:id) | string | Manual conversion | Getting identity parameters from the URL |
c.QueryParam("q") | Query String parameter (e.g. ?q=golang) | string | Manual conversion | Filters, search, and pagination |
c.FormValue("name") | URL-encoded / Multipart Form Body | string | Manual conversion | Standard HTML form input |
c.Bind(&struct) | JSON, XML, Form, Query | error | Automatic via struct tags | JSON API payloads or complex requests |
Echo uses a radix tree for routing, providing O(log n) lookups even with thousands of routes. Unlike Gin, which uses httprouter, Echo builds its own tree with more flexible parameter support.
Configuration and Initialization #
Echo can be customized quite deeply at initialization:
e := echo.New()
// Hide the startup banner in production
e.HideBanner = true
e.HidePort = true
// Custom logger
e.Logger.SetLevel(log.INFO)
// Custom global HTTP error handler
e.HTTPErrorHandler = func(err error, c echo.Context) {
code := http.StatusInternalServerError
msg := "an internal error occurred"
var he *echo.HTTPError
if errors.As(err, &he) {
code = he.Code
if m, ok := he.Message.(string); ok {
msg = m
}
}
// Don't leak error details in production
if os.Getenv("APP_ENV") != "production" {
msg = err.Error()
}
c.JSON(code, map[string]interface{}{
"success": false,
"error": msg,
"path": c.Request().URL.Path,
})
}
Routing #
Echo’s routing uses a clean, readable method-per-HTTP-verb convention.
e.GET("/users", listUsers)
e.POST("/users", createUser)
e.PUT("/users/:id", updateUser)
e.PATCH("/users/:id", patchUser)
e.DELETE("/users/:id", deleteUser)
// All methods
e.Any("/webhook", handleWebhook)
// Custom methods (e.g. for WebDAV)
e.Add("PROPFIND", "/dav/*", davHandler)
Route Parameters #
Echo supports three types of path parameters:
// Named parameter — required
e.GET("/users/:id", func(c echo.Context) error {
id := c.Param("id")
return c.JSON(http.StatusOK, map[string]string{"id": id})
})
// Wildcard — captures all segments after /files/
e.GET("/files/*", func(c echo.Context) error {
path := c.Param("*")
return c.JSON(http.StatusOK, map[string]string{"path": path})
})
// Multiple parameters
e.GET("/orgs/:orgID/repos/:repoID", func(c echo.Context) error {
orgID := c.Param("orgID")
repoID := c.Param("repoID")
return c.JSON(http.StatusOK, map[string]string{
"org": orgID,
"repo": repoID,
})
})
Query Strings #
// GET /search?q=golang&page=2&limit=20
e.GET("/search", func(c echo.Context) error {
q := c.QueryParam("q")
page := c.QueryParam("page")
limit := c.QueryParam("limit")
// With a default value
if page == "" {
page = "1"
}
return c.JSON(http.StatusOK, map[string]string{
"q": q, "page": page, "limit": limit,
})
})
Route Groups #
// Group with a prefix
api := e.Group("/api")
// v1 — public
v1 := api.Group("/v1")
v1.GET("/status", statusHandler)
// v2 — requires authentication
v2 := api.Group("/v2", authMiddleware)
{
users := v2.Group("/users")
users.GET("", listUsers)
users.POST("", createUser)
users.GET("/:id", getUserByID)
users.PUT("/:id", updateUser)
users.DELETE("/:id", deleteUser)
// Nested group with additional middleware
admin := v2.Group("/admin", adminOnlyMiddleware)
admin.GET("/metrics", metricsHandler)
admin.GET("/logs", logsHandler)
}
graph TD
A["/api"] --> B["/v1\n(public)"]
A --> C["/v2\n+ authMiddleware"]
B --> D["GET /status"]
C --> E["/users"]
C --> F["/admin\n+ adminOnly"]
E --> G["GET /"]
E --> H["POST /"]
E --> I["GET /:id"]
E --> J["PUT /:id"]
E --> K["DELETE /:id"]
F --> L["GET /metrics"]
F --> M["GET /logs"]Middleware #
Echo supports three middleware registration levels: global (all routes), group (a set of routes), and route (one specific route). This gives a granularity of control that other frameworks don’t have built-in.
Middleware Registration Levels #
// 1. Global — applies to all routes
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// 2. Group — applies to all routes in the group
adminGroup := e.Group("/admin", adminAuth)
// 3. Route — applies to a single route
e.GET("/sensitive", handler, rateLimiter, auditLog)
graph LR
subgraph "Global Middleware"
A["Logger"] --> B["Recover"]
end
subgraph "Routes"
B --> C["GET /health\n(no extra middleware)"]
B --> D["GET /admin/...\n+ adminAuth"]
B --> E["GET /sensitive\n+ rateLimiter + auditLog"]
endEcho’s Built-in Middleware #
Echo includes high-quality middleware in the middleware subpackage:
import "github.com/labstack/echo/v4/middleware"
// Structured logger
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: `{"time":"${time_rfc3339}","method":"${method}","uri":"${uri}","status":${status},"latency":"${latency_human}"}` + "\n",
}))
// Panic recovery
e.Use(middleware.Recover())
// CORS
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"https://app.example.com"},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
}))
// Rate limiter
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20)))
// Gzip compression
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Level: 5,
}))
// Request ID
e.Use(middleware.RequestID())
// JWT
e.Use(middleware.JWTWithConfig(middleware.JWTConfig{
SigningKey: []byte(os.Getenv("JWT_SECRET")),
}))
// Secure headers (XSS, HSTS, etc.)
e.Use(middleware.Secure())
Custom Middleware #
func AuditMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
// Before the handler
requestID := c.Response().Header().Get(echo.HeaderXRequestID)
err := next(c) // call the next handler
// After the handler completes
log.Printf("audit | id=%s method=%s path=%s status=%d latency=%v",
requestID,
c.Request().Method,
c.Request().URL.Path,
c.Response().Status,
time.Since(start),
)
return err
}
}
Middleware Execution Order #
sequenceDiagram
participant Client
participant Logger as LoggerMiddleware
participant Auth as AuthMiddleware
participant H as Handler
Client->>Logger: Request enters
Logger->>Logger: Record the start time
Logger->>Auth: next(c)
Auth->>Auth: Validate JWT
alt Invalid token
Auth-->>Logger: return HTTPError 401
Logger->>Logger: Record status 401
Logger-->>Client: 401 Unauthorized
else Valid token
Auth->>Auth: Set user in the context
Auth->>H: next(c)
H->>H: Business logic
H-->>Auth: return nil
Auth-->>Logger: return nil
Logger->>Logger: Record status & duration
Logger-->>Client: 200 Response
endBinding and Validation #
Echo has the most flexible binding system among these three frameworks. A single Bind() method handles all data sources, and validation can be integrated directly into the binding lifecycle.
Basic Binding #
type CreateUserRequest struct {
Name string `json:"name" form:"name" query:"name" validate:"required,min=2,max=100"`
Email string `json:"email" form:"email" query:"email" validate:"required,email"`
Age int `json:"age" form:"age" query:"age" validate:"required,gte=18"`
}
func createUser(c echo.Context) error {
req := new(CreateUserRequest)
if err := c.Bind(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := c.Validate(req); err != nil {
return err // handled by the HTTPErrorHandler
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"success": true,
"data": req,
})
}
Registering a Global Validator #
Echo doesn’t include a built-in validator implementation — it defines the echo.Validator interface that you must implement:
import "github.com/go-playground/validator/v10"
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
}
return nil
}
// Register once at initialization
func main() {
e := echo.New()
e.Validator = &CustomValidator{validator: validator.New()}
// ...
}
Binding from Specific Sources #
// Only from path params
type UserURI struct {
ID uint `param:"id" validate:"required"`
}
req := new(UserURI)
if err := c.Bind(req); err != nil { ... }
// Only from the query string
type PaginationQuery struct {
Page int `query:"page"`
Limit int `query:"limit"`
}
// Manual query binding with defaults
page, _ := strconv.Atoi(c.QueryParam("page"))
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if page <= 0 { page = 1 }
if limit <= 0 { limit = 20 }
flowchart LR
A["JSON Body"] --> E["c.Bind(&req)"]
B["Form Data"] --> E
C["Query String"] --> E
D["Path Params\n:id"] --> E
E --> F["Go Struct\npopulated"]
F --> G["c.Validate(&req)"]
G --> H{CustomValidator}
H -- Failed --> I["422 HTTPError"]
H -- Passed --> J["Handler Logic"]Custom Contexts #
The custom context is the feature that most distinguishes Echo from Gin and Fiber. Instead of storing data in a string map (c.Set/c.Get), you can extend echo.Context with typed fields and methods — eliminating type assertions entirely.
Defining a Custom Context #
// Custom context definition
type AppContext struct {
echo.Context
UserID int
UserRole string
TraceID string
}
// Middleware that injects the custom context
func AppContextMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
traceID := c.Response().Header().Get(echo.HeaderXRequestID)
cc := &AppContext{
Context: c,
TraceID: traceID,
}
return next(cc)
}
}
// Authentication middleware that fills the context fields
func AuthMiddlewareWithContext(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cc := c.(*AppContext) // type assertion only once here
token := c.Request().Header.Get("Authorization")
userID, role, err := validateToken(token)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
}
cc.UserID = userID
cc.UserRole = role
return next(cc)
}
}
Using the Custom Context in Handlers #
// ANTI-PATTERN: type assertion in every handler
func getProfile(c echo.Context) error {
userID, ok := c.Get("userID").(int) // repeated type assertion
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized)
}
// ...
}
// CORRECT: custom context — no type assertions in handlers
func getProfile(c echo.Context) error {
cc := c.(*AppContext) // one type assertion, the type is guaranteed
// Direct access without casting
userID := cc.UserID
userRole := cc.UserRole
traceID := cc.TraceID
return c.JSON(http.StatusOK, map[string]interface{}{
"userID": userID,
"userRole": userRole,
"traceID": traceID,
})
}
flowchart TD
A["echo.Context\n(built-in)"] -->|"Embedding"| B["AppContext\n+ UserID int\n+ UserRole string\n+ TraceID string"]
C["AppContextMiddleware"] -->|"Wrap c"| B
D["AuthMiddleware"] -->|"Fill UserID & UserRole"| B
B --> E["Handler\ncc := c.(*AppContext)\ncc.UserID — no type assertion"]Responses #
Echo provides expressive, consistent response methods because all handlers return an error.
JSON and Other Formats #
// JSON
return c.JSON(http.StatusOK, user)
// JSON with pretty print (for debugging)
return c.JSONPretty(http.StatusOK, user, " ")
// JSONP (for cross-origin from older browsers)
return c.JSONP(http.StatusOK, "callback", user)
// XML
return c.XML(http.StatusOK, user)
// String
return c.String(http.StatusOK, "Hello, World!")
// HTML
return c.HTML(http.StatusOK, "<h1>Hello</h1>")
// File
return c.File("/path/to/report.pdf")
return c.Attachment("/path/to/report.pdf", "report.pdf")
// Redirect
return c.Redirect(http.StatusMovedPermanently, "https://example.com")
// No content
return c.NoContent(http.StatusNoContent)
Centralized Response Helpers #
// pkg/response/response.go
package response
import (
"net/http"
"github.com/labstack/echo/v4"
)
type Response struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalItems int `json:"total_items"`
TotalPages int `json:"total_pages"`
}
type PaginatedResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data"`
Meta Meta `json:"meta"`
}
func OK(c echo.Context, data interface{}) error {
return c.JSON(http.StatusOK, Response{Success: true, Data: data})
}
func Created(c echo.Context, data interface{}) error {
return c.JSON(http.StatusCreated, Response{Success: true, Data: data})
}
func Paginated(c echo.Context, data interface{}, meta Meta) error {
return c.JSON(http.StatusOK, PaginatedResponse{
Success: true, Data: data, Meta: meta,
})
}
func BadRequest(c echo.Context, msg string) error {
return echo.NewHTTPError(http.StatusBadRequest, msg)
}
func NotFound(c echo.Context, resource string) error {
return echo.NewHTTPError(http.StatusNotFound, resource+" not found")
}
File Uploads #
func uploadFile(c echo.Context) error {
// Single file
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "file not found")
}
// Validate the size
if file.Size > 10*1024*1024 { // 10 MB
return echo.NewHTTPError(http.StatusRequestEntityTooLarge,
"file size exceeds the 10 MB limit")
}
// Validate the type
ext := strings.ToLower(filepath.Ext(file.Filename))
allowed := map[string]bool{".jpg": true, ".png": true, ".pdf": true}
if !allowed[ext] {
return echo.NewHTTPError(http.StatusBadRequest, "file type not allowed")
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
// Save with a unique name
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
dst, err := os.Create(filepath.Join("uploads", filename))
if err != nil {
return err
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]interface{}{
"filename": filename,
"size": file.Size,
})
}
WebSockets #
Echo provides WebSocket support via the golang.org/x/net/websocket package or third-party libraries such as gorilla/websocket.
go get golang.org/x/net/websocket
import "golang.org/x/net/websocket"
func chatHandler(c echo.Context) error {
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
// Get data from the custom context if needed
// cc := c.(*AppContext)
for {
var msg string
if err := websocket.Message.Receive(ws, &msg); err != nil {
break // client disconnected
}
response := fmt.Sprintf("echo: %s", msg)
if err := websocket.Message.Send(ws, response); err != nil {
break
}
}
}).ServeHTTP(c.Response(), c.Request())
return nil
}
e.GET("/ws/chat", chatHandler)
WebSocket with Gorilla (Recommended) #
go get github.com/gorilla/websocket
import "github.com/gorilla/websocket"
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// Validate the origin in production
return r.Header.Get("Origin") == "https://app.example.com"
},
}
func chatHandler(c echo.Context) error {
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer ws.Close()
for {
msgType, msg, err := ws.ReadMessage()
if err != nil {
break
}
if err := ws.WriteMessage(msgType, msg); err != nil {
break
}
}
return nil
}
sequenceDiagram
participant Client
participant Echo as Echo Router
participant WS as WebSocket Handler
Client->>Echo: GET /ws/chat\nUpgrade: websocket
Echo->>WS: Route to the handler
WS->>WS: upgrader.Upgrade()
WS->>Client: 101 Switching Protocols
loop Active connection
Client->>WS: ReadMessage()
WS->>WS: Process the message
WS->>Client: WriteMessage()
end
Client->>WS: Close frame
WS->>WS: ws.Close()HTTP/2 #
Echo natively supports HTTP/2 through TLS. No additional configuration needed — just run the server with HTTPS.
// HTTP/2 is automatically enabled when using StartTLS
e.Logger.Fatal(e.StartTLS(":443", "cert.pem", "key.pem"))
// Or with auto-TLS via Let's Encrypt
e.Logger.Fatal(e.StartAutoTLS(":443"))
// For development with a self-signed certificate
// generate first: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
e.Logger.Fatal(e.StartTLS(":8443", "cert.pem", "key.pem"))
HTTP/2 provides multiplexing benefits (many requests over one TCP connection), header compression, and server push. For APIs serving many small resources in parallel, HTTP/2 can significantly reduce latency compared to HTTP/1.1.
Error Handling #
Echo uses echo.HTTPError as the standard error type carrying a status code and message. All errors returned by handlers are handled by the global HTTPErrorHandler.
// Return errors with the right status code
return echo.NewHTTPError(http.StatusNotFound, "user not found")
return echo.NewHTTPError(http.StatusBadRequest, "invalid email format")
return echo.NewHTTPError(http.StatusForbidden, "access denied")
// Internal errors — don't leak details to the client
func getUserByID(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "ID must be a number")
}
user, err := userService.GetByID(id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "user not found")
}
// Log the error details, but don't send them to the client
c.Logger().Errorf("GetByID failed: %v", err)
return echo.ErrInternalServerError
}
return c.JSON(http.StatusOK, user)
}
flowchart TD
A["Handler returns an error"] --> B{echo.HTTPError?}
B -- Yes --> C["Take Code & Message\nfrom HTTPError"]
B -- No --> D["Code: 500\nMessage: Internal Server Error"]
C --> E["HTTPErrorHandler\nformats the JSON response"]
D --> E
E --> F{APP_ENV == production?}
F -- Yes --> G["Hide error details"]
F -- No --> H["Show error details\nfor debugging"]
G --> I([Response to the Client])
H --> IGraceful Shutdown #
func main() {
e := echo.New()
e.HideBanner = true
// ... set up routes and middleware
// Run the server in a separate goroutine
go func() {
if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed {
e.Logger.Fatal("server error:", err)
}
}()
// Wait for an interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
// Graceful shutdown with a 10-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
e.Logger.Fatal("shutdown error:", err)
}
e.Logger.Info("server stopped.")
}
Recommended Project Structure #
myapp/
├── main.go
├── internal/
│ ├── handler/
│ │ ├── user.go
│ │ └── product.go
│ ├── middleware/
│ │ ├── auth.go
│ │ ├── context.go ← the custom context is defined here
│ │ └── error.go
│ ├── service/
│ │ └── user.go
│ └── repository/
│ └── user.go
├── pkg/
│ ├── response/
│ │ └── response.go
│ └── validator/
│ └── validator.go ← CustomValidator is implemented here
└── router/
└── router.go
Router Setup #
// router/router.go
package router
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"myapp/internal/handler"
mw "myapp/internal/middleware"
"myapp/pkg/validator"
)
func Setup(userHandler *handler.UserHandler) *echo.Echo {
e := echo.New()
e.HideBanner = true
// Global validator
e.Validator = validator.New()
// Custom error handler
e.HTTPErrorHandler = mw.ErrorHandler
// Global middleware
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: `{"time":"${time_rfc3339}","id":"${id}","method":"${method}","uri":"${uri}","status":${status}}` + "\n",
}))
e.Use(mw.AppContextMiddleware) // inject the custom context
// Health check
e.GET("/health", func(c echo.Context) error {
return c.JSON(200, map[string]string{"status": "ok"})
})
// API routes
api := e.Group("/api/v1")
api.Use(mw.AuthMiddlewareWithContext)
{
users := api.Group("/users")
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.GET("/:id", userHandler.GetByID)
users.PUT("/:id", userHandler.Update)
users.DELETE("/:id", userHandler.Delete)
}
return e
}
Echo vs Gin vs Fiber #
Having covered all three, here are the main differences to help you choose:
| Aspect | Echo | Gin | Fiber |
|---|---|---|---|
| Transport | net/http | net/http | Fasthttp |
| Performance | High | High | Highest |
| net/http compatibility | ✓ Full | ✓ Full | ✗ Partial (adapter) |
| Custom contexts | ✓ Built-in | ✗ (uses c.Set/Get) | ✗ (uses c.Locals) |
| Built-in validator | Interface only | ✓ via binding tags | ✗ (manual) |
| WebSocket | ✓ Mature | Limited | ✓ via package |
| HTTP/2 | ✓ Native | ✗ | ✗ |
| Middleware levels | Global/Group/Route | Global/Group | Global/Group/Route |
When Not to Use Echo #
Keep using Echo if:
✓ You need a type-safe custom context without boilerplate
✓ HTTP/2 is a requirement (APIs serving many parallel resources)
✓ You need full compatibility with the net/http ecosystem
✓ The team wants a framework close to Go idioms
Consider Gin if:
✗ The team is already familiar with Gin and doesn't need custom contexts
✗ Binding with integrated validation (binding tags) is a higher priority
Consider Fiber if:
✗ Raw performance is the absolute priority
✗ The team's background is Express.js / Node.js
Consider the standard net/http if:
✗ The application is very simple and doesn't need framework abstractions
Summary #
- Custom contexts — Echo’s main advantage; extend
echo.Contextwith typed fields to eliminate type assertions in every handler.- Universal
c.Bind()— one method handles JSON, XML, forms, query strings, and path params based on the Content-Type and struct tags.- Validation via an interface — implement
echo.Validatorwithgo-playground/validator; register it once one.Validatorand callc.Validate()in handlers.- Three middleware levels — global (
e.Use), group (g.Use), and per-route (e.GET("/path", handler, mw1, mw2)); use these for granular control.echo.NewHTTPError— return errors with the right status code; let the globalHTTPErrorHandlerformat them consistently.- Native HTTP/2 — automatically enabled when using
StartTLSorStartAutoTLS; no additional configuration needed.- net/http compatible — all
http.Handlermiddleware can be used viaecho.WrapMiddleware(); unlike Fiber, which needs a special adapter.- Graceful shutdown — use
e.Shutdown(ctx)with a timed-out context so in-flight requests finish before the server stops.