Frontend Engineer Interview Preparation — Complete Guide

Frontend Engineer Interview Preparation — Complete Guide

Hey everyone! Welcome back! This is a complete interview preparation guide for Frontend Engineers covering JavaScript, React, Next.js, System Design, and Behavioral rounds.

This covers 3 rounds with real questions and polished answers that you can use in your next interview!

Rounds:

  • Round 1 — Technical (JavaScript + React fundamentals)
  • Round 2 — Advanced Technical / System Design
  • Round 3 — Managerial / Behavioral

Let's dive in!


Round 1 — Technical (JavaScript + React)


Q1: Explain event loop and how asynchronous tasks are executed in JavaScript.

Answer:

JavaScript is a single-threaded language — it has one call stack and can do one thing at a time. But it can handle async operations like API calls, setTimeout, and DOM events using the Event Loop.

The key components are:

  • Call Stack — where JavaScript executes code, one function at a time (LIFO)
  • Web APIs — browser provides these (setTimeout, fetch, DOM events). When we call an async function, it goes to Web APIs
  • Callback Queue (Macrotask Queue) — callbacks from setTimeout, setInterval, DOM events wait here
  • Microtask Queue — Promise callbacks (.then, .catch, .finally) and MutationObserver wait here
  • Event Loop — continuously checks: "Is call stack empty? If yes, pick from microtask queue first, then macrotask queue"
How it works:
==============

console.log("Start");

setTimeout(() => {
    console.log("setTimeout");    // Macrotask
}, 0);

Promise.resolve().then(() => {
    console.log("Promise");       // Microtask
});

console.log("End");

OUTPUT:
Start
End
Promise
setTimeout

Why this order?
================

1. "Start" → Goes to call stack → executes immediately
2. setTimeout → Goes to Web API → after 0ms → callback goes to MACROTASK queue
3. Promise.then → Goes to MICROTASK queue
4. "End" → Goes to call stack → executes immediately
5. Call stack is now EMPTY!
6. Event Loop checks: Microtask queue first → "Promise" executes
7. Event Loop checks: Macrotask queue → "setTimeout" executes

RULE: Microtasks ALWAYS run before Macrotasks!
The Event Loop Flow:
=====================

┌──────────────┐
│  Call Stack   │ ← Executes synchronous code
└──────┬───────┘
       │
       ▼
┌──────────────┐     ┌──────────────┐
│   Web APIs   │ ──→ │  Callback    │
│ (setTimeout, │     │  Queues      │
│  fetch, DOM) │     │              │
└──────────────┘     │ Microtask: ⚡│ ← Promises (.then)
                     │ (HIGH priority)│
                     │              │
                     │ Macrotask: 🐌│ ← setTimeout, setInterval
                     │ (LOW priority) │
                     └──────┬───────┘
                            │
                     ┌──────┴───────┐
                     │  Event Loop  │
                     │  "Is stack   │
                     │   empty?"    │
                     │              │
                     │ Yes → Pick   │
                     │ microtask    │
                     │ first, then  │
                     │ macrotask    │
                     └──────────────┘

Priority: Microtask > Macrotask

Microtasks: Promise.then, Promise.catch, Promise.finally,
            queueMicrotask, MutationObserver

Macrotasks: setTimeout, setInterval, setImmediate,
            requestAnimationFrame, I/O, UI rendering

Real example that interviewers love:

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve()
    .then(() => console.log("3"))
    .then(() => console.log("4"));

setTimeout(() => console.log("5"), 0);

console.log("6");

OUTPUT:
1
6
3
4
2
5

Step by step:
1. "1" → call stack → prints immediately
2. setTimeout(cb, 0) → Web API → macrotask queue
3. Promise.then → microtask queue
4. setTimeout(cb, 0) → Web API → macrotask queue
5. "6" → call stack → prints immediately
6. Stack empty → microtask: "3" prints
7. .then chain → microtask: "4" prints
8. Microtask queue empty → macrotask: "2" prints
9. macrotask: "5" prints
Key Points:
============
- JavaScript is single-threaded but NON-BLOCKING (thanks to event loop)
- Call stack executes synchronous code
- Web APIs handle async operations
- Microtasks (Promises) have HIGHER priority than Macrotasks (setTimeout)
- Event Loop picks tasks only when call stack is EMPTY
- Each macrotask is followed by ALL pending microtasks

