Middleware chains — Chi vs Express, with your real orderings
The exact middleware stacks of both backends, why each element sits where it sits, and the traps you avoided.
Know your own system
Middleware is where cross-cutting concerns live — and where ordering bugs live too. Own both stacks line by line.
Zoth (Go, Chi) — every binary's router
r := chi.NewRouter() r.Use(middleware.Recovery) // 1. panics → 500, never drop conn if otelEnabled { r.Use(otelhttp.NewHandler(next, "http.server")) } // 2. span per request r.Use(middleware.Logging) // 3. structured slog + OTel bridge r.Use(middleware.CORS) // 4. allowlist origins, no credentials r.Use(middleware.Auth) // 5. JWT/API-key → context r.Use(middleware.Tenant) // 6. assert tenant present mount subrouters; policy/integration mutations add middleware.RequireRole(RoleAdmin)
Why this order: Recovery outermost so everything below it is spanned and logged even while failing; auth before tenant because the tenant ID comes from the verified token; role gates on sub-routers (one Use per route group) rather than repeated per handler — centralize authorization by route group, the same principle as traverse's route-level authenticateAdmin.
Details that show depth:
- The in-memory token-bucket rate limiter exists (
middleware/ratelimit, per-tenant buckets, 10-minute cleanup loop) and is unit-tested but not mounted in the router — a gap you should state unprompted ("wired but not enabled; enabling is oner.Useaway — the honest reason is it wasn't needed at demo scale and untested against real traffic patterns"). - Role resolution per request:
authenticateJWThitsuserRepo.GetByIDon every request — deliberately rejecting tokens of deleted users immediately. Cost: one indexed PK lookup per request (~sub-ms); benefit: no stale-role window. That's a consistency/performance tradeoff you made consciously.
Traverse (Express) — the mount order in src/index.ts
app.set('trust proxy', 1) // unwrap exactly ONE proxy hop app.disable('x-powered-by') app.use(cookieParser()) // parse auth_token cookie app.use(requestContext) // OTel span + req.log + requestId app.use('/api/recon', reconRoutes) // BEFORE json parser — stream to disk app.use(express.json({ limit:'10mb', verify: rawBody })) app.use('/api/', generalLimiter) // 100 req/min app.use('/api/auth/login|register|change-password|password-reset', strictLimiter) // 5/15min GET /health, /api/health, /api/version, /api/health/db (before auth) ...route mounts... 404 handler → { error: 'Route not found' } global error handler → 500 { error: 'Internal server error' }
The three load-bearing orderings (interview gold, all commented in the file):
/api/reconbeforeexpress.json— capture bundles are multi-MB and must stream to disk raw; the 10 MB JSON limit would 413 them before the route ran.verify: rawBody— Razorpay HMAC needs exact bytes (Day 17).requestContextbefore everything else — so every log line downstream carriesrequestId+ trace context; correlation is free.
Also: health routes sit outside the rate-limited /api/ mount (uptime checks must never be throttled), and the global error handler is deliberately dumb — every intentional error was already converted to a safe response in sendError; anything that reaches the global handler is by definition unexpected, so it gets a flat 500 and a server-side log. Zero information leakage.
The comparison line
"Chi middleware = function decorators around an http.Handler; Express middleware = an onion of (req,res,next) callbacks. Same mental model, different type systems. What matters is neither framework — it's the ordering decisions: recovery outermost, context before logging, auth before authorization, parsers after streaming routes, and one global error funnel with a strict allow-list of what may reach the client."
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
Why resolve the user's role from the database on every request instead of trusting the JWT claim?
The claim is a snapshot from issuance time (1-hour expiry); a demotion or deletion wouldn't take effect until expiry — in an IAM platform that's a real privilege-persistence window. One indexed PK read per request is sub-millisecond. The tradeoff I knowingly accepted: the API does one extra DB round-trip per request, which matters exactly never at human-paced QPS. If it did, the fix is a short-TTL role cache with invalidation on role change — and I'd measure before adding it.
Your rate limiter is in-memory in both projects. What breaks at 2 replicas?
Limits become per-process: 100/min becomes 200/min effective, and a user pinned to different pods gets inconsistent buckets. The fix is a shared store — Redis token bucket or sliding window (zoth's limiter is already per-tenant keyed, so swapping the bucket map for Redis is localized). Not done yet because both deployments are single-process — it's the first thing I'd change before horizontally scaling either.
What does requestContext give you that plain console.log wouldn't?
It opens an OTel span per request and attaches req.log and req.requestId, so every subsequent log carries the same trace context — and logs exported through the OTel log pipeline correlate with traces automatically in Better Stack. That correlation is literally the 'alert-to-trace' claim on my Popin resume: an alert on a failing endpoint links straight to the exact trace and its log lines, which is what collapsed MTTD.