Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save carefree-ladka/9acd4653d0e5f932167a8b15912bb754 to your computer and use it in GitHub Desktop.

Select an option

Save carefree-ladka/9acd4653d0e5f932167a8b15912bb754 to your computer and use it in GitHub Desktop.
Frontend System Design Prep Guide

Frontend System Design Prep Guide

LLD · HLD · Architecture · Interview Patterns

A complete reference for senior frontend engineers preparing for system design interviews at FAANG and product companies. Covers component-level LLD, application-level HLD, scalability, performance, and real-world design walkthroughs.


📚 Table of Contents

Low-Level Design (LLD)

  1. What Interviewers Actually Evaluate
  2. Component Design Principles
  3. State Management Architecture
  4. API Layer Design
  5. Reusable Component Patterns
  6. Form Design & Validation
  7. Infinite Scroll & Virtualization
  8. Real-Time Features (WebSocket / SSE)

High-Level Design (HLD)

  1. Frontend HLD Framework
  2. Application Architecture Patterns
  3. Micro-Frontend Architecture
  4. CDN, Caching & Asset Strategy
  5. Authentication & Authorization
  6. Offline Support & PWA
  7. Observability: Logging, Monitoring & Error Tracking
  8. Accessibility & i18n at Scale

Design Walkthroughs (Machine-Round Style)

  1. Design a News Feed (Facebook/Twitter)
  2. Design an Autocomplete Search
  3. Design a File Upload System
  4. Design a Chat Application
  5. Design a Video Streaming UI
  6. Design a Google Docs-like Editor
  7. Design a Notification System
  8. Design an E-commerce Product Page

Interview Strategy

  1. How to Structure Your Answer (RADIO Framework)
  2. Performance Checklist
  3. Security Checklist
  4. Common Follow-up Questions

What Interviewers Actually Evaluate

Frontend system design is not just about code — it's about thinking at scale. Interviewers want to see:

Signal What They're Looking For
Requirement clarification Do you ask the right questions before diving in?
Scope definition Can you prioritize MVP vs. nice-to-have?
Architecture decisions Do you explain trade-offs, not just solutions?
Component decomposition Can you break UI into logical, reusable units?
Data modeling How do you shape API responses for the UI?
Performance awareness Do you think about rendering, bundle size, network?
Failure handling Error states, loading states, retry logic
Accessibility Is it an afterthought or built-in?
Scalability What breaks at 10x traffic / 10x data?

Golden rule: Always clarify, scope, then design — never jump straight to implementation.


Component Design Principles

SOLID for UI Components

Single Responsibility — A component does one thing. A UserCard shows user info; a UserCardList manages the list. Never mix both.

Open/Closed — Extend via props/slots/composition, not modification. A Button accepts variant, size, icon rather than hardcoding use cases.

Liskov Substitution — A PrimaryButton should be replaceable by Button without breaking the UI. Subcomponents must honor the parent's contract.

Interface Segregation — Don't force components to accept props they don't need. Split a god-component into focused pieces.

Dependency Inversion — Components depend on abstractions (prop interfaces), not concrete implementations.


Component Classification

├── Primitives (atoms)
│   ├── Button, Input, Badge, Icon, Avatar
│   └── Zero business logic. Pure presentation.
│
├── Composites (molecules)
│   ├── SearchBar = Input + Button + Icon
│   ├── UserCard = Avatar + Text + Badge
│   └── Composed from primitives. Light logic allowed.
│
├── Features (organisms)
│   ├── CommentThread, ProductGrid, CheckoutForm
│   └── Owns data-fetching and business logic.
│
└── Pages / Routes
    ├── Compose feature components.
    └── Owns routing and layout.

Props API Design

// ❌ Vague — caller can't tell what color expects
interface ButtonProps {
  color: string
}

// ✅ Typed, self-documenting, discoverable
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'ghost' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  disabled?: boolean
  leftIcon?: React.ReactNode
  onClick?: (e: React.MouseEvent) => void
  children: React.ReactNode
  // ARIA
  'aria-label'?: string
}

Design your props API the way you'd design a REST API: consistent, predictable, versioned intent.


Compound Component Pattern

For complex components with shared state:

// Usage — clean and readable
<Select value={value} onChange={setValue}>
  <Select.Trigger>{value || "Choose..."}</Select.Trigger>
  <Select.Menu>
    <Select.Option value="a">Option A</Select.Option>
    <Select.Option value="b">Option B</Select.Option>
  </Select.Menu>
</Select>

// Implementation — Context shares state internally
const SelectContext = React.createContext(null)

function Select({ value, onChange, children }) {
  const [open, setOpen] = useState(false)
  return (
    <SelectContext.Provider value={{ value, onChange, open, setOpen }}>
      <div role="combobox">{children}</div>
    </SelectContext.Provider>
  )
}

Select.Trigger = function Trigger({ children }) {
  const { open, setOpen } = useContext(SelectContext)
  return <button onClick={() => setOpen(!open)}>{children}</button>
}

Select.Option = function Option({ value, children }) {
  const { onChange, setOpen } = useContext(SelectContext)
  return (
    <li role="option" onClick={() => { onChange(value); setOpen(false) }}>
      {children}
    </li>
  )
}

Render Props / Headless Pattern

Separate logic from presentation:

// Headless hook — logic only
function useToggle(initial = false) {
  const [on, setOn] = useState(initial)
  const toggle = useCallback(() => setOn(v => !v), [])
  const setTrue = useCallback(() => setOn(true), [])
  const setFalse = useCallback(() => setOn(false), [])
  return { on, toggle, setTrue, setFalse }
}

// Consumer owns the UI
function Accordion({ title, content }) {
  const { on: isOpen, toggle } = useToggle()
  return (
    <div>
      <button onClick={toggle} aria-expanded={isOpen}>
        {title}
      </button>
      {isOpen && <div>{content}</div>}
    </div>
  )
}

State Management Architecture

The State Decision Tree

Before reaching for Redux or Zustand, ask:

Is the state used by only one component?
  → Local state (useState / useReducer)

Is the state shared between a few closely related components?
  → Lift state up / Context

Is the state server data (cached, fetched, synced)?
  → React Query / SWR / RTK Query