Q2: Implement a custom hook in React that debounces input value.

Answer:

A debounce hook delays updating the value until the user stops typing for a specified time. This is very useful for search inputs where we don't want to fire API calls on every keystroke.

// useDebounce.js — Custom Hook

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
    const [debouncedValue, setDebouncedValue] = useState(value);

    useEffect(() => {
        // Set a timer to update debounced value after delay
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        // Cleanup: clear timer if value changes before delay
        return () => {
            clearTimeout(timer);
        };
    }, [value, delay]);

    return debouncedValue;
}

export default useDebounce;

Usage in a Search Component:

import { useState, useEffect } from 'react';
import useDebounce from './useDebounce';

function SearchComponent() {
    const [searchTerm, setSearchTerm] = useState('');
    const debouncedSearch = useDebounce(searchTerm, 300);

    useEffect(() => {
        if (debouncedSearch) {
            // This API call only fires 300ms after user STOPS typing
            fetch(`/api/search?q=${debouncedSearch}`)
                .then(res => res.json())
                .then(data => console.log(data));
        }
    }, [debouncedSearch]);

    return (
        <input
            type="text"
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            placeholder="Search..."
        />
    );
}

// User types "JavaScript":
// J → a → v → a → S → c → r → i → p → t
// Only ONE API call after 300ms of no typing!
How it works:
==============

1. User types → searchTerm updates on every keystroke
2. useDebounce receives the new value
3. useEffect sets a setTimeout for 300ms
4. If user types again before 300ms → cleanup clears the timer
5. Only when user STOPS typing for 300ms → debouncedValue updates
6. The search useEffect fires → API call happens ONCE!

Why this is a good custom hook:
- Reusable across any component
- Handles cleanup automatically
- Follows React patterns (useState + useEffect)
- No external library needed

Q3: Create a reusable dropdown component that supports search and multi-select.

Answer:

I would build a Dropdown component that takes options as props, supports searching/filtering, and allows selecting multiple items.

// MultiSelectDropdown.jsx

import { useState, useRef, useEffect } from 'react';

function MultiSelectDropdown({ options, placeholder = "Select...", onChange }) {
    const [isOpen, setIsOpen] = useState(false);
    const [searchTerm, setSearchTerm] = useState('');
    const [selected, setSelected] = useState([]);
    const dropdownRef = useRef(null);

    // Close dropdown when clicking outside
    useEffect(() => {
        function handleClickOutside(e) {
            if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
                setIsOpen(false);
            }
        }
        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, []);

    // Filter options based on search
    const filteredOptions = options.filter(opt =>
        opt.label.toLowerCase().includes(searchTerm.toLowerCase())
    );

    // Toggle selection
    const toggleOption = (option) => {
        const newSelected = selected.includes(option.value)
            ? selected.filter(v => v !== option.value)    // Remove
            : [...selected, option.value];                 // Add

        setSelected(newSelected);
        onChange?.(newSelected);
    };

    return (
        <div ref={dropdownRef} className="dropdown">
            <div className="dropdown-trigger" onClick={() => setIsOpen(!isOpen)}>
                {selected.length > 0
                    ? `${selected.length} selected`
                    : placeholder}
            </div>

            {isOpen && (
                <div className="dropdown-menu">
                    <input
                        type="text"
                        value={searchTerm}
                        onChange={(e) => setSearchTerm(e.target.value)}
                        placeholder="Search..."
                        className="dropdown-search"
                    />
                    <ul className="dropdown-list">
                        {filteredOptions.map(opt => (
                            <li
                                key={opt.value}
                                onClick={() => toggleOption(opt)}
                                className={selected.includes(opt.value) ? 'selected' : ''}
                            >
                                <input
                                    type="checkbox"
                                    checked={selected.includes(opt.value)}
                                    readOnly
                                />
                                {opt.label}
                            </li>
                        ))}
                    </ul>
                </div>
            )}
        </div>
    );
}

