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.
- What Interviewers Actually Evaluate
- Component Design Principles
- State Management Architecture
- API Layer Design
- Reusable Component Patterns
- Form Design & Validation
- Infinite Scroll & Virtualization
- Real-Time Features (WebSocket / SSE)
- Frontend HLD Framework
- Application Architecture Patterns
- Micro-Frontend Architecture
- CDN, Caching & Asset Strategy
- Authentication & Authorization
- Offline Support & PWA
- Observability: Logging, Monitoring & Error Tracking
- Accessibility & i18n at Scale
- Design a News Feed (Facebook/Twitter)
- Design an Autocomplete Search
- Design a File Upload System
- Design a Chat Application
- Design a Video Streaming UI
- Design a Google Docs-like Editor
- Design a Notification System
- Design an E-commerce Product Page
- How to Structure Your Answer (RADIO Framework)
- Performance Checklist
- Security Checklist
- Common Follow-up Questions
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.
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.
├── 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.
// ❌ 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.
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>
)
}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>
)
}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
┌─────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────┘
// 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) })
},
})
}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.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',
}
}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 }
}// 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 },
},
}// 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>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>FormProvider (context: fields, errors, touched, isSubmitting)
└── Field (label + input + error, reads from context)
└── Input / Select / Checkbox (primitive, dumb)
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>
)
}| 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.
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 />}
</>
)
}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).
| 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 |
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
}
}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
}When asked to design a frontend system at the high level, always cover these dimensions:
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
| 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 |
// 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
})Split a large frontend into independently deployable pieces, each owned by a team.
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
// 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'))// 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' })Browser Cache (fastest, private)
↓ miss
Service Worker Cache (offline-capable)
↓ miss
CDN Edge Cache (fast, shared across users)
↓ miss
Origin Server (source of truth)
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
// 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>
)
}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
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)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}</>
}| 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)
})
)
})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
}
}
}
}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
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)// 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',
}// 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'Facebook News Feed / Twitter timeline style
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?
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")
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
}// 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 virtualizerSSE /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
- Debounced input (300ms)
- Keyboard navigation (↑↓ Enter Escape)
- Highlight matched text
- Recent searches (local)
- Cancel in-flight requests on keystroke
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>
)
}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,
})
}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>
)
}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
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
}idle → validating → requesting_url → uploading → processing → complete
↓
error (retry?)
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
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
}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)
)
},
})
}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
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>
)
}Rich Text Engine (operational transforms / CRDT)
↓
Editor State (document model)
↓
Renderer (block-based React components)
↓
Collaboration Layer (WebSocket + conflict resolution)
| 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 |
// 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,
}))
}
})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 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>
)
}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
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
)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'] })
},
})
}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
☐ 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
☐ 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
| 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 |
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
| 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 |
| 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 |
| 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 |
| 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.