Skip to main content
Self-check questions grouped by level. Answers are in the toggles, so try before peeking.

Junior

WebSockets. Regular HTTP is request→response→done: the server can’t push. WebSockets stay open so the server pushes events the instant they happen.
It’s derived from startTime and endTime relative to now, not stored as a user input. Before start = scheduled, between = live, after end = finished.
“Listen on all network interfaces.” Inside a container, 127.0.0.1 means only the container itself, so Render’s proxy couldn’t reach it. 0.0.0.0 lets external traffic in.

Mid

useWebSocket’s onmessage → routes to onCommentaryaddEventsetQueryData prepends it to ['commentary', matchId] → the panel re-renders → the new CommentaryEvent animates in.
protect() makes a real network call, which would make tests flaky (network/uptime), slow (round-trip per request), and consume quota. The mock always allows, so tests are fast, offline, deterministic.
So it doesn’t fire when no match is selected (which would request /matches/null/commentary and 404). It activates automatically the moment a match is chosen.

Senior

A new match should appear in everyone’s grid (global). Commentary only matters to people watching that specific match (room-scoped, via the matchSubscribers map). Sending all commentary to all clients would be wasteful and leak unrelated matches’ data.
Cloud (valid certs → verify), Local (self-signed → attempt SSL, skip verification), plain Postgres (no SSL → don’t attempt at all). { rejectUnauthorized: false } still attempts a handshake; ssl: false skips it entirely. The plain test DB refuses any handshake, so it needs ssl: false.
A pub/sub backbone (e.g. Redis): each instance publishes broadcasts to a channel and relays to its own local subscribers, because the in-process matchSubscribers map only knows about one instance’s connections.

Staff

It’s the simplest correct design for a single instance: no network, no extra infra, fully understood. It’s invalidated the moment you run more than one API instance, because a broadcast on instance A never reaches a client connected to instance B. That condition, horizontal scaling, is the trigger to introduce pub/sub, and not before.
Because the fix has a non-trivial tradeoff (extra query vs coupling to PG error codes), pinning current behavior with an explanatory test makes any future fix deliberate and reviewed rather than silent, preventing a rushed choice from baking in the wrong tradeoff. A documented, test-guarded known-gap beats a hasty fix.

By domain

The level-based sets above span topics. These sets drill a single domain deep.

Frontend