// Usage:
const options = [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'angular', label: 'Angular' },
    { value: 'svelte', label: 'Svelte' },
];

<MultiSelectDropdown
    options={options}
    placeholder="Select frameworks..."
    onChange={(selected) => console.log(selected)}
/>
Key Design Decisions:
======================
- useRef + clickOutside listener → closes dropdown when clicking outside
- Controlled search input → filters options in real-time
- Checkbox UI → clear visual for multi-select
- onChange callback → parent component gets notified
- Reusable → just pass different options and placeholder

For production, also consider:
- Keyboard navigation (arrow keys, Enter, Escape)
- Accessibility (ARIA roles, screen reader support)
- Virtualization for 1000+ options (react-window)
- Loading state for async options

Q4: How would you optimize React rendering performance in large lists (e.g., 10k+ rows)?

Answer:

Rendering 10,000+ items in the DOM is extremely slow — the browser struggles to create, layout, and paint that many DOM nodes. Here's how I would optimize:

1. Virtualization (Most Important!)

The Problem:
=============
10,000 rows → 10,000 DOM nodes → Browser crashes! 😱

The Solution: VIRTUALIZATION
==============================
Only render what's VISIBLE in the viewport!

If viewport shows 20 rows at a time:
- Render only ~25 rows (20 visible + buffer)
- As user scrolls → swap out old rows, render new ones
- DOM always has ~25 nodes instead of 10,000!

Before (No Virtualization):
  DOM: [Row 1][Row 2][Row 3]...[Row 10000]  → 10,000 DOM nodes 😱

After (Virtualized):
  DOM: [Row 45][Row 46]...[Row 70]  → Only ~25 DOM nodes! 🚀

Libraries:
- react-window (lightweight, recommended)
- react-virtuoso (feature-rich)
- TanStack Virtual (framework-agnostic)
// Using react-window
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
    const Row = ({ index, style }) => (
        <div style={style}>
            {items[index].name}
        </div>
    );

    return (
        <FixedSizeList
            height={600}        // Viewport height
            itemCount={items.length}  // Total items (10,000)
            itemSize={50}       // Each row height
            width="100%"
        >
            {Row}
        </FixedSizeList>
    );
}
// Only ~15-20 DOM nodes at any time, no matter list size!

2. Other Optimization Techniques:

React.memo — Prevent unnecessary re-renders:
  const Row = React.memo(({ data }) => <div>{data.name}</div>);

useMemo — Memoize expensive computations:
  const sortedList = useMemo(() => list.sort(...), [list]);

useCallback — Stable function references:
  const handleClick = useCallback((id) => {...}, []);

Pagination — Load 50 items at a time instead of 10,000

Infinite Scroll — Load more as user scrolls down

Key prop — Always use stable, unique keys (NOT array index!)
  ✅ key={item.id}
  ❌ key={index}
Summary — Large List Optimization:
====================================

1. Virtualization (react-window) → #1 most impactful
2. React.memo on row components → prevent re-renders
3. useMemo for filtered/sorted data → avoid recalculation
4. Stable keys (item.id, not index) → efficient reconciliation
5. Pagination or infinite scroll → load less data
6. Debounce search/filter inputs → fewer re-renders

Q5: Explain the difference between controlled and uncontrolled components with examples.

Answer:

In React, form elements can be handled in two ways — controlled (React controls the value) or uncontrolled (DOM controls the value).

// ✅ CONTROLLED COMPONENT
// React state is the "single source of truth"