Is the state global UI state (theme, auth, modals, notifications)?
  → Context + useReducer OR Zustand

Is the state complex with many transitions?
  → useReducer + Context OR Redux Toolkit

State Categories

┌─────────────────────────────────────────────────────┐
│  UI State         │ Local → Context                 │
│  (open, focused)  │ e.g. isModalOpen, activeTab     │
├───────────────────┼─────────────────────────────────┤
│  Server State     │ React Query / SWR               │
│  (fetched data)   │ caching, background refetch,    │
│                   │ optimistic updates              │
├───────────────────┼─────────────────────────────────┤
│  Form State       │ React Hook Form / Formik        │
│  (inputs, errors) │ uncontrolled where possible     │
├───────────────────┼─────────────────────────────────┤
│  URL State        │ React Router / Next.js router   │
│  (filters, page)  │ shareable, bookmarkable         │
├───────────────────┼─────────────────────────────────┤
│  Global App State │ Zustand / Redux Toolkit         │
│  (auth, theme)    │ minimal — most apps need very   │
│                   │ little true global state        │
└─────────────────────────────────────────────────────┘

React Query Architecture

// Data layer: centralized query functions
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new ApiError(res.status, await res.json())
  return res.json()
}

// Query key factory — prevents typos and enables targeted invalidation
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  detail: (id: string) => [...userKeys.all, 'detail', id] as const,
}

// Hook — composable, testable
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    staleTime: 5 * 60 * 1000,       // 5 minutes
    gcTime: 10 * 60 * 1000,         // 10 minutes in cache
    retry: (failCount, error) => {
      if (error.status === 404) return false  // Don't retry 404s
      return failCount < 3
    },
  })
}

// Mutation with optimistic update
export function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
      updateUser(id, data),

    onMutate: async ({ id, data }) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: userKeys.detail(id) })
      // Snapshot previous value
      const previous = queryClient.getQueryData(userKeys.detail(id))
      // Optimistically update
      queryClient.setQueryData(userKeys.detail(id), (old: User) => ({
        ...old,
        ...data,
      }))
      return { previous }
    },

    onError: (err, { id }, context) => {
      // Rollback on error
      queryClient.setQueryData(userKeys.detail(id), context?.previous)
    },

    onSettled: (data, err, { id }) => {
      // Always refetch after mutation
      queryClient.invalidateQueries({ queryKey: userKeys.detail(id) })
    },
  })
}

API Layer Design

Layered API Architecture

Components
    ↓  call hooks
Custom Hooks (useUser, usePosts)
    ↓  call services
Service Layer (userService.ts)
    ↓  call API client
API Client (axios instance / fetch wrapper)
    ↓  HTTP
Backend API

Each layer has a single responsibility. Components never call fetch directly.


API Client Design

// api/client.ts
const client = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  timeout: 10_000,
  headers: { 'Content-Type': 'application/json' },
})

// Request interceptor: attach auth token
client.interceptors.request.use((config) => {
  const token = tokenStore.get()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

// Response interceptor: global error handling + token refresh
client.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config

    // Token expired → refresh and retry once
    if (error.response?.status === 401 && !original._retry) {
      original._retry = true
      const newToken = await refreshToken()
      tokenStore.set(newToken)
      original.headers.Authorization = `Bearer ${newToken}`
      return client(original)
    }

    // Centralized error normalization
    return Promise.reject(normalizeError(error))
  }
)

function normalizeError(error: AxiosError): AppError {
  return {
    message: error.response?.data?.message ?? error.message,
    status: error.response?.status ?? 0,
    code: error.response?.data?.code ?? 'UNKNOWN',
  }
}

Data Normalization

For relational data, normalize before storing to avoid duplication and enable targeted updates:

// Raw API response (nested, denormalized)
{
  posts: [
    {
      id: "p1",
      author: { id: "u1", name: "Alice" },
      comments: [{ id: "c1", author: { id: "u1", name: "Alice" } }]
    }
  ]
}

// Normalized shape (flat, no duplication)
{
  entities: {
    users:    { u1: { id: "u1", name: "Alice" } },
    posts:    { p1: { id: "p1", authorId: "u1", commentIds: ["c1"] } },
    comments: { c1: { id: "c1", authorId: "u1" } }
  },
  result: ["p1"]
}

Use normalizr or write selectors that assemble the shape:

// Selector rebuilds the view model on demand
const selectPostWithAuthor = (state: State, postId: string) => {
  const post = state.entities.posts[postId]
  const author = state.entities.users[post.authorId]
  return { ...post, author }
}

Reusable Component Patterns

Design Token System

// tokens.ts — single source of truth
export const tokens = {
  color: {
    primary:   { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' },
    neutral:   { 50: '#f9fafb', 500: '#6b7280', 900: '#111827' },
    danger:    { 50: '#fef2f2', 500: '#ef4444', 900: '#7f1d1d' },
  },
  spacing: { 1: '4px', 2: '8px', 3: '12px', 4: '16px', 6: '24px', 8: '32px' },
  radius:  { sm: '4px', md: '8px', lg: '12px', full: '9999px' },
  shadow:  { sm: '0 1px 3px rgba(0,0,0,.1)', md: '0 4px 6px rgba(0,0,0,.1)' },
  font: {
    size: { xs: '12px', sm: '14px', md: '16px', lg: '20px', xl: '24px' },
    weight: { normal: 400, medium: 500, semibold: 600, bold: 700 },
  },
}

Polymorphic Component (the as prop)

// Renders any HTML element or component while preserving type safety
type PolymorphicProps<E extends React.ElementType> = {
  as?: E
  children?: React.ReactNode
} & React.ComponentPropsWithoutRef<E>

function Text<E extends React.ElementType = 'p'>({
  as,
  children,
  ...props
}: PolymorphicProps<E>) {
  const Component = as ?? 'p'
  return <Component {...props}>{children}</Component>
}

// Usage
<Text as="h1" className="title">Heading</Text>
<Text as="span" className="label">Label</Text>
<Text as={Link} href="/about">Nav Link</Text>

Error Boundary Pattern

class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode; onError?: (err: Error) => void; children: React.ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    this.props.onError?.(error)
    reportError(error, info)  // send to Sentry, etc.
  }

  render() {
    if (this.state.hasError) return this.props.fallback
    return this.props.children
  }
}

