A comprehensive guide covering famous interview questions and in-depth explanations of all core system design topics for mid and senior frontend engineers.
| Question | Mid | Senior |
|---|---|---|
| Autocomplete component | ✅ | ✅ (at scale, distributed) |
| News Feed | ✅ | ✅ (real-time, ranking, ads) |
| Chat UI | ✅ | ✅ (WebRTC, threading, presence) |
| Google Docs | ❌ | ✅ |
| Micro-Frontend | ❌ | ✅ |
| Drawing Tool (Figma) | ❌ | ✅ |
| PWA / Offline | ❌ | ✅ |
| Design System | ❌ | ✅ |
The most commonly asked across Google, Meta, Amazon, Flipkart, and Uber:
Break UI into small, reusable, self-contained components. Each component owns its markup, styles, and logic.
Solution:
atoms/ → Button, Input, Label, Icon
molecules/ → SearchBar (Input + Button), FormField (Label + Input)
organisms/ → Header (Logo + Nav + SearchBar), ProductCard
templates/ → PageLayout, DashboardLayout
pages/ → HomePage, ProductPage
Split a large frontend monolith into smaller, independently deployable applications owned by different teams.
Solution:
Key concerns to address:
Shell App (host)
├── /dashboard → loaded from Team A's remote
├── /orders → loaded from Team B's remote
└── /profile → loaded from Team C's remote
| Monorepo | Polyrepo | |
|---|---|---|
| Code sharing | Easy (shared packages) | Harder (publish to npm) |
| CI/CD | Slower (builds all) | Faster per repo |
| Tooling | Nx, Turborepo, Lerna | Standard per repo |
| Team autonomy | Less | More |
Solution: Use Nx or Turborepo for monorepos — they enable incremental builds and affected-only testing, making them fast at scale.
| Metric | Measures | Good Threshold |
|---|---|---|
| LCP (Largest Contentful Paint) | Loading performance | < 2.5s |
| INP (Interaction to Next Paint) | Responsiveness | < 200ms |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 |
Solutions:
<link rel="preload">), use SSR/SSG, optimize server response time, avoid render-blocking resourcesscheduler.yield(), defer non-critical JS, use web workers for heavy computationwidth and height on images/videos, avoid inserting content above existing content, use CSS aspect-ratioSolution:
React.lazy() + Suspense for component-level splittingimport() for library-level splitting// Route-level splitting
const Dashboard = React.lazy(() => import('./Dashboard'));
// Library-level splitting (load heavy lib only when needed)
button.addEventListener('click', async () => {
const { default: Chart } = await import('chart.js');
new Chart(ctx, config);
});
Rendering 10,000 DOM nodes tanks performance. Windowing renders only visible items.
Solution:
position: absolute with calculated top offsets for each itemViewport (600px visible)
├── Item 50 ← rendered
├── Item 51 ← rendered
├── Item 52 ← rendered (visible)
└── Item 53 ← rendered
[Items 0-49 and 54-10000 = NOT in DOM]
<link rel="preload"> for critical assets, <link rel="prefetch"> for next-page assetsBrowser steps: HTML parse → DOM → CSSOM → Render Tree → Layout → Paint → Composite
Solution to optimize:
<script> tags to bottom or use defer / async| State Type | Tool | Example |
|---|---|---|
| Local UI state | useState |
Modal open/close |
| Shared UI state | Context API / Zustand | Theme, sidebar |
| Server/async state | React Query / SWR | API data, cache |
| Complex global state | Redux Toolkit | Large apps, time-travel debug |
| Form state | React Hook Form | Form inputs, validation |
Solution: Separate them clearly. Don't store server data in Redux — use React Query or SWR which give you caching, background refetch, stale-while-revalidate, optimistic updates out of the box.
Component dispatches Action
→ Middleware (Thunk/Saga) handles async
→ Reducer updates Store
→ Selector derives data
→ Component re-renders
Use Redux Toolkit (RTK) to avoid boilerplate. Use RTK Query for server state within Redux ecosystem.
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
No Provider needed, minimal boilerplate, great for mid-sized apps.
| REST | GraphQL | |
|---|---|---|
| Over-fetching | Common | Avoided (request only needed fields) |
| Under-fetching | Multiple round trips | Single query |
| Caching | Easy (HTTP cache) | Complex (normalize by ID) |
| Type safety | Manual | Auto-generated (codegen) |
| Best for | Simple CRUD APIs | Complex, nested data (social feeds) |
| Polling | WebSockets | Server-Sent Events | |
|---|---|---|---|
| Direction | Client → Server | Bidirectional | Server → Client only |
| Overhead | High (repeated requests) | Low (persistent connection) | Low |
| Use case | Infrequent updates | Chat, live collaboration | Live feeds, notifications |
| Reconnection | Automatic | Manual | Automatic |
Solution for choosing:
Show the result of an action immediately without waiting for the server, then reconcile.
Solution:
User clicks "Like"
→ Immediately update UI (likes: 10 → 11)
→ Send API request in background
→ On success: keep the UI as is
→ On failure: roll back to previous state (likes: 11 → 10) + show error toast
React Query and SWR both have built-in onMutate / rollback hooks for this pattern.
Solution:
// Cancellation with AbortController
const controller = new AbortController();
fetch('/api/search?q=' + query, { signal: controller.signal });
// Cancel on next keystroke
return () => controller.abort();
React Query deduplicates identical queries automatically within the same render cycle.
| Header | Behavior |
|---|---|
Cache-Control: max-age=3600 |
Cache for 1 hour |
Cache-Control: no-cache |
Must revalidate with server each time |
Cache-Control: immutable |
Never revalidate (for hashed assets) |
ETag |
Server sends hash; browser sends it back to check if changed |
Solution for static assets: Use content hashing in filenames (main.a3f9c2.js). Set Cache-Control: max-age=31536000, immutable. When file changes, hash changes → new URL → no cache invalidation needed.
Both implement stale-while-revalidate: return cached data immediately, then fetch fresh data in background and update UI.
First visit: fetch from network → store in cache → render
Second visit: render from cache instantly → refetch in background → update if changed
Configuration:
staleTime — how long data is considered fresh (no background refetch)cacheTime — how long unused data stays in memoryrefetchOnWindowFocus — refetch when user returns to tabAvoid storing the same entity in multiple places (causes stale data bugs).
Solution: Normalize by ID, like a database:
// Instead of this (denormalized):
{ posts: [{ id: 1, author: { id: 99, name: "Alice" } }] }
// Store this (normalized):
{
posts: { 1: { id: 1, authorId: 99 } },
users: { 99: { id: 99, name: "Alice" } }
}
Apollo Client and RTK Query do this automatically. For manual normalization, use normalizr.
| Strategy | How it works | Best for |
|---|---|---|
| Cache First | Serve from cache, fallback to network | Static assets, fonts |
| Network First | Try network, fallback to cache | API responses |
| Stale While Revalidate | Serve cache, update in background | Non-critical content |
| Cache Only | Only serve from cache | Offline-only content |
| Network Only | Always hit network | Real-time data |
Attacker injects malicious scripts into your page that execute in other users' browsers.
Solution:
innerHTML, dangerouslySetInnerHTML with user inputContent-Security-Policy header to restrict which scripts can executeHttpOnly cookies so JS cannot access auth tokens// Dangerous
element.innerHTML = userInput;
// Safe
element.textContent = userInput;
// Or sanitize if HTML is needed:
element.innerHTML = DOMPurify.sanitize(userInput);
Attacker tricks a logged-in user into making unintended requests to your server.
Solution:
Origin / Referer headers on the server| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
| localStorage | High (JS accessible) | None | Avoid for auth tokens |
| sessionStorage | High (JS accessible) | None | Avoid for auth tokens |
| HttpOnly Cookie | None (JS can't read) | Medium | Best option |
| Memory (JS variable) | Low | None | Good but lost on refresh |
Best practice: Store access tokens in memory, refresh tokens in HttpOnly + SameSite=Strict cookies.
HTTP header that tells the browser which sources are trusted.
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.trusted.com;
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
Use ARIA only when native HTML semantics aren't sufficient.
<!-- Native HTML (always prefer this) -->
<button>Submit</button>
<!-- ARIA when custom component is needed -->
<div role="button" tabindex="0" aria-label="Submit form"
aria-pressed="false" onkeydown="handleKey(event)">
Submit
</div>
Common ARIA patterns:
aria-live="polite" — announce dynamic content changes to screen readersaria-expanded — for accordions, dropdownsaria-describedby — link an input to its error messagerole="dialog" + aria-modal="true" — for modalsCritical for modals, drawers, and SPAs.
Solution:
<a href="#main">Skip to main content</a> at top of page for keyboard usersEvery interactive element must be reachable and operable via keyboard:
Tab / Shift+Tab — navigate between focusable elementsEnter / Space — activate buttons, linksArrow keys — navigate within components (menus, tabs, listboxes)Escape — close dialogs, dropdownsSolution:
en.json, hi.json, ar.jsont('nav.home') not "Home"// en.json
{ "items": "{{count}} item", "items_plural": "{{count}} items" }
// ar.json (Arabic has 6 plural forms)
{ "items_0": "لا عناصر", "items_1": "عنصر واحد", ... }
Arabic, Hebrew, Persian, Urdu are RTL languages.
Solution:
dir="rtl" on <html> tagmargin-inline-start instead of margin-leftpadding-inline-end instead of padding-rightborder-inline-start instead of border-leftwriting-mode for vertical scripts if neededNever format manually. Use the browser's Intl API:
// Date formatting
new Intl.DateTimeFormat('hi-IN', { dateStyle: 'full' }).format(new Date());
// → "शनिवार, 29 अप्रैल 2026"
// Number formatting
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' }).format(100000);
// → "₹1,00,000.00"
// Relative time
new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(-1, 'day');
// → "yesterday"
| Strategy | Renders at | First Load | SEO | Dynamic Data |
|---|---|---|---|---|
| CSR (Client-Side) | Browser, on request | Slow (blank page) | Poor | Yes |
| SSR (Server-Side) | Server, on request | Fast (full HTML) | Great | Yes |
| SSG (Static Generation) | Build time | Fastest (pre-built) | Great | No (at build time) |
| ISR (Incremental Static) | Build + background regen | Fast | Great | Partially |
When to use:
SSR sends fully rendered HTML → browser loads JS → React "hydrates" by attaching event listeners to existing DOM.
Problems:
Solutions:
Run server-side rendering at CDN edge nodes (close to user) instead of a central server.
Solution:
A JS file that runs in the background, separate from the page, intercepting network requests.
Browser → Service Worker → Cache / Network
Lifecycle:
install — cache static assetsactivate — clean up old cachesfetch — intercept requests and apply caching strategyself.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
Browser-based NoSQL database for storing large amounts of structured data offline.
Solution: Use Dexie.js (wrapper around IndexedDB with a clean API):
const db = new Dexie('MyApp');
db.version(1).stores({ drafts: '++id, title, content, updatedAt' });
// Save draft offline
await db.drafts.add({ title: 'My Post', content: '...', updatedAt: Date.now() });
// Sync when back online
window.addEventListener('online', () => syncDraftsToServer());
Queue failed requests and replay them when connectivity is restored.
Solution:
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-posts') {
event.waitUntil(syncPendingPosts());
}
});
// Register sync from page
navigator.serviceWorker.ready.then((sw) => {
sw.sync.register('sync-posts');
});
manifest.json) with icons and display: standaloneClient ←——— persistent TCP connection ———→ Server
(bidirectional, low latency)
Solution for production:
// Client with reconnection
function connect() {
const ws = new WebSocket('wss://api.example.com/ws');
ws.onclose = () => setTimeout(connect, Math.min(backoff * 2, 30000));
return ws;
}
Algorithm for merging concurrent edits without conflicts. Used in Google Docs, Figma, Notion.
How it works:
Solution: Use Yjs library:
const ydoc = new Y.Doc();
const ytext = ydoc.getText('content');
// Connect to WebSocket sync provider
const provider = new WebsocketProvider('wss://sync.example.com', 'room-id', ydoc);
// Bind to editor (e.g., Quill, CodeMirror, TipTap)
const binding = new QuillBinding(ytext, quill, provider.awareness);
Alternative to CRDT. Server transforms operations against each other to resolve conflicts.
How it works:
CRDT vs OT:
| CRDT | OT | |
|---|---|---|
| Server coordination | Not required | Required |
| Complexity | Higher data overhead | Complex transformation logic |
| Offline support | Excellent | Limited |
| Adoption | Newer (Figma, Notion) | Older (Google Docs) |
[E2E Tests] ← Few, slow, expensive (Playwright, Cypress)
[Integration Tests] ← Some (React Testing Library)
[Unit Tests] ← Many, fast, cheap (Jest, Vitest)
Test individual functions and components in isolation.
// Testing a utility function
test('formatCurrency formats INR correctly', () => {
expect(formatCurrency(100000, 'INR')).toBe('₹1,00,000');
});
// Testing a React component
render(<Button onClick={mockFn}>Click me</Button>);
userEvent.click(screen.getByRole('button'));
expect(mockFn).toHaveBeenCalledOnce();
Test how components work together. Use React Testing Library — tests from user's perspective.
test('user can search and see results', async () => {
render(<SearchPage />);
await userEvent.type(screen.getByRole('searchbox'), 'React');
await waitFor(() => {
expect(screen.getByText('React Tutorial')).toBeInTheDocument();
});
});
Test full user flows in a real browser.
test('user can complete checkout', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
await page.click('[data-testid="checkout"]');
await page.fill('#card-number', '4242424242424242');
await page.click('[data-testid="pay"]');
await expect(page.locator('.success-message')).toBeVisible();
});
Catch unintended UI changes by comparing screenshots.
Solution: Use Chromatic (with Storybook) or Percy — takes screenshots of components, diffs against baseline, flags changes for review.
test('should have no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Code Push → Lint & Type Check → Unit Tests → Build → E2E Tests → Deploy
Solution with GitHub Actions:
Decouple deployment from release. Ship code to production but enable features only for specific users.
Solution:
const isNewDashboardEnabled = await flagsmith.isFeatureEnabled('new_dashboard');
return isNewDashboardEnabled ? <NewDashboard /> : <OldDashboard />;
Run controlled experiments to measure impact of UI changes.
Solution:
const variant = getVariant(userId, 'checkout_button_color'); // 'control' | 'treatment'
const buttonColor = variant === 'treatment' ? 'green' : 'blue';
Solution:
Cache-Control: max-age=31536000, immutable)no-cache (they reference hashed assets)| Tool | Purpose |
|---|---|
| Sentry | JS error tracking, stack traces, user context |
| Datadog RUM | Real User Monitoring, performance metrics |
| LogRocket | Session replay, console logs, network requests |
| Lighthouse CI | Automated performance audits in CI |
Solution for error tracking:
Sentry.init({ dsn: '...', tracesSampleRate: 0.1 }); // 10% of transactions
// Capture custom errors
try {
await processPayment();
} catch (error) {
Sentry.captureException(error, { extra: { userId, cartId } });
}
Last updated: April 2026