function ControlledForm() {
    const [name, setName] = useState('');

    const handleSubmit = (e) => {
        e.preventDefault();
        console.log(name);  // Already have the value!
    };

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={name}              // React controls value
                onChange={(e) => setName(e.target.value)}  // Every keystroke updates state
            />
            <button type="submit">Submit</button>
        </form>
    );
}

// ✅ UNCONTROLLED COMPONENT
// DOM is the "single source of truth"

function UncontrolledForm() {
    const inputRef = useRef(null);

    const handleSubmit = (e) => {
        e.preventDefault();
        console.log(inputRef.current.value);  // Read from DOM directly
    };

    return (
        <form onSubmit={handleSubmit}>
            <input
                ref={inputRef}             // DOM controls value
                defaultValue=""            // Initial value only
            />
            <button type="submit">Submit</button>
        </form>
    );
}
Feature Controlled Uncontrolled
Source of truth React state DOM
Value access Via state variable Via ref (inputRef.current.value)
Re-renders On every keystroke Only on submit
Validation Real-time (on every change) On submit only
Best for Dynamic forms, instant validation Simple forms, file inputs
React recommendation Preferred approach Use when needed
When to use which:
===================

Controlled → When you need:
  - Real-time validation
  - Conditional rendering based on input
  - Format input as user types (phone number, currency)
  - Disable submit button until form is valid

Uncontrolled → When you need:
  - Simple form with no real-time validation
  - File input (<input type="file" /> is ALWAYS uncontrolled)
  - Integration with non-React code
  - Better performance (no re-render per keystroke)

Round 2 — Advanced Technical / System Design


Q1: How would you design a scalable React application for a dashboard with 100+ pages?

Answer:

For a dashboard with 100+ pages, I would focus on modular architecture, code splitting, and consistent patterns.

Architecture:
==============

src/
├── app/                    # App-level config (routes, providers)
│   ├── routes.jsx          # Centralized route config
│   └── providers.jsx       # All context providers
│
├── features/               # Feature-based modules (NOT page-based!)
│   ├── dashboard/
│   │   ├── components/     # Dashboard-specific components
│   │   ├── hooks/          # Dashboard-specific hooks
│   │   ├── api/            # Dashboard API calls
│   │   └── index.jsx       # Dashboard page (lazy loaded)
│   │
│   ├── users/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api/
│   │   └── index.jsx
│   │
│   └── analytics/
│       ├── components/
│       ├── hooks/
│       ├── api/
│       └── index.jsx
│
├── shared/                 # Reusable across all features
│   ├── components/         # Button, Modal, Table, etc.
│   ├── hooks/              # useDebounce, useFetch, etc.
│   ├── utils/              # Helper functions
│   └── layouts/            # Sidebar, Header, PageLayout
│
└── services/               # API client, auth, storage
Key Decisions:
===============

1. FEATURE-BASED structure (not page-based)
   - Each feature is independent and self-contained
   - Easy to find code: "users feature → users folder"
   - Teams can own entire features

2. LAZY LOADING every route
   const Dashboard = React.lazy(() => import('./features/dashboard'));
   - 100 pages but user only loads what they visit
   - Initial bundle stays small

3. SHARED component library
   - Consistent UI across 100+ pages
   - Button, Table, Modal, Form components
   - Design system with Storybook

4. CENTRALIZED state management
   - Server state: TanStack Query (React Query)
   - Global UI state: Zustand (lightweight)
   - Avoid prop drilling with Context for themes/auth

5. ROUTE-BASED code splitting
   - Each route is a separate chunk
   - Prefetch next likely routes on hover

Q2: Explain how code splitting and lazy loading improve performance.

Answer:

Without code splitting, the browser downloads the entire app as one big JavaScript bundle before showing anything. For large apps, this can be 2-5 MB — users see a blank screen while it loads!

Without Code Splitting:
========================

User visits homepage → Browser downloads ENTIRE app
[Home + Dashboard + Users + Analytics + Settings + ...]
Bundle size: 3MB 😱
Load time: 5+ seconds on 3G

With Code Splitting:
=====================

