When the standard library feels too heavy
You are building a service that needs to handle thousands of requests per second, or you just want a router that feels familiar if you have used Express.js in the past. The standard library net/http is robust and zero-dependency, but it requires more boilerplate for middleware, request binding, and structured responses. Fiber fills that gap. It provides a fluent API, built-in middleware, and automatic JSON handling while maintaining high performance. Fiber is built on top of Fasthttp, which trades some standard library compatibility for raw speed by reusing memory and avoiding allocations.
Fiber is a middleware engine
Fiber structures your application around a context object, fiber.Ctx. Think of the context as a ticket that travels through a series of checkpoints. Each checkpoint is a middleware or a handler. The ticket carries the request data, the response buffer, route parameters, and any custom values you attach. Middleware runs before the handler. It can inspect the request, modify the context, or short-circuit the flow by returning an error. The handler processes the business logic and writes the response. If the handler returns an error, Fiber catches it and sends a generic 500 response unless you handle it yourself.
This design keeps handlers focused on logic. You don't parse JSON in every route. You don't check authentication in every function. You register middleware once, and it applies to all routes or a specific group. The context object is pooled. Fiber reuses the underlying memory for each request to reduce garbage collection pressure. This makes Fiber fast, but it also means you must never store a reference to c outside the handler. The context is valid only for the duration of the request.
Minimal example
Here is the simplest Fiber app: define the app, add a route, start listening. The handler receives the context and returns an error. If the error is nil, Fiber sends the response.
package main
import "github.com/gofiber/fiber/v2"
// main initializes the Fiber app and starts the HTTP server.
func main() {
// New creates the app instance with default configuration.
app := fiber.New()
// Get registers a handler for GET requests to the root path.
// The handler function must match the signature func(*fiber.Ctx) error.
app.Get("/", func(c *fiber.Ctx) error {
// SendString writes the response body and sets content-type to text/plain.
// Returning nil signals success to the framework.
return c.SendString("Hello from Fiber")
})
// Listen starts the server on port 3000.
// It blocks the main goroutine until the process is killed.
app.Listen(":3000")
}
Run the code with go run main.go. Open http://localhost:3000 in a browser or use curl. You will see the text response. The server stays alive, waiting for connections.
How the context flows
The fiber.Ctx object is the core of every request. It holds the request, the response, and utility methods. When you call app.Get("/users", handler), Fiber matches the incoming URL to the route. If it matches, Fiber calls your handler and passes the context. Inside the handler, you can read headers with c.Get("Authorization"), extract query parameters with c.Query("page"), or grab route parameters with c.Params("id").
You can attach custom data to the context using c.Locals(). Middleware often uses this to pass values to handlers. For example, an auth middleware might verify a token and store the user ID in the context. The handler then reads the user ID without repeating the verification logic.
// authMiddleware checks the token and stores the user ID in the context.
func authMiddleware(c *fiber.Ctx) error {
token := c.Get("Authorization")
// In a real app, validate the token against a database or JWT library.
if token != "valid-token" {
// Status sets the HTTP status code.
// JSON sends a structured error response.
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Invalid token",
})
}
// Locals stores arbitrary key-value pairs in the context.
// The value is available to downstream handlers.
c.Locals("userID", 123)
// Next passes control to the next middleware or handler.
return c.Next()
}
Fiber handlers return errors. This is a deliberate design choice. If a handler panics, Fiber recovers and returns a 500 response. If you return an error, Fiber sends the error message as the response body. You can customize this behavior with the ErrorHandler config in fiber.New(). The convention is to return errors for exceptional cases and use c.Status() with c.JSON() for expected error responses like 404 or 400.
Goroutines are cheap. Channels are not magic. Fiber is non-blocking, but if you spawn a goroutine inside a handler, you are responsible for its lifecycle. The handler returns when the response is sent. If the goroutine is still running, it continues in the background. Ensure background tasks have a cancellation path. Context is plumbing. Run it through every long-lived call site. Fiber provides c.Context() which returns a context.Context that respects request cancellation. Pass this context to database calls or external API requests so they stop when the client disconnects.
Realistic API structure
A production API needs structure. Define models, parse JSON, handle errors, and return consistent responses. Fiber's BodyParser automatically unmarshals JSON into structs. It uses the json tags to map fields. If the payload is malformed, BodyParser returns an error.
Here is a user management API with a model, middleware, and routes. The code is split into setup and route definitions for clarity.
package main
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
)
// User represents the data structure for the API.
// JSON tags map struct fields to the payload keys.
type User struct {
Name string `json:"name"`
Email string `json:"email"`
}
// main sets up the app with middleware and routes.
func main() {
app := fiber.New()
// Logger middleware prints request details to stdout.
// It runs on every request unless scoped to a group.
app.Use(logger.New())
// CORS middleware allows cross-origin requests from browsers.
// New() uses permissive defaults for development.
app.Use(cors.New())
// setupRoutes attaches handlers to the Fiber app.
setupRoutes(app)
app.Listen(":3000")
}
// setupRoutes defines the API endpoints.
func setupRoutes(app *fiber.App) {
// Get returns a static list of users.
app.Get("/users", func(c *fiber.Ctx) error {
users := []User{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
}
// JSON marshals the slice and sets content-type to application/json.
// It returns an error if marshaling fails.
return c.JSON(users)
})
// Post creates a user from the request body.
app.Post("/users", func(c *fiber.Ctx) error {
var user User
// BodyParser unmarshals JSON into the struct.
// It returns an error if the payload is malformed or missing.
if err := c.BodyParser(&user); err != nil {
// Status sets the HTTP status code before sending the response.
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid input",
})
}
// In a real app, you would save user to a database here.
// Return 201 Created for successful resource creation.
return c.Status(fiber.StatusCreated).JSON(user)
})
// Get with parameter extracts the ID from the URL.
app.Get("/users/:id", func(c *fiber.Ctx) error {
// Params retrieves route parameters by name.
id := c.Params("id")
// fiber.Map is a map[string]any for dynamic responses.
return c.JSON(fiber.Map{
"id": id,
"message": "User details would be fetched here",
})
})
}
Test the endpoints with curl. The logger middleware prints details to the terminal.
# Get all users
curl http://localhost:3000/users
# Create a user
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie", "email": "charlie@example.com"}'
# Get user by ID
curl http://localhost:3000/users/123
The BodyParser reads the request body once. If you call c.Body() manually before BodyParser, the parser might fail because the body is already consumed. Use BodyParser for standard JSON binding. For custom formats, read the body with c.Body() and unmarshal manually.
Structure your routes. Keep handlers thin. Delegate logic to service functions. The handler should parse input, call the service, and return the response. This makes testing easier and keeps the router file clean.
Pitfalls and conventions
Fiber is fast, but it requires attention to detail. The compiler catches type errors, but runtime issues like goroutine leaks or blocking handlers require discipline.
If you forget to return an error from a handler, the compiler rejects the program with missing return at end of function. Fiber handlers must always return an error, even if it is nil.
If you pass the wrong type to c.JSON, the compiler complains with cannot use ... as ... in argument. c.JSON expects a value that can be marshaled to JSON. Structs, slices, maps, and primitives work. Channels and functions do not.
Blocking the handler is a common mistake. Fiber uses a connection pool. If you call time.Sleep or perform a long synchronous operation in a handler, you tie up a connection. Other requests queue up. If the queue fills, Fiber rejects new connections. Offload long work to a goroutine or a worker pool. Pass c.Context() to the goroutine so it can be cancelled if the request times out.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. The worst goroutine bug is the one that never logs. Attach context to background tasks and check for cancellation.
Fiber's fiber.Map is convenient for dynamic responses, but it loses type safety. Prefer structs for responses when the shape is known. Structs provide compile-time checks and better documentation.
Convention aside: gofmt is mandatory. Run gofmt on save. Fiber code follows standard Go formatting rules. Don't argue about indentation; let the tool decide. Most editors run it automatically.
Convention aside: Receiver naming. If you define methods on a struct, use a short name matching the type. (u *User) Validate() is correct. (this *User) or (self *User) is not idiomatic Go.
Convention aside: Public and private names. Exported names start with a capital letter. Unexported names start lowercase. User is public. user is private. Fiber uses this convention for configuration and types.
Decision matrix
Use Fiber when you prioritize raw request throughput and want an Express-like API with middleware support. Use the standard library net/http when you need zero external dependencies, strict control over connection handling, or are building a library that others will embed. Use a standard-library-compatible router like Chi when you want middleware and routing features but must interoperate with packages that expect http.Handler. Use a framework with built-in validation and ORM integration when you are building a monolithic backend and want to minimize boilerplate.