A default memo does a shallow prop compare, but match objects are recreated on every parent render (new reference), so shallow compare would still re-render every card. The custom comparator compares the specific fields that affect output (id, scores, status, isActive, index) by value, so a card re-renders only when its own data actually changed.
The pulse runs the entire session: a JS animation would hold a requestAnimationFrame loop the whole time, while CSS runs on the compositor for free. The event entrance is a one-shot, event-driven animation where Framer Motion’s AnimatePresence enter/exit ergonomics are worth it. Right tool per animation type.
Providers that others depend on go outermost. Theme wraps everything (any provider/component may read it); Query wraps the app (components call useQuery); PostHog needs a Suspense boundary and sits inside Query; New Relic is a pure side-effect with no children dependencies, so innermost. Order encodes the dependency graph.
Animating height triggers layout recalculation and paint on every frame: janky, especially during high-frequency WS updates. transform/opacity are GPU-composited and skip layout/paint. The rule: never animate layout-affecting properties.
Contrast is a relationship between text and its background, and the card background flips white ↔ near-black with the theme. One fixed red can’t pass 4.5:1 on both: dark enough for white is too dark for near-black. So --live holds a different value per theme (#c81e1e light, #f87171 dark). Only colors on a fixed background (the always-yellow header) can be hardcoded.
Setting state synchronously in an effect forces a second render (render → effect → setState → render) and usually means you’re storing something derivable. Prefer deriving during render (the disconnect modal became wsStatus === 'disconnected' && !errorDismissed), or move the update into the callback of the real external event (reconnect side effects fire from the socket’s onopen, not a status-watching effect). Effects are for syncing with external systems, not for reacting to state to set more state.
Render must be a pure function of props + state. A ref is mutable and changing it doesn’t trigger a re-render, so reading .current in render can produce output that’s stale or inconsistent with what React thinks it rendered. If a value affects rendering, it must be state or props; refs are for things render doesn’t need (DOM nodes, timers, previous values) and are touched only in effects/handlers.
NEXT_PUBLIC_* values are inlined into the JS bundle at build time, not read at runtime. The build ran without NEXT_PUBLIC_API_URL set, so the code’s ?? 'http://localhost:8000' fallback got frozen in. Fix: set the var in the deploy env and REBUILD (a Vercel redeploy / a Docker build with the right —build-arg); changing it without rebuilding does nothing.
A live event can also be in the initial REST batch (you click Watch Live, the fetch returns the latest 50, and a WS event for that match arrives that was already in that batch) or be delivered twice. Prepending unconditionally would show it twice AND collide on the React key (the list keys by id). So addEvent/addMatch skip if the id already exists.
The client keeps its match cache fresh incrementally with setQueryData, but only the ADDS (match_created) were handled. The backend also PRUNES matches, and those removals were never reflected on the client, so its copy grew forever and only re-synced on a refetch (refresh). It’s a cache-consistency bug: an incrementally-updated cache must handle removals too. Fix: bound the cache write (keep all live + the watched match + newest finished up to a cap), mirroring the backend’s pruning, or invalidateQueries and refetch (trading “instant” for “always consistent”).

Backend & real-time

setInterval keeps the Node process alive. Without close() clearing it, the test process never exited cleanly: the WS test suite hung. close() also becomes the building block for graceful shutdown on SIGTERM. (Discovered writing the WS tests.)
handleMessage wraps JSON.parse in try/catch and replies { type: 'error', message: 'Invalid JSON' } rather than throwing. A single client’s bad frame must never crash the server or affect other clients, hence defensive parsing at the boundary.
The upgrade handshake is an HTTP request before the socket opens, and an unprotected upgrade would let bots open unlimited connections, exhausting memory (each holds a subscription Set). Arcjet on the upgrade applies a stricter sliding window (5 connections/2s) at exactly that chokepoint.
Commentary only matters to people watching that match (a room). But the score is shown on the match card in EVERY client’s grid, so a score change must reach everyone, or other viewers’ cards go stale. Room-scoping the score would be a bug; broadcasting all commentary to everyone would waste bandwidth and leak unrelated matches. The scope follows “who needs to see it.” (See ADR-010.)
When DEMO_MODE=true the server runs a simulator that keeps live matches and emits commentary/score updates on an interval, so the deployed app is always live for visitors with no external producer. It’s in-process, writing to the DB via Drizzle and calling the broadcast functions directly, so there’s no HTTP hop and thus no Arcjet to fight (a trusted producer shouldn’t face public bot-detection). The honest caveat: co-locating the producer is a demo shortcut; a real pipeline would be a decoupled service/queue. (See ADR-011.)
New Relic patches modules to instrument them, so it must load before those modules do. Under CommonJS, require('newrelic') as the first line works because requires run in the order written. Under ESM, that guarantee breaks: static imports in a file are evaluated before that file’s own body runs, regardless of where they appear in the source, so even a conditional import written above import express would lose, because express’s module gets evaluated first anyway. bootstrap.ts has no other imports of its own, so its conditional New Relic import is guaranteed to run before it dynamically imports index.ts (and everything index.ts pulls in). (See ADR-013.)

Data & persistence

It resets the serial primary-key sequence to 1. Without it, IDs climb across tests, making assertions like expect(data.id).toBe(1) depend on test execution order, which is fragile. RESTART IDENTITY makes each test’s IDs deterministic.
The FK constraint (commentary.matchId → matches.id) rejects the insert with Postgres error 23503. The route’s generic catch turns it into a 500 ‘Failed to create commentary’, the known 404-vs-500 gap. The DB enforces integrity correctly; the API just reports it imprecisely.

DevOps, CI/CD & deploy

Layer caching. npm ci only re-runs when package*.json changes; if source were copied first, every code change would invalidate the dependency-install layer and reinstall everything. Copy manifests → install → copy source orders layers from least to most frequently changing.
On first deploy the Neon Cloud DB has no schema, so every query fails with ‘relation does not exist’. Fix: run npm run db:migrate against the prod DATABASE_URL once before/after deploy (or as a release step). Migrations are a separate concern from running the app.
Containers are ephemeral: file logs vanish on restart and (as ISSUE-002 showed) can crash startup on permission errors. stdout is captured by the platform (Render) and is the only durable log surface in a container. Sportz logs to console-only in production, files only in dev.
Not by itself. Running CI ≠ enforcing it. With Vercel’s Git-integration CD, the platform deploys whatever lands on main regardless of your GitHub Actions result. The gate is branch protection: require the CI checks + a PR on main (admin bypass off). Since production deploys from main, gating main gates production. PR previews stay ungated on purpose, since you want to preview WIP.
It hits the live prod stack (cold-starting free-tier backend, slow, non-deterministic), so running it on every push would be flaky and pointless. It’s a post-deploy check, run manually via npm run test:e2e:deployed (excluded from the normal suite with —grep-invert @deployed). Automating it AFTER a deploy, not as a per-push gate, is the right home for it.

Testing & quality

An environment/concurrency problem, not broken test logic. The tell is the triad: timeouts + passes-individually + shifting failures. Here it was next dev compiling routes on first request: under fullyParallel, several cold compiles serialize past the 30s timeout. Running serially removes the contention (proves it), and the real fix is testing a production build.
A prod build serves pre-compiled routes instantly, so parallel requests don’t queue (no flaky timeouts), and it’s the exact build CI/Vercel ship, so you test what users run, not dev-only behavior. The dedicated port 3100 (with reuseExistingServer:false) keeps the test server off the dev server’s 3000, so they coexist and the suite never silently reuses a flaky dev server.
Determinism. A real socket to the cold free-tier backend makes the test depend on connection timing (e.g. whether the ‘Connected’ badge appears in time), so the same test passes or fails on network luck. Mocking at the network boundary makes the live path fully controlled, fast, and offline.
Mid-animation, the element’s color is blended with the background, so axe computes a contrast ratio that isn’t the real settled one and reports false positives. Scanning only after the entry animation finishes leaves only genuine violations.
Playwright gives each spec a fresh context (empty localStorage), so to the tour every test looks like a first visit → it auto-opens a full-screen overlay that covers the page, breaking the a11y scan and blocking Watch-Live clicks. Prevented two ways: the app skips auto-open under automation (navigator.webdriver, which also protects the deployed smoke test), and the mock setup deterministically pre-sets the “seen” flag with page.addInitScript. General rule: any first-run popup must be suppressible in automated runs.

Security & scaling

Not today: CSRF exploits ambient credentials (cookies) on state-changing requests, and Sportz has no cookie-based auth. The moment cookie sessions are added, CSRF becomes relevant and needs tokens/SameSite. It’s a ‘when auth arrives’ concern, correctly N/A now.
It never helped cross-user load: it caches per-user repeat views. 10k users each cold-loading the same match list still hit the DB 10k times. Server-side caching (Redis) addresses that, and only earns its place once measurements show the DB is the bottleneck.
Matches are fetched as a top-100 window and paginated client-side; scaling past 100 needs server-side pagination. In a real-time feed new rows are constantly inserted at the top, so OFFSET pagination drifts: offset=100 points at different rows between requests, causing duplicates/skips. CURSOR (keyset) pagination anchors on a fixed value (?after=<createdAt|id>WHERE createdAt < :cursor), so pages stay stable as new rows arrive. (Cursor pagination isn’t built; the route accepts only limit.)

Practical exercise

Stand up the full stack locally: start the test Postgres, run migrations, run all 95 backend tests, then npm run dev:docker and POST a match + a commentary event with curl while watching a wscat -c ws://localhost:8000/ws connection receive the broadcast. If the event arrives in the socket, you’ve exercised the entire real-time path end to end. Then, in sportz-ui, run npm run test:e2e and watch Playwright build a prod server on :3100 and drive the same path through the real browser: the front-to-back loop, both sides covered.