User visits homepage → Browser downloads ONLY homepage code
[Home]
Bundle size: 200KB ✅
Load time: <1 second!

User clicks Dashboard → Downloads dashboard chunk
[Dashboard]
Additional: 150KB (loaded on demand)
// React.lazy + Suspense — The main way to code split

import { lazy, Suspense } from 'react';

// Instead of:
// import Dashboard from './Dashboard';  ← loaded immediately!

// Do this:
const Dashboard = lazy(() => import('./Dashboard'));  // ← loaded on demand!
const Analytics = lazy(() => import('./Analytics'));
const Settings = lazy(() => import('./Settings'));

function App() {
    return (
        <Suspense fallback={<LoadingSpinner />}>
            <Routes>
                <Route path="/dashboard" element={<Dashboard />} />
                <Route path="/analytics" element={<Analytics />} />
                <Route path="/settings" element={<Settings />} />
            </Routes>
        </Suspense>
    );
}

// Each route becomes a separate JS chunk
// Downloaded only when user navigates to that route!
Types of Code Splitting:
=========================

1. Route-based splitting → Split by pages (most common)
2. Component-based splitting → Heavy components loaded on demand
   const HeavyChart = lazy(() => import('./HeavyChart'));
3. Library splitting → Load heavy libraries only when needed
   const { PDFViewer } = await import('react-pdf');

Performance Impact:
====================
- Initial load: 3MB → 300KB (10x smaller!)
- TTI (Time to Interactive): 5s → 1s
- Users see content faster
- Unused code is never downloaded

Q3: How do you handle API rate limits gracefully on the frontend?

Answer:

API rate limiting means the server restricts how many requests a client can make in a given time window. If we exceed it, we get 429 Too Many Requests. Here's how I handle it:

Strategies:
============

1. DEBOUNCE user actions (search, autocomplete)
   - Don't fire API on every keystroke
   - Wait 300ms after user stops typing

2. THROTTLE frequent events (scroll, resize)
   - Limit to 1 API call per second

3. REQUEST QUEUE with concurrency limit
   - Maximum 3 API calls in parallel
   - Queue the rest

4. RETRY with exponential backoff
   - If 429 received → wait 1s → retry
   - If still 429 → wait 2s → retry
   - Then 4s → 8s → give up

5. CACHING
   - Cache API responses (TanStack Query does this!)
   - Don't re-fetch data that hasn't changed
   - Use stale-while-revalidate pattern
