Echo Interview Questions and Answers

Last updated:

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

GoREST APIMiddlewareWebSocketPerformance
35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

What is Echo, and how does its radix tree router differ from Go's net/http ServeMux?

BasicFundamentals

Answer

Echo is a thin, performance-focused HTTP framework built directly on net/http, imported as github.com/labstack/echo/v4. Its defining piece is the router: a radix tree (compressed prefix trie) where every registered path is decomposed into shared prefixes, so lookup cost scales with the length of the URL rather than the number of routes. A service with five routes and a service with five thousand routes match at broadly the same speed.

Echo also pre-allocates a parameter slice sized to the deepest route in the tree, so matching a path like /orders/:id/items/:sku performs no heap allocation at request time. That zero-allocation claim in the README is specifically about routing, not about your handler, your JSON encoding, or your database driver. Go's standard ServeMux gained method and wildcard patterns in Go 1.22, so you can now write mux.HandleFunc("GET /users/{id}", h) and read r.PathValue("id"), which closes part of the gap for simple services.

What ServeMux still does not give you is a middleware chain with an ordering model, grouped routes with inherited middleware, a handler signature that returns an error, binding and validation hooks, or the twenty-odd batteries in echo/v4/middleware. Echo's value in 2026 is that plumbing, plus the fact that it never hides net/http from you: c.Request() and c.Response() are the real standard library types, so any http.Handler or http.RoundTripper you already own keeps working.

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.HideBanner = true
	e.Use(middleware.Recover(), middleware.RequestID())

	e.GET("/health", func(c echo.Context) error {
		return c.NoContent(http.StatusNoContent)
	})
	e.GET("/users/:id", func(c echo.Context) error {
		return c.JSON(http.StatusOK, echo.Map{"id": c.Param("id")})
	})

	e.Logger.Fatal(e.Start(":8080"))
}

Key Points

  • Module path is github.com/labstack/echo/v4; v4 is the maintained line
  • Radix tree router: lookup cost tracks URL length, not route count
  • Zero allocation applies to route matching, not to your whole handler
  • Go 1.22 ServeMux added GET /users/{id} patterns but no middleware model
  • c.Request() and c.Response() are plain net/http types underneath
💡 Pro Tip: If an interviewer asks why Echo is fast, do not just say radix tree. Say it allocates nothing during route matching and reuses echo.Context from a sync.Pool, then admit that JSON encoding and the DB round trip dominate real latency anyway.
Q2

How do you declare path parameters and wildcards in Echo, and what wins when two routes could match?

BasicRouting

Answer

