Why are nearly all components client components ('use client') despite using the App Router?
Sportz is interactive and WebSocket-driven: the client holds a live socket and continuously mutates state. Server Components and streaming shine for content that renders once on the server; they’d fight a model where the client is the source of live truth. So the App Router is used for routing/layout, but the interactive surface is client components. This is a deliberate fit-to-purpose choice, not a missed optimization.
Why React Query instead of useState + useEffect + fetch?
The manual version (which existed first) reimplements caching, loading/error state, dedup, and AbortController by hand, and still needs a separate path to merge WebSocket updates. React Query gives all of that, and crucially lets WS events write into the same cache via setQueryData, unifying fetched and live data into one source of truth. It deleted ~50 lines of plumbing per hook.
How does React.memo with a custom comparator help MatchCard, and why is the comparator custom?
When any match updates (a WS score change), the parent re-renders and passes new props to all cards. Without memo, all 6 cards re-render though one changed. The custom comparator checks only the fields that affect output (id, scores, status, isActive, index), so a card re-renders only when its own data changes (~60% fewer renders during an active match). A default shallow compare wouldn’t capture the right subset cleanly.
What's the orchestrator pattern in page.tsx and why not Context or Zustand?
page.tsx owns activeMatchId and wires the three hooks (useWebSocket, useMatches, useCommentary) together because they depend on each other through that shared pivot. Context would re-render every consumer on any change; Zustand is global-store overhead for a single page. For one page with one shared pivot, an orchestrator component is the simplest correct choice. The documented next step (if it grows) is a useSportzApp() hook, then a store, in that order.
Why does the theme toggle need a 'mounted' guard?
next-themes resolves the actual theme from localStorage, which only exists on the client. During SSR the server doesn’t know the user’s preference, so rendering the toggle immediately causes a hydration mismatch / flash of wrong icon. The mounted guard withholds the toggle until the client has resolved the real theme.
What's your rule for animations not causing jank, and why?
Only animate transform and opacity: they’re GPU-composited and don’t trigger layout/paint. Never animate width/height/top/left. Continuous animations (the live pulse) use CSS, not Framer Motion, so they don’t hold a requestAnimationFrame loop for the whole session. And useReducedMotion disables motion for users who request it (an accessibility requirement, not optional).
AnimatePresence with mode='popLayout' on the score: what problem does it solve?
When a score changes, the old number must exit while the new one enters. popLayout removes the exiting element from layout flow immediately so the entering one doesn’t cause a layout shift / both occupying space. The key={score} is what tells AnimatePresence an element left and another arrived.
What does it mean for a color token to be 'theme-aware,' and when does a token NOT need to be?
Contrast is a relationship between a color and its background, so a color shown on a background that flips between themes must change with it. --live is #c81e1e in light, #f87171 in dark, because the ‘Live’ label sits on a card that flips white ↔ near-black, so one fixed red can’t meet 4.5:1 on both. A token on a fixed background (brand yellow on the always-yellow header) doesn’t need to vary. The bug that taught this: a duplicate --color-live in @theme shadowed the theme-aware mapping, because CSS keeps the last declaration.
React 19's hooks rules flagged three patterns in this app. What were they and how did you fix them?
(1) set-state-in-effect: a useEffect that set the disconnect modal on wsStatus change; fixed by DERIVING (wsStatus === 'disconnected' && !errorDismissed) and moving reconnect side effects into a socket callback. (2) refs-in-render: reading newEventIds.current during render to flag “new” events; fixed by making it state passed as a prop (render must be pure; refs don’t trigger re-renders). (3) immutability/use-before-declaration: the reconnect timer referenced connect inside its own definition; fixed with a connectRef so it calls the latest connect. All three “worked” only by relying on current render/closure timing; the rules make them correct under concurrent rendering.
What's a hydration mismatch, and why guard the theme toggle with `mounted` and not `resolvedTheme`?
Hydration is React on the client attaching to server-rendered HTML: the first client render must match the server’s output. The theme lives in localStorage (server can’t read it), so rendering the theme-dependent icon on the first client render would differ from the server HTML → a mismatch, which React resolves by discarding and re-rendering (a flash + wasted SSR). The fix is a mounted flag set in useEffect: it’s false on the server AND the first client render, flipping only after hydration, so both passes match. We tried gating on next-themes’ resolvedTheme to avoid the setState-in-effect the lint rule flags, but that REINTRODUCED the mismatch: next-themes resolves resolvedTheme synchronously on the first client render, so it’s defined on the client but not the server. The takeaway: resolvedTheme isn’t hydration-safe; mounted is; and the react-hooks rule is a justified false positive we scope-disable here.
A deployed Next.js app fetched from localhost in production. Diagnose.
NEXT_PUBLIC_* env vars are inlined into the bundle at build time, not read at runtime. The build ran without NEXT_PUBLIC_API_URL, so the code’s localhost fallback got frozen in. The fix is to set the var in the deploy environment and REBUILD: on Vercel a redeploy, in Docker a new image with the right —build-arg. Saving the var without rebuilding changes nothing, because the value is already a string literal in the shipped JS.
The score celebration fires on a 'score increase' rather than matching scoring event types. Why is that better?
The trigger compares the new total to the last known total for the watched match; if it went up, we celebrate. The alternative, a hand-maintained Set of scoring event types (GOAL, TRY, THREE_POINTER, SIX, GAME…), works but is fragile: it’s five sports’ worth of types, and adding a sport or event means remembering to update the set or the celebration silently stops. Deriving from the score increase is sport-agnostic and needs no maintained list, and we already know the match’s sport to pick the right ball. It’s the same “derive from the real signal, don’t maintain a parallel list” instinct. (Detected in a WS callback, not an effect, and it setState’s the celebration there, a legitimate event-driven update, not a setState-in-effect.)
Why is createApp() separate from server.listen()?
Testability. Supertest drives the Express app object in-memory without binding a port. If construction and listen lived in one file, importing it from a test would start a real server as a side effect and two test files would collide on the port. Separating “build the app” from “run the app” makes the whole route layer testable.
How do REST routes trigger a WebSocket broadcast without importing the WS module?
attachWebSocketServer() returns broadcast functions that index.ts injects onto app.locals. Routes call res.app.locals.broadcastCommentary(…) behind an existence check. This decouples the route layer from the WS layer: the route just says “something happened,” and the seam connects it to “tell the clients.”
Walk the order of gates in the request lifecycle and why that order.
Security (Arcjet) → validation (Zod) → persistence (Drizzle) → broadcast. A request failing Arcjet never touches Zod; one failing Zod never touches the DB. Each layer is a gate that rejects bad traffic as early and cheaply as possible.
How is match status determined, and why isn't it stored from user input?
getMatchStatus derives scheduled/live/finished from startTime/endTime relative to now. Storing a user-supplied status would let it drift out of sync with the clock (a ‘live’ match whose endTime passed). Deriving it makes the timestamps the single source of truth.
What happens on SIGTERM today, and what's the gap?
The process exits immediately, dropping in-flight WS connections uncleanly: no graceful shutdown. attachWebSocketServer now returns close() (stops the heartbeat interval, closes the server) as the building block; a proper SIGTERM handler would stop accepting connections, call close(), drain HTTP, close the DB pool, then exit. Client reconnect-with-backoff currently masks this for users.
Why WebSockets instead of polling or SSE?
Polling is high-latency and wasteful at live-sport cadence. SSE is server→client only: Sportz needs client→server (subscribe/unsubscribe to specific matches). WebSockets are bidirectional and persistent. Raw ws over Socket.IO keeps the dependency surface small, at the cost of hand-rolling rooms/heartbeat/reconnect.
How does reconnection work, and why exponential backoff?
On close, the client retries with delays of 2s, 4s, 8s… capped at 30s, giving up after 10 attempts. Exponential backoff avoids hammering a server that’s down (which a fixed short interval would do, worsening an outage) while still recovering quickly from a brief blip.
What's the heartbeat for and why must its interval be cleared on close?
A client can vanish without a clean close (laptop sleeps). The server pings every 30s and terminates sockets that didn’t pong, killing ghost connections that would otherwise leak memory (each holds a subscription Set). The interval must be cleared by close() or it keeps the process alive (which hung the test suite, ISSUE-001’s sibling).
A goal is scored: trace it end to end.
POST /matches/:id/commentary → Arcjet → Zod → Drizzle insert returning the row → broadcastCommentary(matchId, row) → WS looks up that match’s subscriber Set, sends only to them → client onmessage → onCommentary → setQueryData prepends to [‘commentary’, matchId] → new CommentaryEvent animates in (y:12→0). Unsubscribed clients get nothing.
How do you keep the live feed from showing duplicates?
A WS event can also be in the initial REST batch (Watch Live fetches the latest 50 while an event for that match arrives), or be delivered twice. So addEvent/addMatch dedup by id before writing to the cache: unconditionally prepending would double the item and collide on the React key (the list keys by id). Replacing a match by id on a score update is idempotent for the same reason. (For guarding against dropped events, the general technique is to track the sequence and refetch on a gap, a heavier layer you add when delivery reliability demands it.)
Explain the three SSL cases in db.ts.
Neon Cloud (valid certs → attempt SSL, verify), Neon Local (self-signed → attempt SSL, skip verification with rejectUnauthorized: false), plain Postgres for tests (no SSL → don’t attempt at all, ssl: false). The trap: { rejectUnauthorized: false } still attempts a handshake; only ssl: false disables it. The plain test DB refuses any handshake. (ISSUE-003.)
How do tests get a clean database, and why TRUNCATE ... RESTART IDENTITY CASCADE?
A beforeEach runs it. TRUNCATE is faster than row-by-row DELETE; RESTART IDENTITY resets serial IDs to 1 so assertions like expect(id).toBe(1) aren’t test-order-dependent; CASCADE handles the commentary→matches FK automatically.
Why parameterized queries via Drizzle rather than raw SQL?
User input is never string-concatenated into SQL, closing off SQL injection. The only raw SQL (TRUNCATE in the test reset) takes no user input. This is injection defense by construction, not by sanitizing.
What does Neon Local give you in dev that a plain Postgres container doesn't?
Production parity: it’s real Neon behavior (same engine, same SSL semantics) and forks an ephemeral branch per run that’s deleted on teardown: isolated, disposable, prod-like. A plain container doesn’t match Neon’s SSL/connection behavior (which is exactly how ISSUE-003 surfaced).
Why mock Arcjet but use a real Postgres?
Arcjet’s protect() makes a real network call, so mocking keeps tests fast, offline, deterministic, and quota-free. The database is the opposite: mocking it would mean testing the mock, not the actual SQL/schema/constraints. So we mock the external network dependency and keep the thing we’re actually verifying real.
Why does the Arcjet mock live in a setup file that runs before imports?
arcjet.ts throws at module-import time if ARCJET_KEY is missing. Any test importing app.ts transitively imports arcjet.ts, so the env var and mock must be in place before collection: a setupFile runs before any test file’s imports are evaluated.
Two real bugs were found by writing the WebSocket tests. What does that tell you?
A message-delivery race and a hung-timer (missing close()), both surfaced only because the tests exercised the real socket lifecycle. Tests aren’t just regression guards; writing them forces you through the real lifecycle and exposes latent design gaps (here, no clean shutdown path).
How is the frontend tested end to end, and what do those tests cover that the backend suite can't?
Playwright drives the real UI in Chromium: load the page, click Watch Live and assert the commentary panel fills, toggle dark mode, page through matches, and run an axe accessibility scan. The backend suite proves the API/SQL/socket are correct; the e2e suite proves a user actually sees and can do the right thing: rendering, interaction, theming, and a11y, which only exist in the browser.
Why do the e2e tests mock the network and run against a production build?
Two separate reasons. Mocking (page.route for REST, page.routeWebSocket for WS) removes the dependency on a cold, slow backend whose latency would non-deterministically change what renders. Running next build && next start (not next dev) removes flaky timeouts: dev compiles routes on first request, so fullyParallel serializes cold compiles past the 30s timeout, and the prod build is also what CI/Vercel actually ship, so you test reality. A dedicated port (3100) lets it coexist with the dev server.
You see e2e failures that are all timeouts, pass individually, and change which specs fail each run. How do you reason about that?
That triad is the signature of an environment/concurrency issue, not test-logic bugs. I’d confirm by running serially (—workers=1); if it goes green, contention is the cause. Here it was the dev server compiling routes under parallel load. The fix targets the environment (prod build + dedicated port), not the assertions. The meta-skill: distinguish flaky-by-environment from genuinely-wrong before touching test code.
Why a multi-stage Dockerfile?
The builder stage installs all deps and compiles TS; the runner stage starts fresh, installs only prod deps, and copies just dist/. The final image has no compiler, no devDeps, no source: smaller and lower attack surface.
A container crash-loops with EACCES mkdir 'logs'. Diagnose and fix.
Non-root USER, but COPYed files are root-owned, so the user can’t create logs/ in /app. Winston throws at startup; restart:unless-stopped loops it. Fix: mkdir -p logs && chown -R sportz:sportz /app before USER. Better in prod: log to stdout only, no file transport. (ISSUE-002.)
What does HOST=0.0.0.0 mean and why does a container need it?
Bind to all interfaces. 127.0.0.1 inside a container means only the container itself, so the host/Render proxy can’t reach it. 0.0.0.0 accepts connections routed in from outside. It only matters in containers/remote hosts; locally either works.
Why did docker-compose.prod.yml break, and how do such bugs hide?
It referenced target: production, but the Dockerfile only defines builder and runner. Multi-file Docker setups drift silently because nothing cross-checks a compose target against the Dockerfile’s stage names until you build that file. Fix: match the stage name (runner) and build the prod image in CI so drift fails fast. (ISSUE-005.)
Why does CI use a postgres service container instead of a Neon branch?
Speed (no API round-trip to provision), cost (no Neon quota per run), determinism (starts empty every run), and it works on forks where a Neon secret wouldn’t be available. The plain container mirrors the local test DB exactly, so ‘passes locally’ predicts ‘passes in CI’.
Why run lint and format with continue-on-error then a final gate?
A failing step normally stops the job, so you’d fix lint, push, then discover format is also broken. continue-on-error lets both run; the gate step inspects both outcomes and fails once, surfacing every problem in a single run with fix-command annotations.
Why build multi-arch (amd64 + arm64) images?
amd64 covers most cloud VMs; arm64 covers Apple Silicon and cheaper ARM cloud instances (Graviton). One tag works everywhere: Docker pulls the right architecture for the host. Build cache (type=gha) keeps the slower emulated build fast across runs.
How does CI actually gate the frontend's production deploy, given Vercel deploys on push?
Vercel’s Git integration deploys whatever lands on main regardless of GitHub Actions, so CI doesn’t gate the deploy directly. The gate is branch protection on main: require the three checks (Typecheck, E2E, Lint and Format) plus a PR, with admin bypass disabled. Now code only reaches main if CI is green, and since Vercel’s production deploy comes from main, production is effectively CI-gated. PR preview deploys still run regardless: that’s intentional, you want to preview WIP including failures. “Running CI” and “enforcing CI” are different things; the branch protection rule is the enforcement.
What are the layers of defense on an incoming request?
Helmet (secure headers) → CORS (origin allowlist) → Arcjet (shield, bot detection, sliding-window rate limit) → Zod (input validation) → Drizzle (parameterized queries). Each rejects a class of bad traffic before the next.
Sportz has no auth: is that a vulnerability?
It’s a deliberate scope decision for a public demo, not an oversight: there are no user accounts or protected resources. But it’s the first thing to add before any real multi-user deployment: anyone reaching the API can create matches/commentary. CSRF is N/A until cookie-based auth exists; a CSP header is a recommended hardening step regardless.
How are secrets handled across environments?
Never committed (.env* gitignored; only .env.*.example tracked). Prod injects via the host dashboard (Render, sync:false). CI uses GitHub Actions secrets. The image never bakes secrets in.
What are the three pillars, and where does Sportz stand on each?
Logs (what happened), metrics (how it performs), traces (where a request went). Backend has all three: Winston for logs, New Relic APM for metrics and traces (auto-instruments express/pg/http). Frontend has the same three via New Relic Browser, live in production. PostHog covers a separate, fourth concern on both sides (product analytics), not one of the three pillars.
Why is New Relic lazy-loaded after hydration?
A performance-monitoring agent loaded synchronously in <head> blocks rendering and hurts the very LCP it measures. Loading it after the page is interactive avoids skewing (and degrading) the metric.
Why does PostHog need manual $pageview tracking here?
App Router navigations update the URL via the History API without a full page load, which PostHog’s auto-capture misses. A usePathname effect fires $pageview on every route change so analytics aren’t undercounted.
The backend uses New Relic, not OpenTelemetry. Why?
New Relic’s Node agent auto-instruments express/pg/http itself: no separate OTel SDK or collector needed. The original plan was OTel feeding Sentry (Sentry v8+ is built on OTel), but once New Relic replaced Sentry, that instrumentation layer became redundant: New Relic does its own module-patching. Tradeoff: vendor lock-in over vendor neutrality, bought back by one connected trace (browser → backend) with zero extra infrastructure (ADR-013).
Scale to 100k concurrent: what breaks first and how do you fix it?
The in-process subscription registry. Past one instance, a client on instance A misses events created on instance B. Fix: a Redis pub/sub backbone, where each instance publishes broadcasts to a channel and relays to its own local subscribers. Plus pooled DB connections, per-instance connection limits, and a load balancer. None of this is needed below ~10k.
When would you add Redis, and when is adding it a mistake?
Add it when you horizontally scale the API (multi-instance broadcast) or a measured hot read path justifies caching. It’s a mistake before then: React Query caches client-side, the DB is fast at this scale, and unused infrastructure is pure operational cost.
One process serving REST + WS is simpler to deploy, reason about, and debug, with no inter-component network hops. Splitting early buys distributed-systems complexity for no benefit. Evolution is driven by measured pressure: scale the monolith horizontally first; extract a service only when it develops independent scaling/availability needs.