// Exponential Backoff Implementation

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            const response = await fetch(url, options);

            if (response.status === 429) {
                // Rate limited! Check Retry-After header
                const retryAfter = response.headers.get('Retry-After');
                const delay = retryAfter
                    ? parseInt(retryAfter) * 1000
                    : Math.pow(2, attempt) * 1000;  // 1s, 2s, 4s, 8s

                console.log(`Rate limited. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
                continue;
            }

            return response;
        } catch (error) {
            if (attempt === maxRetries) throw error;
        }
    }
}

// With TanStack Query (built-in retry!)
const { data } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    retry: 3,
    retryDelay: (attempt) => Math.pow(2, attempt) * 1000,
    staleTime: 5 * 60 * 1000,  // Cache for 5 minutes
});
Key Points:
============
- Debounce/throttle → prevent excessive requests
- Exponential backoff → graceful retry on 429
- Caching → avoid unnecessary requests
- Show user-friendly message: "Too many requests, please wait..."
- Never bombard the server — respect rate limits!

Q4: What is hydration in Next.js and when can it cause UI mismatches?

Answer:

Hydration is the process where React takes the server-rendered HTML and attaches JavaScript to it, making it interactive.

How Hydration Works:
=====================

Step 1: Server renders HTML
  Server → Generates static HTML → Sends to browser
  User sees content IMMEDIATELY (but it's not interactive yet)

Step 2: Browser downloads JavaScript bundle
  React JS code loads in the background

Step 3: HYDRATION happens
  React "hydrates" the static HTML:
  - Attaches event listeners (onClick, onChange, etc.)
  - Connects state management
  - Makes everything interactive!

Timeline:
  Server HTML received → User sees content (fast!)
  JS bundle loaded → Hydration → Page becomes interactive

Think of it like:
  Server HTML = a paper menu (you can read it)
  Hydration = turning it into a touchscreen (now you can interact!)

When does hydration mismatch happen?

Hydration mismatch = Server HTML ≠ Client render

React expects the first client render to EXACTLY MATCH
the server HTML. If they don't match → hydration error!

Common causes:
===============

1. Using Date/Time:
   // ❌ Server time ≠ Client time!
   return <p>{new Date().toLocaleString()}</p>

   // ✅ Fix: Use useEffect for client-only values
   const [time, setTime] = useState('');
   useEffect(() => setTime(new Date().toLocaleString()), []);

2. Using window/localStorage:
   // ❌ window doesn't exist on server!
   return <p>Width: {window.innerWidth}</p>

   // ✅ Fix: Check if client-side
   const [width, setWidth] = useState(0);
   useEffect(() => setWidth(window.innerWidth), []);

3. Using Math.random():
   // ❌ Random value differs on server vs client!
   return <div id={Math.random()}>...</div>

4. Conditional rendering based on auth/cookies:
   // ❌ Server doesn't have access to browser cookies
   return isLoggedIn ? <Dashboard /> : <Login />

5. Browser extensions modifying DOM:
   // Extensions add elements → React sees unexpected HTML
How to fix:
============
- Use useEffect for client-only values (runs only on client)
- Use 'suppressHydrationWarning' for intentional mismatches
- Use dynamic import with { ssr: false } for client-only components
- Keep server and client renders identical!

Q5: How would you build a component library used by multiple teams?

Answer:

Component Library Architecture:
================================

1. DESIGN SYSTEM first
   - Design tokens (colors, spacing, typography)
   - Consistent naming conventions
   - Figma ↔ Code alignment

2. MONOREPO structure (using Turborepo or Nx)
   packages/
   ├── ui/                # Core components
   │   ├── Button/
   │   ├── Modal/
   │   ├── Table/
   │   └── package.json
   ├── tokens/            # Design tokens
   │   ├── colors.ts
   │   ├── spacing.ts
   │   └── package.json
   └── utils/             # Shared utilities
       └── package.json

3. DOCUMENTATION with Storybook
   - Every component has stories
   - Interactive playground
   - Props documentation
   - Accessibility guidelines

4. KEY PRINCIPLES:
   - Composable (small, focused components)
   - Accessible (ARIA labels, keyboard navigation)
   - Themeable (dark mode, custom brands)
   - Tree-shakeable (import only what you use)
   - Well-tested (unit + visual regression tests)

5. PUBLISHING:
   - Semantic versioning (1.0.0, 1.1.0, 2.0.0)
   - Changelog for every release
   - npm private registry or GitHub Packages
   - CI/CD auto-publishes on merge to main

Q6: Explain CSR, SSR, and SSG — when would you use each?

Answer:

Feature CSR (Client-Side Rendering) SSR (Server-Side Rendering) SSG (Static Site Generation)
When HTML is generated In browser (runtime) On server (per request) At build time
Initial load Blank → then content loads Full HTML immediately Full HTML immediately
SEO ❌ Poor ✅ Great ✅ Great
Performance Slow first load Fast first load, server cost Fastest (served from CDN)
Dynamic data ✅ Yes ✅ Yes (fresh every request) ❌ Stale until rebuild
Best for Dashboards, admin panels E-commerce, social media Blogs, docs, marketing
When to use which:
===================

CSR (React SPA):
  → Internal dashboards, admin panels
  → Apps behind login (SEO doesn't matter)
  → Highly interactive apps (chat, editors)

SSR (Next.js getServerSideProps):
  → E-commerce product pages (SEO + fresh data)
  → Social media feeds (personalized + SEO)
  → Pages that need fresh data on every request

SSG (Next.js getStaticProps):
  → Blog posts, documentation
  → Marketing/landing pages
  → Pages where data rarely changes

ISR (Incremental Static Regeneration — best of both!):
  → SSG + revalidate every X seconds
  → Fresh enough + fast (CDN cached)
  → getStaticProps with { revalidate: 60 }

Q7: How would you design a frontend architecture that can handle 1M+ users daily?

Answer:

Frontend Architecture for Scale:
=================================

1. CDN for Static Assets
   - Serve JS, CSS, images from CDN (CloudFront, Cloudflare)
   - Users download from nearest edge server
   - 90% of traffic never hits your origin server

2. Code Splitting + Lazy Loading
   - Initial bundle < 200KB
   - Route-based splitting
   - Load heavy libraries on demand

3. Caching Strategy
   - HTTP cache headers (Cache-Control, ETag)
   - Service Worker for offline support
   - TanStack Query for API response caching
   - stale-while-revalidate pattern

4. Optimized Rendering
   - SSR/SSG for SEO pages
   - CSR for interactive dashboards
   - Virtualized lists for large datasets
   - Image optimization (WebP, lazy loading, srcset)

5. Performance Monitoring
   - Web Vitals (LCP, FID, CLS)
   - Error tracking (Sentry)
   - Real User Monitoring (RUM)
   - Performance budgets in CI/CD

6. API Layer
   - GraphQL or BFF (Backend for Frontend)
   - Request deduplication
   - Debounce/throttle user actions
   - Retry with backoff on failures

7. Bundle Optimization
   - Tree shaking (remove unused code)
   - Compression (gzip/brotli)
   - Dynamic imports
   - Analyze bundle with webpack-bundle-analyzer
Rule of thumb:
===============
- First load < 3 seconds on 3G
- LCP (Largest Contentful Paint) < 2.5s
- TTI (Time to Interactive) < 5s
- Bundle size < 200KB initial
- Cache everything you can
- Monitor everything in production

Round 3 — Managerial / Behavioral


Q1: Tell me about a time when a critical production bug broke the UI — how did you handle it?

Answer:

In the logistics platform, we had a production bug where the dashboard stopped loading for all users after a deployment. The entire tracking page was showing a white screen.

How I handled it:

Step 1 — Immediate Response: I first checked the browser console and found a JavaScript error — one of the API responses had changed its format and our code was trying to access a property on undefined. This caused the entire React component tree to crash because we didn't have an Error Boundary.

Step 2 — Quick Fix: I added a null check on the API response and deployed a hotfix within 30 minutes. I also added an Error Boundary component so that if one section crashes, the rest of the page still works.

Step 3 — Root Cause: The backend team had changed the API response structure without informing the frontend team. I set up a shared API contract (using TypeScript interfaces) so both teams agree on the data shape before any changes.

Step 4 — Prevention: I added Sentry for error monitoring so we get alerts before users report issues. I also added integration tests that validate API response shapes.

Key Takeaways:
===============

1. Stay calm — panicking doesn't fix bugs
2. Identify → Fix → Prevent (in that order)
3. Add Error Boundaries for graceful degradation
4. Monitoring (Sentry) catches issues before users report them
5. API contracts between frontend and backend teams
6. Post-mortem: document what happened and how to prevent it

Q2: How do you prioritize technical debt vs. new features?

Answer:

I believe technical debt and new features should not be treated as opposites — they need to coexist.

My approach:

1. Categorize the debt:

  • Critical debt — causes bugs, security issues, or blocks new features → Fix immediately
  • High debt — slows down development significantly → Plan in next sprint
  • Low debt — annoying but not blocking anything → Boy Scout Rule (clean up as you touch it)

2. The 80/20 rule: I typically allocate about 80% of sprint capacity to new features and 20% to technical debt. This keeps the codebase healthy without stopping feature delivery.

3. "Boy Scout Rule": Leave the code better than you found it. When working on a feature, if I see messy code nearby, I clean it up in the same PR. This way, tech debt gets addressed naturally without dedicated "cleanup sprints."

4. Make it visible: I track technical debt in the backlog alongside features so the team and managers can see it. When debt is invisible, it gets ignored until it becomes an emergency.

Real Example:
==============

We had a feature request for real-time notifications.
But our API layer was a mess — duplicated calls, no caching.

Instead of building notifications on top of bad code:
1. I spent 2 days refactoring the API layer (tech debt)
2. Then built notifications on the clean foundation (feature)
3. The feature took 3 days instead of the estimated 7!

Result: Fixing debt FIRST actually made feature delivery FASTER.

Q3: Describe how you handle conflicts with designers or backend teams.

Answer:

Conflicts with cross-functional teams usually happen because of different perspectives — designers think about user experience, backend thinks about system constraints, and frontend is in the middle.

My approach:

1. Listen first, then explain constraints: When a designer gives me a complex animation or interaction, I don't immediately say "that's too hard." I listen to the intent behind the design. Then I explain the technical constraint: "This infinite scroll with 3D animations will drop to 15fps on mobile. Can we achieve the same user goal with a simpler approach?"

2. Propose alternatives, not rejections: Instead of "we can't do this," I say "here are two alternatives that achieve the same goal within our performance budget."

3. With backend teams: When API response structure doesn't match what the frontend needs, instead of arguing, I suggest a BFF (Backend for Frontend) pattern or we agree on a shared API contract before implementation starts. I also use TypeScript interfaces as the common language between frontend and backend.

4. Shared goals: I always bring it back to the user — "What's the best experience we can ship within our timeline?" This aligns everyone.

Key Points:
============
- Listen to understand the intent, not just the requirement
- Propose alternatives, never just reject
- Use data to support your points (performance metrics, UX research)
- Shared language: TypeScript interfaces, Figma prototypes
- Common goal: best user experience within constraints

Q4: How do you communicate complex technical decisions to non-technical stakeholders?

Answer:

The key is to translate technical concepts into business impact. Non-technical people don't care about "we need to refactor the state management" — they care about "this will reduce bugs and ship features faster."

My approach:

1. Use analogies: "Our current codebase is like a house where every room's electricity is connected to one switch. If one room has a problem, the whole house goes dark. Code splitting is like giving each room its own switch."

2. Focus on business outcomes:

  • Instead of "We need to implement caching" → "This will make the app 3x faster and reduce server costs by 40%"
  • Instead of "We need to add Error Boundaries" → "This prevents the entire page from crashing when one small section has an issue"
  • Instead of "We need TypeScript" → "This will catch bugs before they reach users and reduce QA time by 30%"

3. Visual aids: I use simple diagrams, before/after screenshots, and performance metrics graphs. A chart showing "page load: 5s → 1.5s" speaks louder than any technical explanation.

4. Decision framework: When presenting options, I frame it as: "Option A costs X, delivers by Y, and gives us Z benefit." This lets stakeholders make informed decisions without needing to understand the code.

Formula:
=========
"If we [technical action], then [business outcome]"

Examples:
- "If we add monitoring, we'll catch issues before users complain"
- "If we migrate to Next.js, our pages will load 3x faster, improving conversion"
- "If we write tests, we'll reduce production bugs by 40%"

Always answer: "So what?" → Why should they care?

Key Takeaways — Interview Cheat Sheet

Round Focus What They're Testing
Round 1 JavaScript + React fundamentals Do you KNOW the basics deeply?
Round 2 System Design + Architecture Can you THINK at scale?
Round 3 Behavioral + Communication Can you WORK with a team?
Top Tips:
==========

1. Don't just say WHAT — explain WHY
2. Use real examples from your experience
3. Mention trade-offs (nothing is perfect)
4. For system design: think about scale, caching, monitoring
5. For behavioral: use STAR format (Situation, Task, Action, Result)
6. Ask clarifying questions — shows maturity
7. It's OK to say "I don't know, but here's how I'd approach it"

Keep coding, keep learning! See you in the next one!