Echo v4 uses colon-prefixed segments for named parameters and a bare asterisk for a catch-all. /users/:id captures a single segment, readable with c.Param("id"). /files/* captures everything to the end of the path, readable with c.Param("*"), which is what you use for proxy passthroughs and file servers. You can have several parameters in one path, for example /orgs/:orgID/projects/:projectID/runs/:runID, and c.ParamNames() plus c.ParamValues() gives you all of them as parallel slices when you need to iterate. The matching priority question is the one that catches people.

Echo resolves in this order at every node of the tree: static segment first, then parameter segment, then the catch-all. So if you register both /users/me and /users/:id, a request for /users/me hits the static handler and never reaches the param handler, regardless of registration order. This is different from frameworks that match on registration order, and it is usually what you want: you can add a specific route later without reordering anything.

Two related gotchas. First, Echo does not treat /users and /users/ as the same route, so mount middleware.RemoveTrailingSlash() (or AddTrailingSlash) via e.Pre() if clients are inconsistent. Second, c.Param() returns the empty string for a name that was never registered rather than erroring, so a typo in the parameter name produces a silent empty ID instead of a crash. Always validate the parsed value before it reaches a query.

e := echo.New()
e.Pre(middleware.RemoveTrailingSlash())

// Static beats param: /users/me never reaches getUser.
e.GET("/users/me", currentUser)
e.GET("/users/:id", getUser)

// Catch-all for a static asset proxy.
e.GET("/files/*", func(c echo.Context) error {
	key := c.Param("*") // e.g. "2026/invoices/inv-1042.pdf"
	return c.String(http.StatusOK, key)
})

func getUser(c echo.Context) error {
	id, err := strconv.ParseInt(c.Param("id"), 10, 64)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "id must be numeric")
	}
	return c.JSON(http.StatusOK, echo.Map{"id": id})
}

Key Points

  • /users/:id for one segment, /files/* for catch-all via c.Param("*")
  • Match priority is static, then param, then any, independent of registration order
  • c.ParamNames() and c.ParamValues() expose all captures as slices
  • Trailing slashes are distinct routes; fix with e.Pre(middleware.RemoveTrailingSlash())
  • A misspelled c.Param name returns "" silently, so validate before use
Q3

Why does an Echo handler return an error, and what does the framework do with it?

BasicError Handling

Answer

The handler signature is func(c echo.Context) error, and that return value is the whole error-handling design. When a handler or any middleware returns a non-nil error, Echo stops the chain and passes the error to e.HTTPErrorHandler along with the context. The default implementation checks whether the error is an *echo.HTTPError; if so it writes that status code and message as JSON, and if not it writes a generic 500 with the body {"message":"Internal Server Error"}.

This is a deliberate contrast with net/http, where a handler returns nothing and every function has to remember to call http.Error itself. In Echo, forgetting to handle an error is loud rather than silent, because the compiler forces you to do something with the returned value. Construct typed errors with echo.NewHTTPError(http.StatusNotFound, "order not found").

Crucially, attach the underlying cause with .WithInternal(err) rather than embedding it in the client-facing message, because the Internal field is what you log while the Message field is what the caller sees. That separation is how you avoid leaking a Postgres constraint name to a mobile app. Two behaviours worth knowing.

If you need to trigger the error handler from somewhere that cannot return, for example inside a deferred function or a callback, call c.Error(err) directly. And once anything has been written to the response, c.Response().Committed becomes true, so the error handler cannot change the status code any more; it can only log. Interviewers love asking what happens when a handler streams half a response and then fails, and this is the answer.

func getOrder(c echo.Context) error {
	order, err := repo.Find(c.Request().Context(), c.Param("id"))
	if errors.Is(err, sql.ErrNoRows) {
		return echo.NewHTTPError(http.StatusNotFound, "order not found")
	}
	if err != nil {
		// Message goes to the client, Internal goes to the logs only.
		return echo.NewHTTPError(http.StatusBadGateway, "order lookup failed").
			WithInternal(err)
	}
	return c.JSON(http.StatusOK, order)
}

Key Points

  • Returning an error hands control to e.HTTPErrorHandler
  • echo.NewHTTPError(code, msg).WithInternal(cause) separates logs from client output
  • c.Error(err) invokes the handler manually when you cannot return
  • Default handler emits a bare 500 for any non-HTTPError value
  • After c.Response().Committed is true, the status code can no longer change
💡 Pro Tip: Never return the raw database error. echo.NewHTTPError(500, err.Error()) is the single most common way Indian backend teams leak table and column names into public API responses.
Q4

What is the difference between e.Use() and e.Pre() in Echo?

BasicMiddleware

Answer

Both register middleware, but they run at different points relative to routing. e.Pre() middleware executes before the router looks at the request, so it can still mutate the path and change which handler eventually matches. e.Use() middleware executes after the router has resolved a route, so by then c.Path() is populated, path parameters are available, and rewriting the URL has no effect on dispatch. That single distinction explains the whole set of rules. Anything that rewrites URLs belongs in Pre: middleware.RemoveTrailingSlash(), middleware.AddTrailingSlash(), middleware.Rewrite() and middleware.NonWWWRedirect().

Anything that observes or guards a matched route belongs in Use: authentication, logging, metrics, gzip, rate limiting, recovery. Putting RemoveTrailingSlash in Use is a classic bug report: the code looks right, the path is rewritten, and the request still 404s because routing already happened. Ordering inside each bucket is registration order, and the chain is built as nested closures, so the first middleware registered is the outermost wrapper.

That matters for Recover: register it first so it wraps everything else, otherwise a panic in an earlier middleware escapes to the net/http server, which kills the connection and prints a raw stack trace to stderr. One more subtlety. Middleware added with e.Use() after routes are registered still applies, because Echo builds the chain lazily on the first request, but group-level middleware added after routes are attached to that group does not always apply. The safe habit is to register all middleware before any routes.

e := echo.New()

// Before routing: can still change which route matches.
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(middleware.RewriteWithConfig(middleware.RewriteConfig{
	Rules: map[string]string{"/api/old/*": "/api/v2/$1"},
}))

// After routing: sees the matched route.
e.Use(middleware.Recover())
e.Use(middleware.Gzip())
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		// c.Path() is the template, e.g. "/users/:id"
		start := time.Now()
		err := next(c)
		metrics.Observe(c.Path(), c.Response().Status, time.Since(start))
		return err
	}
})

Key Points

  • e.Pre() runs before routing and can rewrite the path
  • e.Use() runs after routing, so c.Path() and params are available
  • RemoveTrailingSlash, AddTrailingSlash and Rewrite must go in Pre
  • First registered is outermost; put Recover first
  • Register middleware before routes to avoid group-level ordering surprises
Q5

What exactly does c.Bind() bind on a GET request versus a POST request?

BasicBinding

Answer

This is the Echo question that separates people who have read the source from people who have read a tutorial. Echo's DefaultBinder.Bind does three things in a fixed order. It always binds path parameters using the param struct tag.

It then binds query parameters using the query tag, but only when the request method is GET, DELETE or HEAD. Finally it binds the request body using the json, xml or form tag, chosen from the Content-Type header. The method restriction on query parameters was introduced in v4.2 and trips up everyone upgrading from older code.

The reasoning is precedence: if a POST arrives at /items?id=1 with a body of {"id":100}, there is no obvious right answer for which value wins, so Echo simply stops binding query parameters for body-carrying methods. If you genuinely need both on a POST, call the individual binders yourself through echo.DefaultBinder: BindPathParams, BindQueryParams, BindBody and BindHeaders, in whatever order your API contract demands. Content-Type drives body binding entirely. application/json uses encoding/json, application/xml uses encoding/xml, and application/x-www-form-urlencoded or multipart/form-data uses the form tag.

If the header is missing or unrecognised on a request that has a body, Echo returns 415 Unsupported Media Type, not 400, which surprises people debugging curl calls without -H. An empty body returns 400 with "Request body can't be empty" rather than leaving your struct zeroed. Bind also does not validate anything; it only populates fields. Validation is a separate step through c.Validate().

type SearchReq struct {
	OrgID string `param:"orgID"`
	Query string `query:"q"    json:"q"`
	Page  int    `query:"page" json:"page"`
}

func search(c echo.Context) error {
	var req SearchReq
	if err := c.Bind(&req); err != nil {
		return err // already an *echo.HTTPError with 400 or 415
	}
	return c.JSON(http.StatusOK, req)
}

// Need query params on a POST too? Call the binders explicitly.
func createFiltered(c echo.Context) error {
	var req SearchReq
	b := new(echo.DefaultBinder)
	if err := b.BindPathParams(c, &req); err != nil {
		return err
	}
	if err := b.BindQueryParams(c, &req); err != nil {
		return err
	}
	return b.BindBody(c, &req)
}

Key Points

  • Order is path params, then query params, then body
  • Query params bind only for GET, DELETE and HEAD since v4.2
  • Body decoder is chosen from Content-Type; wrong header returns 415
  • Struct tags: param, query, json, xml, form, header
  • Bind never validates; call c.Validate() afterwards
💡 Pro Tip: If a POST endpoint suddenly stopped seeing ?page=2 after a dependency bump, you upgraded past Echo v4.2. That is expected behaviour, not a regression.
Q6

How do you wire request validation into Echo using e.Validator?

BasicValidation

Answer

Echo deliberately ships no validator. It defines a one-method interface, echo.Validator with Validate(i interface{}) error, and calls it when you invoke c.Validate(payload). If you call c.Validate without assigning e.Validator, you get a 500 with "invalid Validator instance".

The near-universal choice in Go is github.com/go-playground/validator/v10, driven by validate struct tags. Wrap it in a small type, construct exactly one instance at startup, and assign it to e.Validator. Reusing one *validator.Validate matters: it caches reflection metadata per struct type internally and is safe for concurrent use, so creating one per request throws away that cache and adds measurable allocation under load.

The pattern in real codebases is to convert validator.ValidationErrors into a field-keyed map so the client gets {"errors":{"email":"must be a valid email"}} rather than the library's default English sentence, which mentions the Go struct name and confuses frontend developers. Cast the returned error with errors.As to *validator.ValidationErrors, iterate, and use fe.Field() plus fe.Tag() to build the map. Two production notes.

First, register a tag name function that reads the json tag, otherwise error keys come back as Go field names like EmailAddress instead of email_address. Second, validation belongs at the transport boundary on a dedicated request struct, not on your domain entity: putting validate tags on the same struct you persist is how a field that is required on create silently becomes required on partial update too.

type Validator struct{ v *validator.Validate }

func (cv *Validator) Validate(i any) error {
	if err := cv.v.Struct(i); err != nil {
		var ve validator.ValidationErrors
		if errors.As(err, &ve) {
			fields := map[string]string{}
			for _, fe := range ve {
				fields[fe.Field()] = fe.Tag()
			}
			return echo.NewHTTPError(http.StatusUnprocessableEntity,
				echo.Map{"errors": fields})
		}
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	return nil
}

v := validator.New(validator.WithRequiredStructEnabled())
v.RegisterTagNameFunc(func(f reflect.StructField) string {
	return strings.Split(f.Tag.Get("json"), ",")[0]
})
e.Validator = &Validator{v: v}

type CreateUser struct {
	Email string `json:"email" validate:"required,email"`
	Age   int    `json:"age"   validate:"gte=13,lte=120"`
}

Key Points

  • echo.Validator is a single-method interface; Echo ships no implementation
  • Use one shared *validator.Validate; it caches struct metadata and is goroutine safe
  • RegisterTagNameFunc to surface json names instead of Go field names
  • Convert validator.ValidationErrors into a field-keyed JSON map
  • Validate a request DTO, never the entity you persist
Q7

Which response helpers does echo.Context expose, and what does c.Response().Committed mean?

BasicResponses

Answer

echo.Context wraps the response writer with helpers that set the status, the Content-Type header and the body in one call. The common set is c.JSON(code, v), c.JSONPretty(code, v, indent), c.JSONBlob(code, rawBytes) when you already have serialised bytes, c.String, c.HTML, c.XML, c.Blob(code, contentType, b), c.NoContent(code), c.Redirect(code, url), c.Stream(code, contentType, io.Reader), and c.Attachment(file, name) or c.Inline(file, name) for downloads. c.JSONBlob is the one people forget: if you have already cached a marshalled payload in Redis, sending it through c.JSON re-encodes a string into a quoted JSON string, while c.JSONBlob writes the bytes untouched. Underneath, c.Response() returns *echo.Response, which implements http.ResponseWriter, http.Flusher and http.Hijacker while tracking three extra fields: Status, Size and Committed.

Committed flips to true the moment WriteHeader runs, which means the status line has already gone out on the wire. After that, any attempt to write another header or a different status is ignored and Echo logs "response already committed". This is why a custom HTTPErrorHandler must check Committed before writing, and why a handler that streams rows and then hits a database error cannot suddenly return a clean 500. The practical rule for streaming endpoints is to validate everything you can before the first byte, and to signal mid-stream failures inside the payload itself, for example a terminal error event on an SSE stream. echo.Response also exposes Before and After hooks, useful for stamping a Server-Timing header just before commit.

func cachedProfile(c echo.Context) error {
	if raw, err := rdb.Get(c.Request().Context(), "profile:42").Bytes(); err == nil {
		// Already JSON: write bytes as-is instead of re-encoding.
		return c.JSONBlob(http.StatusOK, raw)
	}
	p, err := repo.Profile(c.Request().Context(), 42)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, p)
}

func timed(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		start := time.Now()
		c.Response().Before(func() {
			c.Response().Header().Set("Server-Timing",
				fmt.Sprintf("app;dur=%d", time.Since(start).Milliseconds()))
		})
		return next(c)
	}
}

Key Points

  • c.JSON, c.JSONBlob, c.Blob, c.String, c.NoContent, c.Stream, c.Attachment
  • c.JSONBlob avoids double-encoding cached JSON bytes
  • *echo.Response tracks Status, Size and Committed
  • Committed turns true at WriteHeader; status is then frozen
  • Response.Before and Response.After hooks run around commit
Q8

How do you read query parameters, form values, headers, cookies and uploaded files in Echo?

BasicRequests

Answer

Echo exposes small accessors on the context for everything that is not the JSON body. c.QueryParam("page") returns a single query value or the empty string, c.QueryParams() returns the full url.Values so you can handle repeated keys like ?tag=go&tag=api, and c.QueryString() gives the raw string. c.FormValue("name") reads urlencoded or multipart form fields and, importantly, will parse the body on first call, so calling it before c.Bind() consumes the reader and leaves Bind with nothing. Headers come from c.Request().Header.Get(...), and Echo provides constants such as echo.HeaderAuthorization, echo.HeaderContentType and echo.HeaderXRequestID to avoid typos. Cookies use c.Cookie("session") which returns (*http.Cookie, error) with http.ErrNoCookie when absent, and c.SetCookie(&http.Cookie{...}).

File uploads go through c.FormFile("file"), returning a *multipart.FileHeader whose Filename, Size and Header fields you should treat as attacker-controlled. Echo has no SaveUploadedFile helper, so you open the header and io.Copy it yourself. Three production rules for uploads.

Cap the request with middleware.BodyLimit("10M") so a hostile client cannot stream gigabytes into your pod's memory before you look at Size. Never trust the Filename for a storage path, sanitise it with filepath.Base or generate a UUID, otherwise a filename of ../../etc/cron.d/x is a path traversal. And detect the real content type by sniffing the first 512 bytes with http.DetectContentType instead of trusting the multipart Content-Type header, because it is trivially spoofed. For anything above a few megabytes, stream straight to S3 with the AWS SDK multipart uploader rather than buffering.

func upload(c echo.Context) error {
	fh, err := c.FormFile("file")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file field is required")
	}
	if fh.Size > 5<<20 {
		return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "max 5MB")
	}

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

	head := make([]byte, 512)
	n, _ := io.ReadFull(src, head)
	if mt := http.DetectContentType(head[:n]); mt != "application/pdf" {
		return echo.NewHTTPError(http.StatusUnsupportedMediaType, mt)
	}
	if _, err := src.Seek(0, io.SeekStart); err != nil {
		return err
	}

	key := uuid.NewString() + filepath.Ext(filepath.Base(fh.Filename))
	return store.Put(c.Request().Context(), key, src)
}

Key Points

  • c.QueryParam, c.QueryParams, c.FormValue, c.FormFile, c.MultipartForm
  • c.FormValue parses the body, so never call it before c.Bind()
  • c.Cookie returns http.ErrNoCookie when the cookie is absent
  • Sanitise multipart Filename; it is attacker controlled
  • Pair uploads with middleware.BodyLimit and content sniffing
Q9

How do you serve static files and go:embed assets from an Echo binary?

BasicStatic Files

Answer

Echo gives you four levels of control. e.Static("/assets", "public") maps a URL prefix to a directory on disk. e.File("/favicon.ico", "public/favicon.ico") maps one URL to one file. e.StaticFS(prefix, fsys) serves from any fs.FS, which is the door to go:embed. And middleware.StaticWithConfig gives you the full knob set: Root, Index, Browse for directory listing, IgnoreBase, and HTML5 which rewrites any unmatched path to index.html so a React or Vue router works on hard refresh. Shipping a single binary is the reason most Go teams use embed.

Declare //go:embed public/* on an embed.FS variable, then strip the directory prefix with echo.MustSubFS(assets, "public") so URLs do not carry a redundant /public segment. MustSubFS panics at startup on a bad path, which is exactly what you want: an embed mistake should fail the deploy, not 404 in production. Two things to get right.

First, embedded files carry no useful modification time, so http.ServeContent cannot emit a Last-Modified that changes between builds. Set your own Cache-Control and use content-hashed filenames from your bundler rather than relying on revalidation. Second, static handlers have historically been the sharp edge in Go web frameworks generally, with path traversal and open redirect issues being the recurring class of bug.

Echo's v4 line has shipped fixes in this area, so pinning an old v4.x patch release specifically to avoid a dependency bump is a bad trade. Run govulncheck ./... in CI and keep the minor version current.

package main

import (
	"embed"
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

//go:embed all:public
var assets embed.FS

func main() {
	e := echo.New()

	// API first, SPA fallback last.
	api := e.Group("/api/v1")
	api.GET("/health", func(c echo.Context) error {
		return c.NoContent(http.StatusNoContent)
	})

	e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
		Root:       "/",
		Index:      "index.html",
		HTML5:      true,
		Browse:     false,
		Filesystem: http.FS(echo.MustSubFS(assets, "public")),
	}))

	e.Logger.Fatal(e.Start(":8080"))
}

Key Points

  • e.Static, e.File, e.StaticFS and middleware.StaticWithConfig cover every case
  • echo.MustSubFS strips the embed directory prefix and fails fast at boot
  • HTML5: true makes SPA client-side routing survive a hard refresh
  • Embedded files have no meaningful mtime; use hashed filenames plus Cache-Control
  • Keep the v4 patch version current and run govulncheck in CI
Q10

What does middleware.Recover() actually do, and how do you configure it for production?

BasicMiddleware

Answer

In Go, an unrecovered panic in a goroutine terminates the entire process. net/http installs its own recover per connection so a panicking handler kills that one request rather than the server, but the client gets an abruptly closed connection with no status code and the stack trace goes to stderr unstructured. middleware.Recover() replaces that with something usable: it defers a recover(), converts whatever was panicked into an error, captures a stack trace, logs it, and hands the error to your HTTPErrorHandler so the client receives a proper 500 JSON body. Configure it with middleware.RecoverWithConfig. StackSize defaults to 4KB, which truncates deep stacks in a layered service, so 8 or 16 kilobytes is a reasonable bump.

DisableStackAll: true limits the trace to the panicking goroutine instead of dumping every goroutine in the process, which is what you want because the all-goroutines dump on a busy pod can be megabytes. DisablePrintStack: true stops Echo writing to its own logger, and LogErrorFunc lets you route the panic into slog or Sentry with the request ID and route attached. Two things Recover cannot do.

It only protects the goroutine running the handler, so a panic inside a goroutine you spawned yourself still takes down the process: every go func() you launch needs its own defer recover(). And it must be registered first with e.Use so that it wraps the rest of the chain; a panic in a middleware registered before Recover escapes it entirely. Treat a recovered panic as a paging-level alert, not a normal error.

e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
	StackSize:         8 << 10,
	DisableStackAll:   true,
	DisablePrintStack: true,
	LogErrorFunc: func(c echo.Context, err error, stack []byte) error {
		slog.Error("panic recovered",
			"err", err,
			"route", c.Path(),
			"method", c.Request().Method,
			"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
			"stack", string(stack),
		)
		return err
	},
}))

// Your own goroutines are NOT covered by the middleware.
go func() {
	defer func() {
		if r := recover(); r != nil {
			slog.Error("worker panic", "recovered", r)
		}
	}()
	process(job)
}()

Key Points

  • Converts a panic into an error routed through HTTPErrorHandler
  • Bump StackSize and set DisableStackAll to avoid megabyte dumps
  • LogErrorFunc routes the panic into slog, Sentry or your APM
  • Does not protect goroutines you spawn; add your own defer recover()
  • Must be the first e.Use so it wraps every other middleware
Q11

How do you get structured JSON request logs out of Echo with log/slog?

BasicLogging

Answer

Echo has two logging middlewares and picking the wrong one is a common review comment. middleware.Logger() is the legacy one: it writes a fixed template through Echo's own gommon-based e.Logger, and while you can change the Format string, you are still stuck inside Echo's logger abstraction. middleware.RequestLoggerWithConfig is the modern replacement. You opt into exactly the fields you want with boolean flags (LogStatus, LogURI, LogRoutePath, LogLatency, LogRemoteIP, LogError, LogResponseSize, LogRequestID) and Echo hands them to your LogValuesFunc as a typed middleware.RequestLoggerValues struct. From there you emit through log/slog, zerolog or zap with zero string formatting.

Set HandleError: true so that the values struct sees the final status code after the HTTPErrorHandler has run; without it a request that ends in a 404 can be logged as 200 because the status had not been written yet when the middleware unwound. Log LogRoutePath rather than only LogURI. RoutePath is the template /orders/:id, which is the low-cardinality value you want for grouping and alerting, while URI is the concrete /orders/9f3c... that you want for debugging a single request.

Log both. For correlation, pair it with middleware.RequestID(), which generates an X-Request-Id header when the client did not send one, and put that ID into every application log line via a context-scoped slog handler. Finally, replace e.Logger itself, or at least silence it, so Echo's internal messages do not arrive as unstructured plain text alongside your JSON in Loki or CloudWatch.

e.Use(middleware.RequestID())
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
	LogStatus:    true,
	LogURI:       true,
	LogRoutePath: true,
	LogMethod:    true,
	LogLatency:   true,
	LogRemoteIP:  true,
	LogRequestID: true,
	LogError:     true,
	HandleError:  true, // report the status the error handler produced
	LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
		attrs := []any{
			"method", v.Method,
			"route", v.RoutePath,
			"uri", v.URI,
			"status", v.Status,
			"latency_ms", v.Latency.Milliseconds(),
			"ip", v.RemoteIP,
			"request_id", v.RequestID,
		}
		if v.Error != nil {
			slog.Error("http", append(attrs, "err", v.Error)...)
			return nil
		}
		slog.Info("http", attrs...)
		return nil
	},
}))

Key Points

  • Prefer middleware.RequestLoggerWithConfig over the legacy middleware.Logger
  • HandleError: true so the logged status reflects the error handler result
  • Log RoutePath for low-cardinality grouping and URI for debugging
  • Pair with middleware.RequestID for cross-service correlation
  • Silence or replace e.Logger so internal messages stay structured
💡 Pro Tip: If your dashboards show a spike of 200s during an incident, check HandleError. Without it the logger records the pre-error status and hides every failure.
Q12

How do you configure CORS correctly in Echo?

BasicSecurity

Answer

middleware.CORS() with no config allows every origin and every method, which is fine for a public read-only API and wrong for anything with credentials. Production setups use middleware.CORSWithConfig and enumerate AllowOrigins explicitly. The rule that catches people is that a wildcard origin and AllowCredentials: true are incompatible under the CORS spec: browsers refuse a response where Access-Control-Allow-Origin is * and credentials were sent.

Echo makes this explicit with a config field named UnsafeWildcardOriginWithAllowCredentials, which you must set to true before it will even emit that combination. If you find yourself reaching for that flag, the design is wrong; enumerate the origins instead. For multi-tenant products where each customer gets a subdomain, use AllowOriginFunc, which receives the origin string and returns (bool, error), so you can check it against a cached allowlist from your database.

Do not build a regex that matches with a suffix check like strings.HasSuffix(origin, ".example.com"), because https://evil-example.com passes a careless version of that test. Set ExposeHeaders for any custom response header the browser needs to read, X-Total-Count for pagination and X-Request-Id for support tickets being the usual pair; without it fetch() silently hides them and engineers lose hours. Set MaxAge to cache the preflight, typically 86400 seconds, to remove an OPTIONS round trip from every cross-origin call, which is a visible latency win on Indian mobile networks. Register CORS early in the chain so preflights short-circuit before authentication middleware rejects an OPTIONS request that carries no Authorization header.

e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
	AllowOrigins: []string{"https://app.example.in", "https://admin.example.in"},
	AllowOriginFunc: func(origin string) (bool, error) {
		u, err := url.Parse(origin)
		if err != nil || u.Scheme != "https" {
			return false, nil
		}
		return tenants.IsKnownHost(u.Host) // exact match, not a suffix check
	},
	AllowMethods: []string{
		http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete,
	},
	AllowHeaders:     []string{echo.HeaderAuthorization, echo.HeaderContentType, "X-Tenant"},
	ExposeHeaders:    []string{"X-Total-Count", echo.HeaderXRequestID},
	AllowCredentials: true,
	MaxAge:           86400,
}))

Key Points

  • Enumerate AllowOrigins; never combine wildcard with AllowCredentials
  • AllowOriginFunc for dynamic multi-tenant subdomains, matched exactly
  • ExposeHeaders is required for the browser to read custom headers
  • MaxAge caches the preflight and removes an OPTIONS round trip
  • Register CORS before auth so preflights are not rejected as unauthenticated
Q13

How do you start an Echo server and shut it down gracefully?

BasicServer Lifecycle

Answer

e.Start(":8080") is the convenience entry point: it builds an http.Server, assigns it to e.Server, and blocks. Variants are e.StartTLS for a certificate and key, e.StartAutoTLS for automatic Let's Encrypt certificates through autocert, e.StartH2CServer for cleartext HTTP/2 behind a mesh sidecar, and e.StartServer(srv) when you want to supply a fully configured *http.Server yourself. You almost always want the last one in production, because the defaults for ReadHeaderTimeout, ReadTimeout, WriteTimeout and IdleTimeout in net/http are zero, meaning no timeout at all.

A single slow-loris client holding a half-open connection ties up a goroutine and a file descriptor indefinitely. Setting ReadHeaderTimeout alone closes the most common resource-exhaustion vector. Graceful shutdown is the other half. e.Shutdown(ctx) delegates to http.Server.Shutdown: it stops accepting new connections, closes idle keep-alive connections, and waits for in-flight handlers to return until the context deadline expires, after which it gives up and returns the context error.

The idiomatic wiring uses signal.NotifyContext to catch SIGINT and SIGTERM, runs e.Start in a goroutine, and treats http.ErrServerClosed as a normal exit rather than a failure. Pick a shutdown timeout slightly shorter than your orchestrator's grace period: Kubernetes defaults terminationGracePeriodSeconds to 30, so a 20 second shutdown context leaves room to flush logs and traces before SIGKILL arrives. Also close your database pool and flush the OpenTelemetry exporter after Shutdown returns, not before, or you will drop the spans for the requests you just finished draining.

func main() {
	e := echo.New()
	e.Use(middleware.Recover())
	e.GET("/health", func(c echo.Context) error { return c.NoContent(204) })

	srv := &http.Server{
		Addr:              ":8080",
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      60 * time.Second,
		IdleTimeout:       120 * time.Second,
	}

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

	go func() {
		if err := e.StartServer(srv); err != nil &&
			!errors.Is(err, http.ErrServerClosed) {
			slog.Error("server failed", "err", err)
			os.Exit(1)
		}
	}()

	<-ctx.Done()
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := e.Shutdown(shutdownCtx); err != nil {
		slog.Error("forced shutdown", "err", err)
	}
	db.Close()
	tracerProvider.Shutdown(context.Background())
}

Key Points

  • e.StartServer(srv) lets you set the timeouts that net/http leaves at zero
  • Always set ReadHeaderTimeout to blunt slow-loris connections
  • e.Shutdown(ctx) drains in-flight requests up to the context deadline
  • http.ErrServerClosed is the normal exit signal, not an error
  • Keep the shutdown timeout under terminationGracePeriodSeconds
Q14

How do route groups work in Echo, and how does middleware inherit across nested groups?

BasicRouting

Answer

e.Group(prefix, middleware...) returns an *echo.Group that shares the parent's router but prepends the prefix to every route registered on it and prepends its middleware to every handler chain. Groups nest, so api := e.Group("/api"), then v1 := api.Group("/v1"), then admin := v1.Group("/admin", RequireRole("admin")) produces /api/v1/admin/... with the full stack of middleware from every level applied outermost-first. This is the standard way to version an API and to separate public, authenticated and internal routes without repeating a UseGuards annotation on every handler.

Two behaviours are worth memorising because they show up as bugs. First, middleware you attach with g.Use() after routes have already been registered on that group is not retroactively applied to those routes, unlike the root-level e.Use() which is resolved lazily. So always create the group with its middleware in the constructor call, or call g.Use() immediately after creating it and before any g.GET.

Second, a group does not automatically register a handler for the prefix itself. Creating e.Group("/api/v1") and registering only /users means /api/v1 returns 404, and more subtly, requests to /api/v1/anything-unmatched fall through to the global 404 without your group middleware running, which means an unauthenticated 404 rather than a 401. If you need group middleware on unmatched paths, register a catch-all g.Any("/*", notFound) on the group. Groups are also where per-tenant rate limits and per-audience CORS policies live in real codebases, since the config differs between a public API and an internal admin panel.

e := echo.New()
e.Use(middleware.Recover(), middleware.RequestID())

public := e.Group("/api/v1")
public.POST("/auth/login", login)
public.POST("/auth/otp", sendOTP, middleware.RateLimiter(
	middleware.NewRateLimiterMemoryStore(rate.Limit(1)), // 1 OTP per second per IP
))

// Auth middleware supplied at construction time, not after the routes.
secure := e.Group("/api/v1", echojwt.WithConfig(echojwt.Config{
	SigningKey: []byte(cfg.JWTSecret),
}))
secure.GET("/me", me)
secure.GET("/orders/:id", getOrder)

admin := secure.Group("/admin", RequireRole("admin"))
admin.GET("/users", listUsers)      // GET /api/v1/admin/users
admin.DELETE("/users/:id", delUser) // recover + jwt + role, in that order

Key Points

  • e.Group(prefix, mw...) nests and accumulates middleware outermost first
  • Group middleware added after routes does not apply retroactively
  • The group prefix itself is not a route; register it if you need it
  • Unmatched paths under a group skip group middleware and 404 globally
  • Add g.Any("/*", handler) when the group must own its own 404s
Q15

How do you write a custom HTTPErrorHandler that maps domain errors to a stable JSON contract?

IntermediateError Handling

Answer

e.HTTPErrorHandler is a single func(err error, c echo.Context) that Echo calls for every error returned anywhere in the chain. Replacing the default is one of the first things a serious Echo codebase does, because the default body is just {"message":"..."} with no error code, no request ID and no field details, which is not enough for a mobile client to branch on. A production handler follows four steps in order.

Step one: return immediately if c.Response().Committed, because the status line has already been written and anything you do now is a logged no-op. Step two: unwrap with errors.As. Check your own domain error type first so a *AppError carrying a machine-readable code like ORDER_ALREADY_PAID maps to the right status, then fall back to *echo.HTTPError for framework-generated 404s and 405s, then default to 500.

Use errors.As rather than a type switch so that wrapped errors from fmt.Errorf("%w") still match. Step three: log. 5xx goes at error level with the he.Internal cause and the stack if you have one; 4xx goes at info or debug level, because a wall of client validation errors at error severity destroys your alerting signal to noise. Step four: write the body, and special-case HEAD requests with c.NoContent(code) because a HEAD response must not carry a body.

Always include the request ID in the payload so a user can quote it to support. Interviewers probe whether you know Committed, whether you use errors.As, and whether you separate the internal cause from the client message.

type AppError struct {
	Code   string
	Status int
	Msg    string
	Err    error
}

func (e *AppError) Error() string { return e.Msg }
func (e *AppError) Unwrap() error { return e.Err }

e.HTTPErrorHandler = func(err error, c echo.Context) {
	if c.Response().Committed {
		return
	}
	status, code, msg := http.StatusInternalServerError, "INTERNAL", "something went wrong"

	var ae *AppError
	var he *echo.HTTPError
	switch {
	case errors.As(err, &ae):
		status, code, msg = ae.Status, ae.Code, ae.Msg
	case errors.As(err, &he):
		status, code = he.Code, http.StatusText(he.Code)
		msg = fmt.Sprint(he.Message)
		if he.Internal != nil {
			err = he.Internal
		}
	}

	rid := c.Response().Header().Get(echo.HeaderXRequestID)
	if status >= 500 {
		slog.Error("request failed", "err", err, "route", c.Path(), "request_id", rid)
	} else {
		slog.Info("client error", "code", code, "route", c.Path(), "request_id", rid)
	}

	if c.Request().Method == http.MethodHead {
		_ = c.NoContent(status)
		return
	}
	_ = c.JSON(status, echo.Map{"code": code, "message": msg, "request_id": rid})
}

Key Points

  • Bail out early when c.Response().Committed is already true
  • Use errors.As so wrapped errors still match your domain types
  • Log 5xx at error level and 4xx at info to protect alerting signal
  • HEAD requests must get c.NoContent, never a JSON body
  • Echo the request ID in the payload so support can trace it
Q16

Why must echo.Context never be used inside a goroutine that outlives the handler?

IntermediateConcurrency

Answer

Echo keeps a sync.Pool of context objects. When a request arrives, Echo pulls a *echo.context from the pool, calls Reset with the new request and response, runs the chain, and then puts the object straight back in the pool. The pool is the reason routing plus dispatch allocates almost nothing per request, and it is also a loaded gun.

If you spawn go func(){ ... c.Param("id") ... }() and the handler returns before that goroutine reads the field, the context has already been reset and handed to a completely different request on another connection. You do not get a panic or a race detector hit reliably; you get a goroutine reading another user's tenant ID, which is a data leak that surfaces weeks later as an impossible bug report. The rule is: extract every value you need into local variables before the go statement, and never capture c itself.

The same trap applies to the request context. c.Request().Context() is cancelled by net/http as soon as the client disconnects or the response completes, so passing it into a fire-and-forget worker means your background job is cancelled the instant you reply 202 Accepted. Since Go 1.21 the correct tool is context.WithoutCancel, which keeps the trace span and any values you stored while detaching the cancellation signal. Add your own timeout on top so the detached work cannot run forever. If you genuinely need a context object inside a long-lived goroutine, Echo exposes e.AcquireContext() and e.ReleaseContext(), but in practice a plain struct of the fields you need is simpler and safer.

// WRONG: c is recycled the moment this handler returns.
func badHandler(c echo.Context) error {
	go func() {
		analytics.Track(c.Param("orderID"), c.RealIP()) // reads a foreign request
	}()
	return c.NoContent(http.StatusAccepted)
}

// RIGHT: snapshot values, detach cancellation, bound the work.
func goodHandler(c echo.Context) error {
	orderID := c.Param("orderID")
	ip := c.RealIP()
	bg := context.WithoutCancel(c.Request().Context()) // keeps trace, drops cancel

	go func() {
		defer func() {
			if r := recover(); r != nil {
				slog.Error("track panic", "recovered", r)
			}
		}()
		ctx, cancel := context.WithTimeout(bg, 10*time.Second)
		defer cancel()
		analytics.Track(ctx, orderID, ip)
	}()

	return c.NoContent(http.StatusAccepted)
}

Key Points

  • echo.Context comes from a sync.Pool and is Reset for the next request
  • Capturing c in a goroutine can leak another request's data, silently
  • Copy the values you need into locals before the go statement
  • c.Request().Context() dies on client disconnect; use context.WithoutCancel
  • Always add an explicit timeout to the detached background context
💡 Pro Tip: Run your test suite with go test -race. It will not catch every pooled-context misuse, but it catches the ones where two goroutines touch the same context concurrently.
Q17

How do you create a custom context type in Echo, and what is the risk?

IntermediateContext

Answer

The pattern is embedding. Define a struct that embeds echo.Context and adds your fields, then write a middleware that wraps the incoming context and passes the wrapper to next. Because the embedded interface satisfies echo.Context, your type is a drop-in, and handlers recover the concrete type with a type assertion.

Teams use this for a resolved tenant, an authenticated user, a request-scoped logger with the request ID pre-attached, or a per-request database transaction. It reads much better than c.Get("user").(*User) sprinkled through every handler, and it gives you compile-time method names instead of stringly-typed keys. The risk is the type assertion.

If a route is registered on a group where the wrapping middleware was not applied, or if a middleware registered earlier in the chain calls next with the original context, then c.(*AppContext) panics at runtime. Two mitigations. Use the comma-ok form and fall back to a sane default instead of the one-value form that panics.

Or better, hide the assertion behind a free function like FromEcho(c) that returns (*AppContext, error) so every handler handles the miss explicitly. Note also that c.Set and c.Get, the alternative approach, write into a plain map[string]interface{} on the context with no mutex, so writing to it from two goroutines is a genuine data race, and reading a key that middleware forgot to set returns nil rather than erroring. Whichever approach you pick, register the wrapper middleware at the root with e.Use before any route is defined, and keep exactly one wrapper layer so nested assertions do not stack.

type AppContext struct {
	echo.Context
	Tenant string
	UserID int64
	Log    *slog.Logger
}

func WithAppContext(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		rid := c.Response().Header().Get(echo.HeaderXRequestID)
		return next(&AppContext{
			Context: c,
			Tenant:  c.Request().Header.Get("X-Tenant"),
			Log:     slog.With("request_id", rid, "route", c.Path()),
		})
	}
}

// Safe accessor: never panics on a route that skipped the middleware.
func App(c echo.Context) (*AppContext, error) {
	ac, ok := c.(*AppContext)
	if !ok {
		return nil, echo.NewHTTPError(http.StatusInternalServerError,
			"app context middleware not mounted")
	}
	return ac, nil
}

func listInvoices(c echo.Context) error {
	ac, err := App(c)
	if err != nil {
		return err
	}
	ac.Log.Info("listing invoices", "tenant", ac.Tenant)
	return c.JSON(http.StatusOK, repo.ByTenant(ac.Tenant))
}

Key Points

  • Embed echo.Context in a struct and pass the wrapper to next
  • Gives compile-time field access instead of stringly-typed c.Get keys
  • A missing wrapper makes c.(*AppContext) panic; use the comma-ok form
  • c.Set / c.Get use an unsynchronised map and are not goroutine safe
  • Register the wrapper once at the root, before any routes
Q18

How do you implement JWT authentication in Echo v4, and where did the JWT middleware go?

IntermediateAuthentication

Answer

JWT support used to live in echo/v4/middleware, but it was extracted into its own module so that the core framework does not force a golang-jwt version on you. In v4 you import github.com/labstack/echo-jwt/v4 as echojwt and use echojwt.WithConfig. This split is exactly the kind of migration detail interviewers use to tell who has actually upgraded a service.

The config you care about: SigningKey plus SigningMethod, or KeyFunc when you need JWKS rotation against an identity provider like Auth0, Cognito or Keycloak; NewClaimsFunc to return your own claims struct so you get typed access instead of jwt.MapClaims and map lookups; TokenLookup to accept the token from more than one place, written as a comma-separated list like "header:Authorization:Bearer ,cookie:access_token"; ContextKey which defaults to "user"; and ErrorHandler to convert the library's error into your own JSON contract. Watch the trailing space in "Bearer ", it is part of the value prefix and omitting it breaks parsing. Three production points.

Never put SigningKey in code or in a committed .env; pull it from AWS Secrets Manager, Vault or Infisical at boot and fail fast if it is missing. Use HS256 only for tokens you both mint and verify inside one trust boundary; the moment a third party verifies your tokens, move to RS256 or ES256 with a published JWKS so you can rotate without redeploying consumers. And keep access tokens short lived with a separate refresh flow, because a JWT cannot be revoked once issued unless you add a denylist lookup, which costs you a Redis round trip on every request and negates half the reason for using JWTs.

import (
	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	UserID int64  `json:"uid"`
	Tenant string `json:"tid"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

secure := e.Group("/api/v1", echojwt.WithConfig(echojwt.Config{
	SigningKey:    []byte(cfg.JWTSecret),
	SigningMethod: jwt.SigningMethodHS256.Name,
	NewClaimsFunc: func(c echo.Context) jwt.Claims { return new(Claims) },
	TokenLookup:   "header:Authorization:Bearer ,cookie:access_token",
	ContextKey:    "user",
	ErrorHandler: func(c echo.Context, err error) error {
		return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired token").
			WithInternal(err)
	},
}))

func CurrentClaims(c echo.Context) (*Claims, bool) {
	tok, ok := c.Get("user").(*jwt.Token)
	if !ok {
		return nil, false
	}
	cl, ok := tok.Claims.(*Claims)
	return cl, ok
}

Key Points

  • JWT middleware moved out of core into github.com/labstack/echo-jwt/v4
  • NewClaimsFunc gives typed claims instead of jwt.MapClaims lookups
  • TokenLookup needs the trailing space in "header:Authorization:Bearer "
  • KeyFunc for JWKS rotation; RS256 or ES256 across trust boundaries
  • JWTs cannot be revoked; short TTL plus refresh, or pay for a denylist lookup
Q19

How does c.RealIP() decide the client IP, and why does that matter behind an ALB or Cloudflare?

IntermediateSecurity

Answer

c.RealIP() is the input to your rate limiter, your audit log and often your fraud rules, so getting it wrong is a security bug rather than a cosmetic one. If you have not set e.IPExtractor, Echo falls back to reading X-Forwarded-For and X-Real-IP headers before dropping to the TCP peer address from r.RemoteAddr. Any client can send those headers, so on a service exposed directly to the internet an attacker sets X-Forwarded-For: 1.2.3.4 and rotates it per request to walk straight past a per-IP rate limit.

The fix is to declare your topology explicitly with e.IPExtractor. echo.ExtractIPDirect() ignores headers entirely and uses the socket peer address, correct when nothing is in front of you. echo.ExtractIPFromXFFHeader(opts...) walks the X-Forwarded-For list from right to left and returns the first address that is not in a trusted range, which is the standard correct algorithm. echo.ExtractIPFromRealIPHeader(opts...) does the same for X-Real-IP. The trust options are echo.TrustLoopback(bool), echo.TrustLinkLocal(bool), echo.TrustPrivateNet(bool) and echo.TrustIPRange(*net.IPNet) for explicit CIDRs. With an AWS ALB or an Nginx ingress inside the VPC, trusting private ranges is usually enough.

With Cloudflare in front, add their published IPv4 and IPv6 ranges as TrustIPRange entries, and prefer their CF-Connecting-IP header, which unlike X-Forwarded-For is overwritten rather than appended at their edge. This is a favourite interview question at Indian consumer-scale companies precisely because OTP and login endpoints get hammered, and a rate limiter keyed on a spoofable header is no rate limiter at all.

// Behind Cloudflare, then an ALB inside the VPC.
cfRanges := []string{"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22"}
opts := []echo.TrustOption{
	echo.TrustLoopback(true),
	echo.TrustLinkLocal(true),
	echo.TrustPrivateNet(true), // the ALB and the ingress
}
for _, cidr := range cfRanges {
	_, n, err := net.ParseCIDR(cidr)
	if err != nil {
		log.Fatal(err)
	}
	opts = append(opts, echo.TrustIPRange(n))
}
e.IPExtractor = echo.ExtractIPFromXFFHeader(opts...)

// Directly exposed service: ignore headers entirely.
// e.IPExtractor = echo.ExtractIPDirect()

e.POST("/auth/otp", sendOTP, middleware.RateLimiterWithConfig(
	middleware.RateLimiterConfig{
		Store: middleware.NewRateLimiterMemoryStore(rate.Limit(0.2)),
		IdentifierExtractor: func(c echo.Context) (string, error) {
			return c.RealIP(), nil // now trustworthy
		},
	},
))

Key Points

  • Without e.IPExtractor, Echo trusts client-supplied X-Forwarded-For
  • ExtractIPDirect for a directly exposed service; XFF variant behind proxies
  • TrustLoopback, TrustLinkLocal, TrustPrivateNet, TrustIPRange define the edge
  • XFF is right-to-left, first untrusted hop wins
  • Cloudflare appends CF-Connecting-IP and overwrites it at the edge
Q20

What is the difference between middleware.Timeout and middleware.ContextTimeout, and which should you use?

IntermediateMiddleware

Answer

Echo ships both and they behave completely differently. middleware.Timeout is built on the http.TimeoutHandler pattern: it runs your handler in a separate goroutine and, if the deadline passes first, writes a 503 to the client and returns. Your handler goroutine keeps running. It is not cancelled, it still holds its database connection, it still finishes its query, and it then tries to write to a response that has already been committed.

Echo's own documentation warns about the race conditions this creates when the handler touches the response or the context after timing out. So Timeout protects the client from waiting, but does nothing for your server: under a slow-database incident you shed load to the caller while your connection pool stays fully saturated, which is precisely the wrong outcome. middleware.ContextTimeout, added later in the v4 line, does the correct thing. It calls context.WithTimeout on the request context and swaps it back onto the request.

Nothing runs in a parallel goroutine. When the deadline hits, ctx.Err() becomes context.DeadlineExceeded, and every well-behaved library downstream that accepts a context, database/sql, pgx, redis, the AWS SDK, http.Client, notices and aborts. The connection goes back to the pool.

That is real backpressure. The catch is that it only works if your code actually threads c.Request().Context() through every call. A handler that calls db.Query instead of db.QueryContext ignores the deadline entirely, and ContextTimeout becomes a no-op.

Use ContextTimeoutWithConfig with an ErrorHandler that converts context.DeadlineExceeded into a 503 or 504 with a Retry-After header. Set the value below your load balancer's idle timeout so your service, not the LB, decides the error shape.

e.Use(middleware.ContextTimeoutWithConfig(middleware.ContextTimeoutConfig{
	Timeout: 3 * time.Second,
	ErrorHandler: func(err error, c echo.Context) error {
		if errors.Is(err, context.DeadlineExceeded) {
			c.Response().Header().Set("Retry-After", "2")
			return echo.NewHTTPError(http.StatusServiceUnavailable,
				"upstream took too long").WithInternal(err)
		}
		return err
	},
}))

// The deadline only reaches the DB if you pass the context down.
func listOrders(c echo.Context) error {
	ctx := c.Request().Context()
	rows, err := db.QueryContext(ctx,
		"SELECT id, total FROM orders WHERE tenant = $1", tenantOf(c))
	if err != nil {
		return err // context.DeadlineExceeded surfaces here
	}
	defer rows.Close()
	return c.JSON(http.StatusOK, scanOrders(rows))
}

Key Points

  • middleware.Timeout runs the handler in another goroutine and never cancels it
  • Timeout can race on a committed response; the docs warn about it
  • middleware.ContextTimeout cancels the request context, giving real backpressure
  • It only works if you pass c.Request().Context() into every downstream call
  • Set the timeout below the load balancer idle timeout and return 503 or 504
💡 Pro Tip: If an interviewer asks how you would protect a service whose downstream is slow, name ContextTimeout plus a bounded database pool. Answering only Timeout signals you have not run this in production.
Q21

How does middleware.RateLimiter work, and why does it break the moment you run two pods?

IntermediateRate Limiting

Answer

Echo's rate limiter is a token bucket driven by golang.org/x/time/rate. You give it a Store, and the bundled implementation is middleware.NewRateLimiterMemoryStoreWithConfig, which takes Rate as a rate.Limit (tokens per second, so rate.Limit(0.2) means one request every five seconds), Burst for the bucket depth, and ExpiresIn plus a sweep interval so idle identifiers get garbage collected instead of growing the map forever. The identifier defaults to c.RealIP(); override it with IdentifierExtractor to key on a tenant ID, an API key or a user ID from the JWT, which is what you want for a paid API where limits are per plan.

Two error hooks exist and people confuse them: ErrorHandler fires when IdentifierExtractor itself fails, while DenyHandler fires when the caller is over the limit, and that is where you return 429 with a Retry-After header. The critical caveat is right there in the name: MemoryStore. Each pod keeps its own map, so with five replicas behind a load balancer your advertised limit of 10 requests per second is effectively 50, and it fluctuates as pods scale.

For anything with a contractual limit you need a shared store. Implement the middleware.RateLimiterStore interface, which is one method, Allow(identifier string) (bool, error), on top of Redis using either INCR with EXPIRE for a fixed window or a small Lua script for a sliding window or GCRA. Also decide deliberately what happens when Redis is down: failing open lets a flood through, failing closed takes your API offline over a cache outage. Most teams fail open for read endpoints and closed for OTP and payment endpoints.

type RedisStore struct {
	rdb    *redis.Client
	limit  int
	window time.Duration
}

// RateLimiterStore is a single method: Allow(identifier) (bool, error).
func (s *RedisStore) Allow(id string) (bool, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	key := "rl:" + id + ":" + strconv.FormatInt(time.Now().Unix()/int64(s.window.Seconds()), 10)
	n, err := s.rdb.Incr(ctx, key).Result()
	if err != nil {
		return true, nil // fail open on a Redis outage
	}
	if n == 1 {
		s.rdb.Expire(ctx, key, s.window)
	}
	return n <= int64(s.limit), nil
}

e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
	Store: &RedisStore{rdb: rdb, limit: 100, window: time.Minute},
	IdentifierExtractor: func(c echo.Context) (string, error) {
		if cl, ok := CurrentClaims(c); ok {
			return cl.Tenant, nil
		}
		return c.RealIP(), nil
	},
	DenyHandler: func(c echo.Context, id string, err error) error {
		c.Response().Header().Set("Retry-After", "60")
		return echo.NewHTTPError(http.StatusTooManyRequests, "rate limit exceeded")
	},
}))

Key Points

  • Token bucket over golang.org/x/time/rate; Rate is tokens per second
  • IdentifierExtractor keys by IP, tenant or API key
  • DenyHandler handles the 429; ErrorHandler handles extractor failures
  • MemoryStore is per pod, so N replicas multiply the effective limit by N
  • Implement RateLimiterStore over Redis and decide fail-open versus fail-closed
Q22

How do you unit test an Echo handler, and when should you use e.ServeHTTP instead of e.NewContext?

IntermediateTesting

Answer

There are two levels and they answer different questions. For a pure handler test, build the request with httptest.NewRequest, a recorder with httptest.NewRecorder, then c := e.NewContext(req, rec). Because you bypassed the router, path parameters are not populated, so you set them manually with c.SetPath("/users/:id"), c.SetParamNames("id") and c.SetParamValues("42").

Call the handler directly, assert on err, then on rec.Code and rec.Body.String(). This is fast, has no middleware in the way, and is the right granularity for testing branch logic in one function. What it cannot tell you is whether the route is registered on the right method, whether your auth middleware protects it, or whether the error handler produced the JSON contract you promised.

For that, register the real routes on a real echo.New() and call e.ServeHTTP(rec, req). Now the radix tree resolves the path, every middleware runs, and your custom HTTPErrorHandler formats the failure, so you are testing the wiring rather than the function. A useful discipline is to build the whole app in a NewRouter(deps) constructor that returns *echo.Echo, so tests construct it with fakes and production constructs it with real clients.

Practical details that come up: set the Content-Type header explicitly with req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) or c.Bind returns 415; use rec.Result().Body for anything that inspects headers plus body together; and for JSON assertions prefer require.JSONEq over string equality so key ordering and whitespace do not make the test brittle. Run everything with -race in CI.

func TestGetUser_NotFound(t *testing.T) {
	e := echo.New()
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)
	c.SetPath("/users/:id")
	c.SetParamNames("id")
	c.SetParamValues("9999")

	err := NewHandler(fakeRepo{}).GetUser(c)

	var he *echo.HTTPError
	require.ErrorAs(t, err, &he)
	require.Equal(t, http.StatusNotFound, he.Code)
}

func TestCreateUser_Wiring(t *testing.T) {
	e := NewRouter(Deps{Repo: fakeRepo{}}) // real middleware and error handler

	body := strings.NewReader(`{"email":"not-an-email"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/users", body)
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()

	e.ServeHTTP(rec, req)

	require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
	require.JSONEq(t, `{"code":"VALIDATION","message":"invalid payload",
		"errors":{"email":"email"},"request_id":""}`, rec.Body.String())
}

Key Points

  • e.NewContext bypasses the router, so set path and params manually
  • e.ServeHTTP exercises routing, middleware and the custom error handler
  • Expose a NewRouter(deps) *echo.Echo constructor so tests can inject fakes
  • Set Content-Type on the test request or Bind returns 415
  • Assert JSON with require.JSONEq, and always run go test -race
Q23

How do you prevent mass assignment when binding request bodies in Echo?

IntermediateSecurity

Answer

Mass assignment is what happens when you call c.Bind(&user) with your database entity as the target. The struct has fields like Role, IsAdmin, Balance and TenantID, and Echo will happily populate every one of them from the JSON body because it uses encoding/json under the hood, which matches by tag or case-insensitive field name. An attacker sends {"email":"x@y.in","role":"admin"} to your signup endpoint and now owns your admin panel.

This is not theoretical; it is one of the most common findings in Go API security reviews. There are three defences and you should use the first one. Define a request DTO per endpoint that contains only the fields a client is allowed to send, bind into that, then map explicitly to your entity in code.

The mapping is a few boring lines and it doubles as documentation of your API contract. Second, if you must bind onto a shared struct, tag the protected fields with json:"-" so encoding/json refuses to populate them, but be careful: that also removes them from responses, which is often not what you want, and it does nothing for the form and query binders which use different tags. Third, for PATCH semantics use pointer fields so you can distinguish "absent" from "set to zero", because a plain int field cannot tell you whether the client sent 0 or nothing at all, and a naive update then silently zeroes a column. Note also that Echo returns 400 with a message naming the offending field when a type mismatch occurs, for example sending a string into an int, which leaks a little of your struct shape; some teams flatten that in the error handler.

// Entity: never a bind target.
type User struct {
	ID       int64
	Email    string
	Role     string // privileged
	TenantID string // privileged
}

// Create DTO: exactly what a client may send.
type CreateUserReq struct {
	Email string `json:"email" validate:"required,email"`
	Name  string `json:"name"  validate:"required,min=2,max=80"`
}

// Patch DTO: pointers distinguish "absent" from "set to zero value".
type PatchUserReq struct {
	Name    *string `json:"name"    validate:"omitempty,min=2,max=80"`
	Timeout *int    `json:"timeout" validate:"omitempty,gte=0,lte=300"`
}

func createUser(c echo.Context) error {
	var req CreateUserReq
	if err := c.Bind(&req); err != nil {
		return err
	}
	if err := c.Validate(&req); err != nil {
		return err
	}
	u := User{
		Email:    req.Email,
		Role:     "member",              // server decides
		TenantID: mustTenantFrom(c),     // server decides
	}
	return c.JSON(http.StatusCreated, repo.Insert(c.Request().Context(), u))
}

Key Points

  • Bind into a per-endpoint request DTO, never into the persisted entity
  • Map DTO to entity explicitly so privileged fields cannot be set by the client
  • json:"-" blocks JSON binding but not the form or query binders
  • Use pointer fields on PATCH DTOs to distinguish absent from zero
  • Bind type-mismatch errors reveal field names; normalise them in the error handler
💡 Pro Tip: A quick audit: grep your codebase for c.Bind( and check the type of every argument. Any bind straight into a model struct is a finding.
Q24

How do compression and decompression middleware work in Echo, and where do they conflict with streaming?

IntermediatePerformance

Answer

middleware.Gzip() wraps the response writer so anything you write is compressed, and it only engages when the client sent Accept-Encoding: gzip. Configure it with GzipWithConfig: Level takes a compress/gzip constant from 1 to 9 where 5 or 6 is the usual latency-versus-ratio compromise, and MinLength sets a byte floor below which compression is skipped, because gzipping a 40 byte JSON response makes it larger and burns CPU. A floor around 1024 is sensible.

The mirror image is middleware.Decompress(), which transparently inflates request bodies that arrive with Content-Encoding: gzip, useful for bulk ingest endpoints where a mobile client uploads a batch of events. Guard it with middleware.BodyLimit because a gzip bomb decompresses to far more than its wire size. The conflict everyone hits is streaming.

Gzip buffers, so an SSE endpoint or a long-poll wrapped in Gzip delivers nothing until the buffer flushes, and clients appear to hang. Echo's gzip writer does implement Flush, so calling c.Response().Flush() after each event usually works, but each flush emits a compression block boundary and costs you most of the compression ratio anyway. The clean answer is a Skipper that excludes streaming routes by checking c.Path().

Two more notes. If your service sits behind Cloudflare, an ALB with compression enabled, or an Nginx ingress with gzip on, you may be compressing twice and wasting CPU on every pod; check before adding it. And always ensure Vary: Accept-Encoding is present so a shared cache does not serve a gzipped body to a client that cannot decode it.

streaming := map[string]bool{"/events": true, "/api/v1/logs/tail": true}

e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
	Level:     5,
	MinLength: 1024,
	Skipper: func(c echo.Context) bool {
		return streaming[c.Path()] ||
			strings.HasPrefix(c.Request().Header.Get(echo.HeaderAccept), "text/event-stream")
	},
}))

// Bulk ingest: accept gzipped bodies, but cap the wire size first.
ingest := e.Group("/api/v1/ingest",
	middleware.BodyLimit("2M"),
	middleware.Decompress(),
)
ingest.POST("/events", func(c echo.Context) error {
	var batch []Event
	if err := c.Bind(&batch); err != nil {
		return err
	}
	if len(batch) > 5000 {
		return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "max 5000 events")
	}
	return c.NoContent(http.StatusAccepted)
})

Key Points

  • GzipConfig Level 5 or 6 and MinLength around 1024 bytes
  • Decompress() inflates gzipped request bodies; pair it with BodyLimit
  • Gzip buffers, so SSE and long-poll routes need a Skipper
  • Flushing per event kills the compression ratio anyway
  • Check whether your CDN or ingress already compresses before enabling it
Q25

How do you reuse existing net/http handlers and middleware inside Echo?

IntermediateInteroperability

Answer

Echo gives you two adapters and knowing both is what lets you adopt it incrementally in an existing Go codebase instead of rewriting everything. echo.WrapHandler(h http.Handler) turns any standard handler into an echo.HandlerFunc, which is how you mount promhttp.Handler() at /metrics, pprof handlers at /debug/pprof/*, or an existing http.FileServer. echo.WrapMiddleware(m func(http.Handler) http.Handler) converts a standard middleware constructor, the func(next http.Handler) http.Handler shape used by chi, gorilla and most vendor SDKs, into an echo.MiddlewareFunc you can pass to e.Use. Going the other direction, *echo.Echo itself implements http.Handler through ServeHTTP, so you can mount an entire Echo app inside a standard mux or wrap it in third-party middleware at the outermost layer. Three things to watch.

First, a wrapped middleware that replaces the request via r.WithContext must call next.ServeHTTP with the new request, and WrapMiddleware handles copying that back onto the Echo context, but a middleware that swaps the ResponseWriter for its own type breaks Echo's Response tracking, so Status, Size and Committed can go stale. Second, wrapped handlers cannot return an error, so they bypass your HTTPErrorHandler entirely and write whatever they write; keep them to infrastructure endpoints. Third, pprof is the classic footgun.

Mounting /debug/pprof on your public listener exposes heap and goroutine dumps to the internet. Put it on a second echo.Echo bound to a localhost-only port, or behind an internal-only group with authentication, and never on the same listener your ALB targets.

import (
	"net/http/pprof"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Public app.
e := echo.New()
e.Use(echo.WrapMiddleware(vendorsdk.Middleware)) // standard net/http middleware
e.GET("/metrics", echo.WrapHandler(promhttp.Handler()))

// Debug listener: localhost only, separate port, never exposed.
dbg := echo.New()
dbg.HideBanner = true
dbg.GET("/debug/pprof/", echo.WrapHandler(http.HandlerFunc(pprof.Index)))
dbg.GET("/debug/pprof/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile)))
dbg.GET("/debug/pprof/heap", echo.WrapHandler(pprof.Handler("heap")))
go func() { _ = dbg.Start("127.0.0.1:6060") }()

// Echo mounted inside a standard mux.
mux := http.NewServeMux()
mux.Handle("/api/", e)

Key Points

  • echo.WrapHandler adapts http.Handler; echo.WrapMiddleware adapts func(http.Handler) http.Handler
  • *echo.Echo implements http.Handler, so it mounts inside a standard mux
  • Wrapped handlers cannot return an error and skip your HTTPErrorHandler
  • A middleware that swaps the ResponseWriter breaks Echo's Status and Size tracking
  • Serve pprof on a separate localhost listener, never on the public one
Q26

What are named routes, e.Reverse and e.Routes used for in a real Echo codebase?

IntermediateRouting

Answer

Every registration method returns a *echo.Route, and that struct has a Name field you can assign. e.GET("/orders/:id", getOrder).Name = "order.detail" registers a name, and e.Reverse("order.detail", 1042) then builds "/orders/1042" by substituting positional arguments into the parameter slots in order. This matters more than it looks. Hardcoded URL strings scattered through email templates, HATEOAS Location headers, redirect targets and server-rendered HTML are the reason nobody ever renames a route once it ships.

With Reverse, changing /orders/:id to /v2/orders/:id is one edit. Note that Reverse takes positional values, not a map, so the argument order must match the parameter order in the path, and it returns an empty string rather than an error for an unknown name, which is worth a startup assertion in a test. e.Routes() returns the full []*echo.Route with Method, Path and Name for everything registered. Teams use it three ways.

As a boot-time sanity check that every route has a name and that no two names collide. As the seed for generating an OpenAPI skeleton or a Postman collection, since Echo does not derive specs from code the way FastAPI does, so most Go teams either hand-write the spec with swaggo annotations and swag init, or generate server stubs from the spec with oapi-codegen. And as a debugging endpoint on the internal listener that dumps the routing table, which saves a lot of guessing when a request 404s and you cannot tell whether the route was registered under the group you thought it was.

e.GET("/orders/:id", getOrder).Name = "order.detail"
e.GET("/orgs/:orgID/members/:userID", getMember).Name = "org.member"

// Positional substitution, in path order.
url := e.Reverse("order.detail", 1042)          // /orders/1042
mem := e.Reverse("org.member", "acme", 77)      // /orgs/acme/members/77

// Boot-time guard: every route must be named exactly once.
func assertRouteNames(e *echo.Echo) error {
	seen := map[string]string{}
	for _, r := range e.Routes() {
		if r.Name == "" || strings.Contains(r.Name, "func") {
			return fmt.Errorf("unnamed route %s %s", r.Method, r.Path)
		}
		if prev, dup := seen[r.Name]; dup {
			return fmt.Errorf("duplicate route name %q on %s and %s", r.Name, prev, r.Path)
		}
		seen[r.Name] = r.Path
	}
	return nil
}

Key Points

  • Registration methods return *echo.Route; set .Name to label it
  • e.Reverse(name, params...) builds URLs positionally, no map
  • Reverse returns "" for an unknown name, so assert names at startup
  • e.Routes() dumps Method, Path and Name for the whole table
  • Echo does not generate OpenAPI; use swaggo annotations or oapi-codegen
Q27

How do you cap request size and read the request body more than once in Echo?

IntermediateRequests

Answer

middleware.BodyLimit("2M") wraps the body in a limited reader and, importantly, also inspects Content-Length so an oversized upload is rejected with 413 before a single byte is read. That short-circuit is why BodyLimit is cheap enough to apply globally, and why every internet-facing Echo service should have it: without a cap, one client streaming an endless body pins a goroutine and grows your heap until the pod is OOMKilled. Set a low global default and raise it on the specific group that accepts uploads.

Reading the body twice is a separate problem with the same root cause. c.Request().Body is an io.ReadCloser, a one-shot stream. Once c.Bind() has consumed it, a webhook signature verifier or an audit logger downstream finds it empty. The fix is to read it into memory once and put a fresh reader back, which is exactly what middleware.BodyDump does internally: it tees the request and response bodies and hands both to your callback.

Use BodyDump for audit logging on sensitive routes, and never globally, because it buffers the whole payload for every request. For webhook signature checks the order matters: read and hash the raw bytes first, then restore the body with io.NopCloser(bytes.NewReader(raw)) so Bind still works. Verifying a signature over a re-marshalled struct instead of the raw bytes is a classic bug, because JSON key ordering and whitespace change the hash. Compare with hmac.Equal, not with ==, to avoid a timing side channel, and check the timestamp so a captured payload cannot be replayed.

e.Use(middleware.BodyLimit("256K"))                 // global floor
uploads := e.Group("/api/v1/files", middleware.BodyLimit("25M"))

// Razorpay-style webhook: verify over raw bytes, then rewind for Bind.
func webhook(c echo.Context) error {
	raw, err := io.ReadAll(c.Request().Body)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "unreadable body")
	}
	c.Request().Body = io.NopCloser(bytes.NewReader(raw)) // rewind for downstream

	mac := hmac.New(sha256.New, []byte(cfg.WebhookSecret))
	mac.Write(raw)
	want := hex.EncodeToString(mac.Sum(nil))
	got := c.Request().Header.Get("X-Razorpay-Signature")
	if !hmac.Equal([]byte(want), []byte(got)) {
		return echo.NewHTTPError(http.StatusUnauthorized, "bad signature")
	}

	var evt WebhookEvent
	if err := c.Bind(&evt); err != nil {
		return err
	}
	return c.NoContent(http.StatusOK)
}

Key Points

  • BodyLimit checks Content-Length first, so oversized requests are cheap to reject
  • Set a small global limit and raise it only on upload groups
  • The body is a one-shot stream; Bind consumes it
  • Restore with io.NopCloser(bytes.NewReader(raw)) after reading
  • Hash raw bytes for webhook signatures and compare with hmac.Equal
Q28

How do you handle sessions and CSRF protection in an Echo application?

IntermediateSecurity

Answer

Echo core has no session store; sessions live in github.com/labstack/echo-contrib/session, a thin wrapper over gorilla/sessions. You register session.Middleware(store) once with a cookie store or a Redis-backed store, then call session.Get("name", c) inside a handler to read or create the session, mutate sess.Values, and call sess.Save(c.Request(), c.Response()) to write it back. Forgetting the Save is the number one session bug: the value is set in memory, the handler returns 200, and nothing persisted.

Configure sess.Options with HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode and a real MaxAge. A cookie store signs and optionally encrypts the payload into the cookie itself, which is stateless but caps you near four kilobytes and makes server-side logout impossible; a Redis store keeps only an opaque ID in the cookie and lets you revoke. CSRF is separate. middleware.CSRFWithConfig implements the double-submit cookie pattern: it generates a token, stores it in a cookie and in the context under the key "csrf", and on unsafe methods it requires a matching value from TokenLookup, typically the X-CSRF-Token header or a hidden _csrf form field.

The counter-intuitive setting is CookieHTTPOnly: false, because the browser JavaScript that sends the header has to be able to read the cookie. Combine it with CookieSameSite: http.SameSiteStrictMode and CookieSecure: true. If your API is pure token-in-Authorization-header with no cookies at all, CSRF does not apply and adding the middleware just breaks your clients; it matters specifically when the browser attaches credentials automatically.

import (
	"github.com/gorilla/sessions"
	"github.com/labstack/echo-contrib/session"
)

store := sessions.NewCookieStore([]byte(cfg.SessionAuthKey), []byte(cfg.SessionEncKey))
store.Options = &sessions.Options{
	Path: "/", MaxAge: 86400 * 7,
	HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
}
e.Use(session.Middleware(store))

e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
	TokenLookup:    "header:X-CSRF-Token,form:_csrf",
	CookieName:     "_csrf",
	CookiePath:     "/",
	CookieSecure:   true,
	CookieHTTPOnly: false, // the browser must read it to echo it back
	CookieSameSite: http.SameSiteStrictMode,
	CookieMaxAge:   3600,
}))

func login(c echo.Context) error {
	sess, _ := session.Get("sid", c)
	sess.Values["uid"] = 42
	if err := sess.Save(c.Request(), c.Response()); err != nil { // do not skip
		return err
	}
	return c.NoContent(http.StatusNoContent)
}

Key Points

  • Sessions come from echo-contrib/session over gorilla/sessions
  • sess.Save(c.Request(), c.Response()) is mandatory or nothing persists
  • Cookie store is stateless but unrevocable; Redis store allows logout
  • CSRF double-submit needs CookieHTTPOnly: false so JS can read the token
  • CSRF is irrelevant for pure Authorization-header APIs with no cookies
Q29

How do you instrument an Echo service with OpenTelemetry and Prometheus without blowing up label cardinality?

AdvancedObservability

Answer

Two packages cover the common case. otelecho, from go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho, is one line: e.Use(otelecho.Middleware("orders-api")). It extracts the W3C traceparent header from the incoming request, starts a server span named after the matched route, and puts the span context onto c.Request().Context(). Everything downstream that accepts a context, otelsql-wrapped database calls, otelhttp-wrapped clients, the Redis and Kafka instrumentations, then nests under it automatically, and outbound requests propagate the header so the trace continues across services.

For metrics, echoprometheus in github.com/labstack/echo-contrib replaces the older prometheus package in the same repo: e.Use(echoprometheus.NewMiddleware("orders")) records request counts, durations and sizes, and e.GET("/metrics", echoprometheus.NewHandler()) exposes them. Cardinality is where teams get hurt. A Prometheus time series is created per unique label combination, so labelling by the raw URI means /orders/9f3c... and /orders/a71b... become separate series, and a service with a million order IDs generates a million series that will take down your Prometheus before it takes down your app.

Always label with c.Path(), the route template, which is bounded by the number of registered routes. Apply the same discipline to trace span names, and drop or hash high-cardinality values into span attributes where they are indexed differently, not into metric labels. Two more rules: exclude /metrics and /health from your own instrumentation with a Skipper so scrapes and probes do not dominate the histograms, and sample traces at the head, typically one to ten percent on a high-traffic path with a tail sampler that always keeps errors and slow requests.

import (
	"github.com/labstack/echo-contrib/echoprometheus"
	"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
)

skipInternal := func(c echo.Context) bool {
	p := c.Path()
	return p == "/metrics" || p == "/health" || p == "/ready"
}

e.Use(otelecho.Middleware("orders-api", otelecho.WithSkipper(skipInternal)))
e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{
	Subsystem: "orders",
	Skipper:   skipInternal,
}))
e.GET("/metrics", echoprometheus.NewHandler())

// Custom histogram: route template only, never the concrete URI.
var dbLatency = prometheus.NewHistogramVec(
	prometheus.HistogramOpts{Name: "db_query_seconds"},
	[]string{"route", "query"}, // bounded label sets
)

func getOrder(c echo.Context) error {
	ctx, span := tracer.Start(c.Request().Context(), "repo.FindOrder")
	span.SetAttributes(attribute.String("order.id", c.Param("id"))) // attribute, not label
	defer span.End()

	t := time.Now()
	o, err := repo.Find(ctx, c.Param("id"))
	dbLatency.WithLabelValues(c.Path(), "find_order").Observe(time.Since(t).Seconds())
	if err != nil {
		span.RecordError(err)
		return err
	}
	return c.JSON(http.StatusOK, o)
}

Key Points

  • otelecho.Middleware extracts traceparent and starts the server span
  • echoprometheus replaces the older echo-contrib/prometheus package
  • Label metrics with c.Path(), never the raw URI, to bound cardinality
  • Skip /metrics and /health so probes do not skew histograms
  • Head sample at a low rate; tail sample to always keep errors and slow spans
Q30

A Kubernetes rollout of your Echo service drops requests despite e.Shutdown. What is going wrong?

AdvancedDeployment

Answer

e.Shutdown drains connections the process already has, but it cannot stop traffic still being routed to the pod. The race is in the ordering of two independent things. When a pod is deleted, the kubelet sends SIGTERM at roughly the same moment the endpoints controller starts removing the pod from the Service, and that removal has to propagate to kube-proxy on every node, to the ingress controller, and possibly to an external ALB target group.

That propagation takes seconds. If your process exits in fifty milliseconds because it had no in-flight requests, every connection routed during that window gets a connection refused, which surfaces as a spike of 502s on exactly the deploys where nothing looks wrong in your application logs. The fix has three parts.

First, add a preStop hook that sleeps five to fifteen seconds, or better, flip a readiness flag and let the sleep cover the propagation delay. Kubernetes runs preStop before SIGTERM, so the pod keeps serving while it is being removed from rotation. Second, split liveness and readiness: readiness must start failing immediately on shutdown so nothing new is routed, while liveness keeps passing so the kubelet does not kill you mid-drain.

Third, size terminationGracePeriodSeconds above your Shutdown context timeout, which itself should exceed your longest expected request. There is a fourth issue specific to keep-alive: an HTTP client holding an idle connection to a terminating pod will reuse it. http.Server.Shutdown closes idle connections, and setting srv.SetKeepAlivesEnabled(false) before Shutdown makes the server send Connection: close on in-flight responses so clients stop reusing that socket.

var ready atomic.Bool

func main() {
	e := echo.New()
	ready.Store(true)

	e.GET("/health", func(c echo.Context) error { // liveness: always OK while alive
		return c.NoContent(http.StatusNoContent)
	})
	e.GET("/ready", func(c echo.Context) error { // readiness: flips on drain
		if !ready.Load() {
			return c.NoContent(http.StatusServiceUnavailable)
		}
		return c.NoContent(http.StatusNoContent)
	})

	srv := &http.Server{Addr: ":8080", ReadHeaderTimeout: 5 * time.Second}
	go func() { _ = e.StartServer(srv) }()

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

	ready.Store(false)              // stop new routing decisions
	time.Sleep(10 * time.Second)    // let endpoint removal propagate
	srv.SetKeepAlivesEnabled(false) // stop keep-alive reuse

	sd, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()
	_ = e.Shutdown(sd)
}

// deployment.yaml
// terminationGracePeriodSeconds: 45
// lifecycle: { preStop: { exec: { command: ["sleep", "10"] } } }

Key Points

  • SIGTERM and endpoint removal race; propagation takes seconds
  • preStop sleep or a readiness flip covers the propagation window
  • Readiness fails immediately on shutdown; liveness must keep passing
  • terminationGracePeriodSeconds must exceed the Shutdown context timeout
  • SetKeepAlivesEnabled(false) stops clients reusing the dying socket
💡 Pro Tip: If your 502 graph spikes only during deploys and your error logs are empty, this is almost always the cause. It is a scheduling race, not an application bug.
Q31

How do you profile and reduce allocations in a hot Echo endpoint, including swapping the JSON serializer?

AdvancedPerformance

Answer

Start by measuring, because the router is rarely the problem. Run go test -bench . -benchmem on the handler to get allocations per operation, then attach pprof on a live pod: go tool pprof http://127.0.0.1:6060/debug/pprof/allocs for allocation sites and profile?seconds=30 for CPU. In a typical Echo service the ranked culprits are encoding/json reflection, string concatenation in log lines, []byte to string conversions, an oversized per-request struct escaping to the heap, and middleware that buffers.

Echo itself contributes almost nothing once the pool is warm. The framework-specific lever is echo.JSONSerializer, an interface with Serialize(c echo.Context, i interface{}, indent string) error and Deserialize(c echo.Context, i interface{}) error. Assign e.JSONSerializer and every c.JSON and c.Bind on the app routes through your implementation, so you can swap in github.com/bytedance/sonic or github.com/goccy/go-json without touching a single handler.

Sonic uses JIT-compiled encoders and is meaningfully faster on large payloads on amd64; goccy is pure Go and portable. Benchmark with your real payload shapes before adopting either, and keep the error mapping the default serializer does, converting a *json.UnmarshalTypeError into a 400 rather than letting a raw decode error become a 500. Other high-yield changes: use sync.Pool for large reusable buffers, prefer strconv over fmt.Sprintf in hot paths, return slices with a preallocated capacity, drop logging from per-request success paths to Debug level, and set GOMEMLIMIT on containerised pods so the garbage collector respects the cgroup limit instead of being OOMKilled. Always confirm the win with a benchmark diff rather than intuition.

import "github.com/bytedance/sonic"

type SonicSerializer struct{}

func (SonicSerializer) Serialize(c echo.Context, i any, indent string) error {
	enc := sonic.ConfigDefault.NewEncoder(c.Response())
	if indent != "" {
		enc.SetIndent("", indent)
	}
	return enc.Encode(i)
}

func (SonicSerializer) Deserialize(c echo.Context, i any) error {
	err := sonic.ConfigDefault.NewDecoder(c.Request().Body).Decode(i)

	var ute *json.UnmarshalTypeError
	var se *json.SyntaxError
	switch {
	case errors.As(err, &ute):
		return echo.NewHTTPError(http.StatusBadRequest,
			fmt.Sprintf("field %s expects %v", ute.Field, ute.Type)).WithInternal(err)
	case errors.As(err, &se):
		return echo.NewHTTPError(http.StatusBadRequest,
			fmt.Sprintf("malformed json at byte %d", se.Offset)).WithInternal(err)
	}
	return err
}

e.JSONSerializer = SonicSerializer{}

Key Points

  • Measure first with -benchmem and pprof allocs before optimising
  • e.JSONSerializer swaps encoding/json for sonic or goccy/go-json globally
  • Keep the default UnmarshalTypeError to 400 mapping in Deserialize
  • strconv over fmt.Sprintf, preallocated slices, sync.Pool for big buffers
  • Set GOMEMLIMIT so the GC respects the container memory limit
Q32

How do you apply backpressure in an Echo service so a slow dependency does not exhaust goroutines and DB connections?

AdvancedConcurrency

Answer

Go's net/http spawns a goroutine per connection, and Echo does nothing to bound that. Goroutines are cheap at two kilobytes of initial stack, so a hundred thousand concurrent requests will not by itself kill you, but each one holding a database connection, a buffered body and an upstream socket will. The failure mode under a slow dependency is a queue that grows until memory or the connection pool runs out, and latency climbs for everyone rather than a subset failing fast.

Real backpressure means bounding concurrency and shedding load deliberately. Layer four controls. First, bound the database at the source with db.SetMaxOpenConns tuned to what your Postgres or PgBouncer can actually serve, plus SetMaxIdleConns and SetConnMaxLifetime; an unbounded pool just moves the queue into the database.

Second, add an in-flight semaphore middleware, a buffered channel of tokens, that returns 503 with Retry-After instead of queueing once the ceiling is hit. Failing one percent of requests in three milliseconds is a far better outcome than making a hundred percent of them wait eight seconds. Third, put a circuit breaker around each external dependency with something like sony/gobreaker so a dead vendor API turns into an immediate typed error instead of a timeout per request.

Fourth, pair all of it with middleware.ContextTimeout so cancelled requests actually release their resources. Use golang.org/x/sync/semaphore with TryAcquire, or a plain buffered channel with a select default, so acquisition never blocks. Expose the in-flight gauge as a metric: the depth of that semaphore is the earliest leading indicator you have that a dependency is degrading.

func MaxInFlight(n int64) echo.MiddlewareFunc {
	sem := semaphore.NewWeighted(n)
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			if !sem.TryAcquire(1) { // never block: shed instead of queueing
				inFlightRejected.Inc()
				c.Response().Header().Set("Retry-After", "1")
				return echo.NewHTTPError(http.StatusServiceUnavailable, "server busy")
			}
			defer sem.Release(1)
			inFlight.Inc()
			defer inFlight.Dec()
			return next(c)
		}
	}
}

e.Use(middleware.ContextTimeout(3 * time.Second))
e.Use(MaxInFlight(200))

// The real ceiling lives here, not in the framework.
db.SetMaxOpenConns(40)
db.SetMaxIdleConns(40)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

Key Points

  • Goroutine per connection is unbounded; the scarce resource is the DB pool
  • SetMaxOpenConns, SetMaxIdleConns and SetConnMaxLifetime bound the real limit
  • A semaphore middleware sheds load with 503 instead of queueing
  • Circuit breakers turn a dead dependency into an instant typed error
  • Export the in-flight gauge; it degrades before latency does
Q33

How do you run WebSockets or Server-Sent Events on Echo, and what breaks them in production?

AdvancedStreaming

Answer

Echo ships no WebSocket implementation. You upgrade from inside an ordinary handler using github.com/coder/websocket (the maintained continuation of nhooyr.io/websocket) or github.com/gorilla/websocket, both of which accept the plain http.ResponseWriter and *http.Request that c.Response() and c.Request() already are. That part is three lines.

Everything that breaks lives in the middleware chain and the server config. Four failure modes come up repeatedly. First, middleware that wraps or owns the response writer is incompatible with streaming: Gzip buffers output, and middleware.Timeout has to hold the response to be able to replace it, which is why Echo's own documentation warns against using it with long-lived connections.

Put a Skipper on both for your /ws and /events routes. Second, http.Server.WriteTimeout covers the entire response, so a sixty second WriteTimeout silently cuts every stream at sixty seconds and looks like a client bug. Either run streaming routes with WriteTimeout at zero, or push the deadline forward per write with http.NewResponseController, which reaches the real writer through the Unwrap chain.

Third, gorilla/websocket permits exactly one concurrent reader and one concurrent writer per connection, and two goroutines calling WriteMessage panic, so funnel every send through a single write pump fed by a buffered channel and disconnect the client when that channel fills. Fourth, idle timeouts upstream: an AWS ALB defaults to sixty seconds and nginx to a sixty second proxy_read_timeout, so emit a WebSocket ping or an SSE comment line every fifteen to thirty seconds. The deploy-day gotcha is that http.Server.Shutdown explicitly does not close hijacked connections, so a pod holding open sockets burns your entire shutdown context on every rollout unless you keep a registry of live connections and close them yourself.

// Streaming routes must skip middleware that buffers or owns the response.
isStream := func(c echo.Context) bool {
	return c.Path() == "/events" || c.Path() == "/ws"
}
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{Skipper: isStream}))
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
	Skipper: isStream,
	Timeout: 5 * time.Second,
}))

func events(c echo.Context) error {
	h := c.Response().Header()
	h.Set(echo.HeaderContentType, "text/event-stream")
	h.Set(echo.HeaderCacheControl, "no-cache")
	h.Set("X-Accel-Buffering", "no") // nginx must not buffer the stream
	c.Response().WriteHeader(http.StatusOK)

	rc := http.NewResponseController(c.Response())
	ctx := c.Request().Context()
	sub := bus.Subscribe(ctx)
	ping := time.NewTicker(20 * time.Second)
	defer ping.Stop()

	for {
		select {
		case <-ctx.Done(): // client disconnected
			return nil
		case ev := <-sub:
			_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
			_, err := fmt.Fprintf(c.Response(), "event: %s\ndata: %s\n\n", ev.Name, ev.Data)
			if err != nil {
				return nil // broken pipe: stop quietly
			}
			c.Response().Flush()
		case <-ping.C:
			fmt.Fprint(c.Response(), ": ping\n\n") // beat the 60s ALB idle timeout
			c.Response().Flush()
		}
	}
}

Key Points

  • No WebSocket layer in Echo; coder/websocket or gorilla/websocket upgrade the raw writer
  • Skip Gzip and middleware.Timeout on /ws and /events routes
  • WriteTimeout truncates streams; extend it via http.NewResponseController
  • One write pump per WebSocket, otherwise concurrent WriteMessage panics
  • Shutdown ignores hijacked connections, so close them from your own registry
💡 Pro Tip: If SSE works on localhost and delivers nothing through your ingress, the stream is being buffered. Send X-Accel-Buffering: no and confirm proxy_buffering is off before touching the handler.
Q34

How do you build an integration test suite for an Echo service with httptest.NewServer and testcontainers-go?

AdvancedTesting

Answer

Handler-level tests built with e.NewContext catch logic bugs, but they never catch the bugs that actually reach production in an Echo service: middleware registered in the wrong order, a Pre rewrite that changes which route matches, an error handler that turns a 404 into a 500, a struct tag typo that silently leaves a field zero, or SQL that only misbehaves against a real Postgres. For those you need the full stack. httptest.NewServer(e) takes *echo.Echo directly because it is an http.Handler, binds a real listener on a random port, and exercises Pre middleware, routing, binding, validation, your HTTPErrorHandler and the exact JSON bytes on the wire. Pair it with testcontainers-go so the database is the same engine and major version you deploy. postgres.Run(ctx, "postgres:17-alpine", ...) with a wait.ForLog strategy starts the container, ConnectionString hands you the DSN, and the ryuk reaper cleans up even when a test panics.

Run migrations once in TestMain, then isolate tests properly: wrap each one in a transaction you roll back, or create a fresh database from a template per test. Sharing a single dirty schema across t.Parallel tests is the most common source of flaky Go suites, and it looks like a framework problem when it is not. Three rules for CI.

Always run go test -race, because the race detector is what catches a handler that captured echo.Context into a goroutine. Add -count=1 when a test depends on external state, since Go caches passing results and a green run can be a replay. Keep container startup in a package-level TestMain so a fifty test package does not spend minutes pulling images. Assert on the response contract, status, headers and a golden JSON body, not on which repository method was called, so refactors do not rewrite the suite.

var baseURL string

func TestMain(m *testing.M) {
	ctx := context.Background()
	pg, err := postgres.Run(ctx, "postgres:17-alpine",
		postgres.WithDatabase("orders"),
		postgres.WithUsername("test"),
		postgres.WithPassword("test"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).WithStartupTimeout(60*time.Second)),
	)
	if err != nil {
		log.Fatalf("start postgres: %v", err)
	}
	dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")

	srv := httptest.NewServer(NewRouter(mustMigrate(dsn))) // the real *echo.Echo
	baseURL = srv.URL

	code := m.Run()
	srv.Close()
	_ = testcontainers.TerminateContainer(pg)
	os.Exit(code)
}

func TestCreateOrder_RejectsNegativeAmount(t *testing.T) {
	res, err := http.Post(baseURL+"/api/v1/orders", echo.MIMEApplicationJSON,
		strings.NewReader(`{"sku":"GS-1","amount":-1}`))
	if err != nil {
		t.Fatal(err)
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusUnprocessableEntity {
		body, _ := io.ReadAll(res.Body)
		t.Fatalf("got %d, want 422: %s", res.StatusCode, body)
	}
}

Key Points

  • *echo.Echo is an http.Handler, so httptest.NewServer(e) runs the whole chain
  • testcontainers-go postgres.Run plus wait.ForLog gives a real database per package
  • Isolate with a rolled-back transaction or a template database, never a shared dirty schema
  • go test -race catches the context-captured-in-a-goroutine class of bug
  • Assert the HTTP contract, not internal calls, so refactors stay cheap
Q35

How do you keep an Echo service on a safe dependency line, and what is the story with v5?

AdvancedTooling

Answer

The import path is github.com/labstack/echo/v4 and that is still the line to be on. A v5 has been developed in the open for a long time and has stayed pre-release, so a v5 import path in a production go.mod is a review comment rather than a modernisation. Maintenance on an Echo service is therefore patch hygiene, not major migrations.

Run govulncheck ./... in CI. It differs from a generic dependency scanner because it does symbol-level reachability analysis against the Go vulnerability database, so it only fails the build when a vulnerable function is genuinely callable from your code. That is the difference between a gate engineers respect and a wall of noise they mute in week two.

The recurring sharp edges in Go web frameworks are static file handlers and middleware that parses attacker-controlled input, so refusing a patch bump because you fear churn is a bad trade: patch releases on a v4 line do not break your API. Remember that echo-contrib and echo-jwt are separate Go modules with their own version numbers, so upgrading echo/v4 upgrades neither, and echo-jwt/v4 is where the JWT middleware went after it left core. The useful commands are go get -u=patch ./... for a security-only bump, go mod tidy -diff to fail CI when go.mod or go.sum is stale, and a toolchain directive plus GOTOOLCHAIN so every laptop and the CI runner compile with the same Go.

One runtime note for 2026: Go 1.25 made the default GOMAXPROCS aware of the cgroup CPU limit, which removed the main reason to import go.uber.org/automaxprocs in a containerised Echo pod. Set GOMEMLIMIT against the pod memory limit for the same reason.

# CI gate that runs on every pull request.
.PHONY: audit
audit:
	go mod tidy -diff            # Go 1.23+: fails when go.mod or go.sum is stale
	go vet ./...
	govulncheck ./...            # reachable vulnerabilities only, not an advisory dump
	go test -race -count=1 ./...

.PHONY: bump-patch
bump-patch:
	go get -u=patch ./...        # security fixes without API churn
	go get -u=patch github.com/labstack/echo-contrib github.com/labstack/echo-jwt/v4
	go mod tidy
	$(MAKE) audit

# go.mod, abridged. The helper modules version independently of echo/v4:
#
#   module github.com/acme/orders
#   go 1.25
#   toolchain go1.25.0
#
#   require (
#       github.com/labstack/echo/v4      // core framework
#       github.com/labstack/echo-contrib // echoprometheus, session
#       github.com/labstack/echo-jwt/v4  // JWT middleware, removed from core
#   )

Key Points

  • Stay on github.com/labstack/echo/v4; v5 has not left pre-release
  • govulncheck does reachability analysis, so its failures are worth gating on
  • echo-contrib and echo-jwt/v4 are separate modules with separate versions
  • go get -u=patch plus go mod tidy -diff keeps patches flowing without API churn
  • Go 1.25 cgroup-aware GOMAXPROCS retires automaxprocs for containerised pods
💡 Pro Tip: Run govulncheck against the built binary as well as the source with govulncheck -mode=binary. It is the only check that catches a vulnerable dependency pulled in by a build tag your local go build never sets.

Companies Hiring Echo

Zomato
Swiggy
Dream11
PhonePe
ShareChat
Razorpay
Uber
Gojek

Salary Insights

Average in India
₹10-28 LPA

Frequently Asked Questions

What salary can I expect for an Echo or Go backend role in India in 2026?

Go backend roles that list Echo, Gin or Fiber sit in roughly the ₹10-28 LPA band, and the framework itself is not what moves you inside it. Freshers and one-year engineers who can build a clean CRUD service with middleware, binding and tests land around ₹6-12 LPA. Three to five years with production ownership, meaning graceful shutdown, observability and a real incident or two, is where the ₹15-22 LPA offers appear. Above that you are being paid for concurrency judgement, latency work and system design rather than route registration. Product companies in Bengaluru, Gurugram and Hyderabad that run Go at scale, such as Zomato, Swiggy, Dream11, PhonePe and ShareChat, pay materially better than services firms for the same years of experience, and fintech tends to add a premium for anyone who can talk convincingly about idempotency and webhook signature verification.

How long does it take to prepare for an Echo interview if I already know Go?

If you are comfortable with goroutines, channels, context and interfaces, two focused weeks is realistic. Echo has a small surface area: the router, the context, the middleware model and the error handler are most of it, and you can read the framework source in an afternoon. Spend week one building one real service end to end, with grouped routes, a custom HTTPErrorHandler, validation, structured logging and graceful shutdown, because interviewers ask about those from experience rather than from documentation. Spend week two on the parts that only show up in production: context cancellation, timeouts, rate limiting across multiple pods, allocation profiling with pprof and Kubernetes drain behaviour. If your Go fundamentals are shaky, fix those first. An interviewer who catches you misusing a context or leaking a goroutine will not care how well you know the framework.

What do interviewers expect from a fresher versus someone with four or more years of experience?

Freshers are assessed on Go itself plus evidence you have actually run a service. Expect to explain the handler signature, path parameters, groups, what c.Bind does, how middleware nests and how you would return a 404 rather than a 500. Having one deployed project with tests and a Dockerfile beats naming five frameworks. From four years upward the questions move to judgement. Why does this endpoint allocate, what happens when the database is slow, how do you avoid dropping requests during a rollout, how do you bound cardinality on your metrics, how do you handle a partially written streaming response. Senior candidates are also expected to have an opinion on where the framework should stop, meaning handlers stay thin and business logic lives in packages Echo never touches, so that a framework change is a transport rewrite rather than a rewrite of the product.

Is Echo worth learning in 2026 given Go 1.22 ServeMux?

The standard library router closed part of the gap when Go 1.22 added method-aware patterns like GET /users/{id} and r.PathValue, and for a small internal service with four endpoints ServeMux plus a couple of hand-written wrappers is genuinely enough. What the standard library still does not give you is a middleware ordering model, grouped routes with inherited middleware, a handler signature that returns an error, binding with struct tags, or a maintained middleware package covering CORS, gzip, rate limiting, request logging, body limits and recovery. That is exactly the plumbing every team ends up writing badly. Echo remains one of the three frameworks Indian job descriptions actually name, alongside Gin and Fiber, so it earns its place on a resume. Learn Go deeply and Echo as a tool, not the other way around, because the interview weighting reflects that.

Echo versus Gin versus Fiber versus chi: which should I learn?

Gin appears in the most Indian job postings, so if you are optimising purely for volume of calls, learn Gin first. Echo and Gin are close relatives: radix tree router, context object, middleware chain, similar benchmarks. The differences that matter in an interview are that Echo handlers return an error while Gin uses c.AbortWithStatusJSON, and that Echo's HTTPErrorHandler centralises error mapping in a way Gin leaves to you. Fiber is the odd one out because it is built on fasthttp rather than net/http, which means faster microbenchmarks but no direct compatibility with the net/http ecosystem, including some tracing and middleware libraries. chi sits at the other end: it is a router over net/http with almost no framework around it, and it is what teams pick when they want the standard interfaces and nothing else. Knowing one well transfers to the others in a day.

Do I need Kubernetes and observability knowledge for an Echo backend role?

For anything above two years of experience in a product company, yes. Go services are almost always containerised, and the questions that separate candidates are operational: what happens to in-flight requests when a pod is deleted, why your 502 graph spikes only during deploys, what liveness and readiness probes should actually check, how you would find the endpoint responsible for a memory increase. Being able to say you have run pprof against a live pod, or that you label metrics with the route template instead of the raw URI to keep Prometheus cardinality sane, marks you as someone who has operated a service rather than only written one. You do not need to administer a cluster. You do need to understand what your service experiences inside one, and to have wired up OpenTelemetry or Prometheus at least once yourself.

Introduction

Echo is a minimal, high performance HTTP framework for Go, maintained as github.com/labstack/echo/v4. It gives you a radix tree router that allocates nothing while matching a route, a pooled echo.Context wrapping the standard *http.Request and http.ResponseWriter, a handler signature that returns an error so every failure funnels into one central handler, and a middleware package covering CORS, gzip, rate limiting, request logging, body limits and panic recovery. Everything underneath is still net/http, which is why Echo services slot into existing Go infrastructure, tracing agents and load balancers without surprises. Teams reach for it when Gin feels too loose and chi feels too bare.

Echo interviews in India rarely stop at routing. Interviewers at Go-heavy engineering teams such as Zomato, Dream11, PhonePe and ShareChat probe the parts that break in production: why echo.Context must never escape into a goroutine, what c.Bind() actually binds on a POST versus a GET, why middleware.Timeout never cancels your database query while middleware.ContextTimeout does, how c.RealIP() behaves behind an ALB or Cloudflare, and how you keep Prometheus label cardinality sane by tagging metrics with c.Path() instead of the raw URL. Expect at least one question on graceful shutdown and one on writing a custom HTTPErrorHandler.

This page collects 35 questions that decide real Echo interviews in 2026, ordered from fundamentals through to production architecture. Each answer explains how the framework actually behaves rather than restating the docs, and most carry a Go snippet you can paste into a scratch main.go and run. Backend engineers with solid Echo plus genuine Go concurrency fundamentals sit in the ₹10-28 LPA band, with the upper half going to people who can discuss allocation profiles, connection draining during a Kubernetes rollout, and custom JSON serializers. Work through the basic tier quickly, then spend most of your prep on the intermediate and advanced sections.

Ready to practice Echo interviews?

Don't just read, practice these Echo 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