// Usage — wrap at feature boundaries, not leaf components
<ErrorBoundary fallback={<ErrorCard message="Failed to load feed" />}>
  <NewsFeed />
</ErrorBoundary>

Form Design & Validation

Architecture

FormProvider (context: fields, errors, touched, isSubmitting)
  └── Field (label + input + error, reads from context)
        └── Input / Select / Checkbox (primitive, dumb)

React Hook Form Best Practices

interface LoginForm {
  email: string
  password: string
  rememberMe: boolean
}

const schema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "At least 8 characters"),
  rememberMe: z.boolean(),
})

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<LoginForm>({ resolver: zodResolver(schema) })

  const onSubmit = async (data: LoginForm) => {
    try {
      await loginUser(data)
    } catch (err) {
      // Map server errors back to fields
      if (err.code === 'WRONG_PASSWORD') {
        setError('password', { message: 'Incorrect password' })
      } else {
        setError('root', { message: 'Login failed. Please try again.' })
      }
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <Field label="Email" error={errors.email?.message}>
        <input
          type="email"
          autoComplete="email"
          aria-describedby={errors.email ? "email-error" : undefined}
          aria-invalid={!!errors.email}
          {...register('email')}
        />
      </Field>

      {errors.root && (
        <div role="alert" aria-live="assertive">
          {errors.root.message}
        </div>
      )}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}

Validation Strategy

Trigger When to Validate UX Pattern
onBlur User leaves a field Best default — not too aggressive
onChange Every keystroke Good for password strength, formats
onSubmit Form submission Always do this too — catches skipped fields
Server-side After submit Map errors back to specific fields

Never show an error before the user has interacted with a field.


Infinite Scroll & Virtualization

Infinite Scroll with Intersection Observer

function useInfiniteScroll({
  fetchMore,
  hasMore,
  threshold = 0.1,
}: {
  fetchMore: () => void
  hasMore: boolean
  threshold?: number
}) {
  const sentinelRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const el = sentinelRef.current
    if (!el || !hasMore) return

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) fetchMore()
      },
      { threshold }
    )

    observer.observe(el)
    return () => observer.disconnect()
  }, [fetchMore, hasMore, threshold])

  return sentinelRef
}

// Usage
function PostFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: ({ pageParam = 0 }) => fetchPosts({ cursor: pageParam }),
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    })

  const sentinelRef = useInfiniteScroll({
    fetchMore: fetchNextPage,
    hasMore: !!hasNextPage,
  })

  const posts = data?.pages.flatMap(p => p.items) ?? []

  return (
    <>
      {posts.map(post => <PostCard key={post.id} post={post} />)}
      <div ref={sentinelRef} style={{ height: 1 }} />
      {isFetchingNextPage && <Spinner />}
    </>
  )
}

List Virtualization

When rendering thousands of items, only render what's visible:

import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,   // estimated row height in px
    overscan: 5,              // extra rows rendered off-screen
  })

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(vItem => (
          <div
            key={vItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${vItem.start}px)`,
            }}
          >
            <ItemRow item={items[vItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

When to use virtualization: Lists with 100+ items, especially with complex row renderers (images, nested elements).


Real-Time Features (WebSocket / SSE)

Choosing the Right Protocol

Protocol Direction Use Case
HTTP Polling Client → Server (repeated) Simple, rare updates. Easy to implement.
Long Polling Client → Server (held open) Near-real-time without WS infrastructure
SSE (EventSource) Server → Client (one-way) Notifications, live feeds, progress bars
WebSocket Bi-directional Chat, collaborative editing, gaming
WebRTC Peer-to-peer Video/audio, screen sharing

WebSocket Manager

class WebSocketManager {
  private ws: WebSocket | null = null
  private reconnectAttempts = 0
  private maxReconnects = 5
  private listeners = new Map<string, Set<(data: unknown) => void>>()
  private messageQueue: unknown[] = []

  connect(url: string) {
    this.ws = new WebSocket(url)

    this.ws.onopen = () => {
      this.reconnectAttempts = 0
      // Flush queued messages
      this.messageQueue.forEach(msg => this.send(msg))
      this.messageQueue = []
    }

    this.ws.onmessage = (event) => {
      const { type, data } = JSON.parse(event.data)
      this.listeners.get(type)?.forEach(cb => cb(data))
    }

    this.ws.onclose = () => {
      this.scheduleReconnect(url)
    }
  }

  private scheduleReconnect(url: string) {
    if (this.reconnectAttempts >= this.maxReconnects) return
    const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000)
    setTimeout(() => {
      this.reconnectAttempts++
      this.connect(url)
    }, delay)
  }

  on(event: string, cb: (data: unknown) => void) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set())
    this.listeners.get(event)!.add(cb)
    return () => this.listeners.get(event)?.delete(cb)
  }

  send(data: unknown) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data))
    } else {
      this.messageQueue.push(data)  // Queue if not connected yet
    }
  }

  disconnect() {
    this.ws?.close()
    this.ws = null
  }
}

SSE for Live Updates

function useLiveNotifications(userId: string) {
  const [notifications, setNotifications] = useState<Notification[]>([])

  useEffect(() => {
    const sse = new EventSource(`/api/notifications/stream?userId=${userId}`)

    sse.addEventListener('notification', (e) => {
      const notif = JSON.parse(e.data)
      setNotifications(prev => [notif, ...prev])
    })

    sse.addEventListener('ping', () => {
      // Keepalive — do nothing
    })

    sse.onerror = () => {
      // EventSource auto-reconnects on error
      console.warn('SSE connection lost, reconnecting...')
    }

    return () => sse.close()
  }, [userId])

  return notifications
}

Frontend HLD Framework

When asked to design a frontend system at the high level, always cover these dimensions:

The 8 Pillars of Frontend HLD

1. Requirements & Constraints
   - DAU, peak concurrent users
   - Geographic distribution (CDN needed?)
   - Device targets (mobile-first? desktop-only?)
   - Bandwidth constraints (3G users?)

2. Architecture Pattern
   - SPA / MPA / SSR / SSG / ISR
   - Monolith vs. Micro-frontend

3. Data Flow & State
   - Where does state live?
   - What's the fetch strategy?
   - How is data normalized?

4. Component Architecture
   - Design system / component library
   - Code splitting boundaries
   - Lazy loading strategy

5. Networking & API
   - REST vs GraphQL vs tRPC
   - Caching strategy (CDN, service worker, in-memory)
   - Real-time needs

6. Performance
   - Core Web Vitals targets
   - Critical path optimization
   - Image strategy

7. Reliability
   - Error boundaries
   - Retry logic
   - Offline support

8. Observability
   - Error tracking (Sentry)
   - Performance monitoring (RUM)
   - Analytics events

Application Architecture Patterns

SPA vs SSR vs SSG vs ISR

Pattern How It Works Best For Trade-offs
SPA One HTML shell, JS renders everything Dashboards, apps with auth Poor initial SEO, slow first load
SSR Server renders HTML per request E-commerce, social, SEO-heavy Higher server cost, TTFB latency
SSG Pre-render at build time Blogs, docs, marketing pages Stale data, long build times
ISR Regenerate pages on demand in background News, product pages Complexity, eventual consistency
Hybrid Mix SSR + SSG + SPA per route Complex apps (Next.js) Requires careful routing design

Code Splitting Strategy

// Route-level splitting (automatic in Next.js, manual in React Router)
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings  = lazy(() => import('./pages/Settings'))

// Feature-level splitting — heavy libraries
const RichEditor = lazy(() => import('./components/RichEditor'))
// Only loaded when user opens the editor

// Component-level with preloading on hover
function NavLink({ to, label, preload }) {
  const handleMouseEnter = () => preload()  // preload on hover
  return (
    <Link to={to} onMouseEnter={handleMouseEnter}>
      {label}
    </Link>
  )
}

// Prefetch on route transition
router.subscribe(({ location }) => {
  const nextRoute = routes.find(r => r.path === location.pathname)
  nextRoute?.component?.preload?.()  // eagerly preload next page assets
})

Micro-Frontend Architecture

Split a large frontend into independently deployable pieces, each owned by a team.

Integration Strategies

Strategy 1: Build-Time Integration
  npm publish each MFE → host installs as dependency
  ✅ Simple, type-safe  ❌ Requires rebuild to update

Strategy 2: Iframe
  Each MFE in a sandboxed iframe
  ✅ Full isolation, any tech stack  ❌ UX friction, hard to share state

Strategy 3: Module Federation (Webpack 5)
  Each MFE exposes modules; host loads them at runtime
  ✅ Independent deploys, shared dependencies  ❌ Complex config, version conflicts

Strategy 4: Web Components
  Each MFE is a custom element
  ✅ Framework-agnostic  ❌ Performance, debugging, styling friction

Module Federation Setup

// mfe-products/webpack.config.js (remote)
new ModuleFederationPlugin({
  name: 'products',
  filename: 'remoteEntry.js',
  exposes: {
    './ProductGrid': './src/components/ProductGrid',
    './useCart': './src/hooks/useCart',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})

// shell/webpack.config.js (host)
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    products: 'products@https://products.cdn.com/remoteEntry.js',
    checkout: 'checkout@https://checkout.cdn.com/remoteEntry.js',
  },
  shared: { react: { singleton: true } },
})

// Shell usage
const ProductGrid = React.lazy(() => import('products/ProductGrid'))

Communication Between MFEs

// Shared event bus (custom events on window)
const eventBus = {
  emit: (event: string, detail: unknown) =>
    window.dispatchEvent(new CustomEvent(event, { detail })),

  on: (event: string, handler: (e: CustomEvent) => void) => {
    window.addEventListener(event, handler as EventListener)
    return () => window.removeEventListener(event, handler as EventListener)
  },
}

// Cart MFE listens
eventBus.on('product:add-to-cart', (e) => {
  cartStore.add(e.detail.productId)
})

// Product MFE emits
eventBus.emit('product:add-to-cart', { productId: 'p123' })

CDN, Caching & Asset Strategy

Caching Hierarchy

Browser Cache (fastest, private)
    ↓ miss
Service Worker Cache (offline-capable)
    ↓ miss
CDN Edge Cache (fast, shared across users)
    ↓ miss
Origin Server (source of truth)

Cache-Control Strategy

HTML pages:
  Cache-Control: no-cache  (always revalidate, instant updates)

JS/CSS bundles (content-hashed filenames like app.a3f2b1.js):
  Cache-Control: public, max-age=31536000, immutable  (cache forever)

API responses:
  Cache-Control: private, max-age=60  (1 minute, per-user)

Public assets (images, fonts):
  Cache-Control: public, max-age=86400, stale-while-revalidate=604800

Image Strategy

// Responsive images with srcset
<img
  src="/hero-800.webp"
  srcSet="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1600.webp 1600w"
  sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
  loading="lazy"
  decoding="async"
  width={800}
  height={600}
  alt="Hero image"
/>

// Blur-up placeholder pattern
function ProgressiveImage({ src, placeholder, alt }) {
  const [loaded, setLoaded] = useState(false)
  return (
    <div style={{ position: 'relative' }}>
      <img src={placeholder} style={{ filter: 'blur(20px)' }} aria-hidden />
      <img
        src={src}
        alt={alt}
        style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
        onLoad={() => setLoaded(true)}
      />
    </div>
  )
}

Authentication & Authorization

Token Architecture

Access Token:
  - Short-lived (15 min)
  - Stored in memory (JS variable)
  - Sent in Authorization header
  - Never in localStorage (XSS risk)

Refresh Token:
  - Long-lived (7-30 days)
  - Stored in HttpOnly, Secure, SameSite=Strict cookie
  - Not accessible to JS (XSS-proof)
  - Used to obtain new access tokens

CSRF Protection:
  - SameSite=Strict cookies prevent cross-origin requests
  - For SameSite=Lax: include CSRF token in headers

Auth State Machine

type AuthState =
  | { status: 'initializing' }
  | { status: 'unauthenticated' }
  | { status: 'authenticating' }
  | { status: 'authenticated'; user: User; token: string }
  | { status: 'refreshing'; user: User }
  | { status: 'error'; message: string }

// Transitions
// initializing → authenticated (token in cookie + valid)
// initializing → unauthenticated (no token)
// unauthenticated → authenticating (login started)
// authenticating → authenticated (login success)
// authenticating → error (login failed)
// authenticated → refreshing (access token expired)
// refreshing → authenticated (new token obtained)
// refreshing → unauthenticated (refresh token expired → force logout)

Route Protection

function ProtectedRoute({
  children,
  requiredRole,
}: {
  children: React.ReactNode
  requiredRole?: UserRole
}) {
  const { status, user } = useAuth()

  if (status === 'initializing') return <PageSpinner />

  if (status !== 'authenticated') {
    return <Navigate to="/login" state={{ from: location }} replace />
  }

  if (requiredRole && !hasRole(user, requiredRole)) {
    return <Forbidden />
  }

  return <>{children}</>
}

Offline Support & PWA

Service Worker Caching Strategies

Strategy When to Use How It Works
Cache First Static assets (fonts, icons) Return cache, update in background
Network First API data that must be fresh Try network, fall back to cache
Stale While Revalidate Content that can be slightly stale Return cache immediately, fetch update
Cache Only Fully offline content Always serve from cache
Network Only Payments, auth Never cache, always go to network
// service-worker.js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)

  // API: Network first with cache fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then(res => {
          const clone = res.clone()
          caches.open('api-cache').then(c => c.put(event.request, clone))
          return res
        })
        .catch(() => caches.match(event.request))
    )
    return
  }

  // Assets: Cache first
  event.respondWith(
    caches.match(event.request).then(cached => {
      return cached ?? fetch(event.request)
    })
  )
})

Offline Action Queue

class OfflineQueue {
  private queue: PendingAction[] = []

  constructor() {
    window.addEventListener('online', () => this.flush())
    this.queue = JSON.parse(localStorage.getItem('offline-queue') ?? '[]')
  }

  enqueue(action: PendingAction) {
    this.queue.push({ ...action, timestamp: Date.now() })
    localStorage.setItem('offline-queue', JSON.stringify(this.queue))
  }

  async flush() {
    while (this.queue.length > 0) {
      const action = this.queue[0]
      try {
        await executeAction(action)
        this.queue.shift()
        localStorage.setItem('offline-queue', JSON.stringify(this.queue))
      } catch {
        break  // Stop on failure, retry later
      }
    }
  }
}

Observability: Logging, Monitoring & Error Tracking

What to Instrument

Errors:
  - JavaScript errors (window.onerror, unhandledrejection)
  - API failures (4xx, 5xx)
  - Component rendering errors (ErrorBoundary)

Performance:
  - Core Web Vitals (LCP, FID/INP, CLS)
  - API response times
  - Component render times (React Profiler)
  - Long tasks (PerformanceObserver)

User Behavior:
  - Page views, route changes
  - Feature usage events
  - Funnel conversion steps
  - Rage clicks, dead clicks

Structured Logging

class Logger {
  private context: Record<string, unknown> = {}

  setContext(ctx: Record<string, unknown>) {
    this.context = { ...this.context, ...ctx }
  }

  log(level: 'info' | 'warn' | 'error', message: string, data?: unknown) {
    const entry = {
      level,
      message,
      timestamp: new Date().toISOString(),
      sessionId: getSessionId(),
      userId: getCurrentUserId(),
      url: location.href,
      ...this.context,
      ...(data ? { data } : {}),
    }

    if (level === 'error') {
      Sentry.captureMessage(message, { extra: entry })
    }

    sendToAnalytics(entry)
  }
}

// Web Vitals reporting
import { onCLS, onFID, onLCP, onINP, onTTFB } from 'web-vitals'

function reportVitals(metric) {
  analytics.track('web_vital', {
    name: metric.name,
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    rating: metric.rating,  // 'good' | 'needs-improvement' | 'poor'
    navigationType: metric.navigationType,
  })
}

onCLS(reportVitals)
onLCP(reportVitals)
onINP(reportVitals)
onTTFB(reportVitals)

Accessibility & i18n at Scale

Accessibility Non-Negotiables

// Semantic HTML first
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/home">Home</a></li>
  </ul>
</nav>

// Focus management — modals, drawers, route changes
useEffect(() => {
  const firstFocusable = dialogRef.current?.querySelector(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  ) as HTMLElement
  firstFocusable?.focus()
}, [isOpen])

// Announcements for dynamic content
function LiveRegion({ message }: { message: string }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="true"
      style={{ position: 'absolute', clip: 'rect(0,0,0,0)' }}
    >
      {message}
    </div>
  )
}

// Screen reader-only text
const srOnly = {
  position: 'absolute',
  width: '1px',
  height: '1px',
  overflow: 'hidden',
  clip: 'rect(0,0,0,0)',
  whiteSpace: 'nowrap',
}

i18n Architecture

// Locale file structure
// locales/en.json
{
  "common.save": "Save",
  "common.cancel": "Cancel",
  "feed.empty": "No posts yet",
  "feed.count": "{{count}} post",
  "feed.count_plural": "{{count}} posts"
}

// React i18next usage
const { t, i18n } = useTranslation()

t('feed.count', { count: 1 })    // "1 post"
t('feed.count', { count: 42 })   // "42 posts"

// Number, date, currency formatting — always use Intl
const formatCurrency = (amount: number, currency: string, locale: string) =>
  new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)

const formatDate = (date: Date, locale: string) =>
  new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date)

// RTL support
document.documentElement.dir = i18n.dir()  // 'ltr' or 'rtl'

Design a News Feed

Facebook News Feed / Twitter timeline style

Requirements Clarification

Ask the interviewer:

  • Infinite scroll or pagination?
  • Real-time updates (new posts appear automatically)?
  • What post types? (text, image, video, link preview)
  • Is ranking algorithmic or chronological?
  • Estimated DAU and posts/day?

Component Tree

FeedPage
├── FeedHeader (create post, filters)
├── StoryRail (optional)
├── FeedList
│   ├── FeedItem (Post)
│   │   ├── PostHeader (avatar, name, time, menu)
│   │   ├── PostContent (text, image/video, link preview)
│   │   ├── PostReactions (like, comment, share counts)
│   │   └── PostActions (action buttons)
│   └── SkeletonPost (loading placeholder)
├── IntersectionSentinel (triggers next page)
└── NewPostsBanner ("3 new posts — click to refresh")

Data Model

interface Post {
  id: string
  authorId: string
  author: {           // denormalized for display — avoid join on read
    id: string
    name: string
    avatarUrl: string
  }
  content: {
    text?: string
    media?: Array<{ type: 'image' | 'video'; url: string; aspectRatio: number }>
    linkPreview?: { url: string; title: string; image: string }
  }
  reactions: { like: number; love: number; haha: number }
  commentCount: number
  shareCount: number
  createdAt: string   // ISO 8601
  cursor: string      // opaque cursor for pagination
}

interface FeedPage {
  items: Post[]
  nextCursor: string | null
  newPostCount?: number   // posts created since last fetch
}

Feed Architecture

// Cursor-based pagination (not offset — avoids duplicate/missing items on insert)
function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam, limit: 20 }),
    getNextPageParam: (last) => last.nextCursor,
    // Poll for new post count every 30s without refetching entire feed
    refetchInterval: false,
  })
}

// Polling for new posts
function useNewPostCount(lastSeenCursor: string) {
  return useQuery({
    queryKey: ['feed', 'new-count', lastSeenCursor],
    queryFn: () => fetchNewPostCount(lastSeenCursor),
    refetchInterval: 30_000,
    staleTime: 0,
  })
}

// Image feed performance
// - Lazy load images below the fold
// - Render skeleton at exact post height to prevent layout shift
// - Use ResizeObserver to measure dynamic content heights for virtualizer

Real-Time New Posts

SSE /api/feed/events?userId=...
  → event: 'new_posts', data: { count: 3, latestCursor: "..." }

UI shows: "3 new posts" banner
User clicks → insert new posts at top, scroll to top, reset cursor

Design an Autocomplete Search

Requirements

  • Debounced input (300ms)
  • Keyboard navigation (↑↓ Enter Escape)
  • Highlight matched text
  • Recent searches (local)
  • Cancel in-flight requests on keystroke

Component Design

function Autocomplete({ onSelect }: { onSelect: (result: SearchResult) => void }) {
  const [query, setQuery] = useState('')
  const [activeIndex, setActiveIndex] = useState(-1)
  const debouncedQuery = useDebounce(query, 300)
  const { data: results = [], isLoading } = useSearch(debouncedQuery)

  function handleKeyDown(e: React.KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, results.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
        if (activeIndex >= 0) onSelect(results[activeIndex])
        break
      case 'Escape':
        setQuery('')
        break
    }
  }

  return (
    <div role="combobox" aria-expanded={results.length > 0}>
      <input
        value={query}
        onChange={e => { setQuery(e.target.value); setActiveIndex(-1) }}
        onKeyDown={handleKeyDown}
        aria-autocomplete="list"
        aria-controls="search-listbox"
        aria-activedescendant={activeIndex >= 0 ? `result-${activeIndex}` : undefined}
      />
      <ul id="search-listbox" role="listbox">
        {results.map((result, i) => (
          <li
            key={result.id}
            id={`result-${i}`}
            role="option"
            aria-selected={i === activeIndex}
            onClick={() => onSelect(result)}
          >
            <HighlightMatch text={result.label} query={query} />
          </li>
        ))}
      </ul>
    </div>
  )
}

Search Hook with Request Cancellation

function useSearch(query: string) {
  return useQuery({
    queryKey: ['search', query],
    queryFn: ({ signal }) => searchApi(query, { signal }),  // AbortController signal
    enabled: query.length >= 2,
    staleTime: 60_000,   // Search results cached for 1 minute
    placeholderData: keepPreviousData,
  })
}

Highlight Matching Text

function HighlightMatch({ text, query }: { text: string; query: string }) {
  const parts = text.split(new RegExp(`(${escapeRegex(query)})`, 'gi'))
  return (
    <span>
      {parts.map((part, i) =>
        part.toLowerCase() === query.toLowerCase()
          ? <mark key={i}>{part}</mark>
          : part
      )}
    </span>
  )
}

Design a File Upload System

Upload Architecture

User selects file(s)
    ↓
Client validates (type, size, count)
    ↓
Request presigned URL from your API
    ↓
Upload directly to S3/GCS (bypasses your server)
    ↓
Notify your API of completion
    ↓
Backend processes (virus scan, thumbnail generation, DB record)
    ↓
Polling or webhook for processing status

Chunked Upload for Large Files

async function uploadLargeFile(file: File, onProgress: (pct: number) => void) {
  const CHUNK_SIZE = 5 * 1024 * 1024  // 5MB chunks
  const chunks = Math.ceil(file.size / CHUNK_SIZE)

  // 1. Initiate multipart upload
  const { uploadId, fileId } = await api.post('/uploads/initiate', {
    filename: file.name,
    contentType: file.type,
    size: file.size,
  })

  // 2. Upload each chunk in parallel (max 3 concurrent)
  const partPromises = []
  for (let i = 0; i < chunks; i++) {
    const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE)
    partPromises.push(
      uploadChunk({ uploadId, fileId, chunk, partNumber: i + 1 })
        .then(part => {
          onProgress(Math.round(((i + 1) / chunks) * 100))
          return part
        })
    )
  }

  const parts = await Promise.all(partPromises)

  // 3. Complete multipart upload
  await api.post('/uploads/complete', { uploadId, fileId, parts })

  return fileId
}

Upload UI State Machine

idle → validating → requesting_url → uploading → processing → complete
                                              ↓
                                           error (retry?)

Design a Chat Application

Architecture Decisions

Message Delivery:    WebSocket (bi-directional, low latency)
Message History:     REST API (paginated, cursor-based)
Presence (online):   WebSocket heartbeat + Redis TTL
Typing Indicators:   WebSocket events (debounced, not stored)
Read Receipts:       WebSocket + persisted in DB
File Attachments:    Presigned URL upload (same as file upload design)
Push Notifications:  FCM/APNS via service worker

Message Data Model

interface Message {
  id: string
  conversationId: string
  senderId: string
  content: {
    type: 'text' | 'image' | 'file' | 'system'
    text?: string
    attachment?: { url: string; name: string; size: number }
  }
  status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
  createdAt: string
  clientId: string  // temp ID for optimistic updates
}

Optimistic Message Sending

function useSendMessage(conversationId: string) {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: sendMessage,

    onMutate: async (newMessage) => {
      // Optimistically add message with "sending" status
      const optimistic: Message = {
        ...newMessage,
        id: `temp-${Date.now()}`,
        status: 'sending',
        createdAt: new Date().toISOString(),
      }

      queryClient.setQueryData(
        ['messages', conversationId],
        (old: MessagePage[]) => insertMessage(old, optimistic)
      )

      return { optimisticId: optimistic.id }
    },

    onSuccess: (realMessage, _, { optimisticId }) => {
      // Replace temp message with real one from server
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: MessagePage[]) => replaceMessage(old, optimisticId, realMessage)
      )
    },

    onError: (_, __, { optimisticId }) => {
      // Mark as failed, show retry button
      queryClient.setQueryData(
        ['messages', conversationId],
        (old: MessagePage[]) => markFailed(old, optimisticId)
      )
    },
  })
}

Design a Video Streaming UI

Key Challenges

1. Adaptive Bitrate Streaming (ABR)
   - HLS (.m3u8) or DASH (.mpd) formats
   - Player switches quality based on bandwidth
   - Libraries: HLS.js, Shaka Player, Video.js

2. Buffering UX
   - Show spinner only after 500ms stall (avoid flash)
   - Buffer ahead by 30s in low-bandwidth mode

3. Performance
   - Lazy load player (heavy bundle)
   - Intersection Observer to pause off-screen videos
   - Preload metadata only initially: <video preload="metadata">

4. Thumbnail Previews on Scrub
   - Sprite sheet of thumbnails (1 image, N frames)
   - Show at scrub position using CSS background-position

5. Autoplay Policy
   - Chrome: autoplay only if muted
   - Always start muted, offer unmute

Player Architecture

function VideoPlayer({ src, thumbnailSprite }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null)
  const [state, dispatch] = useReducer(playerReducer, initialPlayerState)

  // Initialize HLS
  useEffect(() => {
    if (!videoRef.current || !src.endsWith('.m3u8')) return
    const hls = new Hls({ startLevel: -1 })  // auto quality
    hls.loadSource(src)
    hls.attachMedia(videoRef.current)
    hls.on(Hls.Events.MANIFEST_PARSED, () => dispatch({ type: 'READY' }))
    hls.on(Hls.Events.ERROR, (_, data) => {
      if (data.fatal) dispatch({ type: 'ERROR', error: data.type })
    })
    return () => hls.destroy()
  }, [src])

  // Pause when off-screen
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => { if (!entry.isIntersecting) videoRef.current?.pause() },
      { threshold: 0.5 }
    )
    if (videoRef.current) observer.observe(videoRef.current)
    return () => observer.disconnect()
  }, [])

  return (
    <div className="player-wrapper">
      <video ref={videoRef} muted playsInline />
      <Controls state={state} dispatch={dispatch} />
      <ThumbnailPreview sprite={thumbnailSprite} />
    </div>
  )
}

Design a Google Docs-like Editor

Architecture Overview

Rich Text Engine (operational transforms / CRDT)
    ↓
Editor State (document model)
    ↓
Renderer (block-based React components)
    ↓
Collaboration Layer (WebSocket + conflict resolution)

Conflict Resolution: OT vs CRDT

Approach How Libraries Best For
Operational Transform (OT) Server serializes operations ShareDB, Yjs (OT mode) Google Docs-style, server-authoritative
CRDT Distributed, always mergeable Yjs, Automerge P2P or offline-first

Collaborative Cursor Tracking

// Each user's cursor position broadcast via WebSocket
interface CollaboratorCursor {
  userId: string
  name: string
  color: string   // assigned per session
  anchor: { blockId: string; offset: number }
  focus:  { blockId: string; offset: number }
}

// Server broadcasts to all users in the document
ws.on('message', (data) => {
  const { type, payload } = JSON.parse(data)
  if (type === 'cursor_update') {
    setCursors(prev => ({
      ...prev,
      [payload.userId]: payload,
    }))
  }
})

Design a Notification System

Notification Types & Delivery

In-App Toast:         Ephemeral, 3-5s, low priority updates
Notification Center:  Persisted, paginated, "mark as read"
Push Notification:    OS-level, requires permission + service worker
Email:                Async, digest or immediate (backend concern)

Notification Architecture

// Notification data model
interface Notification {
  id: string
  userId: string
  type: 'mention' | 'reply' | 'reaction' | 'follow' | 'system'
  actor?: { id: string; name: string; avatarUrl: string }
  target?: { type: 'post' | 'comment'; id: string; preview: string }
  read: boolean
  createdAt: string
}

// React context for toasts
const ToastContext = React.createContext<{
  show: (toast: ToastConfig) => void
  dismiss: (id: string) => void
}>(null)

function ToastProvider({ children }) {
  const [toasts, setToasts] = useState<Toast[]>([])

  function show(config: ToastConfig) {
    const id = nanoid()
    setToasts(prev => [...prev, { ...config, id }])
    setTimeout(() => dismiss(id), config.duration ?? 4000)
  }

  function dismiss(id: string) {
    setToasts(prev => prev.filter(t => t.id !== id))
  }

  return (
    <ToastContext.Provider value={{ show, dismiss }}>
      {children}
      <ToastStack toasts={toasts} onDismiss={dismiss} />
    </ToastContext.Provider>
  )
}

Design an E-commerce Product Page

Critical Rendering Path

Priority 1 (LCP target):
  - Product hero image (preload in <head>)
  - Product title, price
  - Add to cart button

Priority 2 (below fold):
  - Image gallery
  - Variant selector (color, size)
  - Description, specifications

Priority 3 (lazy loaded):
  - Reviews (separate route/fetch)
  - Recommendations
  - Recently viewed

Variant State Machine

interface ProductState {
  selectedColor: string | null
  selectedSize: string | null
  quantity: number
  selectedVariant: Variant | null  // derived from color + size
}

// A size is available only for the selected color
const availableSizes = product.variants
  .filter(v => v.color === state.selectedColor && v.stock > 0)
  .map(v => v.size)

// Selected variant derived — never stored redundantly
const selectedVariant = product.variants.find(v =>
  v.color === state.selectedColor &&
  v.size === state.selectedSize
)

Add to Cart — Optimistic with Conflict Resolution

function useAddToCart() {
  return useMutation({
    mutationFn: ({ variantId, quantity }) => cartApi.add(variantId, quantity),

    onMutate: async ({ variantId, quantity }) => {
      await queryClient.cancelQueries({ queryKey: ['cart'] })
      const previous = queryClient.getQueryData(['cart'])

      queryClient.setQueryData(['cart'], (old: Cart) => ({
        ...old,
        items: mergeCartItem(old.items, { variantId, quantity }),
        total: recalculateTotal(old.items, variantId, quantity),
      }))

      return { previous }
    },

    onError: (_, __, context) => {
      queryClient.setQueryData(['cart'], context?.previous)
      toast.show({ type: 'error', message: 'Failed to add to cart' })
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] })
    },
  })
}

How to Structure Your Answer (RADIO Framework)

Use this framework for every frontend system design question:

R — Requirements
    "Before I design anything, let me clarify requirements."
    - Functional: What must the system do?
    - Non-functional: Scale, latency, offline, a11y?
    - Out of scope: What are we NOT building today?

A — Architecture
    "Let me start with the high-level architecture."
    - Rendering strategy (SPA/SSR/SSG)
    - Component tree overview
    - Data flow diagram

D — Data Model
    "Let's define the data shapes the UI needs."
    - API response shapes
    - Client-side state model
    - Normalization strategy

I — Interface (API Layer)
    "How does the UI communicate with the backend?"
    - Endpoints, methods, query params
    - Pagination strategy
    - Real-time protocol

O — Optimizations
    "Now let me walk through performance, reliability, and UX."
    - Performance (lazy loading, virtualization, caching)
    - Error states and retry
    - Loading states and skeletons
    - Accessibility
    - Edge cases

Time allocation in a 45-minute interview:

  • R: 5 min
  • A: 10 min
  • D: 8 min
  • I: 7 min
  • O: 10 min
  • Questions: 5 min

Performance Checklist

Loading Performance

☐ Preload critical resources (hero images, fonts)
☐ Inline critical CSS (above-the-fold)
☐ Code split at route and feature boundaries
☐ Tree shake unused code
☐ Serve modern JS (ES2020+) to modern browsers
☐ Compress assets (Brotli > gzip)
☐ Optimize images: WebP/AVIF, correct dimensions, srcset
☐ Lazy load below-fold images
☐ Resource hints: dns-prefetch, preconnect for API domains
☐ Font display: swap to avoid invisible text

Runtime Performance

☐ Memoize expensive computations (useMemo)
☐ Stabilize callbacks (useCallback for child props)
☐ Avoid unnecessary re-renders (React.memo on list items)
☐ Virtualize long lists (>100 items)
☐ Debounce search inputs, throttle scroll handlers
☐ Avoid layout thrashing (batch DOM reads/writes)
☐ Use CSS transforms for animations (not top/left)
☐ Web Workers for CPU-intensive operations
☐ requestAnimationFrame for smooth animations
☐ Avoid synchronous localStorage access in render

Core Web Vitals Targets

Metric Good Needs Improvement Poor
LCP (Largest Contentful Paint) ≤ 2.5s 2.5–4.0s > 4.0s
INP (Interaction to Next Paint) ≤ 200ms 200–500ms > 500ms
CLS (Cumulative Layout Shift) ≤ 0.1 0.1–0.25 > 0.25

Security Checklist

XSS Prevention:
  ☐ Never dangerouslySetInnerHTML with user content
  ☐ Sanitize rich text with DOMPurify before rendering
  ☐ Content Security Policy headers

CSRF Prevention:
  ☐ SameSite=Strict cookies for session tokens
  ☐ CSRF token for state-changing requests

Authentication:
  ☐ Access tokens in memory, not localStorage
  ☐ Refresh tokens in HttpOnly cookies
  ☐ Short access token TTL (15 min)

Dependency Security:
  ☐ Audit dependencies (npm audit)
  ☐ Lock file committed and reviewed
  ☐ Subresource Integrity (SRI) for CDN scripts

Data Exposure:
  ☐ Never log sensitive data (tokens, passwords)
  ☐ Strip PII from error reports
  ☐ Validate all inputs client-side AND server-side

Common Follow-up Questions

Question Key Points to Cover
"How would you handle 10x the traffic?" CDN, SSG, edge caching, micro-frontend splitting
"What if the API is slow?" Optimistic updates, skeleton loading, stale-while-revalidate
"How do you test this?" Unit (component), Integration (user events), E2E (Playwright/Cypress)
"How do you handle network failures?" Retry with exponential backoff, offline queue, error boundaries
"How do you make this accessible?" Semantic HTML, ARIA, keyboard navigation, screen reader testing
"How do you measure success?" Core Web Vitals, error rate, conversion funnel, session duration
"How do you deploy without downtime?" Blue-green deploy, canary releases, feature flags
"How do you handle different screen sizes?" Mobile-first CSS, responsive images, touch targets ≥ 44px
"How would you migrate from Redux to Zustand?" Feature-by-feature, maintain existing interface, parallel stores
"How do you prevent memory leaks?" Cleanup effects, abort fetch on unmount, remove event listeners

Quick Reference: When to Use What

State

Need Solution
Component toggle, local UI useState
Complex local state useReducer
Shared between siblings Lift state up
Avoid prop drilling useContext
Server data, caching React Query / SWR
Global UI (auth, theme) Zustand / Context
URL-reflected state Router search params
Form state React Hook Form

Fetching Pattern

Need Pattern
Single resource useQuery
List with pagination useInfiniteQuery
Mutation + optimistic UI useMutation with onMutate
Dependent queries enabled: !!dependency
Parallel queries useQueries
Prefetch on hover queryClient.prefetchQuery

Architecture

App Type Stack
Marketing site Next.js SSG + ISR
Dashboard / SaaS Next.js SSR + React Query
Social / feed Next.js hybrid + WebSocket
Docs site Next.js SSG / Astro
Large org with many teams Module Federation (MFE)
High interactivity, offline SPA + Service Worker

Remember: In a system design interview, there are no perfect answers — only well-reasoned trade-offs. Communicate your thinking out loud, acknowledge what you're deprioritizing, and show that you understand the consequences of each decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment