Full-Stack System Design, Frontend-Leaning
Field guide for whiteboard rounds that are ~2/3 frontend component design, ~1/3 backend. Companion to the L5 backend guide.
Key numbers card
| Thing | Number |
|---|---|
| Feels instant / feels sluggish / feels broken | <100ms / >300ms / >1s (show progress past 1s) |
| Frame budget at 60fps | 16ms per frame (all JS + layout + paint) |
| Core Web Vitals "good" | LCP <2.5s Β· INP <200ms Β· CLS <0.1 |
| Search debounce | 200-300ms |
| Autosave debounce | 1-2s after last keystroke |
| DOM nodes before a list needs virtualization | ~500-1,000 rows |
| Fetch-all is fine up to roughly | ~1-5K rows / low MBs of JSON |
| JS bundle budget (initial, gzipped) | ~200KB is respectable; every 100KB β 100-300ms parse on mid phones |
| localStorage limit | ~5MB, synchronous (never large data) |
| Upload chunk size | 5-10MB per part (S3 multipart minimum 5MB) |
| Polling interval that won't hurt anyone | 10-30s with jitter; below ~5s consider SSE/WebSocket |
| staleTime for reporting-style data | minutes, not seconds (my prod default: 30min) |
| Access token / refresh token lifetime | ~15min / days, refresh in httpOnly cookie |
| Image formats by weight | AVIF < WebP < JPEG; hero image ~100-200KB |
Part 1: The 2-page guide
The round you're passing
Format: whiteboard, no coding. Roughly one third backend, two thirds frontend component design. The backend is often given to you as boilerplate ("here are the endpoints"), and your job is to design the frontend around it. You produce a general architecture, boxes and arrows, with emphasis on frontend components, then go deep on specific technology choices (why this framework, how to structure the API) and call out performance, scalability, and security issues yourself, before being asked.
What's actually graded, per the recruiter: trade-offs, thinking out loud, planning ahead, and driving the interview. It is explicitly not a checklist. A candidate who covers eight topics shallowly loses to one who makes four decisions with visible reasoning and adjusts when the interviewer pushes. Every section of this guide ends in a decision sentence for that reason, the unit of value in this round is "I chose X over Y because Z."
What to study (priority order)
| Priority | Topic | Why |
|---|---|---|
| 1 | Component decomposition + state placement (Ch 2-3) | The 2/3 of the round. Every question starts here. |
| 1 | Data fetching: query cache, pagination vs fetch-all, optimistic updates (Ch 4) | Your strongest real experience; every dashboard/table question lives here. |
| 1 | API design around a given backend, error/loading contracts (Ch 9) | The Plaid format literally hands you a backend. |
| 2 | Performance: CWV, virtualization, code splitting (Ch 5-6) | You're expected to raise these unprompted. |
| 2 | Security: XSS, CSRF, token storage, PII (Ch 11) | Fintech interviewer; weak answers here are disqualifying. |
| 3 | Real-time, forms/flows, uploads/CDN (Ch 7-8, 10, 12) | Differentiators; also covers the "design Netflix" curveball. |
Study method: read a worked example (Ch 18-20), then re-derive it on paper from just the prompt. If you can reproduce the boxes and the decision sentences without looking, you're ready. Don't memorize the prose.
How to approach any question (45 min)
- Requirements, 5 min. Who is the user, what are the 2-3 core flows, what scale (rows per user, users, read vs write), what devices, what freshness. For FE questions add: does it need to work embedded / offline / on mobile web? Write the list on the board, it's your contract for the rest of the hour.
- Read or sketch the API, 5 min. If the backend is given, read it aloud and extract: entities, pagination style, error shape, auth. Say what's missing ("I don't see a batch endpoint, I'll flag where I need one"). If it's not given, sketch 3-5 endpoints and move on.
- Boxes and arrows, 10 min. Draw the frontend skeleton (Ch 1): component tree on the left, state/data layer in the middle, API on the right. Name the 4-6 major components. This diagram is your table of contents, you'll spend the rest of the interview zooming into its boxes.
- Data flow deep dive, 10 min. Pick the hardest flow (usually the big list or the mutation) and walk it end to end: cache key, loading states, pagination, invalidation. This is where your production stories become answers.
- The three passes, 10 min. Performance pass (what's slow, what would I measure), scalability pass (10x rows, 10x users, what breaks first), security pass (XSS/CSRF/tokens/PII). Announce each pass by name, this is the "planning ahead" signal.
- Wrap, 5 min. Restate the 2-3 decisions you'd revisit with more time and what you'd measure post-launch.
"Before I draw anything, let me pin down the core flows and the data scale, the right frontend architecture is completely different for 200 rows vs 200 thousand. Then I'll read through the API you've given me, sketch the component and state architecture, deep-dive the riskiest flow, and finish with explicit performance, scaling, and security passes. Sound good?"
That one paragraph does four things: shows a plan (planning ahead), sets you up to drive, gives the interviewer a place to redirect early (taking feedback), and buys you thinking time.
Driving the interview & taking feedback
- Narrate decisions, not facts. "There are two ways to paginate this" is trivia. "I'll fetch-all here because per-user data is ~2K rows and the expensive part is the backend aggregation, if it were unbounded I'd cursor-paginate" is a graded answer.
- Close each section and hand off. "That's the state layer. I'd like to deep-dive the table's data flow next, unless you'd rather I go into the API design?" You keep the wheel while offering the map.
- When pushed, bend visibly. If the interviewer says "what if the dataset is 500K rows," don't defend the old design. Say "that changes my answer" and re-derive: server-side pagination + virtualization + search moves server-side. Interviewers push to test whether you update; updating fast is a senior signal, not a loss.
- Flag debts instead of hiding them. "I'm hand-waving auth refresh for now, I'll come back to it in the security pass" beats hoping nobody notices.
- Use your scars. "In production I've seen exactly this bite us: hydration writes echoing server state back as PATCHes" is worth ten textbook sentences. You have real stories for caching, migrations, dual-write, experiment-gated rollouts; deploy them.
Chapter 1: The frontend skeleton
The diagram you start from
Backend rounds have a default skeleton; so do frontend rounds. Learn it cold so the first boxes cost zero thought.
βββββββββββββββββββββββββββ Browser βββββββββββββββββββββββββββ
β β
β Component tree State layer β
β ββ App shell (routing, auth) ββ β
β β ββ Page: Dashboard β UI state (local/context) β
β β β ββ FilterBar β β selection, modals, β
β β β ββ SummaryCards β β form drafts β
β β β ββ TransactionsTable β β
β β β ββ Row (virtualized) Server cache (query lib) β
β β β ββ Pagination β β keyed by request β
β β ββ Page: Detail β β staleTime / gc β
β βββββββββββββββββββββββββββββββ β invalidation β
β β
β Data layer: query/mutation hooks Β· API client (fetch, β
β auth header, retries, error normalization) β
βββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββ
β HTTPS (JSON) static assets β CDN
βΌ
API / BFF layer β services / DB (often given as boilerplate)
What each layer owns, in one sentence each
- App shell: routing, auth gate, error boundary, layout. Nothing domain-specific.
- Pages: compose components and connect them to data hooks; own which queries fire.
- Components: render props, emit events. Ideally no fetching inside leaf components, they stay testable and reusable.
- UI state: things only the client knows: selection, open modals, unsaved form input.
- Server cache: the client's copy of backend truth, keyed by request identity, with staleness rules. This is TanStack Query / SWR territory.
- API client: one place for base URL, auth header injection, retry policy, and normalizing errors into a shape components can render.
"I split state into UI state and server cache because they have opposite lifecycles: server data is shared, refetchable, and staleness-managed; UI state is private and dies with the view. Most frontend spaghetti comes from putting both in one store."
Chapter 2: Component design
Decomposing a mock into a tree
Given a mock (or asked to imagine one), decompose by responsibility, not by visual region. A visual region ("the top area") is not a component; "the thing that owns filter state and emits filter-change events" is. Practical procedure:
- Circle every piece of the UI that renders one entity (a transaction row, an account card). Those are your leaf components.
- Circle every piece that renders a collection plus its chrome (table + pagination + empty state). Those are your container components.
- Whatever coordinates two containers (filters affecting both a chart and a table) belongs to their common parent, that's where shared state lives (see Ch 3).
Name components on the whiteboard with their data dependency next to them: TransactionsTable(accountId, filters). This makes the later data-flow discussion free, the arrows already exist.
Component APIs: designing props like you design REST
Interviewers at product companies grade component API design like backend interviewers grade endpoint design. The same virtues apply: minimal surface, hard to misuse, evolvable.
- Data down, events up. Props in, callbacks out (
onSelect(id), not a passed-in setter the child mutates). The component never reaches out and grabs global state, that's what makes it reusable and testable. - Configuration vs composition. Twenty boolean props (
showHeader,showFooter,compactMode...) is the config trap; each new use case adds a prop and the matrix explodes. Prefer composition:<Table><Table.Toolbar/><Table.Row/></Table>or arenderRowslot. Decision rule: 2-3 variants β props; open-ended variants β slots/children. - Make invalid states unrepresentable. Not
isLoading+isError+dataas three independent props (8 combinations, 5 nonsensical), but onestate: 'loading' | 'error' | 'ready'discriminated union. In TS this eliminates a whole bug class at compile time. - Stable identity for lists. Rows need real keys (entity ids, not array index), otherwise reorder/optimistic-insert breaks reconciliation and input state jumps rows.
Controlled vs uncontrolled
Controlled: parent owns the value, child renders it and emits changes. Needed whenever anything else must react to the value while typing (live filter, cross-field validation, character counter). Cost: re-render per keystroke, parent complexity.
Uncontrolled: child owns its value internally (or the DOM does); parent reads it on submit. Cheaper and simpler; right default for plain forms.
"I make inputs uncontrolled by default and promote them to controlled only when another component needs the value live. Controlled-everything is the common over-engineering; it buys re-renders per keystroke for reactivity nobody asked for."
Accessibility, the five things to say unprompted
- Semantic elements first:
button,table,nav; ARIA only where semantics don't exist (a custom combobox getsrole="combobox",aria-expanded,aria-activedescendant). - Full keyboard path: tab order, Enter/Escape in modals, arrow keys in menus and grids. A modal traps focus and returns it on close.
- Async changes get announced: a live region (
aria-live="polite") for "12 results found" and for toast errors, or screen-reader users never learn the search finished. - Loading states: skeletons need
aria-busy; disabled-while-submitting buttons keep their label ("Savingβ¦"), don't blank out. - Color is never the only signal (error borders + icon + text, not red alone), and contrast β₯ 4.5:1.
Chapter 3: State management
The four kinds of state
| Kind | Examples | Lives in | Lifecycle |
|---|---|---|---|
| Server cache | transactions, accounts, user profile | Query library cache | Refetchable, shared, staleness-managed |
| UI state | open modal, selected rows, active tab | Component state / small context | Dies with the view |
| Form state | draft input, dirty flags, field errors | Form lib or local state | Dies on submit/cancel |
| URL state | current page, filters, sort, selected id | The URL | Survives refresh, shareable, back-button works |
The classification is the answer to half of all state questions. "Should filters go in Redux?" No: filters that describe what you're looking at belong in the URL (shareable, refresh-safe, back-button); the data they select belongs in the server cache; neither needs a global store.
Where state lives: the lifting algorithm
- Start with state in the component that uses it.
- Two siblings need it β lift to the common parent, pass down.
- The parent chain gets silly (prop drilling through 4+ layers) β context, scoped as small as possible (a
TableSelectionContext, not anAppContext). - Genuinely app-global and frequently changing from many places β then, and only then, a store (Redux/Zustand).
Say the failure mode out loud, it's a strong experience signal: a single giant context re-renders every consumer on every change, and it accretes, everyone adds "just one more field" until it's a god object. The fixes: split contexts by concern, memoize values, or move server data out into the query cache, which usually shrinks the "global state" problem to almost nothing. (I've lived this: our app's main context provider grew until unrelated components re-rendered on any change; the durable fix was moving server data into the query cache and splitting what remained.)
Context vs Redux vs query cache
| Tool | Right job | Wrong job (common misuse) |
|---|---|---|
| Local state | 90% of UI state | β |
| Context | Low-frequency shared values: theme, auth session, feature flags | High-frequency data (re-render storms); server data |
| Redux/Zustand | Complex multi-view UI state with devtools/undo needs | Being a hand-rolled server cache (loading flags, refetch logic in reducers) |
| Query cache (TanStack/SWR) | All server data: caching, dedup, staleness, invalidation | UI state (it's keyed to requests, not views) |
"Most apps that think they need Redux need a query cache. Once server data moves into a cache keyed by request, what's left of 'global state' is usually a theme, a session, and a couple of modals, context handles that fine."
Chapter 4: Data fetching & caching
The query cache model (what TanStack Query actually is)
Name the library once, then explain the model, the model is what's graded. A query cache is a client-side map from request identity β {data, status, timestamp}:
- Query key = request identity.
['transactions', accountId, {filters}]. Everything follows from the key: two components using the same key share one cache entry and one in-flight request (automatic deduplication, this alone kills the "widget loads twice" class of bugs). - Stale-while-revalidate. Cached data renders instantly; if older than
staleTime, a background refetch follows and the UI updates. The user sees data at memory speed and freshness converges.gcTimeseparately controls when unused entries are evicted. - Declarative status. Every query exposes
isPending / isError / data, killing hand-rolled loading booleans and try/catch scattered across components. - Targeted invalidation. After a mutation, invalidate by key prefix:
invalidateQueries(['transactions', accountId])refetches exactly the affected slices, not the page. - Dependent and parallel queries compose: a query can wait on another's result (
enabled), or fan out in parallel batches (useQueries).
"I'd put a query-cache layer, TanStack Query in a React app, between components and the API client. It buys dedup, stale-while-revalidate, and targeted invalidation for free, all things teams otherwise hand-roll badly in context. I've led this migration in production; the mechanism I trust is the query key."
Paginate vs fetch-all: the decision that owns table questions
The textbook default is server-side pagination. Ask two questions before accepting it:
- How big is one user's dataset? Up to a few thousand rows / low MBs, the client can hold all of it.
- What does one page cost the backend? If serving page 2 requires computing the whole dataset anyway (any "sort by aggregated metric X" over a reporting service does, computing X for all N rows then discarding all but 50), pagination saves bytes, not compute, and compute is usually the expensive resource.
| Server-side pagination | Fetch-once + derive in memory | |
|---|---|---|
| Interaction latency (sort/search/page) | Network-bound: RTT + backend work per click, hundreds of ms to seconds | Memory-bound: ~ms, zero network per interaction |
| Backend load | One query per interaction; expensive if each recomputes the dataset | One query per session (per staleTime window) |
| Client memory / initial payload | Small | Whole dataset, must be bounded |
| Freshness | Per interaction | Snapshot; bounded by staleTime |
| Right when | Unbounded data, cheap page computation, fast-changing data | Bounded per-user data, expensive backend aggregation, read-heavy exploration UI |
With fetch-all, run interactions as a pure derivation chain over the cached dataset: search β filter β sort β paginate, each step memoized. Sorting and paging become array operations; the UI updates within a frame.
When the dataset doesn't fit (hierarchies, huge accounts): don't force either extreme. Batch + progressive render: fetch in chunks (e.g. 10 parents at a time via parallel queries), render skeleton rows, fill as batches land. The user sees structure immediately and data streams in.
"Server-side pagination optimizes bytes over the wire; if the bottleneck is backend compute per interaction, it's the wrong optimization. For a bounded per-user dataset I fetch once and derive search/sort/page in memory, interactions drop from seconds to milliseconds and backend load drops to one query per session. I apply it per-surface: where data was too big in my last migration, I batched with progressive rendering instead."
Mutations, optimistic updates, and races
- Baseline mutation flow: disable the control while in flight, on success invalidate affected query keys, on error surface a retryable toast. This is correct and boring; say it's your default.
- Optimistic updates (apply the change to the cache immediately, roll back on error) are for high-frequency, high-success-rate, low-stakes actions: toggles, likes, renames. The cost is rollback complexity and a lying UI during failures. For money movement or anything with server-side validation you can't replicate client-side, stay pessimistic, show the spinner.
- Write races: two rapid edits to the same entity can complete out of order. Defenses: disable-while-pending (simplest), request versioning (send
updatedAt, server rejects stale writes), or sparse PATCHes so concurrent writes touching different fields merge safely (last-write-wins damage confined to a single key, this is how my settings store handled multiple writers). - Idempotency: retries on flaky networks mean the same mutation may arrive twice. POSTs that create things carry a client-generated idempotency key; the server dedupes. Payments-grade table stakes (Ch 9).
Chapter 5: Lists at scale
Virtualization: fetching β rendering
Fetch-all solves the network problem; the DOM problem remains. Ten thousand table rows is ~10K Γ (cells Γ nodes) DOM elements, layout and memory die long before the network does. Rule of thumb: past ~500-1,000 rows, virtualize: render only the ~30 rows in (and near) the viewport, absolutely positioned inside a container whose height equals rowCount Γ rowHeight, and recycle row components as the user scrolls (react-window / TanStack Virtual).
- Fixed row heights make it trivial; variable heights need measurement or estimates (harder, say so).
- Costs to name: Ctrl+F stops working across unrendered rows (provide your own search, you already have the data in memory), screen readers need row count hints (
aria-rowcount), and scrollbar jumpiness with bad height estimates. - Note the independence: virtualization pairs with either fetching strategy. Fetch-all + virtualize is the sweet spot for bounded datasets: all interactions in memory, constant DOM size.
Infinite scroll vs numbered pages
- Infinite scroll: feeds and browse surfaces, where the goal is continuous consumption and nobody needs "page 7." Implement with an IntersectionObserver sentinel + cursor pagination. Costs: no footer reachability, back-button/scroll restoration needs work, harder to link to a position.
- Numbered pages: work tools and audit surfaces, where users need to return to, cite, or share a location ("page 3 of the March transactions"). Plays well with URL state.
- Cursor vs offset (matters for both): offset (
?page=3) breaks under insertion, rows shift and users see duplicates/gaps, andOFFSET nis O(n) for the DB. Cursors (?after=txn_889, an opaque key from the last row) are stable under writes and O(log n). Default to cursors for anything that grows; offset is acceptable for small, static datasets.
Chapter 6: Rendering & performance
CSR vs SSR vs hybrid
| CSR (SPA) | SSR / streaming | Hybrid (SSR shell + CSR data) | |
|---|---|---|---|
| First paint | Slow: HTML β JS download/parse β fetch β render | Fast: HTML arrives populated | Fast shell, data hydrates in |
| SEO / link previews | Poor without prerendering | Native | Good |
| Interactivity model | Everything after load is instant-feeling | Server render per navigation, or hydrate into SPA | SPA after first load |
| Infra cost | Static hosting + CDN | Render servers (or edge) | Render servers |
| Right for | Authenticated tools, dashboards, anything behind login | Public content: marketing, docs, e-commerce listings | Public app-like products (feeds, search) |
Decision drivers, in order: does SEO/first-visit speed on public pages matter (SSR side), or is it an authenticated tool where users pay the load once and interact for an hour (CSR side)? Most real answers are hybrid by route: marketing pages SSR'd/static, the app itself CSR. Two honest caveats worth volunteering: SSR doesn't fix a slow API, if the bottleneck is a downstream aggregation service, server-rendering just moves the wait; and hydration cost is real, you ship HTML plus the JS to make it alive.
Core Web Vitals: the vocabulary of the performance pass
- LCP (Largest Contentful Paint, <2.5s): when the main content shows. Levers: CDN/edge caching, preload the hero request, SSR/streaming, image formats and sizes.
- INP (Interaction to Next Paint, <200ms): worst-case click-to-visual-response. Levers: less main-thread JS, memoized derivations, virtualization, moving heavy transforms off the interaction path (or to a worker).
- CLS (Cumulative Layout Shift, <0.1): content jumping. Levers: reserve space for images/skeletons/ads, never inject above existing content.
- Measure with percentiles from real users (RUM), not lab runs: p50 tells you the typical experience, p90/p99 tell you the worst experiences, and regressions hit the tail first. Separate initial load from SPA navigation metrics, they have different budgets and different fixes (my prod dashboards track exactly this split).
Bundles & code splitting
- Route-level splitting is the 80% win: each page loads its own chunk (
React.lazy/ dynamic import); the initial bundle carries the shell only. - Split below routes only for genuinely heavy leaf features (chart library, rich-text editor, PDF viewer): load on first use with a skeleton.
- Other levers, one line each: tree-shaking (import functions, not libraries), compression (brotli), fingerprinted immutable assets on a CDN (Ch 12), font subsetting +
font-display: swap, and a bundle-size budget in CI so regressions fail loudly instead of accreting.
"Performance pass: first load is bounded by bundle and LCP, so route-splitting, CDN, and preloading the primary query. Interactions are bounded by INP, so in-memory derivations and virtualization for the table. Navigations should hit the warm query cache. And I'd instrument all three with RUM percentiles, p90 regressions hit the slowest users first, before I claim any of it works."
Chapter 7: Search & input patterns
Every design with a search box gets probed on the same three failure modes; volunteering them is cheap credibility.
- Debounce (200-300ms after last keystroke) so you're not issuing a request per character. Distinguish from throttle (at most every N ms, for scroll/resize handlers). If results are already client-side (fetch-all designs), skip debouncing entirely, filter synchronously per keystroke, it's just an array filter.
- The stale-response race: user types "ca" then "cat"; the "ca" response arrives after "cat"'s and overwrites better results with worse. Fixes, in preference order:
AbortControllerto cancel the in-flight request when a new one fires; or tag requests with a sequence number and drop any response older than the latest. A query cache keyed by the search term gives you this for free, each term is its own cache entry, and stale entries never overwrite the active key. - Empty/error/typing states: "no results" β "still typing" β "search failed." Three distinct UI states; naming them shows product care. Keep the previous results rendered (dimmed) while the next query loads, full-flash to skeleton on every keystroke is the amateur tell.
- For typeahead at scale (backend third of the question): prefix index or n-gram search behind a small dedicated endpoint, cached aggressively, results capped (top 8), and highlight matching substrings client-side.
Chapter 8: Real-time updates
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | Client pulls | Server β client stream | Bidirectional |
| Infra cost | None (plain HTTP, cache-friendly) | Long-lived connection, one per client | Long-lived + connection state, gateway boxes |
| Latency | Up to one interval | ~Instant | ~Instant |
| Right for | Dashboards, statuses, anything where 10-30s staleness is fine | Notifications, progress bars, price tickers (server-push only) | Chat, collab editing, anything the client streams up |
Say the escalation rule: start with polling (with jitter, and a query cache makes it one line: refetchInterval), escalate to SSE when the interval you'd need drops below ~5s, and to WebSocket only when the client also needs to push. Most "real-time" dashboard requirements dissolve under the question "what's the actual freshness requirement?", reporting data computed daily does not need a socket. Also mention refetchOnWindowFocus: free freshness exactly when the user is looking, no infra at all.
Chapter 9: API design & data modeling
Reading a provided backend (the Plaid format)
When handed boilerplate endpoints, extract five things out loud, this five-minute read shapes your whole frontend design and is itself a graded skill:
- Entities and their relationships. "So we have accounts, each with many transactions, and transactions carry a category. That's my data model on the client too."
- Pagination style. Cursor or offset? Page size caps? This decides your list architecture (Ch 5) immediately.
- Error contract. Status codes only, or structured bodies (
{code, message, retryable})? If unstructured, say you'll normalize in the API client so components render one error shape. - Auth. Where does the token come from, where does it go, what happens on 401? (Sets up your security pass.)
- What's missing for your UI. The deliberate gaps are often the test: no batch endpoint (N+1 from the client), no aggregate/summary endpoint (client would compute sums over paginated data, wrong), no search param. Flag each: "I'd request a
/summaryendpoint here; computing this client-side over paginated data would be both wrong and slow."
Designing the API (when they ask you to shape it)
- Resources, not verbs:
GET /accounts/:id/transactions, not/getTransactions. Filters, sort, cursor as query params, which also makes GETs cacheable. - Design endpoints around screens, honestly. The dashboard needs summary cards + first page of transactions; that's either two clean resource GETs the client fires in parallel, or one aggregated
/dashboardBFF endpoint. Tradeoff sentence: aggregate endpoints cut round trips and move fan-out server-side (good over mobile latency), but couple the API to today's screen layout, fine in a BFF you own, bad in a shared public API. - Partial updates are PATCHes with sparse payloads: send only changed keys; server merges per-key. Concurrent writers touching different fields then compose safely, and "absent" stays distinguishable from "reset to default" (hard-won production lesson from my settings migration).
- Idempotency for writes that create: client-generated
Idempotency-Keyheader; server stores key β result and replays the stored result on retry. The double-submitted payment becomes one transfer. For a fintech interview, raise this before they do. - Versioning: additive changes (new optional fields) are free; breaking changes get
/v2or header versioning. Clients must ignore unknown fields, that's what keeps additive free. - Error contract worth writing on the board:
{code: "insufficient_funds", message, retryable: false, requestId}.codeis for program logic and i18n,messageis a developer aid (never rendered verbatim),retryabledrives the client's retry policy,requestIdmakes support tickets debuggable.
Data modeling in five sentences
Model the nouns as tables/collections with clear ownership: users, accounts (user_id), transactions (account_id, posted_at, amount_cents, category, status). Money is integer cents (never floats) plus a currency code. Status fields are enums with an explicit state machine (pending β posted | failed), the frontend renders per-state, so ambiguity here becomes UI bugs. Index what you filter and sort by (account_id, posted_at composite for "recent transactions per account"). For flexible per-user blobs (view settings, preferences), a JSON column trades schema enforcement for velocity, enforce the shape at the API layer with a schema (OpenAPI/zod) instead, and promote a key to a real column when you need to index it.
REST vs GraphQL vs BFF
| REST | GraphQL | BFF (backend-for-frontend) | |
|---|---|---|---|
| Fetch shape | Fixed per endpoint; over/under-fetch at the edges | Client picks fields; one round trip for nested data | One endpoint per screen need, shaped server-side |
| Caching | HTTP-native (URLs are cache keys) | Harder (POST body queries); needs client cache smarts | HTTP-native |
| Cost | Endpoint sprawl for many screens | Schema/resolver infra, query cost control, N+1 resolvers | You own another service per client type |
| Right when | Default; few clients, stable screens | Many diverse clients (iOS/Android/web/partners) with different data needs | One frontend team wants screen-shaped APIs over microservices |
"REST by default, it's cacheable and boring. GraphQL earns its infra cost when many client types need different slices of the same graph. If the real problem is 'this screen needs five services,' I'd rather add a thin BFF than adopt GraphQL, same aggregation win, much less machinery. And a query cache on the client keeps the transport swappable later."
Chapter 10: Forms & multi-step flows
Multi-step flows (onboarding, a bank-linking wizard, checkout) are secretly state-machine questions. Design them as one:
- Model steps as an explicit state machine,
select-institution β credentials β mfa β select-accounts β success, with defined transitions, not astepIndexinteger. Conditional steps (MFA sometimes) become explicit branches instead of index arithmetic. Each step declares what data it needs and what it emits. - Draft state is form state (Ch 3): lives locally per step, committed to a flow-level context on step completion. Nothing hits the server until a step semantically completes, exceptβ¦
- Resumability: for flows users abandon (long applications), persist progress server-side keyed by a flow id, so a returning user resumes at step 3. For low-stakes flows, sessionStorage is enough. Say which one and why: server persistence is a product decision (do we want cross-device resume?) with a privacy cost (we're now storing partial data, see Ch 11).
- Validation in layers: field-level on blur (fast feedback), step-level on next (cross-field), server-side at commit (authoritative, the client's version is UX, never security). Render server rejections into field errors via the error contract's
code. - Double-submit: the final "Confirm" button is where idempotency keys earn their keep, disable-while-pending and idempotent server-side, belt and suspenders, because networks retry without asking.
- Interruption handling: browser refresh mid-flow (rehydrate from persisted progress), back button (map steps to history entries so back means "previous step," not "lose everything"), and session expiry mid-flow (preserve draft, re-auth, resume).
Chapter 11: Security, fintech-grade
Run this as a named pass. Order: how code gets injected, how requests get forged, where tokens live, what data leaks.
- XSS (injected script runs in your page, steals tokens/data): the framework escapes rendered strings by default, so the bugs live at the escape hatches:
dangerouslySetInnerHTML,href={userUrl}(mindjavascript:URLs), and third-party scripts. Defenses: never render unsanitized HTML (DOMPurify if you must), a Content-Security-Policy that whitelists script sources, and treating any user-originated string as hostile, including ones from your own API (a transaction memo is attacker-controlled text). - CSRF (another site makes the user's browser fire a state-changing request with their cookies): relevant iff auth rides on cookies. Defenses:
SameSite=Lax/Strictcookies (the modern 90%), plus CSRF tokens for anything cross-site, plus "state-changing = never GET." - Where tokens live: the exam question. localStorage is readable by any XSS payload, so no long-lived secrets there. Pattern to give: short-lived access token (~15 min) held in memory, refresh token in an
httpOnly, Secure, SameSitecookie the JS can't read; on 401, the API client refreshes once and replays the request; refresh rotation limits stolen-token lifetime. Tradeoff honesty: httpOnly cookies reintroduce CSRF surface, hence SameSite above, the two answers go together. - PII discipline: mask account numbers in the UI (
Β·Β·Β·Β·4821) and let users explicitly reveal; never put PII in URLs (they land in logs, history, referrers) or analytics events; redact from error reports and session replays;autocomplete="off"on sensitive fields is a hint, not a control. Client-side "encryption" of localStorage is theater, the key is in the same JS, don't offer it. - Embedded/iframe surfaces (widget products): sandbox attribute,
postMessagewith strict origin checks both directions, and never trust the embedding page (Ch 17 and Ch 19 run this in full). - Transport & headers, one breath: HTTPS everywhere + HSTS, CSP,
X-Content-Type-Options: nosniff, frame-ancestors to control who may embed you.
"My token model: access token in memory, refresh token in an httpOnly SameSite cookie, silent refresh on 401. XSS can't read what JS can't see, and SameSite closes the CSRF door that cookies open. localStorage tokens fail the first half of that sentence."
Chapter 12: CDNs, media, resumable uploads
CDN mechanics, the frontend half
- A CDN is a geographically distributed HTTP cache; the client's TLS terminates at a nearby edge, cache hits never touch your origin. First win is static assets; second win is edge-cacheable API GETs (public, non-personalized).
- The header contract: immutable fingerprinted assets (
app.3f9c2.js) getCache-Control: public, max-age=31536000, immutable, cached forever because a content change changes the URL. The HTML that references them getsno-cache(revalidate each time), so deploys propagate instantly: new HTML β new asset URLs. This pair of sentences is the whole deploy/caching story; interviewers love it because most candidates get it half right. ETag/304s cut bytes, not round trips;stale-while-revalidateserves the cached copy while refreshing behind the scenes (same philosophy as your query cache, one layer down).- Images: serve responsive sizes (
srcset), modern formats (AVIF/WebP with fallback), lazy-load below the fold (loading="lazy"), and reserve dimensions to protect CLS. Resize/transcode at upload or at the edge, never ship originals.
Video delivery (the Netflix question, frontend view)
- Video is never one file over one request. It's transcoded into a bitrate ladder (multiple resolutions), each rendition chopped into 2-10s segments, described by a manifest (HLS/DASH). The player fetches the manifest, then segments, all plain HTTP, all CDN-cacheable, which is the point.
- Adaptive bitrate (ABR) runs in the client: the player measures segment download throughput and buffer fill, and picks the next segment's quality. Buffer running low β step down; sustained headroom β step up. Startup fast: begin low, upgrade after a few segments.
- Frontend concerns to volunteer: preload the manifest + first segments of the likely next play (hover on a tile); buffer-ahead target (~30s) balancing memory vs stall risk; resume position synced periodically; DRM (encrypted segments, license server) name-checked, not deep-dived.
Resumable uploads
Any "user uploads something big" (video platform, document verification, statement upload) gets this design:
- Initiate: client asks the API to start an upload (
POST /uploadswith filename, size, content type); API returns an upload id and presigned URLs for object storage, so bytes go browser β S3 directly, never through your app servers (they'd be a bandwidth bottleneck and add nothing). - Chunk: client slices the file (
File.slice), 5-10MB parts, uploads N parts in parallel (3-4 concurrent), tracks per-part completion. Progress UI is completed-bytes/total, cheap and honest. - Resume: on failure/refresh, ask the server which parts it has (
GET /uploads/:id), upload only the missing ones. This is the "resumable" part: state lives server-side, keyed by upload id, so even a browser crash loses nothing but the in-flight chunks. (tus is the open protocol name to drop.) - Complete: client calls complete; server verifies parts (checksums), assembles, then kicks async processing (virus scan, transcode) via a queue, status polled or pushed to the client (
processing β ready), render per-state.
"Big uploads go browser β object storage with presigned URLs, chunked at 5-10MB with a few parts in parallel. Resume is just 'ask the server which parts it has.' App servers stay out of the byte path, they coordinate, storage carries."
Chapter 13: Rendering strategies & "why this framework"
Ch 6 gave you the CSR/SSR table. This chapter is the depth behind it, because "why this framework" is a named rubric line and the honest answer is always derived from the rendering requirement, never from taste. The trap is naming a framework first and reverse-engineering a reason.
The five points on the spectrum
They differ on exactly two questions. When is the HTML generated, and who generates it. Everything else follows.
| Strategy | HTML built | Per-user data? | Right for |
|---|---|---|---|
| CSR (SPA) | In the browser, after JS loads | Yes, client-fetched | Authenticated tools, dashboards, anything behind a login |
| SSG (static) | At build time, once | No | Docs, marketing, blog. Pure CDN, no servers |
| ISR (incremental static) | At build, then re-built on a timer or on demand | No, but content changes | Product catalogs, pricing pages, anything editorial with a CMS |
| SSR | Per request, on a server | Yes | Public pages that are personalized or need fresh SEO-visible data |
| Streaming SSR | Per request, sent in chunks as ready | Yes | SSR where one slow query would otherwise block the whole page |
The decision rule to say out loud. Is the content public and crawlable? If no, SEO is off the table and CSR is legitimate. Is it the same for every user? If yes, SSG or ISR beats SSR because a CDN edge hit is cheaper and faster than any render server. Does first paint on a cold, slow connection drive money? If yes, you need server-generated HTML of some kind.
Hydration, and why it's the expensive part
This is the concept most candidates fumble, so be precise. SSR sends HTML that looks finished. It has no event listeners, so nothing works yet. Hydration is React re-running your component tree in the browser, comparing it against the server's DOM, and attaching listeners. So on an SSR page you ship the markup and the JS that would have produced that markup. You paid twice for one screen.
- The gap you should name. Between first paint and end of hydration the page is visible but dead. Clicks land on nothing. That window is a real INP and user-trust problem, and it's worse on mid-tier phones where parse time dominates. This is why "SSR is faster" is only true for paint, not for interactive.
- Hydration mismatch. If the client's first render doesn't match the server HTML, React throws it out and re-renders from scratch, losing the whole benefit. Classic causes are
Date.now(),Math.random(),localStorage, andwindowchecks in render. Fix by rendering the server-safe value first and moving the browser-only value into an effect. - Selective / progressive hydration is the mitigation. Hydrate interactive regions on priority or on interaction rather than the whole tree top-down. Islands architecture (Astro) is the extreme version, where the page is static HTML and only marked components ship JS at all.
React Server Components and Suspense as a network boundary
The one-sentence version. RSC moves components to the server permanently, so they never ship JS to the browser at all, and they can read the database directly instead of going through an HTTP endpoint you designed.
- Server Components render on the server and stream a serialized description of the UI, not HTML and not a bundle. They cannot use state, effects, or event handlers, because there is no browser for them to run in.
- Client Components are the old thing, marked
'use client'. That directive is a boundary, not a file property, so everything imported below it also becomes client code. Bundle regressions in RSC apps almost always trace to a'use client'placed too high in the tree. - Props crossing the boundary must be serializable. You can pass data down, you cannot pass a function down.
- The architectural consequence worth saying. RSC collapses the BFF layer (Ch 9). If the component that needs the data can query for it directly, you stop designing endpoints whose only job is shaping data for one screen. That's a real reduction in API surface, and a real coupling of UI to schema.
Suspense is what makes streaming useful. A <Suspense fallback> boundary tells the server "flush everything above this now, send this region's HTML later when its data resolves." So Suspense boundaries are not a loading-spinner convenience, they are where you cut the page into delivery units. Draw them on the whiteboard. Shell and nav flush immediately, the slow transaction table streams in behind its own boundary. That single move turns a 900ms blocking SSR page into a 100ms shell.
The framework answer, derived
| Pick | When | The cost you admit |
|---|---|---|
| Vite + React SPA | Authenticated dashboard. Every route is behind a login, SEO is irrelevant, users load once and stay an hour. | Slow cold start, and you own routing/data-loading choices yourself. |
| Next.js (App Router) | Mixed product. Public marketing and docs need SEO and fast first paint, the app itself is interactive. Route-level choice of SSG/ISR/SSR/streaming is the actual selling point. | Render servers to run and pay for, a real learning curve on the server/client boundary, and framework lock-in. |
| Remix / React Router 7 | Form and mutation heavy, and you want progressive enhancement so the app degrades to working HTML forms without JS. | Smaller ecosystem, and the web-standards-first model is unfamiliar to most teams. |
| Astro | Content-dominant with islands of interactivity. Docs site with a live demo widget. | Wrong tool the moment the product is mostly app. |
Say the split explicitly, because "hybrid by route" is the answer that survives follow-ups. Marketing and docs static or ISR on a CDN. The authenticated app CSR, because SSR-ing per-user data you can't cache buys paint speed at the cost of a render server on the critical path. And name the honest caveat that SSR does not fix a slow API. If the bottleneck is a downstream aggregation service, server rendering just relocates the wait and now your render server is blocked too.
"Rendering follows from crawlability and personalization. This product is behind auth, so SEO is off the table and I'd ship a Vite SPA with route-level code splitting. The marketing surface is a separate static build on the CDN. If we later needed a public, personalized, SEO-visible page, that's where I'd introduce streaming SSR with Suspense boundaries around the slow queries, and I'd accept render servers as the cost. What I wouldn't do is SSR an authenticated dashboard, that's paying for a render server and hydration to speed up a paint the user sees once per session."
"I'd use Next.js because it's the standard." That's the answer that loses the point. Derive it or pick something else. Claiming SSR improves interactivity. It improves paint and delays interactivity. Forgetting the render server is now a scaling and availability problem that a static CDN deploy never had.
Chapter 14: Browser security & auth flows, the deep pass
Ch 11 covers XSS, CSRF, token storage, and PII, which is the application layer. This chapter is the two layers you get pushed into after you answer that well. The browser's own policy mechanisms, and the OAuth flow that produced the token in the first place. Fintech rounds go here reliably.
Start from the same-origin policy
Everything below is a controlled exception to one rule. An origin is scheme + host + port, and by default code from one origin cannot read responses from another. https://app.plaid.com and https://api.plaid.com are different origins. So is http:// versus https:// on the same host.
The thing people get wrong. The browser sent the cross-origin request and the server did process it. CORS only controls whether your JS is allowed to read the response. That's why CORS is not a defense against CSRF, the damage is already done server-side before the response comes back.
CORS, including the preflight you'll be asked about
A simple request goes straight out. GET, HEAD, or POST, with only a short allowlist of headers, and a Content-Type of text/plain, multipart/form-data, or application/x-www-form-urlencoded. The browser sends it and then checks Access-Control-Allow-Origin before handing you the body.
Anything else triggers a preflight, which is a separate OPTIONS request asking permission first. The two things that trigger it in practice are Content-Type: application/json and a custom header like Authorization or X-Request-Id. Which means essentially every real API call preflights.
OPTIONS /v1/transactions β Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
200 β Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600 β cache the preflight
- The performance point to volunteer. A preflight doubles the round trips on every request until
Access-Control-Max-Agecaches it. Setting that header is the fix, and it's a nice detail because most people never mention it. - Credentials mode is the trap. If you send cookies (
credentials: 'include'),Access-Control-Allow-Origin: *is rejected by the browser. You must echo the exact origin, and echoing it from a validated allowlist rather than reflecting whatever arrived. ReflectingOriginblindly with credentials enabled is a real vulnerability. - Preflights bypass service workers' cache and cannot be avoided by same-site tricks. If it's a genuine problem, the answer is a same-origin proxy path like
/api/*on your own domain, which removes CORS entirely. That's a legitimate reason to put a BFF in front (Ch 9).
CSP that actually works
Ch 11 says "add a CSP." Here's what a real one looks like and why the naive version is useless. An allowlist of domains is not a defense if any allowlisted domain hosts a JSONP endpoint or a bundler, which most CDNs do.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-r4nd0m' 'strict-dynamic'; β per-request nonce, not a domain list
style-src 'self';
img-src 'self' data: https://cdn.example.com;
connect-src 'self' https://api.example.com; β where fetch/XHR/WS may go
frame-ancestors 'none'; β who may iframe YOU (clickjacking)
form-action 'self'; β where forms may POST
object-src 'none'; base-uri 'none';
report-uri /csp-violations β ship Report-Only first
- Nonce +
strict-dynamicis the modern shape. The server emits a fresh random nonce per response, only tagged scripts run, andstrict-dynamiclets those trusted scripts load their own dependencies. This survives an injected<script>because the attacker can't guess the nonce. - Deploy in
Content-Security-Policy-Report-Onlyfirst, collect violations for a week, then enforce. Saying this signals you've actually shipped one, because enforcing a CSP blind breaks production immediately. frame-ancestorsreplacesX-Frame-Options. Keep both only for old browsers. This is your clickjacking answer, where an attacker invisibly overlays your page over their own to steal clicks.- CSP is defense in depth, not a fix. Say that. It reduces the impact of an XSS you failed to prevent. It does not excuse rendering unsanitized HTML.
Cookie attributes, precisely
| Attribute | What it does | Get it wrong and |
|---|---|---|
HttpOnly | JS cannot read it via document.cookie | An XSS payload exfiltrates the session |
Secure | Only sent over HTTPS | Leaks on any accidental plain-HTTP request |
SameSite=Lax | Not sent on cross-site subrequests, but sent on top-level navigation | Default-ish and safe. CSRF via forms and images is blocked |
SameSite=Strict | Never sent cross-site at all | Following a link from email logs the user out, which is a UX regression, not a bug |
SameSite=None | Sent everywhere. Requires Secure | Mandatory for third-party embeds, and the thing cookie deprecation is killing (Ch 17) |
Path / Domain | Scope. Domain widens to subdomains | A compromised subdomain now reads your session cookie |
__Host- prefix | Browser enforces Secure, no Domain, Path=/ | Cheap hardening most people don't know |
The OAuth 2.0 flow, and why PKCE exists
You know "exchange a public token for an access token on the backend." That is the authorization-code pattern, so connect it to the standard vocabulary.
1. Browser β /authorize?client_id&redirect_uri&state&code_challenge=S256(verifier)
2. User authenticates at the provider (your app never sees the password)
3. Provider redirects back β /callback?code=abc&state=...
4. BACKEND β POST /token { code, client_secret, code_verifier }
5. Backend receives access_token (short TTL) + refresh_token
6. Backend sets an httpOnly session cookie. The browser never holds either token.
- Why a code and not the token directly? The implicit flow put the token in the URL fragment, where it lands in history, logs, and referrers. The code is single-use, short-lived, and useless without the secret. Implicit flow is deprecated, say so.
- PKCE exists for clients that can't hold a secret, meaning SPAs and mobile. The client generates a random
verifier, sends its SHA-256 as thecode_challenge, then proves ownership by presenting the verifier at exchange. So an intercepted code is worthless. PKCE is now recommended for confidential clients too. stateis CSRF protection for the callback itself. Without it an attacker initiates a flow and lands their own code in the victim's session.nonceis the OIDC equivalent, binding the ID token to your request.- OAuth is authorization, OIDC is authentication. OIDC adds an
id_token, which is a JWT describing who the user is. Using a raw OAuth access token as proof of identity is a known anti-pattern.
JWT versus opaque tokens, a common probe. A JWT is self-verifying, so any service can check the signature with no network call, which is what makes it scale. The cost is that you cannot revoke it. Once issued it's valid until expiry. So the real-world answer is short-lived access JWTs, roughly 5 to 15 minutes, plus a long-lived opaque refresh token that is revocable because it hits a database. Refresh rotation on top, meaning each refresh issues a new refresh token and invalidates the old one, so a replayed refresh token signals theft and you kill the whole family.
The rest of the pass, one line each
- Supply chain. Your bundle is mostly other people's code. Lockfiles committed,
npm auditor Dependabot in CI,--ignore-scriptsfor install-time hooks, and Subresource Integrity (integrity="sha384-...") on any script you load from a CDN so a compromised CDN can't swap it. - Secrets in the bundle. Anything prefixed
VITE_orNEXT_PUBLIC_ships to the browser in plaintext. Publishable keys are fine there by design, secrets never are. Say this unprompted when you draw the SDK, it's exactly the Plaid public-key versus secret split. - Enumeration and rate limits. "Email not found" versus "wrong password" leaks account existence. Return the same message, and rate-limit by IP and by account so an attacker can't rotate IPs.
- Headers you say in one breath. HSTS with preload,
X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-originso PII in URLs doesn't leak outbound, andPermissions-Policyto drop camera and geolocation you never use. - Audit logging. In fintech, every read of financial data is an event with actor, resource, timestamp, and request ID. It's a compliance requirement, not a nice-to-have, and mentioning it reads as domain maturity.
"Auth is authorization-code with PKCE. The browser never holds a token, the backend does the exchange with the secret and sets an httpOnly, Secure, SameSite=Lax session cookie. Access tokens are 15-minute JWTs so services verify without a network hop, refresh tokens are opaque and rotating so they stay revocable. Then CSP with a per-request nonce as defense in depth for the XSS I didn't catch, and frame-ancestors so nobody can clickjack the transfer button."
Chapter 15: Webhooks & getting pushed data to the browser
Ch 8 gave you polling versus SSE versus WebSocket, which is the last mile to the browser. This chapter is the mile before it. In a fintech architecture the interesting data does not originate in your system, so the question "how does the UI know something changed" has two halves and most candidates only answer the second.
Why webhooks exist at all
Because you don't own the event. A bank posts a transaction on its own schedule. Your server has no way to know except to ask repeatedly, and polling a third party for every one of a million linked accounts is absurd, expensive, and still stale. So the provider calls you. A webhook is just an HTTP POST to a URL you registered, sent when something happened. That's the whole idea. The complexity is entirely in the failure modes.
Frame it as an inversion. Polling means you control timing and the cost scales with your poll rate times your user count. Webhooks mean the provider controls timing, the cost scales with actual event volume, and you inherit an endpoint that must be always-on, publicly reachable, and hostile-input-safe.
The full path, end to end
ββββββββββββ 1. POST /webhooks/plaid βββββββββββββββββ
β Provider β βββββββββββββββββββββββββββββββΊ β Webhook β
β (bank / β signed, at-least-once β receiver β
β Plaid) β βββββββββββ 200 OK ββββββββββββ β (thin!) β
ββββββββββββ within ~5s, always βββββββββ¬ββββββββ
β 2. verify sig
β 3. dedupe on event_id
β 4. enqueue + ACK
βΌ
βββββββββββββββββ
β Queue (SQS / β
β Kafka) ββββββΌβββΊ DLQ
βββββββββ¬ββββββββ
β 5. worker
βΌ
ββββββββββββββββββββββββββββ
β Worker: FETCH from the β
β provider API, write DB, β
β emit internal event β
ββββββββββββ¬ββββββββββββββββ
β 6. fanout
ββββββββββ΄βββββββββ
βΌ βΌ
βββββββββββββββ βββββββββββββββ
β Pub/Sub β β Push / emailβ
β (Redis) β βββββββββββββββ
ββββββββ¬βββββββ
β 7. SSE to the right user's tabs
βΌ
βββββββββββββββ
β Browser: β 8. invalidate query
β query cacheβ β refetch β re-render
βββββββββββββββ
Walk the whiteboard along that line and you've answered the question completely. The three boxes people forget are the queue, the dedupe check, and the DLQ.
Rule 1. The receiver is thin. ACK in milliseconds.
Verify the signature, dedupe, write the event to a queue, return 200. Nothing else. Do not update the database, do not call the provider's API, do not send an email, in the request handler.
The reason is a feedback loop worth naming. Providers time out webhook deliveries in a few seconds and retry on non-2xx. If your handler does real work it gets slow, slow means timeouts, timeouts mean retries, retries mean more load, which makes it slower. You brown out under exactly the traffic spike you most needed to survive. A thin receiver plus a queue turns an availability problem into a backlog you can drain.
Corollary. Return 200 even for events you don't care about, otherwise you're asking to be retried forever. Return a 4xx only for a genuinely malformed or unverifiable request, because that's a signal not to retry.
Rule 2. Verify the signature, on the raw bytes
Your endpoint is public. Anyone can POST to it claiming a transfer settled. Signature verification is the entire trust model.
The HMAC shape (Stripe, GitHub, and most providers). The sender computes HMAC-SHA256(secret, timestamp + "." + rawBody) and sends it as a header. You recompute and compare.
// Node, Express. NOTE: express.raw(), not express.json()
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const [ts, sig] = parseHeader(req.get('X-Signature'));
// (a) replay window β reject anything older than ~5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
// (b) recompute over the EXACT bytes received
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${ts}.${req.body}`) // req.body is a Buffer here
.digest('hex');
// (c) constant-time compare, never ===
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
await queue.send(event); // then, and only then, enqueue
res.sendStatus(200);
});
- Raw body, not parsed. The single most common webhook bug.
JSON.parsethenJSON.stringifyreorders keys and changes whitespace, so the bytes you hash are not the bytes that were signed and every signature fails. This is a great detail to volunteer because it's obviously experience rather than theory. - Constant-time comparison.
===on strings short-circuits at the first differing byte, which leaks the correct prefix through response timing.timingSafeEqualexists for this. - The timestamp is what stops replay. A signature alone is valid forever, so a captured request could be resent. Signing
timestamp + bodyand rejecting old timestamps bounds the window. - Key rotation. Accept two secrets during a rollover and verify against either, otherwise rotating means dropping events.
- The asymmetric variant. Some providers, Plaid included, send a signed JWT in a verification header instead of an HMAC, which you validate against their published public key (a JWK you fetch and cache) and whose body hash you compare to your raw body. The advantage is that you hold only a public key, so a breach of your side can't forge events. Same three steps, different primitive. Confirm the exact header and algorithm in their docs before the interview rather than guessing on the whiteboard.
Rule 3. Assume at-least-once, out-of-order delivery
This is where your idempotency knowledge plugs in, and it's the highest-value part of the chapter.
Duplicates are guaranteed, not hypothetical. If your 200 is lost in transit, the provider retries an event it already delivered successfully. So every consumer must be safe to run twice.
-- the dedupe table. The unique constraint IS the mechanism.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY, -- provider's id
received_at TIMESTAMPTZ DEFAULT now()
);
-- in the worker, same transaction as the actual work:
BEGIN;
INSERT INTO processed_events (event_id) VALUES ($1); -- throws on duplicate
...do the real work...
COMMIT; -- both land, or neither does
Doing the insert and the work in one transaction is the part that matters. Insert-then-crash-then-retry would otherwise skip the work forever, having recorded it as done.
Ordering is not guaranteed either, and this is where the frontend-relevant punchline lives.
Treat the webhook as a signal, not as data. Don't apply the payload as a delta. Use it as "something about account X changed" and then fetch the current state from the provider's API. An out-of-order or duplicated signal is then harmless, because refetching current truth twice gives the same answer. Applying deltas out of order corrupts a balance permanently.
If you genuinely must apply the payload, you need a monotonic version or sequence per entity and you drop anything older than what you've stored. Say that as the fallback, but lead with signal-not-data. It's the answer that makes the whole system tolerant instead of fragile.
Rule 4. Retries, DLQ, and the reconciliation backstop
- Provider retries with exponential backoff for hours or days. So a brief outage on your side is survivable, which is a real argument for failing loudly with a 500 rather than swallowing an error and returning 200 on data you didn't persist.
- Your own retries live in the queue. After N attempts the message goes to a dead letter queue. The DLQ is not a formality, it's the thing you alert on. DLQ depth above zero is a page.
- Poison messages. One event that always throws would otherwise block a partition forever. DLQ after bounded attempts is what prevents that.
- The backstop nobody mentions. Webhooks get lost. Providers have outages, your endpoint has a bad deploy, a signature rotation drops an hour of events. So you run a periodic reconciliation job, nightly or hourly, that pulls current state for recently-active accounts and repairs drift. Webhooks are the fast path, reconciliation is the correctness guarantee. Saying this is a strong senior signal, because it shows you don't trust the happy path to be the only path.
Rule 5. Getting it to the browser without a stampede
Now you're back in Ch 8 territory, but with a specific constraint. The worker knows an event happened, and it needs to reach only the tabs belonging to that one user.
- Worker publishes to Redis pub/sub on a per-user channel,
user:1234:events. Never broadcast to all connections and filter client-side, that leaks other users' activity into a channel their browser can see. - SSE endpoint per connected client subscribes to that user's channel. SSE is right here because the flow is server-to-client only. It's plain HTTP, it reconnects automatically, and it gives you
Last-Event-IDso a client can resume from where it dropped. - The message is tiny and carries no data.
{ type: 'transactions.updated', accountId: 'acc_1' }. Same signal-not-data principle, one layer up. It avoids duplicating auth checks in the push path, because the refetch goes through your normal authorized endpoint. - The client invalidates rather than patches.
queryClient.invalidateQueries(['transactions', accountId]). The query cache then refetches only what's actually mounted on screen, which is the whole reason the cache layer earns its place.
The scaling problem to raise unprompted. A bank syncs and fires 500 webhooks for one user in two seconds. Naively that's 500 SSE messages and 500 refetches, and you've DDoSed yourself from the inside. Two fixes, and naming either wins the point. Coalesce server-side, debouncing per user so you emit at most one "changed" signal per few seconds. Or coalesce client-side, since a query cache already dedupes concurrent invalidations of the same key. Do both. Also add refetchOnWindowFocus, which gets you free freshness precisely when someone is looking.
- Multi-tab. Five open tabs means five SSE connections against a ~6-per-origin HTTP/1.1 cap, and five refetches of identical data. Elect one leader tab holding the connection and relay to the others over
BroadcastChannel, or accept it and note the tradeoff. HTTP/2 multiplexing makes the connection cap much less painful, which is worth mentioning. - Connection state is the scaling cost. Long-lived connections mean your gateway holds N sockets and cannot be a stateless autoscaled box in the naive way. This is the honest reason to start with polling and escalate only when the freshness requirement justifies it.
Testing and operating it
- Local development. Your laptop isn't publicly reachable, so you need a tunnel (ngrok, Cloudflare Tunnel) or the provider's CLI that forwards events to localhost. Mentioning this is a small credibility marker.
- Tests. Store real captured payloads as fixtures and POST them at your handler. Test the signature failure path, the duplicate path, and the out-of-order path explicitly, because those are the three that break in production.
- Metrics that matter. Delivery-to-processed lag at p99, verification failure rate (a spike means either an attack or a botched key rotation), duplicate rate, DLQ depth, and reconciliation drift count. Drift trending up means your webhook path is quietly broken even though nothing is erroring.
- An admin replay endpoint. When you fix a consumer bug you need to reprocess yesterday's events, which is only safe because you made consumers idempotent. The two features reinforce each other.
"Third-party data arrives by webhook, not polling, because the provider owns the timing. My receiver is deliberately thin. Verify the signature over the raw bytes with a constant-time compare and a timestamp window, dedupe on the provider's event id, enqueue, return 200 in single-digit milliseconds. A worker then treats the event as a signal and fetches current state from the API rather than applying the payload as a delta, which makes duplicates and out-of-order delivery harmless. Then it publishes a tiny notification to that user's Redis channel, SSE carries it to their tabs, and the browser invalidates the affected query key so only mounted views refetch. Coalesced per user, because a bank sync can fire hundreds of events in seconds. And a nightly reconciliation job repairs anything the webhook path lost, because it will lose things."
Doing the work in the handler. The retry feedback loop above. Hashing the parsed body. Every signature fails and you'll never guess why. Trusting the payload without verifying. Your endpoint is public. Assuming exactly-once, in-order delivery. Neither is true of any real provider. Having no reconciliation path, which means silent permanent data loss the first time your endpoint 500s for an hour.
Chapter 16: HTTP caching, the layer under the query cache
You know TanStack Query (Ch 4). That's an in-memory, per-tab, application-level cache that dies on refresh. Underneath it sit three more caches you don't control but do configure, and interviewers probe this because it separates people who've shipped from people who've read.
The four caches, from closest to furthest
| Cache | Lives | Survives reload? | Configured by |
|---|---|---|---|
| Query cache (TanStack) | JS heap, one tab | No | staleTime, gcTime |
| Service Worker cache | Disk, per origin | Yes, and works offline | Your own JS |
| HTTP cache (browser) | Disk / memory, per origin | Yes | Response headers |
| CDN / shared cache | Edge POPs, all users | Yes | Response headers + purge API |
The framing to say. The query cache decides whether to make a request. The HTTP cache decides whether that request touches the network. They're complementary, and a well-configured pair means a warm navigation costs zero bytes.
Freshness versus validation, the core distinction
Every caching header answers one of two questions, and keeping them separate is most of the battle.
- Freshness means "can I use this without asking?" That's
Cache-Control: max-age. A fresh hit is zero network. This is the fast path. - Validation means "is my copy still good?" That's
ETagandLast-Modified. A validation costs a full round trip but usually saves the body, returning304 Not Modifiedwith no payload.
So a 304 is not a cache hit in the sense that matters for latency. It saves bandwidth, not time. On a 200ms RTT mobile connection, a 304 is still 200ms. People conflate these constantly, and drawing the distinction is a cheap way to look precise.
The directives, and what each is actually for
Cache-Control: max-age=300 fresh for 5 min in ANY cache
Cache-Control: private, max-age=60 browser only. CDNs must not store it
Cache-Control: public, max-age=31536000, immutable
fingerprinted assets. never revalidate
Cache-Control: no-cache store it, but ALWAYS validate first
Cache-Control: no-store never write to disk at all
Cache-Control: max-age=60, stale-while-revalidate=600
serve stale instantly, refresh behind it
Cache-Control: max-age=0, must-revalidate no stale serving, ever, even offline
Vary: Accept-Encoding, Authorization cache key includes these headers
ETag: "a1b2c3" opaque version id for validation
Age: 240 how long a shared cache has held it
no-cachedoes not mean don't cache. It means store it and revalidate every time.no-storeis the one that means don't store. This exact confusion is a common interview gotcha, so get it right and you gain credibility for free.privateis a security control, not a performance one. Any authenticated JSON must beprivate, otherwise a shared CDN can serve user A's balance to user B. That's the caching bug that ends careers in fintech. Say it when you draw the CDN.immutableplus a content hash in the filename is the whole static-asset strategy.app.a1b2c3.jscan be cached for a year because a new build produces a new filename. Never version assets with a query string, some intermediaries ignore query strings in the cache key.stale-while-revalidateis the best directive most people don't know. The user gets an instant response from a slightly stale copy while the cache refreshes in the background. It converts a latency problem into a freshness tradeoff, and it's the exact same idea TanStack Query implements in JS. Naming that parallel is a strong moment.Varyis a footgun. Each varied header multiplies your cache keys.Vary: User-Agenteffectively disables caching because there are millions of UA strings.Vary: Accept-Encodingis necessary and fine.
The strategy per resource type
| Resource | Headers | Why |
|---|---|---|
HTML shell / index.html | no-cache (or short max-age) | It contains the hashed asset URLs. Cache it and users are pinned to an old deploy forever |
| Hashed JS / CSS | public, max-age=31536000, immutable | Filename changes on change, so it can never be wrong |
| Authenticated JSON | private, no-store, or private, max-age=0, must-revalidate + ETag | Never in a shared cache. ETag still saves bandwidth on polling |
| Public reference data (categories, currencies) | public, max-age=3600, stale-while-revalidate=86400 | Same for everyone, changes rarely. Should be a CDN hit |
| User avatars / uploads | public, max-age=31536000, immutable on a content-addressed URL | Change the URL on change, not the bytes at a URL |
The deploy interaction is worth volunteering. The reason the HTML shell must not be cached is that a stale shell references chunk filenames that no longer exist on the CDN, so a user mid-session hits a 404 on a lazy-loaded route. Two mitigations. Keep the previous build's chunks around for a release or two rather than purging on deploy. And detect a chunk-load error in the app, then prompt "a new version is available, reload." That's a concrete production war story and it lands well.
ETags, and how they interact with concurrency
GET /accounts/1 β 200 ETag: "v7" { balance: 4210 }
GET /accounts/1 β If-None-Match: "v7"
β 304 Not Modified (no body, saves the payload)
The second use is the one that impresses. The same ETag gives you optimistic concurrency control on writes.
PATCH /accounts/1 β If-Match: "v7" { nickname: "Rent" }
β 412 Precondition Failed if someone else wrote v8 first
That is the HTTP-native answer to the lost-update problem, and it pairs directly with your optimistic-update UI (Ch 4). A 412 means "your view was stale," so you refetch, show the conflict, and let the user decide. It also composes with idempotency keys, which handle the duplicate-submit problem, whereas If-Match handles the concurrent-edit problem. They solve different failures and knowing which is which is a genuinely senior distinction.
Strong versus weak ETags. W/"v7" means semantically equivalent but not byte-identical, which is what you get after gzip or minor serialization changes. Strong ETags are required for range requests and for If-Match to be meaningful.
The shared-cache layer, briefly
- Cache key. By default it's method plus URL plus
Varyheaders. Normalize it deliberately, stripping tracking params likeutm_*so a hundred variants of one URL don't fragment your hit rate. - Invalidation. Purge by URL is precise but you must know every URL. Surrogate keys or cache tags are the better pattern, tagging responses with an entity id and purging by tag when that entity changes. Prefer changing the URL over purging where you can, because an immutable URL never needs invalidating.
s-maxagesets a different TTL for shared caches than for browsers, so you can hold something at the edge for an hour while browsers revalidate every minute.- Cache stampede. A popular entry expires and a thousand simultaneous requests all miss and hit the origin. Fixes are request coalescing at the edge,
stale-while-revalidate, and jittered TTLs so keys don't expire in lockstep. - Never cache authenticated responses at the edge unless the cache key includes the user, which usually means it isn't worth caching. Repeat the
privatepoint here. This is the failure mode with the worst blast radius in the whole chapter.
"Caching is layered and I'd configure each layer for what it's good at. Hashed bundles get a year with immutable on the CDN, the HTML shell gets no-cache so a deploy actually reaches people. Authenticated JSON is private so it can never enter a shared cache, with ETags so polling costs a 304 instead of a payload. Public reference data goes public with stale-while-revalidate, which is the CDN version of what my query cache does in memory. And the same ETag doubles as optimistic concurrency control on writes with If-Match, so a concurrent edit returns 412 instead of silently overwriting."
Chapter 17: Cross-origin embedding & third-party SDKs
Ch 19 walks the Link-shaped widget end to end. This chapter is the platform mechanics underneath it, because "how does an SDK you ship run inside a page you don't control" is the single most Plaid-specific architecture question there is. It's also the area where the browser is actively changing under everyone's feet, which makes it good material for the "under uncertainty" part of the rubric.
The problem statement
A merchant puts four lines of your script on their checkout page. A user then types their bank credentials into something that appears inside the merchant's page. Two hard requirements fall out immediately.
- The host page must never be able to read those credentials. Not by reading the DOM, not by patching
fetch, not by keylogging the input. - The host page must not be able to break your UI, and your UI must not break theirs. Their global CSS reset, their jQuery, their
z-index: 999999header.
Both requirements point at the same answer, and the reasoning is the answer.
Why an iframe, and not injected DOM
The naive approach is a script that injects a modal into the host's DOM. State the two reasons that fails.
- Security. Injected DOM lives in the host's origin, so the host's JS can read every keystroke with one event listener, walk the DOM for the password field, or monkey-patch
XMLHttpRequestbefore your script even loads. There is no defense, because you're a guest in their execution context. - Isolation. Their CSS cascades into your markup. Shadow DOM solves the styling half, and it's worth naming as the right tool for a non-sensitive widget, but it does nothing for the security half because it's the same JS context.
A cross-origin iframe is a separate origin, and therefore a separate everything. Separate DOM the host cannot query, separate JS context they cannot patch, separate CSS, separate storage. The same-origin policy does the work for you. That's the sentence to say.
So the SDK splits into two pieces, and drawing this split is most of the whiteboard answer.
MERCHANT PAGE (merchant.com) YOUR ORIGIN (cdn.you.com)
ββββββββββββββββββββββββββββββ
β <script src="cdn.you.com/ β
β link.js"> β thin loader, ~10KB:
β β - creates the iframe
β ββββββββββββββββββββββββ β - postMessage bridge
β β iframe ββββΌβββββββ - public API (open/exit)
β β src=cdn.you.com/... β β - NO credential logic
β β β β
β β YOUR full app. β β the real app, your origin:
β β Host cannot read β β - bank credential UI
β β the DOM or the JS β β - talks to YOUR api directly
β ββββββββββββββββββββββββ β - host page sees nothing
ββββββββββββββββββββββββββββββ
Why the loader must be tiny. You're on someone else's critical rendering path. Load it async, keep it in the tens of KB, and never block their page. The heavy app only downloads when the user actually opens the flow, inside the iframe, where its cost is yours and not theirs.
postMessage, done correctly
The iframe and the host need to talk. The host says "open," the iframe says "the user finished, here's a public token." postMessage is the only channel across origins, and it's trivially easy to do insecurely.
// SENDING β always name the exact target origin, never '*'
iframe.contentWindow.postMessage({ type: 'link.open', config }, 'https://cdn.you.com');
// RECEIVING β validate in this order, and bail early
window.addEventListener('message', (e) => {
if (e.origin !== 'https://cdn.you.com') return; // 1. WHO sent it
if (e.source !== iframe.contentWindow) return; // 2. which frame exactly
const msg = e.data;
if (!msg || typeof msg.type !== 'string') return; // 3. shape it like hostile input
if (!ALLOWED_TYPES.has(msg.type)) return; // 4. allowlist, not denylist
handle(msg);
});
'*'as targetOrigin is a data leak. If the frame you think you're talking to has navigated elsewhere, your message goes to whoever is there now. Name the origin.- Checking
originis mandatory and not the default. Every frame on the page, and every popup, can post to your window. Without the check, any of them can drive your state machine. - String comparison, not
startsWith.origin.startsWith('https://you.com')matcheshttps://you.com.evil.tld. This is a real bug class. - Treat
e.dataas hostile. Validate the shape, allowlist the message types, and neverevalor route it into a DOM sink. - Never send the actual credential or access token across the bridge. Pass an opaque, short-lived, single-use public token that the merchant's backend exchanges for real credentials using their secret. That's exactly your token-exchange knowledge applied, and the reason the design is safe is that the value crossing into the untrusted page is worthless on its own.
Hardening the frame both ways
sandboxon the iframe grants capabilities explicitly.sandbox="allow-scripts allow-forms allow-same-origin". Note the sharp edge worth naming,allow-scriptsplusallow-same-origintogether lets the frame remove its own sandbox, so that combination is only acceptable for content you control, which here you do.allow/ Permissions Policy to grant only what the flow needs, for exampleallow="camera"for document capture and nothing else.referrerpolicyso your iframe URL doesn't leak the merchant's full page URL, which may itself contain PII or a cart id.- Protect yourself from being embedded by the wrong people. Your app pages set
frame-ancestors 'none', but the widget URL deliberately can't, so it validates instead. The frame checks its owndocument.referreror ancestor origin against the allowlist registered for that publishable key. This is also why the key is scoped to registered domains, and saying that ties the security model to the product's onboarding. - Clickjacking, the other direction. A hostile embedder overlays your consent button. Defenses are the ancestor allowlist above, plus requiring a real user gesture, plus not making the destructive action a single click on a page you don't control.
The live problem, third-party storage partitioning
This is the part that makes you sound current, and it's genuinely unsettled, which suits the "clear under uncertainty" criterion.
What changed. Browsers have been shutting down cross-site tracking, and embedded SDKs are collateral damage. Safari's ITP and Firefox's ETP block third-party cookies outright, and Chrome has been partitioning storage. The practical effect is that your iframe's cookies and localStorage are keyed by the pair (your origin, the top-level site), not by your origin alone. So a user who authenticated in your iframe on merchant-a.com arrives on merchant-b.com as a total stranger, and even a returning visit can lose state.
What you do about it, in order of preference.
- Design for statelessness. Don't depend on cross-site persistence at all. Each session starts from a short-lived token the merchant's backend created. This is the answer that ages well and it's the one to lead with.
- Pass state explicitly through the bridge or the iframe URL rather than relying on ambient cookies. Signed, short-TTL, single-use.
- The Storage Access API.
document.requestStorageAccess()lets a frame ask for unpartitioned access, but it generally requires a user gesture and prior first-party interaction, and behavior differs across browsers. Name it, and name the caveat. - CHIPS, meaning
Set-Cookie: ...; SameSite=None; Secure; Partitioned, is the sanctioned way to keep a per-top-level-site cookie. It fixes "remember state on this merchant" and explicitly does not fix "recognize the user across merchants," which is the correct privacy outcome. - Popup instead of iframe as the fallback for flows that truly need first-party context, since a popup is a top-level context on your own origin with real first-party storage. The cost is popup blockers and a worse mobile experience, so it's a fallback, not the default.
Then the honest closing note. Do not build anything that depends on cross-site identity, because that's the capability the platform is deliberately removing and any workaround has a shelf life.
What the SDK's public API should look like
You'll likely be asked to sketch it. Apply Ch 2's props-as-API thinking to a third-party surface, where the constraint is that you can never make a breaking change.
const handler = YourSDK.create({
token: 'link-sandbox-abc', // created server-side, short TTL, single use
onSuccess: (publicToken, meta) => {/* send to THEIR backend to exchange */},
onExit: (err, meta) => {}, // user bailed, or a real error
onEvent: (name, meta) => {}, // analytics hook, fire-and-forget
});
handler.open();
handler.destroy(); // MUST exist: remove listeners + iframe
- Config in, callbacks out. No DOM knobs. If they can pass a selector or a stylesheet you've made your internals into public API and can never refactor.
destroy()is not optional. Host pages are SPAs that unmount. Without it you leak an iframe and amessagelistener on every open.- Version the URL, not just the package.
cdn.you.com/v2/link.js, because merchants pin scripts and never upgrade. You will support v1 for years, so plan for concurrent versions rather than hoping. - Every callback fires exactly once and errors are typed. An
error_codethe merchant can branch on, plus a human message, plus a request id for support. Silent failure in an embedded flow is unsupportable, because you can't see their console. - Fail visibly and degrade. If the iframe can't load, the SDK must tell the merchant through
onExitrather than leaving a blank overlay on their checkout.
"The SDK is a thin async loader on the merchant's page plus a cross-origin iframe holding the real app. The iframe is the security boundary, and the same-origin policy is what enforces it, so the host page cannot read the credentials the user types or patch my network layer, and their CSS can't reach my UI. The two sides talk over postMessage with an explicit target origin and an origin check plus a message-type allowlist on receive, and the only thing that ever crosses into the untrusted page is a single-use public token their backend exchanges server-side. I'd assume no third-party storage, because it's being partitioned away, so each session is bootstrapped from a short-lived server-created token rather than a cookie. CHIPS or the Storage Access API are the fallbacks if we need per-merchant persistence, and I'd deliberately avoid depending on cross-merchant identity because the platform is removing it."
Injecting a modal into the host DOM and calling it isolated. postMessage(msg, '*'), or receiving without an origin check. Sending a real access token across the bridge instead of a single-use public token. Assuming third-party cookies work. No destroy(), so the widget leaks in every host SPA. A heavy synchronous loader that tanks the merchant's LCP, which is how you get uninstalled.
Chapter 18: Worked example, transactions dashboard from a given API
Prompt shape: "Here's our backend. Design a web dashboard where a user views their accounts and transactions, with search, filters, and category summaries."
Given boilerplate:
GET /accounts β [{id, name, mask, balance_cents, currency}]
GET /accounts/:id/transactions β {items: [{id, posted_at, amount_cents,
?after=cursor&limit=100 merchant, category, status}], next_cursor}
PATCH /transactions/:id β update category
Auth: Bearer token. Errors: {code, message, request_id}
Step 1: Requirements (say the numbers)
Clarify: rows per user? Say the interviewer answers "a few thousand transactions, a handful of accounts." Freshness? Transactions post hourly-ish, not real-time. Devices: desktop web primarily. Core flows: scan recent activity, find a specific transaction, recategorize, see spend by category. That scale answer just decided the architecture, note it out loud.
Step 2: Read the API aloud, flag gaps
- Cursor pagination, good, stable under new transactions posting.
- No search or filter params on the transactions endpoint β either request them, or fetch-all and derive client-side. At ~2-5K rows, client-side wins (Ch 4).
- No summary endpoint β category totals computed over paginated data would be wrong (sums need all rows). Since fetch-all pulls all rows anyway, derive summaries in memory. If the interviewer later 10x's the scale, this flips to a requested
/summaryendpoint, plant that flag now. - Errors are structured with request ids, so the API client normalizes and support tickets are debuggable.
Step 3: Boxes and arrows
App shell (auth, routing, error boundary)
ββ DashboardPage ββ owns: URL state (account, filters, page)
ββ AccountSwitcher β useAccounts() [cache: ['accounts']]
ββ SummaryCards (by category) β derived, no fetch
ββ FilterBar (search, date, cat.) β writes URL params
ββ TransactionsTable
ββ Row Γ ~50 (virtualized if needed)
ββ Pagination (client-side)
Data layer: useTransactions(accountId) [cache: ['txns', accountId]]
β drains cursor pages until next_cursor = null (parallel-ish, sequential cursors)
useUpdateCategory() β PATCH + targeted cache update
API client: auth header, 401 refresh-and-replay, error normalization
Step 4: Data flow deep dive (the 2/3 you're graded on)
- Fetch strategy:
useTransactionsdrains the cursor until exhausted (~2-5K rows, a few hundred KB gzipped), cached under['txns', accountId]with staleTime ~5 min. Search/filter/sort/page run as a memoized derivation chain over the cached array: every interaction is zero-network and sub-frame. SummaryCards reduce over the same filtered array, summaries always agree with the visible table, for free, because there's exactly one source dataset. - URL state: account id, filters, and page live in the URL β shareable views, back button works, refresh-safe (Ch 3).
- Recategorize (the mutation): optimistic, category edits are low-stakes and high-success: update the cache row immediately, PATCH in background, roll back with a toast on failure. Idempotent by nature (PATCH sets a value).
- Loading/error states: skeleton table on cold load; dimmed stale data + background refetch on revisit (stale-while-revalidate); per-row spinner never blocks the table; error state offers retry and shows
request_id.
Step 5: The three passes
- Performance: initial load = one accounts call + first transactions page rendered immediately while the rest of the cursor drains (progressive: table fills top-down). Route-split the dashboard from settings/etc. Virtualize the table past ~1K visible rows. Measure LCP + INP with RUM percentiles, split initial-load from navigation.
- Scale flip: "What if a business account has 500K transactions?" Fetch-all dies; answer changes to: server-side search/filter/sort (request those params), cursor-paginated windows, virtualized infinite scroll, and the
/summaryendpoint becomes mandatory. Say it as a per-surface decision, consumer accounts keep the fast path, business accounts get the server path, gated by a count check. - Security: Bearer token in memory + refresh cookie (Ch 11); transaction memos are attacker-controlled strings, rendered escaped, never as HTML; no PII in URLs (account ids are fine, numbers/names are not); mask account numbers in the switcher.
Chapter 19: Worked example, embeddable widget (Link-shaped)
Prompt shape: "Design an embeddable widget that third-party sites drop into their page so users can complete a sensitive multi-step flow (e.g. connect a bank account)." This is the home-turf question for a fintech interview, it composes Ch 10 (flows) + Ch 11 (security) + component API design (Ch 2) at the product's core.
The architecture decision: how does third-party embedding work?
| Option | Mechanics | Verdict |
|---|---|---|
| NPM component in their app | Runs in the host page's JS context | Rejected for the sensitive core: host page (and its XSS) can read every keystroke, including credentials |
| Full redirect to our domain | Leave host site, come back with a code | Secure but kills conversion; acceptable fallback (and needed for some bank OAuth flows anyway) |
| Iframe on our origin + thin SDK (chosen) | Host includes small script; script injects an iframe served from our domain; postMessage bridge | Credentials typed inside our origin, the browser's same-origin policy walls the host page out. The host page never sees the data, by construction |
Boxes and arrows
Host page (untrusted)
ββ SDK script (~10KB): create({token, onSuccess, onExit}) β open()
β injects β <iframe src="https://widget.ours.com?session=...">
β bridge β postMessage (origin-checked both ways)
ββ receives only: success(public_token) / exit(error) / events
Inside iframe (our origin) β the real app:
FlowStateMachine: select-institution β credentials β mfa? β select-accounts β success
per-step components, flow context, our API client
β our API: session-scoped short-lived token, never exposed to host
The SDK's component API (Ch 2 applied)
Keep the host-facing surface tiny and evolvable: a constructor taking a server-minted short-lived session token (the host's backend creates it, so our API never trusts the browser), two callbacks (onSuccess(public_token), onExit(error?)), and an optional onEvent for analytics. The success payload is a one-time public token the host exchanges server-side for real credentials/access, so nothing durable ever transits the browser bridge. Version the SDK independently of the iframe app: the iframe deploys continuously (it's just our web app), the SDK is a stable, boring shim, this split is what makes "fix a bug for all customers without them redeploying" possible.
Deep-dive points interviewers pull on
- postMessage discipline: validate
event.originon every message in both directions, allowlist message types, nevertargetOrigin: '*'. The bridge is the attack surface. - The flow is a state machine (Ch 10): MFA is a conditional branch; bank OAuth is a step that opens a popup/redirect to the bank and resumes on return; every step handles refresh/abandon (server-persisted flow state keyed by session).
- Failure UX is the product: bank endpoints are flaky, so per-institution health awareness, retry with backoff, and a "try another way" path. Distinguish user errors (wrong password β re-prompt) from system errors (institution down β suggest retry later), the error contract's
codedrives this. - Performance: the SDK must be tiny (it loads on every host page view, not just when opened), so lazy-load the iframe app on
open(), preconnect on hover/intent. Widget LCP budget is tight because a slow-opening modal reads as broken. - Scale/multi-tenant: per-host configuration (allowed products, branding) fetched by session token; rate-limit per host; CSP
frame-ancestorscontrols who may embed the iframe at all.
Chapter 20: Worked example, Netflix-style browse + resumable upload
Prompt shape: "Design the Netflix home/browse experience," sometimes with "and how do creators upload videos?" bolted on. Frontend-leaning version spends most time on the browse surface and the player handoff; Ch 12 carries the media mechanics.
Browse surface, boxes and arrows
BrowsePage
ββ HeroBillboard (preloaded, LCP element)
ββ Row Γ ~20 (category shelves) β virtualized vertically
ββ TitleCard Γ ~50 per row β virtualized horizontally, lazy images
Data: GET /browse β shelf metadata + first N cards per shelf (one aggregated call)
GET /shelf/:id/titles?after=β¦ β horizontal infinite scroll per shelf
Cache: ['browse'] staleTime ~10min; personalization makes it per-user (no CDN for JSON)
Player route: code-split; manifest + first segments prefetched on card hover/focus
- The aggregated
/browseendpoint is a deliberate API-design choice (Ch 9): the shelf screen needs 20 lists at once; 20 REST calls from the client over mobile RTTs is the wrong shape. One BFF endpoint returns shelf skeletons + first cards; each shelf then paginates independently with cursors. - Two-axis virtualization: vertical shelf windowing + horizontal card windowing per shelf; images lazy with reserved dimensions (CLS) and
srcsetsizes; DOM stays constant while the catalog is effectively infinite. - Perceived performance is the product: hero preloaded (it is the LCP), skeleton shelves, hover-intent prefetch of the player chunk + video manifest + first segments so pressing play starts in <1s. SSR question: the logged-in browse page is personalized, hybrid answer, SSR the shell/hero for fast first paint, hydrate shelves client-side (or full CSR if it's behind login and TV-app-like).
- Player: ABR loop from Ch 12 (measure throughput + buffer β pick next segment quality), resume position PATCHed periodically (sparse, idempotent), offline/downloads name-checked.
- Upload path (creator side): exactly Ch 12's resumable pipeline: initiate β presigned multipart chunks β resume by asking which parts landed β complete β async transcode via queue with status polling (
uploading β processing β ready, render per-state). Tie-off sentence: "the upload UI is a state machine over the upload record's status field."