Fiber Interview Questions and Answers

Last updated:

Check out 35 of the most common Fiber interview questions, then take an AI-powered practice interview

GoExpress-inspiredPerformanceWebSocketMiddleware
35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

Why does Fiber build on fasthttp instead of net/http, and what do you give up for that choice?

BasicFundamentals

Answer

Fiber wraps valyala/fasthttp, a from-scratch HTTP server that optimises for the case most JSON APIs actually hit: many small requests, short-lived, with a handful of headers. fasthttp keeps a sync.Pool of RequestCtx objects, parses headers lazily into byte slices rather than allocating a map[string][]string per request, and reuses read and write buffers. The result is fewer allocations per request and less GC pressure, which is where Fiber's benchmark numbers come from. Three real costs follow.

First, Fiber handlers are not http.Handler, so the entire net/http middleware ecosystem (chi middleware, gorilla handlers, otelhttp, net/http/pprof) does not plug in directly; you need the adaptor package to bridge in either direction. Second, fasthttp does not implement HTTP/2 or HTTP/3. You cannot serve gRPC on the same port, and you depend on an ALB, nginx or Cloudflare in front to terminate h2 for browsers.

Third, because buffers are recycled, every string you read out of the request is only valid until the handler returns. Interviewers use this question to check whether you picked Fiber deliberately or by benchmark screenshot. A strong answer names the workload it suits (high-QPS internal JSON APIs behind a proxy that already terminates HTTP/2) and the workloads it does not (gRPC gateways, HTTP/2 server push, teams heavily invested in stdlib middleware).

Key Points

  • fasthttp pools RequestCtx and parses headers lazily to cut allocations
  • Fiber handlers are not http.Handler; the adaptor package bridges both ways
  • No HTTP/2 or HTTP/3 in fasthttp, so no same-port gRPC
  • Recycled buffers mean request-derived strings expire when the handler returns
Q2

What does a Fiber handler signature look like, and what happens when it returns a non-nil error?

BasicHandlers

Answer

A Fiber handler is func(c fiber.Ctx) error in v3 and func(c *fiber.Ctx) error in v2. The error return is the whole error-handling contract: you never write a status code and a body on the failure path, you return an error and let the app-level ErrorHandler translate it. If the returned error is a *fiber.Error (created by fiber.NewError(code, message) or one of the pre-built sentinels like fiber.ErrNotFound, fiber.ErrUnauthorized, fiber.ErrConflict), the default handler responds with that status code and the message as plain text.

Any other error becomes 500 Internal Server Error with the text of err.Error(), which is why leaking a raw database error out of a handler is both a debugging nuisance and an information disclosure risk. Middleware behaves the same way: if a middleware returns an error instead of calling c.Next(), the chain stops and the ErrorHandler runs. The subtlety interviewers probe: returning nil without writing anything produces a 200 with an empty body, not a 204, and calling c.JSON then also returning an error will produce mangled output because the response buffer already has bytes in it. Decide per handler whether you are writing or returning, never both.

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
	"github.com/gofiber/fiber/v3/middleware/logger"
	recoverer "github.com/gofiber/fiber/v3/middleware/recover"
)

func main() {
	app := fiber.New(fiber.Config{AppName: "orders-api"})
	app.Use(recoverer.New(), logger.New())

	app.Get("/health", func(c fiber.Ctx) error {
		return c.SendString("ok")
	})

	app.Get("/orders/:id", func(c fiber.Ctx) error {
		id := c.Params("id")
		if id == "" {
			return fiber.NewError(fiber.StatusBadRequest, "id is required")
		}
		// Do not write AND return an error from the same branch.
		return c.JSON(fiber.Map{"id": id})
	})

	log.Fatal(app.Listen(":3000"))
}
💡 Pro Tip: In v2 the handler takes *fiber.Ctx; in v3 fiber.Ctx is an interface and is passed by value. That single change is the largest mechanical part of a v2 to v3 migration.
Q3

How do route parameters, optional parameters, wildcards and route constraints work in Fiber?

BasicRouting

Answer

Fiber's router uses a compiled path parser rather than a plain radix tree, which is why it supports a richer pattern syntax than most Go routers. A colon marks a named parameter: /users/:id, read back with c.Params("id"). A trailing question mark makes it optional: /users/:id? matches both /users and /users/42.

A plus sign requires one or more characters: /files/+ . An asterisk is a greedy wildcard, and multiple wildcards are numbered, so /assets/*/v/* gives you c.Params("*1") and c.Params("*2"). Parameters can also carry constraints in angle brackets, which turn a validation concern into a routing concern: /users/:id<int> will simply not match /users/abc, so the request falls through to the next matching route or your 404 handler instead of reaching a handler that then has to strconv.Atoi and return 400.

The supported constraints include int, bool, float, alpha, guid, minLen, maxLen, len, range, datetime and regex. Two behaviours catch people out. Routes match in registration order for overlapping patterns, so a wildcard registered before a specific path will swallow it.

And by default the router is case-insensitive and ignores trailing slashes; if /Users and /users must be distinct endpoints you have to set CaseSensitive: true and StrictRouting: true in fiber.Config. Regex constraints are compiled at startup, so a bad pattern panics at boot rather than failing per request.

app := fiber.New(fiber.Config{CaseSensitive: true, StrictRouting: true})

// Named parameter
app.Get("/users/:id", func(c fiber.Ctx) error {
	return c.SendString("user " + c.Params("id"))
})

// Optional parameter
app.Get("/reports/:month?", func(c fiber.Ctx) error {
	return c.SendString(c.Params("month", "current"))
})

// Constrained parameter: /orders/abc never reaches this handler
app.Get("/orders/:id<int>", func(c fiber.Ctx) error {
	return c.JSON(fiber.Map{"orderID": c.Params("id")})
})

// Range + guid constraints
app.Get("/page/:n<range(1,100)>", handlePage)
app.Get("/tenants/:tid<guid>", handleTenant)

// Multiple wildcards: c.Params("*1"), c.Params("*2")
app.Get("/assets/*/v/*", func(c fiber.Ctx) error {
	return c.JSON(fiber.Map{"path": c.Params("*1"), "ver": c.Params("*2")})
})

Key Points

  • Syntax: :name, :name?, +, * with numbered wildcards *1 and *2
  • Constraints like <int>, <guid>, <range(1,100)> make bad paths not match at all
  • Overlapping routes resolve in registration order
  • CaseSensitive and StrictRouting are off by default
Q4

How do you bind a JSON request body to a struct, and what changed between v2 BodyParser and v3 Bind?

BasicBinding

Answer

In v2 you call c.BodyParser(&dto). It inspects the Content-Type header and dispatches to the right decoder: application/json, application/xml, application/x-www-form-urlencoded and multipart/form-data are all handled, with struct tags json, xml and form respectively. If the Content-Type is missing or unrecognised it returns fiber.ErrUnprocessableEntity, which surprises people testing with curl and no -H flag.

In v3 body parsing moved behind a fluent binder: c.Bind().Body(&dto), with siblings c.Bind().Query(&dto), c.Bind().URI(&dto), c.Bind().Header(&dto) and c.Bind().Cookie(&dto). You can force a specific decoder with c.Bind().JSON(&dto) when you do not trust the client to set headers correctly. Two migration traps are worth memorising because they show up in code review rounds.

First, v2's c.Bind() was the template-variable API, not a request binder; in v3 that became c.ViewBind(), so old code that reads c.Bind(fiber.Map{...}) will not compile. Second, v3's binder integrates with a StructValidator configured on fiber.Config, so a single c.Bind().Body(&dto) call can decode and validate in one step and return a 400 automatically. Regardless of version, keep the DTO separate from your database model, use pointer fields or a dedicated presence flag when you need to distinguish an omitted field from a zero value, and cap the payload with the BodyLimit config so a 500 MB POST cannot allocate its way through your pod memory limit.

type CreateOrder struct {
	SKU      string  `json:"sku" validate:"required,max=64"`
	Qty      int     `json:"qty" validate:"required,gte=1,lte=100"`
	CouponID *string `json:"couponId"` // pointer: distinguishes null from ""
}

// Fiber v3
app.Post("/orders", func(c fiber.Ctx) error {
	var in CreateOrder
	if err := c.Bind().Body(&in); err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
	}
	return c.Status(fiber.StatusCreated).JSON(in)
})

// Fiber v2 equivalent
app.Post("/orders", func(c *fiber.Ctx) error {
	var in CreateOrder
	if err := c.BodyParser(&in); err != nil {
		return fiber.ErrUnprocessableEntity
	}
	return c.Status(fiber.StatusCreated).JSON(in)
})
💡 Pro Tip: v2 c.Bind() set template variables; v3 c.Bind() binds requests and the old behaviour is now c.ViewBind(). Grep for c.Bind( before starting a v3 migration.
Q5

What are the different ways to write a response in Fiber, and when does c.Status versus c.SendStatus matter?

BasicResponses

Answer

c.JSON(v) marshals a value, sets Content-Type to application/json and writes it. c.SendString(s) writes text/plain. c.Send([]byte) writes raw bytes without touching Content-Type. c.SendFile(path) streams a file with range support. c.Status(code) only sets the status and returns the Ctx for chaining, so c.Status(fiber.StatusCreated).JSON(order) is the standard created response. c.SendStatus(code) both sets the status and writes the canonical status text as the body, unless a body has already been written, in which case it only sets the code. That difference matters for 204: c.SendStatus(fiber.StatusNoContent) is correct, while c.Status(204).JSON(nil) writes the four bytes null with a 204, which strict clients and some proxies treat as a protocol violation. fiber.Map is just a shorthand for map[string]any and is fine for small ad-hoc payloads, but for anything on a hot path define a struct so the encoder can be specialised. In v3 c.JSON takes an optional content type as a second argument, which is how you emit application/problem+json for RFC 9457 error bodies without hand-setting the header.

Two production notes: c.JSON on a very large slice buffers the whole thing in memory before writing, so stream instead past a few megabytes, and returning a value from a handler does nothing on its own. Unlike Express-style frameworks with implicit serialisation, in Fiber you must call a send method explicitly.

app.Post("/orders", func(c fiber.Ctx) error {
	return c.Status(fiber.StatusCreated).JSON(order)
})

app.Delete("/orders/:id", func(c fiber.Ctx) error {
	if err := svc.Delete(c.Params("id")); err != nil {
		return err
	}
	return c.SendStatus(fiber.StatusNoContent) // 204, no body
})

// v3: second argument overrides the content type
app.Get("/problem", func(c fiber.Ctx) error {
	return c.Status(fiber.StatusPaymentRequired).JSON(fiber.Map{
		"type":   "https://example.com/probs/insufficient-balance",
		"title":  "Insufficient balance",
		"status": 402,
	}, "application/problem+json")
})

app.Get("/invoice.pdf", func(c fiber.Ctx) error {
	c.Set(fiber.HeaderContentDisposition, `attachment; filename="invoice.pdf"`)
	return c.SendFile("./files/invoice.pdf")
})
Q6

What is c.Locals used for, and what is the safe way to read a value out of it?

BasicContext

Answer

c.Locals is Fiber's request-scoped key-value bag, and it is how middleware hands data to downstream handlers: an authenticated user ID, a tenant, a request ID, a database transaction. Under the hood it is fasthttp's RequestCtx.SetUserValue and UserValue, so it lives exactly as long as the request and is wiped when the Ctx returns to the pool. Writing is c.Locals("userID", id) and reading is c.Locals("userID"), which returns any.

The classic production incident is the unchecked type assertion: c.Locals("userID").(string) panics with interface conversion: interface {} is nil, not string the moment any route reaches the handler without passing through the auth middleware that sets it. Always use the comma-ok form, or use v3's generic helper fiber.Locals[string](c, "userID"), which returns the zero value instead of panicking. Two more habits worth naming in an interview.

Use unexported typed keys rather than bare strings if the codebase has multiple packages writing locals, because a collision between two middlewares that both use the key user is silent and very hard to find. And never store the *fiber.Ctx itself or anything derived from it (a raw c.Params string, a c.Body slice) in a struct that escapes the handler, since both the Ctx and the underlying buffers are recycled. If a background goroutine needs the user ID, copy it into a plain string first.

type ctxKey string

const userIDKey ctxKey = "userID"

func AuthMiddleware(c fiber.Ctx) error {
	claims, err := verify(c.Get(fiber.HeaderAuthorization))
	if err != nil {
		return fiber.ErrUnauthorized
	}
	c.Locals(userIDKey, claims.Subject) // already a copied string
	return c.Next()
}

func Handler(c fiber.Ctx) error {
	// v3 generic helper: no panic when the key is missing
	uid := fiber.Locals[string](c, userIDKey)
	if uid == "" {
		return fiber.ErrUnauthorized
	}

	// v2 style, always use comma-ok
	// uid, ok := c.Locals(userIDKey).(string)
	// if !ok { return fiber.ErrUnauthorized }

	return c.JSON(fiber.Map{"userID": uid})
}

Key Points

  • Locals is backed by fasthttp UserValue and dies with the request
  • Bare type assertions on Locals panic when middleware did not run
  • fiber.Locals[T](c, key) in v3 returns the zero value instead
  • Use typed keys to avoid silent collisions between packages
Q7

How does middleware ordering work in Fiber, and what does c.Next() actually do?

BasicMiddleware

Answer

Fiber middleware is just a handler registered with app.Use, and the router builds one ordered stack per matched route. c.Next() calls the next handler in that stack and returns its error, which means you can act both before and after the downstream handler by capturing the return value: start a timer, call err := c.Next(), then record the duration and the status. Not calling c.Next() short-circuits the chain, which is exactly what an auth middleware does when it returns fiber.ErrUnauthorized. The rule that trips up almost everyone once: registration order is execution order, and app.Use only applies to routes registered after it.

If you register your routes and then call app.Use(recover.New()), the recover middleware protects nothing, because the route stacks were already built. Put app.Use calls at the top of your setup function, in the order recover, request ID, logger, CORS, then auth, so a panic in the logger is still caught and every log line already has a request ID. app.Use with a path prefix scopes the middleware: app.Use("/api", authMiddleware) runs for anything under /api, and unlike route registration, prefix matching here is not exact. Group-level middleware attaches the same way with app.Group("/admin", requireAdmin). A subtle one worth mentioning: because errors propagate up through c.Next(), a middleware can catch and rewrite a downstream error, which is a clean way to map domain errors to HTTP responses per subtree rather than globally.

func Timing(c fiber.Ctx) error {
	start := time.Now()
	err := c.Next() // run the rest of the chain
	metrics.Observe(c.Route().Path, c.Response().StatusCode(), time.Since(start))
	return err
}

app := fiber.New()

// Order matters, and Use must come BEFORE route registration.
app.Use(recoverer.New(recoverer.Config{EnableStackTrace: true}))
app.Use(requestid.New())
app.Use(logger.New(logger.Config{Format: "${locals:requestid} ${status} ${latency} ${path}\n"}))
app.Use(Timing)

// Scoped to a prefix
app.Use("/api", AuthMiddleware)

api := app.Group("/api/v1")
api.Get("/orders", listOrders)
💡 Pro Tip: If a middleware seems to never run, check whether it was registered after the route. Fiber builds the handler stack at registration time, not at request time.
Q8

How do you write a custom ErrorHandler in Fiber and map domain errors to status codes?

BasicError Handling

Answer

fiber.Config takes an ErrorHandler func(c fiber.Ctx, err error) error that runs whenever any handler or middleware returns a non-nil error. The default implementation checks whether the error is a *fiber.Error and, if so, responds with its Code and Message as text/plain; everything else becomes a 500 with err.Error() in the body. In production you almost always replace it, for three reasons: you want JSON, not plain text; you do not want internal error strings such as pq: duplicate key value violates unique constraint reaching the client; and you want your own domain errors mapped without every handler repeating the translation.

The idiomatic implementation uses errors.As to unwrap, so a fiber.Error wrapped by fmt.Errorf with %w still resolves correctly. Register domain sentinels (ErrInsufficientBalance, ErrOrderLocked) in the same switch. Two things to get right.

Log the original error with the request ID inside the handler before you sanitise it, otherwise you have thrown away the only copy. And remember the ErrorHandler itself can fail: if it returns an error, Fiber falls back to a bare 500, so keep it allocation-light and never call anything that can panic. Interviewers also like asking where a panic goes: it does not reach the ErrorHandler on its own. You need the recover middleware, which converts the panic into an error and hands it to the ErrorHandler.

var ErrInsufficientBalance = errors.New("insufficient balance")

func errorHandler(c fiber.Ctx, err error) error {
	code := fiber.StatusInternalServerError
	msg := "internal server error"

	var fe *fiber.Error
	switch {
	case errors.As(err, &fe):
		code, msg = fe.Code, fe.Message
	case errors.Is(err, ErrInsufficientBalance):
		code, msg = fiber.StatusPaymentRequired, "insufficient balance"
	case errors.Is(err, sql.ErrNoRows):
		code, msg = fiber.StatusNotFound, "not found"
	}

	if code >= 500 {
		log.Error("request failed",
			"reqid", c.Locals("requestid"), "path", c.Path(), "err", err)
	}

	return c.Status(code).JSON(fiber.Map{"error": msg, "code": code})
}

app := fiber.New(fiber.Config{ErrorHandler: errorHandler})

Key Points

  • ErrorHandler is set on fiber.Config, not registered as middleware
  • Use errors.As so wrapped *fiber.Error values still resolve
  • Never return raw driver error strings to clients
  • Panics reach the ErrorHandler only if the recover middleware is installed
Q9

What are the options for reading query parameters, and how do typed query helpers work?

BasicBinding

Answer

c.Query("page") returns the raw string and takes an optional default as a second argument, so c.Query("page", "1") is safe when the parameter is absent. c.Queries() returns the whole query string as a map[string]string, useful for pass-through proxies. For typed access, v2 gives you c.QueryInt("page", 1), c.QueryBool and c.QueryFloat, plus c.QueryParser(&filter) to fill a struct using query struct tags. v3 replaces the parser with c.Bind().Query(&filter) and adds a package-level generic helper, fiber.Query[int](c, "page", 1), which parses and falls back to the supplied default when the value is missing or malformed. Repeated parameters like ?tag=a&tag=b bind into a []string field; a comma-separated single value binds too if you set the appropriate tag, which is a common source of confusion when a frontend switches serialisation style.

Three practical points interviewers reward. Always supply a default rather than checking for the empty string in five places. Always clamp a limit parameter server-side, because fiber.Query[int](c, "limit", 20) with a client sending limit=1000000 becomes an unbounded database scan. And remember the zero-copy rule applies here too: the string returned by c.Query points into the recycled request buffer, so if you are stashing it beyond the handler you must copy it first.

type OrderFilter struct {
	Status []string `query:"status"`
	Page   int      `query:"page"`
	Limit  int      `query:"limit"`
}

app.Get("/orders", func(c fiber.Ctx) error {
	// Individual typed reads (v3 generics)
	page := fiber.Query[int](c, "page", 1)
	limit := fiber.Query[int](c, "limit", 20)
	if limit > 100 {
		limit = 100 // never trust a client-supplied page size
	}

	// Or bind the whole struct at once
	var f OrderFilter
	if err := c.Bind().Query(&f); err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "bad filter")
	}

	return c.JSON(fiber.Map{"page": page, "limit": limit, "filter": f})
})

// v2: page := c.QueryInt("page", 1); err := c.QueryParser(&f)
💡 Pro Tip: Clamp limit and offset in the handler, not in the database layer. An unbounded limit is the single most common way a read endpoint takes down a Postgres replica.
Q10

How do you serve static assets in Fiber, and what changed in v3?

BasicStatic Files

Answer

In v2, static serving is a method on the app: app.Static("/assets", "./public", fiber.Static{Compress: true, ByteRange: true, MaxAge: 86400, Index: "index.html"}). In v3 that method was removed and replaced with a proper middleware in github.com/gofiber/fiber/v3/middleware/static, mounted on a wildcard route: app.Get("/assets*", static.New("./public", static.Config{Browse: false})). The v3 form is more consistent (static is now just middleware and composes with your other middleware) but it does mean the route needs the wildcard, and forgetting it is the number one migration bug: /assets/app.js returns 404 while /assets returns the index.

The old filesystem middleware, which wrapped an fs.FS, was folded into the same static middleware, which now accepts an FS option so you can serve an embed.FS binary-embedded SPA. Points to raise unprompted: enable Compress only if something upstream is not already gzipping, because double compression wastes CPU; set MaxAge and rely on hashed filenames for cache busting instead of no-cache; and for an SPA you also need a catch-all route that returns index.html for unknown paths so client-side routing works on a hard refresh. In most real deployments in India the static tier is fronted by CloudFront or Cloudflare anyway, so the Fiber static middleware is mostly a development and single-binary-deploy convenience rather than the production serving path.

import (
	"embed"
	"github.com/gofiber/fiber/v3"
	"github.com/gofiber/fiber/v3/middleware/static"
)

//go:embed dist/*
var assets embed.FS

func mount(app *fiber.App) {
	// v3: static is middleware and needs a wildcard route
	app.Get("/assets*", static.New("", static.Config{
		FS:       assets,
		Compress: true,
		MaxAge:   86400,
		Browse:   false,
	}))

	// SPA fallback so deep links survive a hard refresh
	app.Get("/*", func(c fiber.Ctx) error {
		return c.SendFile("dist/index.html")
	})
}

// v2 equivalent:
// app.Static("/assets", "./public", fiber.Static{Compress: true, MaxAge: 86400})
Q11

How do you organise routes with groups, and how do you mount a sub-application?

BasicRouting

Answer

app.Group(prefix, middleware...) returns a Router you register routes on, and groups nest, so api := app.Group("/api"); v1 := api.Group("/v1", authMiddleware) produces /api/v1/... with auth applied only to that subtree. Group middleware runs before route middleware, and both run after any app-level app.Use. app.Route(prefix, fn) is a variant that takes a callback, which reads nicely when you want the whole subtree defined in one lexical block. For composing whole applications, v2 had app.Mount("/admin", adminApp); v3 removed Mount and you now pass the sub-app to app.Use("/admin", adminApp) instead.

Mounting matters more than it looks: the sub-app keeps its own ErrorHandler and its own middleware stack, so you can give an internal admin API different error formatting and different timeouts from the public API without splitting binaries. The gotcha is configuration inheritance. A mounted sub-app does not inherit the parent's fiber.Config, so settings such as BodyLimit, ReadTimeout and ErrorHandler must be set on each app, and forgetting that is how a mounted upload service ends up rejecting files at the parent's 4 MB default.

Also, app.Use with a prefix strips nothing from c.Path(), so a sub-app route still sees the full path in logs even though matching happens against the remainder. For versioning, prefer separate groups over separate binaries until the contracts genuinely diverge; two groups sharing service code is far cheaper to operate than two deployments.

func Register(app *fiber.App) {
	api := app.Group("/api")

	v1 := api.Group("/v1", AuthMiddleware, RateLimit())
	v1.Get("/orders", listOrders)
	v1.Post("/orders", createOrder)

	v2 := api.Group("/v2", AuthMiddleware)
	v2.Get("/orders", listOrdersV2)

	// Sub-application with its own config and error handler
	admin := fiber.New(fiber.Config{
		BodyLimit:    50 * 1024 * 1024,
		ErrorHandler: adminErrorHandler,
	})
	admin.Post("/bulk-upload", bulkUpload)

	// v3: mount via Use. v2 used app.Mount("/admin", admin).
	app.Use("/admin", admin)
}

Key Points

  • Group middleware applies to the subtree only, app.Use applies globally
  • v3 replaced app.Mount with app.Use(prefix, subApp)
  • Sub-apps do NOT inherit the parent fiber.Config
  • Nested groups compose prefixes and middleware in order
Q12

How do you read and set headers and cookies in Fiber, and how does encryptcookie fit in?

BasicHTTP

Answer

Reading a request header is c.Get(name, defaultValue), and Fiber exposes canonical names as constants (fiber.HeaderAuthorization, fiber.HeaderXForwardedFor, fiber.HeaderContentType) so you avoid typo bugs. Setting a response header is c.Set(name, value); c.Append adds another value to an existing header rather than replacing it. Header lookup is case-insensitive as HTTP requires, but the value you get back is a string pointing into the recycled buffer, so the copy rule applies.

Cookies are read with c.Cookies("session", "") and written with c.Cookie(&fiber.Cookie{...}), where the fields that matter in a security review are HTTPOnly, Secure, SameSite and Expires or MaxAge. c.ClearCookie(name) sets an expired cookie of the same name, and it only works if the path and domain match what you originally set, which is why sign-out sometimes appears to do nothing. Fiber ships an encryptcookie middleware that transparently AES-encrypts cookie values in both directions, so a session identifier or a small preference blob is opaque to the client; you configure it with a 32-byte base64 key generated by encryptcookie.GenerateKey() and an Except list for cookies that must stay readable, notably the CSRF cookie. Two things to state without being asked: never put anything you would not want replayed into a cookie even when encrypted, because encryption is not integrity plus freshness, and remember that SameSite=None requires Secure=true, otherwise modern browsers silently drop the cookie and your cross-site flow breaks only in production.

app.Use(encryptcookie.New(encryptcookie.Config{
	Key:    os.Getenv("COOKIE_KEY"), // encryptcookie.GenerateKey()
	Except: []string{"csrf_"},       // CSRF cookie must stay readable
}))

app.Post("/login", func(c fiber.Ctx) error {
	token, err := auth.Login(c.Get(fiber.HeaderAuthorization))
	if err != nil {
		return fiber.ErrUnauthorized
	}

	c.Cookie(&fiber.Cookie{
		Name:     "session",
		Value:    token,
		Path:     "/",
		HTTPOnly: true,
		Secure:   true,
		SameSite: fiber.CookieSameSiteLaxMode,
		MaxAge:   7 * 24 * 3600,
	})

	c.Set("X-Request-Id", c.Locals("requestid").(string))
	return c.SendStatus(fiber.StatusNoContent)
})
💡 Pro Tip: c.ClearCookie only works when Path and Domain match the original Set-Cookie. Mismatched paths are the usual reason a logout endpoint returns 200 but the user stays logged in.
Q13

How do you handle multipart file uploads in Fiber and guard against oversized bodies?

BasicFile Uploads

Answer

c.FormFile("file") returns a *multipart.FileHeader for a single field, and c.SaveFile(fh, dst) writes it to disk. For multiple files or mixed fields, c.MultipartForm() returns the parsed form with form.File["documents"] as a slice and form.Value for the text fields. The important production controls are in fiber.Config.

BodyLimit defaults to 4 MB, and anything larger is rejected before your handler runs with a 413 and the message body size exceeds the given limit, which is a good default but catches teams off guard when a KYC document upload starts failing only for scanned PDFs. Raising BodyLimit is not free: by default fasthttp buffers the entire body in memory, so a 100 MB limit multiplied by concurrent uploads is your pod memory ceiling. Setting StreamRequestBody: true changes that, the body becomes a reader you consume incrementally, which is what you want when piping to S3 with the AWS SDK multipart uploader.

Validate before you trust: check the file size from fh.Size, sniff the real content type from the first 512 bytes with http.DetectContentType rather than believing fh.Header.Get("Content-Type"), and never build the destination path from the client-supplied filename without filepath.Base, because a filename of ../../etc/cron.d/x is a path traversal write. In most Indian production stacks the file never lands on the pod at all; the handler issues a presigned S3 or GCS URL and the browser uploads directly, which removes the bandwidth, the disk and the traversal risk in one move.

app := fiber.New(fiber.Config{
	BodyLimit:         25 * 1024 * 1024, // 413 above this
	StreamRequestBody: true,            // do not buffer the whole body
})

app.Post("/kyc/upload", func(c fiber.Ctx) error {
	fh, err := c.FormFile("document")
	if err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "document field missing")
	}
	if fh.Size > 10<<20 {
		return fiber.NewError(fiber.StatusRequestEntityTooLarge, "max 10MB")
	}

	f, err := fh.Open()
	if err != nil {
		return err
	}
	defer f.Close()

	head := make([]byte, 512)
	n, _ := f.Read(head)
	if ct := http.DetectContentType(head[:n]); ct != "application/pdf" {
		return fiber.NewError(fiber.StatusUnsupportedMediaType, ct)
	}

	safe := filepath.Base(fh.Filename) // block ../ traversal
	return c.SaveFile(fh, filepath.Join("/data/kyc", safe))
})

Key Points

  • BodyLimit defaults to 4 MB and returns 413 before the handler runs
  • StreamRequestBody avoids buffering large uploads in pod memory
  • Sniff content type from bytes, never trust the multipart header
  • filepath.Base the client filename to block path traversal
Q14

Which fiber.Config fields would you set before shipping a Fiber service to production?

BasicConfiguration

Answer

fiber.Config is passed to fiber.New and cannot be changed afterwards, so this is a design-time decision. The timeouts come first: ReadTimeout and WriteTimeout are both zero by default, meaning no timeout, which leaves you open to slowloris-style connection holding and to a slow upstream pinning goroutines forever. Set ReadTimeout around 10 to 15 seconds, WriteTimeout slightly above your slowest legitimate response, and IdleTimeout for keep-alive reaping.

BodyLimit guards memory. ErrorHandler gives you JSON errors instead of plain text. AppName shows up in the startup banner and in some middlewares, useful in logs.

DisableStartupMessage: true keeps the ASCII banner out of structured log pipelines. CaseSensitive and StrictRouting change routing semantics and should be decided up front, not later. JSONEncoder and JSONDecoder let you swap encoding/json for goccy/go-json or bytedance/sonic, which is one of the highest-leverage single-line performance changes on a JSON-heavy service.

ReadBufferSize matters if you receive unusually large headers, for example long JWTs in an Authorization header, where the default 4096 bytes produces a confusing 431 or a connection reset. For v3 specifically, proxy trust moved into TrustProxy and TrustProxyConfig, and listen-time settings such as graceful shutdown context moved out of Config into fiber.ListenConfig passed to app.Listen. Interviewers like this question because the defaults are permissive and a candidate who has actually operated a Fiber service will name the timeout defaults from memory.

import "github.com/goccy/go-json"

app := fiber.New(fiber.Config{
	AppName:               "payments-api",
	DisableStartupMessage: true,
	ErrorHandler:          errorHandler,

	// Zero by default: no timeout at all
	ReadTimeout:  15 * time.Second,
	WriteTimeout: 20 * time.Second,
	IdleTimeout:  75 * time.Second,

	BodyLimit:      8 * 1024 * 1024,
	ReadBufferSize: 16 * 1024, // long JWTs in headers

	CaseSensitive: true,
	StrictRouting: true,

	// Drop-in faster JSON
	JSONEncoder: json.Marshal,
	JSONDecoder: json.Unmarshal,

	// v3 proxy trust
	TrustProxy: true,
	TrustProxyConfig: fiber.TrustProxyConfig{
		Proxies: []string{"10.0.0.0/8"},
	},
})
💡 Pro Tip: ReadTimeout and WriteTimeout default to zero, which means unlimited. Every Fiber service that has ever been slowloris-ed shipped with those defaults.
Q15

Why does a string read from c.Params or c.Body sometimes contain unrelated data later, and how do you prevent it?

IntermediateMemory Model

Answer

This is the single most Fiber-specific bug there is, and it comes straight from fasthttp's zero-copy design. When you call c.Params("sku"), c.Query("q"), c.Get("X-Api-Key") or c.Body(), you do not get a fresh allocation. You get a string or slice header pointing directly into the RequestCtx read buffer.

The instant your handler returns, Fiber releases the Ctx back to its sync.Pool and fasthttp reuses that buffer for whatever connection is served next. Anything you kept a reference to now describes bytes belonging to a different request. The symptoms are nasty because they are load-dependent and never reproduce locally: a cache key that occasionally holds another tenant's identifier, log lines with the wrong path, a map lookup that stops matching, or in the worst case a background job writing to the wrong account.

The fix is to copy before the value escapes the handler. Use strings.Clone from the standard library, utils.CopyString from the gofiber utils package, or append([]byte(nil), c.Body()...) for a byte slice. Copy whenever the value goes into a struct that outlives the request, onto a channel, into a goroutine, into a package-level cache, or into a Locals key you plan to read after the response.

One useful nuance to raise: values that came out of json.Unmarshal are already safe, because the decoder allocates new strings, so a bound DTO can be passed around freely. The blunt global fix is fiber.Config{Immutable: true}, which copies every request-derived value up front. It works, and it costs an allocation on every read, so it belongs on a low-traffic admin service rather than on the hot path you chose Fiber for.

var recent []string // package level, outlives every request

// WRONG: the string points into a buffer that is about to be recycled
app.Get("/track/:sku", func(c fiber.Ctx) error {
	recent = append(recent, c.Params("sku"))
	return c.SendStatus(fiber.StatusAccepted)
})

// RIGHT: copy out of the request buffer before anything escapes
app.Post("/track/:sku", func(c fiber.Ctx) error {
	sku := strings.Clone(c.Params("sku"))        // or utils.CopyString
	body := append([]byte(nil), c.Body()...)     // owned copy of the bytes

	go analytics.Record(sku, body)               // safe: both are ours
	return c.SendStatus(fiber.StatusAccepted)
})

// Blunt global opt-out: correct everywhere, allocates on every read
app := fiber.New(fiber.Config{Immutable: true})

Key Points

  • Request-derived strings alias a pooled buffer that is reused immediately
  • Copy with strings.Clone or utils.CopyString before the value escapes
  • Structs filled by json.Unmarshal are already safe to keep
  • Immutable: true fixes it globally at an allocation per read
💡 Pro Tip: Run the suite with go test -race and add one test that fires 200 concurrent requests. Aliasing bugs are invisible at concurrency 1.
Q16

What is the correct way to use request data inside a goroutine that outlives the handler?

IntermediateConcurrency

Answer

Three separate rules have to hold at once, and interviewers usually check all three. First, the Ctx itself is pooled. Capturing c in a closure and touching it after the handler returns is a data race against whatever request now owns that Ctx.

Copy the scalars you need into plain variables before you spawn. Second, pick the right context. In v2, c.Context() returns the *fasthttp.RequestCtx and c.UserContext() returns the context.Context you can attach values to; in v3, c.Context() returns a context.Context and c.RequestCtx() gives you the fasthttp object.

Whichever version, that context is tied to the request and gets cancelled once the response is written, so handing it to a background job produces the classic context canceled error on a job that was supposed to run for thirty seconds. Use context.WithoutCancel to keep values while dropping cancellation, or start from context.Background with your own timeout. Do pass the request context into database calls that should die when the client hangs up, which is exactly what you want on a search endpoint.

Third, a panic in a spawned goroutine is not recoverable by the recover middleware. Middleware only wraps the handler's own stack, and an unrecovered panic in any goroutine takes the whole process down, dropping every in-flight request on that pod. Every goroutine you start needs its own deferred recover. In production, prefer a bounded worker pool or a queue over unbounded go statements, otherwise a traffic spike turns into millions of goroutines and an OOMKill.

app.Post("/webhooks/payments", func(c fiber.Ctx) error {
	// 1. Copy everything the worker needs out of the pooled buffers
	payload := append([]byte(nil), c.Body()...)
	sig := strings.Clone(c.Get("X-Signature"))

	// 2. Do NOT inherit the request context: it dies with the response
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

	// 3. Bounded pool + per-goroutine recover
	pool.Submit(func() {
		defer cancel()
		defer func() {
			if r := recover(); r != nil {
				log.Error("webhook worker panic", "recover", r)
			}
		}()
		process(ctx, payload, sig)
	})

	return c.SendStatus(fiber.StatusAccepted)
})

// In-handler work that SHOULD stop when the client disconnects:
// rows, err := db.QueryContext(c.Context(), query, args...)
💡 Pro Tip: The recover middleware protects the handler goroutine only. One unhandled panic in a goroutine you started kills the whole pod, not just that request.
Q17

Behind an ALB or nginx, c.IP() returns the load balancer address. How do you configure trusted proxies correctly?

IntermediateNetworking

Answer

By default c.IP() returns the remote address of the TCP socket, which behind any load balancer is the balancer itself. The naive fix is to set ProxyHeader: fiber.HeaderXForwardedFor so Fiber reads the header instead, and that alone is a security hole: any client can send X-Forwarded-For: 1.2.3.4 and defeat your rate limiter, your IP allowlist and your audit log in one request. The header must only be believed when the connection came from a hop you trust.

In v2 that is EnableTrustedProxyCheck: true with TrustedProxies listing your balancer CIDRs; in v3 it is TrustProxy: true with a TrustProxyConfig that takes Proxies, plus convenience flags for Private, Loopback and LinkLocal ranges. When the check is on and the peer is not trusted, Fiber ignores the header and falls back to the socket address, which is the behaviour you want. c.IPs() gives the whole X-Forwarded-For chain; remember an ALB appends rather than replaces, so the leftmost entry is client-supplied and only trustworthy if every hop in between is yours. With CloudFront or Cloudflare in front, prefer their dedicated headers (CloudFront-Viewer-Address, CF-Connecting-IP) since those are overwritten at the edge.

Getting this wrong has a very recognisable production signature: the limiter middleware, whose default KeyGenerator is c.IP(), hashes every request to the same bucket, so the service works fine at low load and then returns 429 to everybody the moment traffic crosses the limit. Related point for Indian consumer traffic: even a correct client IP is a weak identity because carrier CGNAT puts thousands of mobile users behind one address.

app := fiber.New(fiber.Config{
	// v3 proxy trust
	TrustProxy: true,
	TrustProxyConfig: fiber.TrustProxyConfig{
		Proxies: []string{"10.0.0.0/8"}, // only the ALB subnets
		Private: false,
	},
	ProxyHeader: fiber.HeaderXForwardedFor,
})

// v2 equivalent:
// EnableTrustedProxyCheck: true,
// TrustedProxies:          []string{"10.0.0.0/8"},
// ProxyHeader:             fiber.HeaderXForwardedFor,

app.Get("/whoami", func(c fiber.Ctx) error {
	return c.JSON(fiber.Map{
		"ip":      c.IP(),  // trustworthy only when the hop is trusted
		"chain":   c.IPs(), // full X-Forwarded-For list, left to right
		"trusted": c.IsProxyTrusted(),
	})
})

Key Points

  • ProxyHeader without a trusted-proxy check lets clients spoof their IP
  • v2: EnableTrustedProxyCheck + TrustedProxies; v3: TrustProxy + TrustProxyConfig
  • ALBs append to X-Forwarded-For, so the leftmost hop is client-controlled
  • Symptom of a misconfiguration: one shared rate-limit bucket, 429 for everyone
Q18

How do you rate limit a Fiber API that runs as six replicas, and what does the default limiter get wrong?

IntermediateMiddleware

Answer

limiter.New defaults to Max: 5 requests per Expiration: 1 minute, keyed on c.IP(), stored in an in-process memory store. Two of those defaults break at scale. The memory store is per process, so six replicas behind a load balancer each grant the full Max and your effective limit is six times what you configured; worse, the number moves every time the deployment scales or a pod restarts.

Swap Storage for a shared driver from the gofiber/storage repository, usually the Redis one, and every replica then decrements the same counter. The IP key is the other problem. For an authenticated API you want the tenant or API key as the bucket, because Indian mobile traffic arrives through carrier CGNAT and hundreds of legitimate users can share one address; keying on IP either throttles real users or forces the limit so high it stops protecting anything.

Write a KeyGenerator that prefers the API key or the user ID from Locals and only falls back to the IP for anonymous routes. Other config worth naming: LimiterMiddleware: limiter.SlidingWindow{} instead of the default fixed window, which removes the burst that a fixed window allows at the boundary; Next to exempt /healthz and internal calls so a probe failure cannot be caused by your own limiter; LimitReached to emit a JSON body instead of the plain 429 text; and SkipSuccessfulRequests or SkipFailedRequests when you only want to count one class. The middleware already sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After, so clients can back off properly. Remember that with a Redis store you have added a network round trip to every request; keep the limiter after cheap rejections and monitor its latency.

import (
	"github.com/gofiber/fiber/v3/middleware/limiter"
	redisstore "github.com/gofiber/storage/redis/v3"
)

store := redisstore.New(redisstore.Config{URL: os.Getenv("REDIS_URL")})

app.Use(limiter.New(limiter.Config{
	Max:                60,
	Expiration:         time.Minute,
	Storage:            store,                  // shared by every replica
	LimiterMiddleware:  limiter.SlidingWindow{}, // no boundary burst
	KeyGenerator: func(c fiber.Ctx) string {
		if k := c.Get("X-Api-Key"); k != "" {
			return "key:" + k // per tenant, not per CGNAT address
		}
		return "ip:" + c.IP()
	},
	Next: func(c fiber.Ctx) bool {
		return c.Path() == "/healthz" || c.Path() == "/readyz"
	},
	LimitReached: func(c fiber.Ctx) error {
		return c.Status(fiber.StatusTooManyRequests).
			JSON(fiber.Map{"error": "rate limit exceeded"})
	},
}))
💡 Pro Tip: Exempt your health endpoints in Next. A limiter that 429s the kubelet probe turns a traffic spike into a rolling pod restart.
Q19

How do you implement WebSockets in Fiber, and what breaks once the connection is upgraded?

IntermediateWebSockets

Answer

Fiber does not ship WebSockets in core; you pull github.com/gofiber/contrib/websocket, which wraps fasthttp's WebSocket support behind a gorilla-shaped API. The pattern is two-stage. A normal Fiber middleware runs first, checks websocket.IsWebSocketUpgrade(c), authenticates the request and stashes anything the connection will need with c.Locals, then calls c.Next().

The route handler is websocket.New(func(conn *websocket.Conn)), and inside it you read the stashed values with conn.Locals and route parameters with conn.Params. The critical point is that the upgrade handler is your last chance to run middleware. Once the connection is live, none of your auth, logging or rate limiting runs again, so a long-lived socket keeps whatever authorisation it was granted at connect time; if tokens can be revoked you need an explicit expiry check inside the read loop.

Browsers cannot set an Authorization header on a WebSocket, so authentication usually rides in a query parameter or a cookie, and a query token ends up in access logs unless you scrub it. The other classic failure is concurrency: the connection handler runs in its own goroutine, and two goroutines calling conn.WriteMessage at once panics with a concurrent write error. Funnel all writes through a single writer goroutine fed by a buffered channel, and close that channel from exactly one place.

Add SetReadDeadline plus a ping and pong handler, because an ALB with a sixty second idle timeout silently drops quiet connections and without deadlines your reader blocks forever holding memory. Finally, Prefork and WebSockets do not mix for broadcast: connections land in different processes, so fan-out has to go through Redis pub/sub or a dedicated hub service.

// Authenticate HERE. After the upgrade no middleware ever runs again.
app.Use("/ws", func(c *fiber.Ctx) error {
	if !websocket.IsWebSocketUpgrade(c) {
		return fiber.ErrUpgradeRequired
	}
	uid, err := auth.Verify(c.Query("token"))
	if err != nil {
		return fiber.ErrUnauthorized
	}
	c.Locals("uid", uid)
	return c.Next()
})

app.Get("/ws/:room", websocket.New(func(conn *websocket.Conn) {
	uid, _ := conn.Locals("uid").(string)
	out := make(chan []byte, 32)

	// Exactly one writer goroutine: concurrent writes panic.
	go func() {
		defer conn.Close()
		for msg := range out {
			if conn.WriteMessage(websocket.TextMessage, msg) != nil {
				return
			}
		}
	}()

	hub.Join(conn.Params("room"), uid, out)
	defer func() { hub.Leave(conn.Params("room"), uid); close(out) }()

	conn.SetPongHandler(func(string) error {
		return conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	})
	for {
		if _, _, err := conn.ReadMessage(); err != nil {
			return
		}
	}
}))

Key Points

  • IsWebSocketUpgrade guard plus c.Locals is the only place to authenticate
  • conn.WriteMessage is not safe for concurrent use: serialise through a channel
  • Read deadlines and ping/pong are required behind idle-timeout load balancers
  • Prefork splits connections across processes, so broadcast needs Redis pub/sub
Q20

How do you stream a response in Fiber, for example server-sent events or a multi-hundred-megabyte CSV export?

IntermediateStreaming

Answer

c.JSON and c.Send buffer the whole payload before writing, so a large export either spikes pod memory or gets OOMKilled. Fiber exposes fasthttp's streaming writer instead. In v3 that is c.SendStreamWriter(func(w *bufio.Writer)); in v2 you reach through with c.Context().SetBodyStreamWriter(fasthttp.StreamWriter(fn)).

Inside the callback you write chunks and call w.Flush() after each one. Forgetting Flush is the number one bug report: the code looks right, and the client receives nothing until the four kilobyte bufio buffer happens to fill, which for a low-rate event feed can be minutes. Flush also doubles as your disconnect detector, because it returns an error once the peer is gone, so check it and return rather than looping forever against a dead socket.

Set your headers before returning the stream writer, since once bytes are on the wire it is too late: text/event-stream, Cache-Control no-cache, and X-Accel-Buffering: no when nginx sits in front, because nginx will otherwise buffer the entire response and defeat the whole exercise. Two config interactions matter. WriteTimeout applies to the whole response, so a fifteen second WriteTimeout silently kills every SSE connection at fifteen seconds; long-lived streams need it at zero with an application-level idle timeout instead.

And the compress middleware buffers, so exclude streaming routes from it. For SSE specifically, send a comment line as a keepalive every fifteen to thirty seconds so intermediaries do not reap the connection, and watch out for browsers capping concurrent EventSource connections per origin on HTTP/1.1, which is a real constraint given fasthttp has no HTTP/2.

app.Get("/events", func(c fiber.Ctx) error {
	c.Set(fiber.HeaderContentType, "text/event-stream")
	c.Set(fiber.HeaderCacheControl, "no-cache")
	c.Set(fiber.HeaderConnection, "keep-alive")
	c.Set("X-Accel-Buffering", "no") // stop nginx buffering the stream

	ctx := c.Context()
	feed := bus.Subscribe(c.Query("symbol"))

	// v3 helper. v2: c.Context().SetBodyStreamWriter(fasthttp.StreamWriter(fn))
	return c.SendStreamWriter(func(w *bufio.Writer) {
		defer bus.Unsubscribe(feed)
		tick := time.NewTicker(20 * time.Second)
		defer tick.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case ev := <-feed:
				fmt.Fprintf(w, "event: tick\ndata: %s\n\n", ev.JSON)
			case <-tick.C:
				fmt.Fprint(w, ": keepalive\n\n")
			}
			if err := w.Flush(); err != nil {
				return // peer disconnected
			}
		}
	})
})
💡 Pro Tip: Set WriteTimeout to 0 on any app that serves SSE. A non-zero WriteTimeout cuts every stream at exactly that duration and looks like a client bug.
Q21

How do the session and csrf middlewares work together in Fiber, and what are the common misconfigurations?

IntermediateSecurity

Answer

The session middleware keeps server-side state behind a cookie or header identifier. In v2 you build a store with session.New(session.Config{...}) and call store.Get(c) inside a handler; in v3 you register session.New(...) with app.Use and pull the session out with session.FromContext(c). Either way the mistake that costs an hour of debugging is forgetting sess.Save(): mutations live only in memory until you save, so logins appear to succeed and then the user is anonymous on the next request.

Configure Storage with a shared driver (Redis, Postgres, DynamoDB from gofiber/storage) because the default memory store means a user's session vanishes whenever the load balancer sends them to a different pod. Set CookieHTTPOnly, CookieSecure and CookieSameSite explicitly, cap the idle timeout, and call sess.Regenerate() immediately after a successful login so a pre-authentication identifier planted by an attacker cannot be reused, which is the session fixation attack reviewers ask about by name. The csrf middleware defaults to the double-submit cookie pattern: it writes a csrf_ cookie and expects the same value back in a header or form field named by KeyLookup, returning 403 when they disagree.

Wire it to your session store when you have one, which upgrades it to the stronger synchroniser token pattern. Two integration traps: if you also run encryptcookie you must add the CSRF cookie name to its Except list, otherwise the browser-side JavaScript reads an encrypted blob and every mutating request fails; and CookieHTTPOnly must stay false for the readable half of a double-submit setup. If your API authenticates purely with a bearer token and never with cookies, CSRF does not apply and adding the middleware just breaks your clients.

store := session.NewStore(session.Config{
	Storage:        redisStore, // never the default memory store
	KeyLookup:      "cookie:sid",
	CookieHTTPOnly: true,
	CookieSecure:   true,
	CookieSameSite: "Lax",
	IdleTimeout:    30 * time.Minute,
})
app.Use(session.NewWithStore(store))

app.Use(csrf.New(csrf.Config{
	KeyLookup:      "header:X-Csrf-Token",
	CookieName:     "csrf_",
	CookieSecure:   true,
	CookieHTTPOnly: false, // the SPA has to read it back
	CookieSameSite: "Lax",
	Session:        store, // synchroniser token, not bare double submit
	Expiration:     time.Hour,
}))

// encryptcookie must skip the CSRF cookie or the SPA reads ciphertext
app.Use(encryptcookie.New(encryptcookie.Config{
	Key:    os.Getenv("COOKIE_KEY"),
	Except: []string{"csrf_"},
}))

app.Post("/login", func(c fiber.Ctx) error {
	sess := session.FromContext(c)
	if err := sess.Regenerate(); err != nil { // block session fixation
		return err
	}
	sess.Set("uid", user.ID)
	return sess.Save() // without this, nothing persists
})
Q22

How do you test Fiber handlers, and what does app.Test actually do?

IntermediateTesting

Answer

app.Test takes an *http.Request and runs it through the real router, the real middleware stack and your real ErrorHandler, using an in-memory listener instead of a TCP socket. No port binding, no flaky ports in CI, and tests can run in parallel. The signature differs by version: v2 is app.Test(req, msTimeout ...int) where -1 disables the timeout, and v3 is app.Test(req, ...fiber.TestConfig) with fields Timeout and FailOnTimeout.

The default is one second, which is why a handler you are stepping through in a debugger fails with a test timeout error; set the timeout to zero while debugging. Build requests with httptest.NewRequest and always set Content-Type, because v2's BodyParser returns 422 Unprocessable Entity on a missing or unknown content type and the resulting test failure looks nothing like the real cause. The design decision that makes handlers testable is dependency injection: have your route registration take an interface for the service layer so a test can pass a stub, rather than reaching for a package-level database handle.

Test through the HTTP layer, not around it, so you also cover binding, validation, the status code and the error mapping. Beyond happy paths, cover the ErrorHandler translation of each domain error, a request that skips the auth middleware, and a body above BodyLimit expecting 413. Run everything with -race as a matter of policy, since Fiber's pooled Ctx makes accidental sharing very easy to write and very hard to spot. What app.Test does not cover: real network timeouts, TLS, Prefork behaviour and graceful shutdown, so keep a smoke test against a running binary for those.

func newTestApp(svc OrderService) *fiber.App {
	app := fiber.New(fiber.Config{ErrorHandler: errorHandler})
	RegisterOrderRoutes(app, svc) // injected, not a package global
	return app
}

func TestCreateOrder(t *testing.T) {
	app := newTestApp(&stubOrders{})

	body := strings.NewReader(`{"sku":"ABC-1","qty":2}`)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", body)
	req.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON)

	// v3. Timeout: 0 disables the 1s cap when stepping through a debugger.
	resp, err := app.Test(req, fiber.TestConfig{Timeout: 2 * time.Second})
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusCreated {
		t.Fatalf("status = %d, want 201", resp.StatusCode)
	}

	var out CreateOrder
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatal(err)
	}
}

// v2: resp, err := app.Test(req, -1) // -1 means no timeout

Key Points

  • app.Test uses an in-memory listener, so no ports and no flakiness
  • Default 1s timeout; v2 passes -1, v3 passes TestConfig{Timeout: 0}
  • Missing Content-Type makes v2 BodyParser return 422 in tests
  • Always run go test -race because the Ctx is pooled
Q23

How do the cache, etag and compress middlewares interact, and what is wrong with the default cache key?

IntermediatePerformance

Answer

cache.New stores a rendered response and replays it for the configured Expiration, adding a CacheHeader (X-Cache by default) with hit or miss so you can measure the ratio. Its default KeyGenerator uses the request path alone. That is a correctness bug in almost every real API: /orders?status=paid and /orders?status=failed collapse to one entry, and if the response depends on the caller then one tenant is served another tenant's data.

Always supply a KeyGenerator that includes the query string and any header that changes the body, and use Next to skip caching entirely for anything carrying an Authorization header. The middleware caches GET and HEAD by default; extending Methods to POST is almost always a mistake. Set Storage to a shared driver if you want a hit on one pod to help the others, and remember that CacheControl: true also emits a max-age header, which pushes the same staleness onto browsers and CDNs where you cannot invalidate it. etag.New hashes the response body and answers 304 Not Modified when If-None-Match matches.

It saves bandwidth, not compute, because the handler still ran and the body was still built, so pair it with cache rather than treating it as a substitute. compress.New negotiates gzip, deflate and brotli from Accept-Encoding, with zstd added in v3, and Level ranges from LevelDisabled through LevelBestCompression. Ordering matters: register compress before cache and you store compressed bytes that may be replayed to a client that never asked for that encoding. Register cache first, compress after. And if CloudFront or Cloudflare already compresses at the edge, running compress in the pod just burns CPU you are paying for twice.

app.Use(cache.New(cache.Config{
	Expiration:   60 * time.Second,
	CacheControl: true,
	CacheHeader:  "X-Cache",
	Storage:      redisStore,
	Methods:      []string{fiber.MethodGet, fiber.MethodHead},
	// The default key is the PATH ONLY. Everything that varies goes in here.
	KeyGenerator: func(c fiber.Ctx) string {
		return c.Path() + "?" + string(c.Request().URI().QueryString())
	},
	Next: func(c fiber.Ctx) bool {
		// Never cache anything user-specific
		return c.Get(fiber.HeaderAuthorization) != ""
	},
}))

// After cache, so we never store pre-compressed bytes
app.Use(compress.New(compress.Config{Level: compress.LevelBestSpeed}))
app.Use(etag.New(etag.Config{Weak: true}))
💡 Pro Tip: Alert on the X-Cache hit ratio, not just on latency. A KeyGenerator change that silently drops the hit rate to zero shows up as a database bill before it shows up as a p99 regression.
Q24

How do you shut a Fiber service down gracefully inside Kubernetes without dropping in-flight requests?

IntermediateDeployment

Answer

app.Listen blocks, so you install a signal handler in a separate goroutine. Use signal.NotifyContext for SIGTERM and SIGINT, then call app.ShutdownWithTimeout(d) or app.ShutdownWithContext(ctx) in v2, which stops the listener, refuses new connections and waits for in-flight handlers before returning. If the deadline passes, it returns context deadline exceeded and the remaining connections are cut.

In v3 this moved into fiber.ListenConfig: pass GracefulContext, ShutdownTimeout and the OnShutdownSuccess or OnShutdownError hooks to app.Listen and Fiber wires the whole thing for you. Shutting down correctly is more than calling Shutdown. Kubernetes sends SIGTERM and removes the pod from the Endpoints object at the same time, and that removal propagates asynchronously through kube-proxy, the ingress controller and any ALB target group.

If you exit immediately you will keep receiving traffic for several seconds after you stopped listening, which shows up as 502s during every deploy. The correct order is: flip your readiness probe to failing, sleep long enough for deregistration (five to ten seconds is typical, or use a preStop hook), then shut down the server, then close the database pool, drain your queue consumers and flush the OpenTelemetry exporter. terminationGracePeriodSeconds in the pod spec must be comfortably larger than your shutdown timeout plus that sleep, otherwise the kubelet SIGKILLs you mid-request. Two Fiber-specific notes: WebSocket connections never drain on their own, so you need to broadcast close frames yourself; and with Prefork the signal reaches the master process, which complicates the whole flow, which is one more reason not to use Prefork under an orchestrator.

func main() {
	app := buildApp()

	ctx, stop := signal.NotifyContext(context.Background(),
		os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		<-ctx.Done()
		readiness.Store(false)      // fail /readyz first
		time.Sleep(8 * time.Second) // let kube-proxy and the ALB deregister
		if err := app.ShutdownWithTimeout(20 * time.Second); err != nil {
			log.Error("forced shutdown", "err", err)
		}
	}()

	if err := app.Listen(":3000"); err != nil {
		log.Fatal(err)
	}

	db.Close()
	_ = tracerProvider.Shutdown(context.Background())
}

// v3: hand the signal context straight to Listen
// app.Listen(":3000", fiber.ListenConfig{
// 	GracefulContext:   ctx,
// 	ShutdownTimeout:   20 * time.Second,
// 	OnShutdownSuccess: func() { log.Info("drained") },
// })

Key Points

  • ShutdownWithTimeout in v2; GracefulContext and ShutdownTimeout in v3 ListenConfig
  • Fail readiness and wait for endpoint deregistration before you stop listening
  • terminationGracePeriodSeconds must exceed shutdown timeout plus the drain sleep
  • WebSockets and Prefork both need extra handling during drain
Q25

Fiber has no built-in validator. How do you validate request payloads, and what does v3's StructValidator change?

IntermediateValidation

Answer

Fiber deliberately ships no validation, so the standard pairing is go-playground/validator/v10. In v2 the flow is manual: c.BodyParser(&dto), then validate.Struct(&dto), then translate the returned validator.ValidationErrors into a field-level JSON response. Construct the validator once at startup with validator.New() and keep it on your handler struct, because it caches reflection metadata per type and building it per request throws that cache away.

In v3 this becomes a first-class hook: fiber.Config takes a StructValidator, any type with a Validate(out any) error method, and c.Bind().Body(&dto) then decodes and validates in one call, returning your error automatically so handlers stop repeating the same six lines. Four gotchas worth naming. Validator ignores unexported fields silently, so a lowercase field is never checked.

The required tag rejects zero values, which means qty=0 or active=false fail even when they are legal; use a pointer field with required, or omitempty with min. By default error messages contain the Go field name, so register a tag name function that reports the json name instead, otherwise your API tells a mobile client about a field called CouponID that does not exist in the payload. And validation is not authorisation: a well-formed request for someone else's order still needs an ownership check. For Indian payment and KYC flows, register custom rules once (PAN, GSTIN, IFSC, a ten-digit mobile) rather than scattering regexes through handlers.

type structValidator struct{ v *validator.Validate }

func (s structValidator) Validate(out any) error {
	if err := s.v.Struct(out); err != nil {
		var ve validator.ValidationErrors
		if errors.As(err, &ve) {
			fields := make(map[string]string, len(ve))
			for _, e := range ve {
				fields[e.Field()] = e.Tag()
			}
			return &ValidationError{Fields: fields}
		}
		return err
	}
	return nil
}

v := validator.New()
// Report the json name, not the Go field name
v.RegisterTagNameFunc(func(f reflect.StructField) string {
	return strings.Split(f.Tag.Get("json"), ",")[0]
})
_ = v.RegisterValidation("ifsc", func(fl validator.FieldLevel) bool {
	return ifscRe.MatchString(fl.Field().String())
})

app := fiber.New(fiber.Config{StructValidator: structValidator{v: v}})

type Payout struct {
	IFSC   string `json:"ifsc" validate:"required,ifsc"`
	Amount *int64 `json:"amount" validate:"required,gt=0"` // pointer: 0 vs absent
}

app.Post("/payouts", func(c fiber.Ctx) error {
	var in Payout
	if err := c.Bind().Body(&in); err != nil { // decode + validate
		return err
	}
	return c.SendStatus(fiber.StatusAccepted)
})
💡 Pro Tip: Build validator.New() once. Creating it inside a handler discards the per-type reflection cache and shows up as a real CPU cost above a few thousand requests per second.
Q26

What does the timeout middleware actually cancel, and how should timeouts be layered across a Fiber service?

IntermediateReliability

Answer

The honest answer is that it cancels nothing by itself. Go cannot preempt an arbitrary function, so timeout.New wraps your handler, watches a context with a deadline, and when the deadline fires it returns fiber.ErrRequestTimeout (408) to the client. The handler goroutine keeps running until it finishes on its own.

That matters because the resource you were trying to protect, a database connection or a goroutine slot, is still held; you have only stopped making the client wait. The middleware becomes real only when the work inside honours the context, which means every downstream call takes it: db.QueryContext or the pgx equivalent, http.NewRequestWithContext, the ctx-taking Redis methods. In v2 use timeout.NewWithContext, which passes a cancellable context through c.UserContext; the older timeout.New in v2 spawned a goroutine and touched the pooled Ctx from it, which is exactly the unsafe pattern the memory model forbids.

Layering is the part interviewers actually want. Deadlines should shrink as you go inward so the innermost layer fails first and produces a useful error instead of a cascade: load balancer idle timeout above the server WriteTimeout, WriteTimeout above the per-handler timeout, the handler timeout above the database statement timeout and the outbound HTTP client Timeout. Also set fiber.Config ReadTimeout and IdleTimeout, remembering both default to zero, and configure the database pool with SetMaxOpenConns and SetConnMaxLifetime, because a handler timeout with an unbounded pool just moves the queue from your service into Postgres.

import "github.com/gofiber/fiber/v3/middleware/timeout"

// The handler must actually use ctx or the timeout only frees the client
func getOrder(c fiber.Ctx) error {
	ctx := c.Context()
	row := db.QueryRowContext(ctx,
		"SELECT id, status FROM orders WHERE id = $1", c.Params("id"))

	var o Order
	if err := row.Scan(&o.ID, &o.Status); err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			return fiber.ErrGatewayTimeout
		}
		return err
	}
	return c.JSON(o)
}

app.Get("/orders/:id", timeout.New(getOrder, timeout.Config{
	Timeout: 2 * time.Second,
}))

// Budget shrinks inward: ALB 30s > WriteTimeout 20s > handler 2s > DB 1500ms
db.SetMaxOpenConns(25)
db.SetConnMaxLifetime(30 * time.Minute)

// v2: timeout.NewWithContext(getOrder, 2*time.Second)

Key Points

  • The middleware returns 408 but the handler goroutine keeps running
  • It only reclaims resources if every downstream call takes the context
  • v2 timeout.New is unsafe with the pooled Ctx; use NewWithContext
  • Timeout budgets must decrease from the edge inward
Q27

How do you wire liveness and readiness endpoints in Fiber, and why should liveness not check the database?

IntermediateObservability

Answer

v3 ships middleware/healthcheck, registered per probe: app.Get(healthcheck.LivenessEndpoint, healthcheck.NewHealthChecker(...)) and the same for readiness and startup, defaulting to /livez, /readyz and /startupz. v2 used one middleware with LivenessEndpoint, ReadinessEndpoint, LivenessProbe and ReadinessProbe fields on a single Config. The probe function returns a bool and the middleware answers 200 or 503. The design rule matters more than the API.

Liveness answers a single question: is this process wedged such that only a restart fixes it. It must be trivially cheap and must not touch the database, Redis or any other service. If liveness checks Postgres and Postgres has a thirty second blip, the kubelet restarts every replica simultaneously, the restarts stampede the recovering database and you have converted a degraded dependency into a full outage.

Readiness is where dependency checks belong, because failing readiness only removes the pod from the load balancer and it comes back on its own. Even there, cache the result for a second or two: six replicas probing every second add six queries per second of pure overhead, and a probe that itself times out under load makes an overload worse. Use the startup probe for slow warmups (cache priming, config fetch) so you can keep the liveness threshold tight without killing a pod that is still booting.

Finally, exempt the probe paths from the logger, the limiter and auth, or your logs fill with probe noise and a rate limiter can 503 the kubelet. The monitor middleware dashboard is a development convenience, not a metrics backend; expose Prometheus separately.

import "github.com/gofiber/fiber/v3/middleware/healthcheck"

var ready atomic.Bool

// Liveness: process-local only. No database, no Redis, no network.
app.Get(healthcheck.LivenessEndpoint, healthcheck.NewHealthChecker(
	healthcheck.Config{
		Probe: func(c fiber.Ctx) bool { return true },
	},
))

// Readiness: dependency checks, cached so probes do not add load
var lastCheck atomic.Int64
var lastOK atomic.Bool

app.Get(healthcheck.ReadinessEndpoint, healthcheck.NewHealthChecker(
	healthcheck.Config{
		Probe: func(c fiber.Ctx) bool {
			if !ready.Load() {
				return false // flipped false on SIGTERM before draining
			}
			now := time.Now().Unix()
			if now-lastCheck.Load() < 2 {
				return lastOK.Load()
			}
			ctx, cancel := context.WithTimeout(c.Context(), 500*time.Millisecond)
			defer cancel()
			ok := db.PingContext(ctx) == nil
			lastOK.Store(ok)
			lastCheck.Store(now)
			return ok
		},
	},
))
💡 Pro Tip: A liveness probe that checks a shared dependency turns every dependency blip into a fleet-wide restart. Keep it process-local and put the dependency checks in readiness.
Q28

How do you reuse an existing net/http handler or middleware inside a Fiber app, and what does it cost?

IntermediateInteroperability

Answer

The adaptor package bridges both directions. adaptor.HTTPHandler(h) and adaptor.HTTPHandlerFunc(f) wrap a net/http handler as a Fiber handler, adaptor.HTTPMiddleware(mw) wraps a func(http.Handler) http.Handler, and adaptor.FiberHandler and adaptor.FiberApp go the other way when something expects an http.Handler. This is how you mount things the Go ecosystem only ships as net/http: promhttp.Handler() for Prometheus, net/http/pprof, an OAuth callback from a library that predates Fiber, or a vendor SDK's webhook verifier. The cost is real and worth stating unprompted.

The adaptor materialises a full http.Request from the fasthttp context, which means copying the body and building the header map that fasthttp went to such trouble to avoid, then converts the response back. You have given up the exact property you picked Fiber for. That is fine on /metrics and /debug/pprof, which are scraped a few times a minute, and wrong on a hot API route where you should port the middleware to a native Fiber handler instead.

Two functional limits: hijacking the connection does not work through the adaptor, so a net/http WebSocket library will not upgrade, and streaming through http.ResponseWriter with Flush does not stream cleanly, so use Fiber's own stream writer. Context values do not cross automatically either: what a wrapped middleware sets on the http.Request context is not visible as c.Locals unless you copy it back, which is what v2's adaptor.CopyContextToFiberContext exists for. Interviewers use this question to see whether you understand that Fiber is not a net/http framework wearing a different API.

import (
	"net/http"
	_ "net/http/pprof"

	"github.com/gofiber/fiber/v3/middleware/adaptor"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Scraped a few times a minute: the conversion cost is irrelevant here
internal := app.Group("/internal", requireInternalNetwork)
internal.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler()))
internal.Get("/debug/pprof/*", adaptor.HTTPHandler(http.DefaultServeMux))

// A net/http middleware wrapped for Fiber
app.Use(adaptor.HTTPMiddleware(vendor.VerifyWebhookSignature))

// The other direction: hand a Fiber app to something expecting http.Handler
var h http.Handler = adaptor.FiberApp(app)

// Do NOT do this on a hot route: every request rebuilds an http.Request
// app.Get("/api/v1/orders", adaptor.HTTPHandler(legacyOrdersHandler))

Key Points

  • adaptor.HTTPHandler, HTTPHandlerFunc and HTTPMiddleware bring net/http in
  • adaptor.FiberHandler and FiberApp export Fiber as an http.Handler
  • Each conversion rebuilds the request and body, so keep it off hot paths
  • Connection hijacking and ResponseWriter streaming do not survive the bridge
Q29

What does Prefork actually do, and why is it usually the wrong setting inside Kubernetes?

AdvancedArchitecture

Answer

fiber.Config{Prefork: true} makes the process re-exec itself once per available CPU. Each child binds the same port using SO_REUSEPORT, so the kernel distributes incoming connections across the children instead of every connection funnelling through one accept loop. On bare metal with many cores this can raise throughput a little and gives each child its own smaller GC heap.

Inside a container it usually costs more than it gives. First, process count follows GOMAXPROCS, which without automaxprocs reads the node's CPU count, not your cgroup limit, so a pod limited to one core happily forks sixteen children on a sixteen-core node and multiplies resident memory until the OOMKiller intervenes. Second, and more damaging, every piece of in-process state fragments silently: an in-memory cache, the default in-memory rate limiter, a WebSocket hub, a Prometheus registry, an in-process job scheduler, a warmed connection pool.

Your limiter now allows N times Max, your /metrics scrape returns whichever child answered, and a broadcast reaches only the clients connected to one child. Third, operations get worse. SIGTERM reaches the master, so graceful shutdown needs extra care; a panicking child is respawned by the master so crashes can go unnoticed; and attaching a debugger or reading logs means figuring out which child served the request.

Fiber gives you fiber.IsChild() so one-time work such as migrations or a scheduler runs only in the master, which is the tell that this feature assumes you have shared state to worry about. Under an orchestrator the equivalent scaling knob already exists: run more pods, which the HPA can adjust and which the load balancer already understands.

import _ "go.uber.org/automaxprocs" // respect the cgroup CPU limit

app := fiber.New(fiber.Config{
	Prefork: os.Getenv("PREFORK") == "true", // off by default in k8s
})

func main() {
	// Run one-time work in the master only, never once per child
	if !fiber.IsChild() {
		if err := migrations.Up(dsn); err != nil {
			log.Fatal(err)
		}
		go scheduler.Start()
	}

	log.Printf("pid=%d child=%v", os.Getpid(), fiber.IsChild())
	log.Fatal(app.Listen(":3000"))
}

// With Prefork on, none of these can stay in process memory:
//   limiter.Config{}            -> needs a shared Storage
//   an in-memory response cache -> needs Redis
//   a WebSocket hub             -> needs Redis pub/sub
//   the Prometheus registry     -> per-child, scrapes are unreliable
💡 Pro Tip: If a candidate says Prefork made their service faster, ask what their in-memory rate limiter did afterwards. It is the fastest way to find out whether they measured or copied a config.
Q30

A Fiber service shows rising memory and p99 under load. How do you profile it and what do you usually find?

AdvancedPerformance

Answer

Mount middleware/pprof, which registers the standard /debug/pprof endpoints, but put it on an internal group behind network policy or a separate listener, because those endpoints are both an information leak and a cheap denial of service. Then work in a fixed order. Start with goroutine?debug=2 while load is running: if the count climbs monotonically and never returns to baseline you have a leak, and the stack traces name the exact line, usually a channel send with no receiver, a WebSocket read loop with no deadline, or a goroutine holding a context that is never cancelled.

Next take a heap profile and compare inuse_space against alloc_space. Growing inuse_space with stable alloc_space means retention, and in Fiber that is nearly always a request-derived string or slice stashed in a package-level map, the memory model bug in retention form. High alloc_space with flat inuse_space means garbage pressure, so pull the top allocation sites from /debug/pprof/allocs.

The recurring offenders are JSON marshalling, fiber.Map on hot paths (a map allocation plus reflection where a struct would do), fmt.Sprintf in a logger format string, and a middleware building a slice per request. Take a thirty second CPU profile under the same load and check whether encoding/json, regexp or your own code dominates. Confirm fixes with go test -bench -benchmem and benchstat rather than eyeballing a single run, and use go build -gcflags=-m to see what escapes to the heap. Two runtime settings finish the job: set GOMEMLIMIT to roughly eighty percent of the pod memory limit so the collector works harder before the kernel kills you, and use automaxprocs so GOMAXPROCS matches the cgroup quota instead of the node.

import "github.com/gofiber/fiber/v3/middleware/pprof"

// Internal only. Never expose pprof on a public route.
internal := app.Group("/internal", requireInternalNetwork)
internal.Use(pprof.New(pprof.Config{Prefix: "/internal"}))

// Then, against a pod under real load:
//   go tool pprof -http=:8080 http://svc:3000/internal/debug/pprof/allocs
//   go tool pprof http://svc:3000/internal/debug/pprof/profile?seconds=30
//   curl -s http://svc:3000/internal/debug/pprof/goroutine?debug=2 | head -50
//   go tool pprof -base before.heap after.heap

// Lock and block profiles are off by default
runtime.SetMutexProfileFraction(5)
runtime.SetBlockProfileRate(10000)

// Let the GC defend the pod memory limit instead of the OOMKiller
// env: GOMEMLIMIT=1600MiB on a 2Gi limit, plus go.uber.org/automaxprocs

Key Points

  • goroutine?debug=2 first: a monotonic count is a leak with a named stack
  • inuse_space growing means retention, alloc_space high means GC pressure
  • Usual Fiber offenders: JSON, fiber.Map on hot paths, Sprintf in logging
  • Set GOMEMLIMIT near the pod limit and use automaxprocs for GOMAXPROCS
Q31

You are migrating a live Fiber v2 service to v3. What actually breaks, and how do you sequence it?

AdvancedMigration

Answer

Most of v3 is a compile error, which is the good case. The handler signature changes from func(c *fiber.Ctx) error to func(c fiber.Ctx) error, where Ctx became an interface passed by value, and that touches every handler and middleware in the codebase. Request parsing consolidates: BodyParser, QueryParser, ParamsParser and ReqHeaderParser all move behind c.Bind().Body(), .Query(), .URI() and .Header(). app.Static is gone, replaced by middleware/static mounted on a wildcard route. app.Mount is gone, replaced by app.Use(prefix, subApp).

Listener configuration moves out of fiber.Config into fiber.ListenConfig passed to app.Listen, which is also where graceful shutdown and TLS now live. Proxy trust renames from EnableTrustedProxyCheck and TrustedProxies to TrustProxy and TrustProxyConfig. You also gain generic helpers, fiber.Query[T], fiber.Params[T] and fiber.Locals[T], and a StructValidator hook on Config.

The dangerous changes are the ones that still compile. c.Bind() existed in v2 as the template-variable setter and now means request binding; the template form is c.ViewBind(). And the static middleware without a trailing wildcard route matches the prefix but not the files under it, so /assets returns the index and /assets/app.js returns 404, in production, silently. Sequence it as: get integration tests green on v2 first, branch, bump the module path to /v3 and let the compiler drive, upgrade every gofiber/contrib and gofiber/storage dependency to its v3 line in the same commit since mixed versions do not link, then diff behaviour on a canary with real traffic before promoting. Grep for c.Bind(, app.Static, app.Mount and ListenTLS before you start.

// v2
func handler(c *fiber.Ctx) error {
	var in Order
	if err := c.BodyParser(&in); err != nil {
		return err
	}
	page := c.QueryInt("page", 1)
	uid := c.Locals("uid").(string) // panics when auth did not run
	return c.JSON(fiber.Map{"uid": uid, "page": page})
}

app.Static("/assets", "./public")
app.Mount("/admin", adminApp)
app.ListenTLS(":443", "cert.pem", "key.pem")

// v3
func handler(c fiber.Ctx) error {
	var in Order
	if err := c.Bind().Body(&in); err != nil {
		return err
	}
	page := fiber.Query[int](c, "page", 1)
	uid := fiber.Locals[string](c, "uid") // zero value, no panic
	return c.JSON(fiber.Map{"uid": uid, "page": page})
}

app.Get("/assets*", static.New("./public")) // the * is mandatory
app.Use("/admin", adminApp)
app.Listen(":443", fiber.ListenConfig{
	CertFile:    "cert.pem",
	CertKeyFile: "key.pem",
})
💡 Pro Tip: The two migration bugs that reach production are c.Bind() changing meaning and static.New without a wildcard route. Both compile fine.
Q32

How do you get distributed tracing, metrics and correlated logs out of a Fiber service?

AdvancedObservability

Answer

Use otelfiber from the gofiber/contrib repository, matched to your Fiber major version. Registered with app.Use, it starts a server span per request, records status and duration, and extracts W3C traceparent from inbound headers so a call arriving from another service continues the same trace. The detail that decides whether the setup survives is span naming: name spans from c.Route().Path, the registered pattern, not c.Path(), or every order identifier becomes its own span name and metric series and you get a cardinality explosion that your backend will bill you for or drop.

Propagation is the second half. Downstream calls must carry the request context, c.UserContext() in v2 or c.Context() in v3, into db.QueryContext and otelhttp-instrumented clients; without it you get one orphan span per service instead of a trace. For logs, run the requestid middleware, pull the trace and span IDs out of the context with trace.SpanContextFromContext, and put trace_id on every line so a log search jumps straight into the trace.

Emit RED metrics keyed by route pattern and status class, and add Go runtime metrics because GC pause and goroutine count explain most Fiber latency mysteries. Three operational notes people forget: the batch span processor drops spans silently when its queue fills under load, so alert on the exporter's dropped counter; you must call TracerProvider.Shutdown during graceful shutdown or you lose the traces from the requests you most wanted to see; and sample by ratio for normal traffic while keeping errors, which is cleanest done as tail sampling in the collector rather than in the process.

import (
	"github.com/gofiber/contrib/otelfiber/v2"
	"go.opentelemetry.io/otel/trace"
)

app.Use(otelfiber.Middleware(
	otelfiber.WithServerName("orders-api"),
	// Bound cardinality: the route pattern, never the concrete path
	otelfiber.WithSpanNameFormatter(func(c *fiber.Ctx) string {
		return c.Method() + " " + c.Route().Path
	}),
	otelfiber.WithNext(func(c *fiber.Ctx) bool {
		return c.Path() == "/livez" || c.Path() == "/readyz"
	}),
))

// Correlate every log line with the trace
app.Use(func(c fiber.Ctx) error {
	sc := trace.SpanContextFromContext(c.Context())
	if sc.IsValid() {
		c.Locals("trace_id", sc.TraceID().String())
		c.Set("X-Trace-Id", sc.TraceID().String())
	}
	return c.Next()
})

// Propagate, or every service starts its own orphan trace
rows, err := db.QueryContext(c.Context(), query, args...)

// Flush on shutdown or you lose the last traces
defer tp.Shutdown(context.Background())

Key Points

  • Name spans from c.Route().Path to keep metric cardinality bounded
  • Pass c.Context() (v3) or c.UserContext() (v2) into every downstream call
  • Put trace_id on log lines via trace.SpanContextFromContext
  • Shut the TracerProvider down during drain or the last traces vanish
Q33

Fiber cannot serve HTTP/2 or gRPC. What follows from that architecturally, and when would you not choose Fiber?

AdvancedArchitecture

Answer

fasthttp implements HTTP/1.1 only. There is no HTTP/2, no h2c, no HTTP/3, no server push and no trailers, and this is a design position rather than a missing feature: the optimisations that make fasthttp fast assume the HTTP/1.1 connection model. The practical consequences are manageable but you must know them. gRPC needs HTTP/2 with trailers, so you cannot serve gRPC through Fiber at all; the normal pattern is one binary with two listeners, a grpc.Server on one port and the Fiber app on another, shut down together.

Browsers still get HTTP/2 because your ALB, CloudFront, Cloudflare or nginx terminates it and speaks HTTP/1.1 to the pod, which is the standard deployment and costs you nothing on the client side. What it does cost is on the internal hop: connection reuse between proxy and pod is HTTP/1.1 pipelining rules, and browser per-origin connection limits bite anything that holds connections open, which is why SSE fan-out is more constrained here than on an HTTP/2 stack. A service mesh sidecar generally solves the internal hop by speaking h2 proxy to proxy.

When would I not choose Fiber? When the service is primarily gRPC or gRPC-Web. When the organisation standardises on net/http and depends on libraries that only expose http.Handler, because the adaptor tax then applies everywhere.

When you need bidirectional streaming or trailers. And when the team is new to Go, because Fiber's pooled Ctx gives you a class of production bug that Chi or Gin simply do not have. The honest closing point: for the majority of CRUD APIs the HTTP layer is not the bottleneck, the database is, so pick Fiber when you have measured that it matters.

// One binary, two listeners: gRPC cannot ride on the Fiber port
func main() {
	app := buildFiberApp()
	grpcSrv := grpc.NewServer()
	pb.RegisterOrdersServer(grpcSrv, &ordersService{})

	lis, err := net.Listen("tcp", ":9090")
	if err != nil {
		log.Fatal(err)
	}

	g, ctx := errgroup.WithContext(context.Background())
	g.Go(func() error { return grpcSrv.Serve(lis) })     // HTTP/2
	g.Go(func() error { return app.Listen(":3000") })    // HTTP/1.1
	g.Go(func() error {
		<-ctx.Done()
		grpcSrv.GracefulStop()
		return app.ShutdownWithTimeout(20 * time.Second)
	})

	if err := g.Wait(); err != nil {
		log.Fatal(err)
	}
}

// Ingress terminates h2 for browsers and talks HTTP/1.1 to the pod.
// Anything needing trailers or bidi streaming must use the gRPC port.
💡 Pro Tip: Interviewers ask this to see whether you can argue against your own stack. A candidate who cannot name a case where Fiber is the wrong choice usually has not run it in production.
Q34

How much does swapping the JSON encoder actually buy, and how do you benchmark a Fiber service honestly?

AdvancedPerformance

Answer

fiber.Config exposes JSONEncoder and JSONDecoder, so replacing encoding/json with goccy/go-json or bytedance/sonic is two lines and applies to c.JSON and the binder. On JSON-heavy endpoints with large payloads the improvement is real, since encoding/json is reflection-driven and dominates CPU profiles on those routes; sonic uses code generation and assembly and is generally the fastest on amd64 and arm64, while goccy is pure Go and safer on unusual platforms and toolchains. Two cautions belong in the answer: these libraries are compatible but not identical, and differences around HTML escaping, number precision and error message text have bitten teams whose tests asserted on error strings; and setting the encoder in fiber.Config changes only what Fiber calls, so anywhere your own code imports encoding/json directly it keeps the old path.

Benchmarking honestly is the harder half. Generate load from a separate machine with k6, vegeta or wrk, because a load generator on the same box competes for CPU and flatters the result. Warm the process before measuring so the JIT, connection pools and caches are hot.

Report p99 and p999, not mean, and hold the dependency behaviour constant by stubbing the database so you are measuring the change and not query variance. Run each variant at least three times and compare with benchstat rather than trusting one run. Above all, resist the hello-world benchmark: two hundred thousand requests per second on a route that returns a constant tells you nothing about a service whose p99 is one hundred and eighty milliseconds of Postgres. The ordered wins are almost always fix the N+1 query, add caching, cut allocations, then swap the encoder.

import "github.com/bytedance/sonic"

app := fiber.New(fiber.Config{
	JSONEncoder: sonic.Marshal,
	JSONDecoder: sonic.Unmarshal,
	// goccy alternative: json.Marshal / json.Unmarshal from goccy/go-json
})

// Structs beat fiber.Map on hot paths: no map alloc, less reflection
type OrderResponse struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	Amount int64  `json:"amount"`
}

// Pass-through payloads: skip the decode and re-encode round trip
type Envelope struct {
	Type    string          `json:"type"`
	Payload json.RawMessage `json:"payload"`
}

// Measure properly, from another host, and compare with benchstat:
//   k6 run --vus 200 --duration 3m load.js
//   go test -bench=BenchmarkEncode -benchmem -count=10 ./... > new.txt
//   benchstat old.txt new.txt
Q35

A Fiber service starts timing out at peak load while CPU sits around forty percent. How do you diagnose and fix it?

AdvancedProduction

Answer

Low CPU with high latency means you are queueing somewhere, not computing. Start with the admission layer, because Fiber's defaults are extremely permissive. fiber.Config Concurrency defaults to 256 times 1024, roughly 262,000 simultaneous connections, and fasthttp will happily accept them all, allocating a worker goroutine and read and write buffers for each. Long before CPU saturates, resident memory climbs and the scheduler thrashes, so the pod either gets OOMKilled or every request slows down together.

Combine that with ReadTimeout and WriteTimeout defaulting to zero and there is nothing to shed a backlog: slow clients accumulate forever. The second queue is almost always the database. If the pool is capped with SetMaxOpenConns(25) and two thousand requests are in flight, 1,975 goroutines are blocked in the pool waiting, which looks like slow queries in application metrics while Postgres itself is idle.

Check pool wait count and wait duration before you blame the database. The third is retry amplification: clients that retry a timeout turn a ten percent latency blip into three times the offered load, so retries need jitter, a budget and a circuit breaker. The fix is admission control rather than more capacity.

Set Concurrency to something you can actually serve, set the timeouts, add a semaphore that sheds with 503 and Retry-After once in-flight work exceeds what the pool can absorb, and make the timeout budget shrink inward so the innermost layer fails first. Shedding early is not a defeat: a service that returns 503 in two milliseconds recovers, and one that queues everything does not. Watch fasthttp's concurrency limit exceeded responses and the pool wait metric as your leading indicators.

app := fiber.New(fiber.Config{
	// Default is 256 * 1024 connections. Pick a number you can serve.
	Concurrency:    4096,
	ReadTimeout:    10 * time.Second,
	WriteTimeout:   20 * time.Second,
	IdleTimeout:    60 * time.Second,
	ReadBufferSize: 8 * 1024,
})

// Admission control: shed fast instead of queueing forever
var inflight = make(chan struct{}, 200) // sized near DB pool capacity

func shed(c fiber.Ctx) error {
	select {
	case inflight <- struct{}{}:
		defer func() { <-inflight }()
		return c.Next()
	default:
		c.Set(fiber.HeaderRetryAfter, "1")
		return fiber.NewError(fiber.StatusServiceUnavailable, "overloaded")
	}
}

app.Use(shed)

// The real queue is usually here. Export these before blaming Postgres.
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
stats := db.Stats() // stats.WaitCount, stats.WaitDuration

Key Points

  • fiber.Config Concurrency defaults to about 262,000 connections
  • Zero-valued ReadTimeout and WriteTimeout mean a backlog never drains
  • db.Stats().WaitCount tells you whether the queue is the pool, not the query
  • Shed with 503 and Retry-After rather than queueing past capacity
💡 Pro Tip: Graph db.Stats().WaitDuration next to request p99. When the two curves match, the fix is pool sizing and admission control, not a faster framework.

Companies Hiring Fiber

Zerodha
Dream11
Razorpay
PhonePe
Groww
Flipkart
Swiggy
Uber

Salary Insights

Average in India
₹10-28 LPA

Frequently Asked Questions

What salary can a Fiber developer expect in India in 2026?

Fiber is not priced on its own; you are paid as a Go backend engineer and Fiber is one line on the CV. Freshers with real Go projects typically start around ₹6-10 LPA, and the same profile at a product company with a strong hiring bar lands ₹10-14 LPA. With two to four years of Go in production the band is roughly ₹14-24 LPA, and five or more years running high-throughput services pushes ₹25-45 LPA at fintech and consumer-scale employers such as Zerodha, Dream11, PhonePe, Razorpay, Groww and Flipkart. Service companies and mid-size product teams sit noticeably lower, often ₹8-16 LPA for the same years. What actually moves the number is evidence of load: a service you can describe at a specific QPS, the p99 you held, how you profiled it and what you changed. Candidates who can only recite the Fiber API get the lower half of every band.

How long does it take to prepare for a Fiber interview if I already know Go?

If you are comfortable with goroutines, channels, interfaces, context and the standard library, two to three weeks of focused evenings is realistic. Week one: build a small service with groups, custom ErrorHandler, binding and validation, the logger, recover, requestid and CORS middlewares, and write tests through app.Test. Week two: the Fiber-specific material that decides interviews, which is the fasthttp memory model, the copy rule for request-derived strings, Ctx lifetime across goroutines, trusted proxies, rate limiting with a shared store, streaming and graceful shutdown. Week three: profile it. Run pprof under load, swap the JSON encoder, measure the difference honestly and be able to talk about where the time actually went. If you do not know Go yet, add six to eight weeks before any of this, because every Fiber interview is a Go interview first and concurrency questions carry more weight than framework questions.

Is Fiber worth learning in 2026, or should I pick Gin or Echo?

Learn Go properly and treat the framework as a two-day detail, because the concepts transfer almost completely. That said, the three are not interchangeable in one respect: Gin, Echo and Chi are built on net/http, and Fiber is built on fasthttp. That single difference gives Fiber its allocation profile and takes away HTTP/2, same-port gRPC and drop-in compatibility with the net/http middleware ecosystem. Gin remains the most common Go framework in Indian job posts, Chi is the usual choice on teams that want to stay close to the standard library, and Fiber shows up where somebody wanted Express ergonomics or chased benchmark numbers. Knowing Fiber well is genuinely useful because the questions it forces you to answer, buffer reuse, context lifetime, pooling, are exactly the questions that separate a senior Go engineer from a competent one. Put Go first on the CV and the framework second.

What is the difference in how freshers and experienced candidates are assessed on Fiber?

Freshers are asked to build. Expect a take-home or a live task: a CRUD resource with route groups, a middleware, JSON binding with validation, a custom error handler and a couple of tests, judged on whether the code compiles, whether errors are returned rather than swallowed, and whether you understood that a handler returns an error. Experienced candidates are asked what broke. Interviewers move quickly to the operational surface: why a cache key held the wrong tenant, why the rate limiter counted every request as one client, what Prefork does inside a pod with a CPU limit, how you shut down without 502s during a deploy, how you found the allocation that was driving GC. A five-year candidate who cannot describe one production incident in detail interviews worse than a two-year candidate who can.

Does Fiber experience transfer to other Go frameworks and to jobs abroad?

Yes, and more than the framework name suggests. Routing, middleware chains, binding and validation, structured logging and graceful shutdown look nearly identical in Gin, Echo and Chi, so moving over costs a weekend. The transferable depth is the runtime knowledge Fiber forces on you: sync.Pool semantics, zero-copy slices, context propagation and cancellation, GC pressure and how to measure it with pprof. That vocabulary is what interviewers overseas and at remote-first employers actually test. One practical caution when applying abroad or to platform teams: because fasthttp has no HTTP/2 or HTTP/3, some organisations standardise on net/http and will ask why you chose Fiber. Have a real answer about workload fit rather than a benchmark chart, and mention that you know when Fiber is the wrong tool.

Which companies in India actually run Fiber in production?

Fiber sits inside a broader Go hiring market rather than having its own job category, so search for Go roles and read the stack description. Go backends are common at Zerodha, Dream11, Razorpay, PhonePe, Groww, Flipkart, Swiggy and Uber's India engineering teams, and within those organisations different services use different frameworks, with Fiber showing up most often on internal APIs and high-QPS JSON services where a team wanted Express-shaped code. Smaller fintech, gaming and adtech startups pick it more freely because they have no legacy net/http investment. Practically, apply to Go roles, mention Fiber alongside Gin or Chi rather than as your only framework, and lead with throughput and reliability stories. Nobody in India is hiring for Fiber specifically; they are hiring for a Go engineer who can keep a service up at load.

Introduction

Fiber is the Express-shaped Go framework: app.Get, app.Use, c.JSON, c.Next, and handlers that return an error instead of writing into a ResponseWriter. What makes it genuinely different from Gin, Echo or Chi is the transport underneath. Fiber is built on valyala/fasthttp, not net/http. Request objects are pooled and recycled, headers are parsed lazily into byte slices instead of a map allocated per request, and the Ctx you were handed goes back into a sync.Pool the instant your handler returns. That design buys throughput on small JSON payloads and costs you stdlib compatibility, HTTP/2, and the right to hold on to request data.

Hiring for Fiber in India sits inside broader Go backend hiring. Go services power order routing, fantasy-sports contest engines, payment switches and internal platform APIs at companies like Zerodha, Dream11, Razorpay, PhonePe, Groww and Flipkart, and Fiber turns up wherever a team wanted Express ergonomics without giving up goroutines and static binaries. Interviewers almost never stop at routing. They ask why the string from c.Params turned into garbage inside a goroutine, what Prefork actually does inside a Kubernetes pod with a 1 CPU limit, why the rate limiter thought every request came from the load balancer, and what breaks when you move a service from v2 to v3.

This set covers 35 questions ordered from fundamentals up to production architecture, with runnable Go on most of them. The basic block handles routing, binding, middleware, configuration and the error contract. The intermediate block is where offers are usually decided: buffer reuse and CopyString, Ctx lifetime across goroutines, trusted proxies, rate limiting with shared storage, WebSockets, streaming, sessions and CSRF, and testing through app.Test. The advanced block covers Prefork trade-offs, cancellation and timeouts, the v2 to v3 migration surface, JSON encoder swaps and pprof profiling, OpenTelemetry wiring, and the HTTP/2 gap that decides whether Fiber belongs in a stack at all.

Ready to practice Fiber interviews?

Don't just read, practice these Fiber questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview