Zomato

Real Zomato Interview Experience

4 Rounds: Technical Deep Dive, Hands-on Coding, System Design & HR

4
Rounds
3
Weeks Process
42 LPA
Package
SDE 2
Gurgaon
Vasanth

By Vasanth Bhat

Staff Software Engineer @ Walmart Global Tech

Mentored 100+ frontend developers through successful interviews

Round 1: Technical (60 mins)

Projects deep dive, React internals, architectural concepts, and 2 coding problems using Tries. The interviewer is not just checking if you solved it — they want to see how you think. Talk through every step, even the wrong ones.

1️⃣ React SSR: How It Works and When to Use It

What is Server-Side Rendering?
The server generates the full HTML for a page and sends it to the client. The browser can display content immediately without waiting for JavaScript to load and execute.
CSR vs SSR vs SSG:
CSR (Client-Side Rendering): Browser downloads empty HTML + JS bundle → JS executes → content appears. Slow first paint.
SSR (Server-Side Rendering): Server renders HTML → Browser shows content immediately → JS hydrates for interactivity.
SSG (Static Site Generation): HTML generated at build time → Served from CDN → Fastest possible delivery.
How SSR Works (Step by Step):
// 1. Server receives request
// 2. Server runs React component tree
// 3. renderToString() generates HTML
// 4. HTML sent to client (user sees content!)
// 5. Client downloads JS bundle
// 6. Hydration: React attaches event listeners to existing DOM

// Server-side (Express + React)
import { renderToString } from 'react-dom/server';
import App from './App';

app.get('*', (req, res) => {
  // Server renders the React tree to HTML string
  const html = renderToString(<App url={req.url} />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `);
});

// Client-side (Hydration)
import { hydrateRoot } from 'react-dom/client';
import App from './App';

// hydrateRoot attaches listeners to server-rendered HTML
// instead of re-rendering the entire DOM
hydrateRoot(document.getElementById('root'), <App />);
When to Use SSR:
  • SEO-critical pages: Product pages, blog posts, landing pages
  • First Contentful Paint matters: E-commerce, news sites
  • Social sharing: Open Graph tags need server-rendered content
  • Slow devices: Reduce client-side computation for low-end phones
When NOT to Use SSR:
  • Dashboards: Not SEO-relevant, heavy interactivity
  • Real-time apps: Chat, live feeds — content changes too fast
  • Authenticated pages: Content is user-specific, can't cache

2️⃣ Virtual DOM Diffing and Reconciliation

What is the Virtual DOM?
A lightweight JavaScript representation of the actual DOM. React keeps two copies: the current virtual DOM and the new one after state changes. It "diffs" them to find minimal changes needed. The Reconciliation Algorithm:
// Simplified Virtual DOM node structure
const vNode = {
  type: 'div',           // element type
  props: { className: 'card' },  // attributes
  children: [            // child nodes
    { type: 'h1', props: {}, children: ['Hello'] },
    { type: 'p', props: {}, children: ['World'] }
  ]
};

// Simplified Diffing Algorithm
function diff(oldTree, newTree) {
  const patches = [];
  
  // Rule 1: Different types → replace entire subtree
  if (oldTree.type !== newTree.type) {
    patches.push({ type: 'REPLACE', node: newTree });
    return patches;
  }
  
  // Rule 2: Same type → diff props
  const propPatches = diffProps(oldTree.props, newTree.props);
  if (propPatches.length > 0) {
    patches.push({ type: 'PROPS', changes: propPatches });
  }
  
  // Rule 3: Recursively diff children
  const childPatches = diffChildren(
    oldTree.children, 
    newTree.children
  );
  patches.push(...childPatches);
  
  return patches;
}

function diffProps(oldProps, newProps) {
  const changes = [];
  
  // Check for changed or new props
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) {
      changes.push({ key, value: newProps[key] });
    }
  }
  
  // Check for removed props
  for (const key in oldProps) {
    if (!(key in newProps)) {
      changes.push({ key, value: undefined });
    }
  }
  
  return changes;
}

function diffChildren(oldChildren, newChildren) {
  const patches = [];
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  
  for (let i = 0; i < maxLen; i++) {
    if (!oldChildren[i]) {
      patches.push({ type: 'ADD', node: newChildren[i] });
    } else if (!newChildren[i]) {
      patches.push({ type: 'REMOVE', index: i });
    } else {
      patches.push(...diff(oldChildren[i], newChildren[i]));
    }
  }
  
  return patches;
}
Key Rules of React's Reconciliation:
  • Different types: Destroy old tree, build new one (no diffing)
  • Same type DOM elements: Keep node, update changed attributes only
  • Same type components: Update props, re-render, diff children
  • Keys in lists: Help React identify which items moved/added/removed — O(n) instead of O(n²)
Why Keys Matter:
// ❌ Without keys — React re-renders ALL items when list changes
{items.map((item, index) => <Item key={index} data={item} />)}

// ✅ With stable keys — React only updates changed items  
{items.map(item => <Item key={item.id} data={item} />)}

3️⃣ WebSockets vs Polling: When Does Each Make Sense?

Comparison:
Aspect WebSockets Polling Long Polling
Connection Persistent, bi-directional New HTTP request each time Held open until data arrives
Latency Real-time (ms) Interval-dependent (seconds) Near real-time
Server Load Persistent connections (memory) High (repeated requests) Moderate
Best For Chat, live tracking, gaming Dashboards, status checks Notifications, feeds
Implementation Examples:
// WebSocket — Zomato delivery tracking
const socket = new WebSocket('wss://api.zomato.com/track');

socket.onopen = () => {
  socket.send(JSON.stringify({ orderId: '12345' }));
};

socket.onmessage = (event) => {
  const { lat, lng, eta } = JSON.parse(event.data);
  updateDeliveryMap(lat, lng);
  updateETA(eta);
};

socket.onclose = () => {
  // Reconnect with exponential backoff
  setTimeout(() => reconnect(), retryDelay);
};


// Polling — Restaurant availability check
function pollAvailability(restaurantId) {
  const intervalId = setInterval(async () => {
    const response = await fetch(
      `/api/restaurants/${restaurantId}/status`
    );
    const { isOpen, waitTime } = await response.json();
    updateUI(isOpen, waitTime);
  }, 30000); // Every 30 seconds
  
  return () => clearInterval(intervalId);
}
When to use at Zomato:
  • WebSocket: Live delivery tracking (rider location updates every 2-3 seconds)
  • WebSocket: Order status changes (placed → confirmed → preparing → on the way)
  • Polling: Restaurant menu availability (changes infrequently)
  • SSE (Server-Sent Events): One-way notifications (offers, promotions)

4️⃣ Authentication Patterns in Frontend Apps

Common Patterns:
// 1. JWT (JSON Web Token) — Stateless auth
// Token stored in memory (not localStorage for security)
class AuthService {
  #accessToken = null;
  
  async login(email, password) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      credentials: 'include', // Send cookies
      body: JSON.stringify({ email, password })
    });
    
    const { accessToken } = await response.json();
    this.#accessToken = accessToken;
    // Refresh token stored in HttpOnly cookie by server
  }
  
  async refreshToken() {
    // Refresh token sent automatically via HttpOnly cookie
    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include'
    });
    
    const { accessToken } = await response.json();
    this.#accessToken = accessToken;
  }
  
  getAuthHeader() {
    return { Authorization: `Bearer ${this.#accessToken}` };
  }
}


// 2. Session-based — Server maintains state
// Cookie sent automatically with every request
// Simpler but harder to scale (sticky sessions)


// 3. OAuth 2.0 / PKCE — Third-party login
// Frontend redirects to Google/Facebook
// Provider redirects back with auth code
// Backend exchanges code for tokens
Security Best Practices:
  • Access Token: Store in memory (JavaScript variable), short-lived (15 min)
  • Refresh Token: HttpOnly, Secure, SameSite cookie — inaccessible to JS
  • Never use localStorage for tokens — vulnerable to XSS
  • CSRF Protection: SameSite=Strict cookies + CSRF tokens
  • Token Rotation: Issue new refresh token on each refresh

5️⃣ Coding: Implement Autocomplete with Trie

Difficulty: Medium | Time: 20 minutes

Problem Statement:
Design a search autocomplete system. Given a list of words and a prefix, return all words that start with the given prefix. Optimize for fast prefix lookups using a Trie data structure.
📝 Examples:
• Words: ["pizza", "pasta", "paneer", "burger", "biryani"]
• Prefix "p" → ["pizza", "pasta", "paneer"]
• Prefix "pa" → ["pasta", "paneer"]
• Prefix "bi" → ["biryani"]
• Prefix "z" → []
🎯 Solution (Trie Implementation):
class TrieNode {
  constructor() {
    this.children = {};
    this.isEndOfWord = false;
  }
}

class Trie {
  constructor() {
    this.root = new TrieNode();
  }
  
  // Insert word into Trie — O(m) where m = word length
  insert(word) {
    let node = this.root;
    for (const char of word) {
      if (!node.children[char]) {
        node.children[char] = new TrieNode();
      }
      node = node.children[char];
    }
    node.isEndOfWord = true;
  }
  
  // Find all words with given prefix — O(p + n)
  // p = prefix length, n = number of matching words
  autocomplete(prefix) {
    let node = this.root;
    
    // Navigate to the prefix node
    for (const char of prefix) {
      if (!node.children[char]) {
        return []; // No words with this prefix
      }
      node = node.children[char];
    }
    
    // Collect all words from this node (DFS)
    const results = [];
    this._collectWords(node, prefix, results);
    return results;
  }
  
  _collectWords(node, currentWord, results) {
    if (node.isEndOfWord) {
      results.push(currentWord);
    }
    
    for (const char in node.children) {
      this._collectWords(
        node.children[char], 
        currentWord + char, 
        results
      );
    }
  }
  
  // Search if exact word exists — O(m)
  search(word) {
    let node = this.root;
    for (const char of word) {
      if (!node.children[char]) return false;
      node = node.children[char];
    }
    return node.isEndOfWord;
  }
  
  // Check if any word starts with prefix — O(p)
  startsWith(prefix) {
    let node = this.root;
    for (const char of prefix) {
      if (!node.children[char]) return false;
      node = node.children[char];
    }
    return true;
  }
}

// Test
const trie = new Trie();
const words = ['pizza', 'pasta', 'paneer', 'burger', 'biryani', 'palak'];

words.forEach(word => trie.insert(word));

console.log(trie.autocomplete('p'));   // ['pizza', 'pasta', 'paneer', 'palak']
console.log(trie.autocomplete('pa'));  // ['pasta', 'paneer', 'palak']
console.log(trie.autocomplete('bi'));  // ['biryani']
console.log(trie.autocomplete('z'));   // []
console.log(trie.search('pizza'));     // true
console.log(trie.search('piz'));       // false
console.log(trie.startsWith('piz'));   // true
📊 Trie Structure Visualization:
After inserting: "pizza", "pasta", "paneer"

         root
        /    \
       p      b
       |      |
       a      u/i
      / \      ...
     s   n
     |   |
     t   e
     |   |
     a*  e
          |
          r*

* = isEndOfWord
💡 Key Insights:
  • Prefix Search: O(p) to navigate to prefix node — independent of total words
  • Space Trade-off: Uses more memory than array but gives O(p) prefix lookup vs O(n*m) for filtering
  • Real-world use: Restaurant search on Zomato, autocomplete suggestions
  • Optimization: Add frequency/ranking to TrieNode for relevance-based results

6️⃣ Coding: Group Anagrams

Difficulty: Medium | Time: 15 minutes

Problem Statement:
Given an array of strings, group the anagrams together. Return the answer in any order.
📝 Examples:
• Input: ["eat", "tea", "tan", "ate", "nat", "bat"]
• Output: [["eat","tea","ate"], ["tan","nat"], ["bat"]]
🎯 Optimal Solution (HashMap with sorted key):
function groupAnagrams(strs) {
  const map = new Map();
  
  for (const str of strs) {
    // Sort characters to create canonical key
    // All anagrams produce the same sorted key
    const key = str.split('').sort().join('');
    
    if (!map.has(key)) {
      map.set(key, []);
    }
    map.get(key).push(str);
  }
  
  return Array.from(map.values());
}

// Alternative: Character frequency as key (faster for long strings)
function groupAnagramsOptimal(strs) {
  const map = new Map();
  
  for (const str of strs) {
    // Build frequency array (26 chars)
    const freq = new Array(26).fill(0);
    for (const char of str) {
      freq[char.charCodeAt(0) - 97]++;
    }
    
    // Use frequency as key (e.g., "1,0,0,...,1,0,1")
    const key = freq.join(',');
    
    if (!map.has(key)) {
      map.set(key, []);
    }
    map.get(key).push(str);
  }
  
  return Array.from(map.values());
}

// Test
console.log(groupAnagrams(["eat","tea","tan","ate","nat","bat"]));
// [["eat","tea","ate"], ["tan","nat"], ["bat"]]
💡 Key Insights:
  • Sorting approach: O(n * k log k) — simple and clean
  • Frequency approach: O(n * k) — better for long strings
  • Canonical Key: All anagrams map to the same key (sorted string or frequency signature)
  • Time: O(n * k log k) or O(n * k) | Space: O(n * k)

💡 Round 1 Tips:

  • The interviewer wants to see how you think — talk through every step, even the wrong ones
  • For conceptual questions, connect theory to real-world examples (Zomato's actual features)
  • Projects deep dive: know every decision you made and WHY
  • Ask clarifying questions before coding — it shows you think like a senior
  • Do not write anything on your resume you cannot explain under pressure

Every answer will have a follow up — prepare to go deeper on everything. Surface-level knowledge will not pass this round. Learn how our cohort members prepare for depth →

Round 2: Hands-on Coding (75 mins)

This round caught most people off guard. Harder than Round 1. Deep React internals, JavaScript mastery, and implementation from scratch. If you have used useState or useEffect, know exactly how they work under the hood.

1️⃣ Implement useState from Scratch

Difficulty: Hard | Time: 20 minutes

Problem Statement:
Implement a simplified version of React's useState hook that maintains state across re-renders, supports functional updates, and batches state changes.
Key Insight:
React stores hook state in a linked list (or array) attached to the fiber node. Each call to useState during a render corresponds to a specific position in this array. This is WHY hooks must be called in the same order every render (no conditionals!).
🎯 Complete Implementation:
// Simulates React's internal hook system
let hooks = [];        // Array to store hook states
let hookIndex = 0;     // Current hook position
let component = null;  // Current component being rendered

function useState(initialValue) {
  const currentIndex = hookIndex;
  
  // On first render, initialize state
  // On re-renders, retrieve existing state
  if (hooks[currentIndex] === undefined) {
    hooks[currentIndex] = 
      typeof initialValue === 'function' 
        ? initialValue()  // Lazy initialization
        : initialValue;
  }
  
  const setState = (newValue) => {
    // Support functional updates: setState(prev => prev + 1)
    const nextState = typeof newValue === 'function'
      ? newValue(hooks[currentIndex])
      : newValue;
    
    // Only re-render if state actually changed
    if (Object.is(hooks[currentIndex], nextState)) return;
    
    hooks[currentIndex] = nextState;
    
    // Trigger re-render (simplified)
    reRender();
  };
  
  hookIndex++;
  return [hooks[currentIndex], setState];
}

function reRender() {
  hookIndex = 0;  // Reset hook index for new render pass
  component();    // Re-execute the component function
}

// Example usage:
function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Zomato');
  
  console.log(`Count: ${count}, Name: ${name}`);
  
  return {
    increment: () => setCount(prev => prev + 1),
    setName: (n) => setName(n)
  };
}

// Simulate React rendering
component = Counter;
const ui = Counter();  // "Count: 0, Name: Zomato"
ui.increment();        // Triggers re-render → "Count: 1, Name: Zomato"
ui.increment();        // Triggers re-render → "Count: 2, Name: Zomato"
Why Hooks Must Be Called in Order:
// ❌ This breaks because hook positions shift between renders
function BadComponent({ showExtra }) {
  const [a, setA] = useState(1);    // hookIndex = 0
  
  if (showExtra) {
    const [b, setB] = useState(2);  // hookIndex = 1 (sometimes!)
  }
  
  const [c, setC] = useState(3);    // hookIndex = 1 or 2 ???
  // React can't match hooks to their state!
}

// ✅ Always call hooks at top level
function GoodComponent({ showExtra }) {
  const [a, setA] = useState(1);    // Always hookIndex = 0
  const [b, setB] = useState(2);    // Always hookIndex = 1
  const [c, setC] = useState(3);    // Always hookIndex = 2
  // Conditionally USE the values, not the hooks
}
💡 Key Insights:
  • Array-based storage: Hooks use position (index) to identify state, not names
  • Order dependency: This is why hooks can't be inside conditionals/loops
  • Object.is comparison: React uses this for bailout optimization
  • Lazy initialization: useState(() => expensiveComputation()) runs only on first render
  • Batching: Multiple setState calls in same event handler are batched into one re-render

2️⃣ Implement useEffect from Scratch

Difficulty: Hard | Time: 20 minutes

Problem Statement:
Implement a simplified version of React's useEffect that runs side effects after render, supports dependency arrays, and handles cleanup functions. 🎯 Complete Implementation:
let effectHooks = [];    // Store effect metadata
let effectIndex = 0;     // Current effect position

function useEffect(callback, deps) {
  const currentIndex = effectIndex;
  const prevEffect = effectHooks[currentIndex];
  
  // Determine if effect should run
  let shouldRun = false;
  
  if (!prevEffect) {
    // First render — always run
    shouldRun = true;
  } else if (!deps) {
    // No dependency array — run every render
    shouldRun = true;
  } else {
    // Compare dependencies with previous render
    shouldRun = deps.some(
      (dep, i) => !Object.is(dep, prevEffect.deps[i])
    );
  }
  
  if (shouldRun) {
    // Schedule effect to run AFTER render (async)
    queueMicrotask(() => {
      // Run cleanup from previous effect first
      if (prevEffect && prevEffect.cleanup) {
        prevEffect.cleanup();
      }
      
      // Run the effect and store cleanup function
      const cleanup = callback();
      effectHooks[currentIndex] = { deps, cleanup };
    });
  }
  
  effectIndex++;
}

// Reset for re-render
function resetEffects() {
  effectIndex = 0;
}

// Unmount — run all cleanups
function unmount() {
  effectHooks.forEach(effect => {
    if (effect && effect.cleanup) {
      effect.cleanup();
    }
  });
  effectHooks = [];
}

// Example usage:
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  // Effect with dependency — re-runs when roomId changes
  useEffect(() => {
    console.log(`Connecting to room: ${roomId}`);
    const ws = new WebSocket(`/chat/${roomId}`);
    
    ws.onmessage = (e) => {
      setMessages(prev => [...prev, JSON.parse(e.data)]);
    };
    
    // Cleanup function — runs before next effect or unmount
    return () => {
      console.log(`Disconnecting from room: ${roomId}`);
      ws.close();
    };
  }, [roomId]); // Only re-run if roomId changes
  
  // Effect without deps — runs every render
  useEffect(() => {
    document.title = `${messages.length} messages`;
  });
  
  // Effect with empty deps — runs once (mount only)
  useEffect(() => {
    console.log('Component mounted');
    return () => console.log('Component unmounted');
  }, []);
}
Effect Lifecycle:
Mount:
  1. Component renders (DOM updated)
  2. useEffect callback runs (after paint)
  3. Cleanup function stored for later

Update (deps changed):
  1. Component re-renders (DOM updated)  
  2. Previous cleanup runs FIRST
  3. New effect callback runs
  4. New cleanup stored

Unmount:
  1. Component removed from DOM
  2. All cleanup functions run
💡 Key Insights:
  • Runs after render: Effects are async — don't block painting
  • Cleanup runs before new effect: Prevents stale subscriptions
  • Empty deps []: Mount + unmount only — like componentDidMount
  • No deps: Runs after every render — rarely what you want
  • Object.is comparison: Same as useState bailout mechanism

3️⃣ Closures with Real Production Examples

Type: Conceptual + Code | Time: 15 minutes

What is a Closure?
A function that retains access to its lexical scope (outer variables) even after the outer function has returned.
// Production Example 1: API Client Factory
function createApiClient(baseURL, authToken) {
  // These are "closed over" — private to this instance
  let requestCount = 0;
  
  return {
    get(endpoint) {
      requestCount++;
      return fetch(`${baseURL}${endpoint}`, {
        headers: { Authorization: `Bearer ${authToken}` }
      });
    },
    getRequestCount() {
      return requestCount;
    }
  };
}

const zomatoApi = createApiClient('https://api.zomato.com', 'token123');
zomatoApi.get('/restaurants'); // Uses closed-over baseURL and authToken
console.log(zomatoApi.getRequestCount()); // 1


// Production Example 2: Rate Limiter
function createRateLimiter(maxCalls, timeWindow) {
  const calls = []; // Closed over
  
  return function(fn) {
    const now = Date.now();
    // Remove expired timestamps
    while (calls.length && calls[0] <= now - timeWindow) {
      calls.shift();
    }
    
    if (calls.length >= maxCalls) {
      throw new Error('Rate limit exceeded');
    }
    
    calls.push(now);
    return fn();
  };
}

const limiter = createRateLimiter(5, 60000); // 5 calls per minute


// Production Example 3: Memoization
function memoize(fn) {
  const cache = new Map(); // Closed over — persists between calls
  
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const expensiveCalculation = memoize((n) => {
  console.log('Computing...');
  return n * n;
});

expensiveCalculation(5); // "Computing..." → 25
expensiveCalculation(5); // Cache hit → 25 (no log)
Common Closure Pitfall (var in loops):
// ❌ Problem: var is function-scoped, not block-scoped
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3 (all reference same `i`)

// ✅ Fix 1: Use let (block-scoped)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2

// ✅ Fix 2: Create closure with IIFE
for (var i = 0; i < 3; i++) {
  ((j) => {
    setTimeout(() => console.log(j), 100);
  })(i);
}
// Output: 0, 1, 2

4️⃣ let vs var: Where It Actually Matters

Key Differences:
Feature var let const
Scope Function Block Block
Hoisting Yes (undefined) Yes (TDZ) Yes (TDZ)
Re-declaration Allowed Error Error
Global object window.x = val No No
// Where it ACTUALLY matters in production:

// 1. Loop closures (most common bug)
for (var i = 0; i < buttons.length; i++) {
  buttons[i].onclick = () => alert(i); // Always shows last value!
}
// Fix: use let → each iteration gets its own binding

// 2. Temporal Dead Zone (TDZ)
console.log(x); // undefined (var is hoisted)
var x = 5;

console.log(y); // ReferenceError! (let is in TDZ)
let y = 5;

// 3. Switch statements (block scoping matters!)
switch(action) {
  case 'A':
    var result = 1;  // Leaks to entire function!
    break;
  case 'B':
    var result = 2;  // Re-declares (no error with var)
    break;
}
// Fix: use let with explicit blocks
switch(action) {
  case 'A': {
    let result = 1;  // Scoped to this case
    break;
  }
}

5️⃣ Rules of Custom Hooks and Why They Exist

The Rules:
  1. Only call hooks at the top level — no loops, conditions, or nested functions
  2. Only call hooks from React functions — components or other custom hooks
  3. Custom hooks must start with "use" — naming convention for lint tooling
WHY These Rules Exist:
// React identifies hooks by CALL ORDER (index in array)
// If order changes between renders, React assigns wrong state!

// Custom Hook Example: useDebounce
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouncedValue;
}

// Custom Hook: useFetch
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let cancelled = false;
    
    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error(response.statusText);
        const json = await response.json();
        
        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
    
    fetchData();
    return () => { cancelled = true; }; // Prevent state update after unmount
  }, [url]);
  
  return { data, loading, error };
}

// Usage in component
function RestaurantList() {
  const [search, setSearch] = useState('');
  const debouncedSearch = useDebounce(search, 300);
  const { data, loading, error } = useFetch(
    `/api/restaurants?q=${debouncedSearch}`
  );
  
  // Composable, testable, reusable!
}
💡 Why custom hooks are powerful:
  • Logic extraction: Share stateful logic without changing component hierarchy
  • Composition: Hooks can call other hooks — build complex from simple
  • Testable: Test hook logic independently from UI
  • No HOC/Render Props wrapper hell: Cleaner component tree

6️⃣ Tailwind CSS vs CSS-in-JS: Argue for One

The Debate (be ready to argue either side):
Tailwind CSS

✅ Zero runtime cost
✅ Tiny production CSS (purged)
✅ Design system constraints built-in
✅ No naming decisions
✅ Fast prototyping

❌ Verbose HTML/JSX
❌ Learning curve for utility classes
❌ Dynamic styles need workarounds
CSS-in-JS (styled-components)

✅ Co-located styles with logic
✅ Dynamic styles from props/state
✅ Full CSS power (pseudo-elements)
✅ Automatic scoping
✅ TypeScript integration

❌ Runtime overhead (parsing CSS)
❌ Larger bundle size
❌ SSR complexity (style extraction)
Strong argument for Tailwind (at Zomato's scale):
"At Zomato's scale (millions of users on 3G/4G), I'd choose Tailwind.

1. ZERO runtime overhead — CSS-in-JS parses styles in the browser, 
   adding 10-20ms per component mount. With 100+ components on a 
   restaurant page, that's 1-2 seconds of wasted JS execution.

2. Production CSS is ~10KB (purged) vs 50-100KB for CSS-in-JS runtime.
   On Jio networks at 1Mbps, that's 400ms+ saved.

3. Server-side rendering is trivial — no style extraction step, no 
   hydration mismatch risk.

4. Design consistency — utility classes enforce spacing/color system. 
   No 'margin: 13px' one-offs.

For dynamic styles, I'd use CSS variables with Tailwind:
  style={{ '--progress': `${percent}%` }}
  className='w-[var(--progress)]'
"

💡 Round 2 Tips:

  • This round goes deeper than you expect — surface-level React knowledge won't cut it
  • Implement useState/useEffect from scratch multiple times until it's muscle memory
  • Understand the "why" behind every rule — interviewers probe for understanding
  • Have strong opinions on styling approaches — but back them with performance data
  • Practice explaining closures with production examples, not just counter demos

This round is where most candidates fail. Using hooks is easy — understanding how they work under the hood is what Zomato tests. See how we teach React internals →

Round 3: Hiring Manager (60 mins) | System Design + Principles

This is not just a culture round. They expect you to defend your architecture decisions and push back if they challenge your approach. The manager round is harder than the first round — most people do not expect that.

1️⃣ Design a Location-Based Restaurant Discovery System

Type: System Design | Time: 25 minutes

Problem:
Design the frontend architecture for a location-based restaurant discovery system (like Zomato's home page). Must handle: user location detection, nearby restaurant fetching, real-time availability, and performant rendering of restaurant cards with images. High-Level Architecture:
┌─────────────────────────────────────────────────┐
│                  CLIENT (React)                   │
├─────────────────────────────────────────────────┤
│  Location Layer                                   │
│  ├── Geolocation API (GPS)                       │
│  ├── IP-based fallback                           │
│  └── Manual city selection                       │
├─────────────────────────────────────────────────┤
│  Data Layer                                       │
│  ├── React Query (caching + prefetching)         │
│  ├── Geohash-based API calls                     │
│  └── Optimistic updates for favorites            │
├─────────────────────────────────────────────────┤
│  Rendering Layer                                  │
│  ├── Virtualized grid (react-window)             │
│  ├── Intersection Observer (lazy images)         │
│  └── Skeleton loading states                     │
├─────────────────────────────────────────────────┤
│  Real-time Layer                                  │
│  ├── WebSocket: delivery ETAs                    │
│  ├── SSE: restaurant open/close status           │
│  └── Polling: menu availability (30s)            │
└─────────────────────────────────────────────────┘
         │
         │ HTTPS / WSS
         ▼
┌─────────────────────────────────────────────────┐
│              API GATEWAY (BFF)                    │
├─────────────────────────────────────────────────┤
│  /restaurants?lat=X&lng=Y&radius=5km             │
│  /restaurants/:id/menu                           │
│  /restaurants/:id/availability (SSE)             │
│  /track/:orderId (WebSocket)                     │
└─────────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────┐
│              BACKEND SERVICES                     │
├─────────────────────────────────────────────────┤
│  Spatial DB (PostGIS)  │  Redis (caching)       │
│  Geohash indexing      │  CDN (images)          │
│  Search (Elasticsearch)│  Event bus (Kafka)     │
└─────────────────────────────────────────────────┘
Frontend Implementation Details:
// Location detection with fallback chain
async function getUserLocation() {
  // Strategy 1: Browser Geolocation API
  try {
    const pos = await new Promise((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(resolve, reject, {
        enableHighAccuracy: true,
        timeout: 5000
      });
    });
    return { lat: pos.coords.latitude, lng: pos.coords.longitude };
  } catch (e) {
    // Strategy 2: IP-based geolocation (fallback)
    const response = await fetch('/api/location/ip');
    return response.json();
  }
}

// Geohash-based API for efficient spatial queries
// Geohash converts lat/lng → string prefix for range queries
function getGeohash(lat, lng, precision = 6) {
  // precision 6 ≈ 1.2km × 0.6km cell
  // Nearby restaurants share geohash prefix
  return encodeGeohash(lat, lng, precision);
}

// React Query for caching + prefetching
function useNearbyRestaurants(location) {
  return useQuery({
    queryKey: ['restaurants', location.lat, location.lng],
    queryFn: () => fetchRestaurants(location),
    staleTime: 5 * 60 * 1000,     // Cache valid for 5 min
    refetchOnWindowFocus: true,     // Refresh when user returns
    placeholderData: (prev) => prev // Show stale while fetching
  });
}

// Optimistic filter updates
function useFilteredRestaurants(filters) {
  const queryClient = useQueryClient();
  
  // Apply filters client-side from cached data (instant)
  // while fetching server-filtered results in background
  return useQuery({
    queryKey: ['restaurants', filters],
    queryFn: () => fetchWithFilters(filters),
    placeholderData: () => {
      const allRestaurants = queryClient.getQueryData(['restaurants']);
      return applyFiltersLocally(allRestaurants, filters);
    }
  });
}
Schema Design (asked in follow-up):
-- Restaurants table with spatial indexing
restaurants:
  id: UUID (PK)
  name: VARCHAR
  location: POINT (lat, lng)  -- PostGIS spatial type
  geohash: VARCHAR(8)         -- For prefix-based range queries
  cuisine_type: VARCHAR[]
  avg_rating: DECIMAL
  is_open: BOOLEAN
  delivery_radius_km: INT
  avg_delivery_time_min: INT

-- Spatial index for "nearby" queries
CREATE INDEX idx_restaurant_location ON restaurants 
  USING GIST (location);

-- Geohash index for partition-based lookups  
CREATE INDEX idx_restaurant_geohash ON restaurants (geohash);

-- Query: Find restaurants within 5km
SELECT * FROM restaurants 
WHERE ST_DWithin(location, ST_MakePoint(77.5946, 12.9716), 5000)
  AND is_open = true
ORDER BY avg_rating DESC
LIMIT 20;

2️⃣ SOLID Principles with Frontend Examples

// S — Single Responsibility Principle
// Each component/function does ONE thing

// ❌ Bad: Component fetches, filters, AND renders
function RestaurantPage() {
  const [data, setData] = useState([]);
  const [filters, setFilters] = useState({});
  
  useEffect(() => { /* fetch logic */ }, []);
  const filtered = data.filter(/* filter logic */);
  
  return (/* complex render logic */);
}

// ✅ Good: Separated concerns
function RestaurantPage() {
  const { data } = useRestaurants();           // Data fetching hook
  const filtered = useFilteredData(data);       // Filter logic hook
  return <RestaurantGrid items={filtered} />;  // Render only
}


// O — Open/Closed Principle
// Open for extension, closed for modification

// ✅ Plugin-based filter system
const filterStrategies = {
  price: (items, value) => items.filter(i => i.price <= value),
  rating: (items, value) => items.filter(i => i.rating >= value),
  cuisine: (items, value) => items.filter(i => i.cuisine === value),
  // Add new filters without modifying existing code!
  distance: (items, value) => items.filter(i => i.distance <= value),
};

function applyFilters(items, activeFilters) {
  return Object.entries(activeFilters).reduce(
    (result, [key, value]) => filterStrategies[key](result, value),
    items
  );
}


// L — Liskov Substitution Principle
// Subtypes must be usable wherever parent type is expected

// ✅ All button variants work with same props interface
function PrimaryButton({ onClick, children, ...props }) {
  return <button className="btn-primary" onClick={onClick} {...props}>{children}</button>;
}
function GhostButton({ onClick, children, ...props }) {
  return <button className="btn-ghost" onClick={onClick} {...props}>{children}</button>;
}
// Both can replace <Button /> without breaking parent component


// I — Interface Segregation Principle
// Don't force components to depend on props they don't use

// ❌ Bad: Card gets entire restaurant object (100+ fields)
<RestaurantCard restaurant={fullRestaurantObject} />

// ✅ Good: Card receives only what it renders
<RestaurantCard 
  name={restaurant.name}
  rating={restaurant.rating}
  imageUrl={restaurant.imageUrl}
  deliveryTime={restaurant.deliveryTime}
/>


// D — Dependency Inversion Principle
// Depend on abstractions, not concrete implementations

// ✅ Analytics service abstracted behind interface
const analyticsService = {
  track: (event, data) => { /* implementation */ }
};

// Component depends on abstraction, not specific analytics SDK
function OrderButton({ analytics = analyticsService }) {
  const handleClick = () => {
    analytics.track('order_placed', { restaurantId });
  };
}

3️⃣ Database Isolation Levels and When They Matter

The four SQL isolation levels (weakest → strongest):
Level Dirty Read Non-Repeatable Read Phantom Read Use Case
Read Uncommitted ✗ Possible ✗ Possible ✗ Possible Analytics (approximate OK)
Read Committed ✓ Prevented ✗ Possible ✗ Possible Most web apps (PostgreSQL default)
Repeatable Read ✓ Prevented ✓ Prevented ✗ Possible Financial reports, inventory
Serializable ✓ Prevented ✓ Prevented ✓ Prevented Payments, seat booking
Zomato-relevant examples:
1. Restaurant seat/table booking (Zomato Dining):
   → Serializable: Two users booking the last table must 
     not both succeed. Need strictest isolation.

2. Order placement:
   → Repeatable Read: During checkout, prices and availability 
     should not change mid-transaction.

3. Restaurant listing page:
   → Read Committed: OK if another user's review appears 
     after page load. Eventual consistency acceptable.

4. Analytics dashboard (restaurant partner):
   → Read Uncommitted: Approximate order counts are fine. 
     Performance > precision for dashboards.
Frontend implications:
  • Optimistic UI: Show success immediately, reconcile if transaction fails
  • Stale data handling: Show "price changed" toasts when cart data is stale
  • Conflict resolution: "Someone else took the last slot" messaging

4️⃣ How Would You Architect Zomato's Restaurant Listing Page?

Architecture Decisions:
// Component Architecture
// ─────────────────────
// RestaurantListingPage (route-level, code-split)
//   ├── FilterBar (sticky, client-state)
//   │   ├── CuisineFilter
//   │   ├── PriceFilter
//   │   ├── RatingFilter
//   │   └── SortDropdown
//   ├── RestaurantGrid (virtualized)
//   │   └── RestaurantCard (memoized)
//   │       ├── LazyImage (intersection observer)
//   │       ├── RatingBadge
//   │       ├── DeliveryInfo
//   │       └── OfferTag
//   ├── InfiniteScrollTrigger (intersection observer)
//   └── SkeletonLoader (SSR placeholder)

// Performance Strategy:
const RestaurantListingPage = lazy(() => 
  import('./RestaurantListingPage')
);

// Data fetching strategy
function useRestaurantListing(location, filters) {
  return useInfiniteQuery({
    queryKey: ['restaurants', location, filters],
    queryFn: ({ pageParam = 0 }) => 
      fetchRestaurants({ ...filters, offset: pageParam, limit: 20 }),
    getNextPageParam: (lastPage) => lastPage.nextOffset,
    staleTime: 2 * 60 * 1000,
    // Prefetch next page for instant scroll
    onSuccess: (data) => {
      const lastPage = data.pages[data.pages.length - 1];
      if (lastPage.nextOffset) {
        queryClient.prefetchInfiniteQuery(/* next page */);
      }
    }
  });
}

// Image loading strategy
// 1. LQIP (Low Quality Image Placeholder) — 20 byte base64
// 2. Intersection Observer triggers full image load
// 3. Progressive JPEG for perceived speed
// 4. WebP with JPEG fallback (picture element)

// Critical rendering path:
// 1. SSR: Server renders first 6 restaurant cards (above fold)
// 2. Skeleton: Below-fold shows animated placeholders
// 3. Hydration: React takes over, enables interactivity
// 4. Lazy load: Images and below-fold content load on scroll

5️⃣ How Do You Handle a Frontend Performance Regression in Production?

Incident Response Framework:
Step 1: Detect & Quantify (0-15 min)
• Check RUM alerts (Web Vitals: LCP, FID, CLS degradation)
• Identify the scope: all users or specific segment (device/geo/browser)?
• Check recent deployments — did anything ship in the last 24h?
• Measure: "LCP increased from 2.1s → 4.8s on mobile"

Step 2: Correlate & Isolate (15-30 min)
• Git blame: what changed? (bundle size diff, new dependencies, config change)
• Check CDN health, API response times, third-party scripts
• Use Chrome DevTools Performance tab on the affected page
• Source map exploration: which chunk grew? What got imported?

Step 3: Mitigate (30-60 min)
• If deployment-caused: rollback immediately, investigate later
• If third-party: disable the script (feature flag), notify vendor
• If gradual: implement emergency lazy loading / code splitting

Step 4: Fix & Prevent (1-3 days)
• Root cause fix with proper code review
• Add performance budget CI check (bundle size limit, Lighthouse score gate)
• Add synthetic monitoring for the specific metric
• Postmortem document: what happened, why, how to prevent
Prevention Tooling:
// Bundle size budget in CI
// webpack.config.js
module.exports = {
  performance: {
    maxAssetSize: 250000,      // 250KB per chunk
    maxEntrypointSize: 500000, // 500KB entry
    hints: 'error'             // Fail build if exceeded
  }
};

// Lighthouse CI in GitHub Actions
// Fail PR if performance score drops below 90

💡 Hiring Manager Round Tips:

  • This round is harder than the first round — most people do not expect that
  • They expect you to defend your architecture decisions and push back if challenged
  • Know SOLID principles with concrete frontend examples, not textbook definitions
  • For system design: start with requirements, then high-level, then drill into frontend specifics
  • Project discussions: know every decision you made, trade-offs, and what you'd change in hindsight
  • SQL vs NoSQL: know when to use each and defend your choice with data patterns

The manager round tests if you think like a senior engineer. Defending your decisions under pressure is the invisible skill that separates SDE 2 from SDE 1. Learn how we prepare for this →

Round 4: HR Discussion (30 mins)

Compensation negotiation, motivation assessment, and relocation logistics.

1️⃣ Current CTC, Expected CTC, Notice Period

How to handle:
  • Be honest about current CTC — they will verify via offer letter
  • For expected CTC, research the market range for SDE 2 at Zomato (35-50 LPA depending on experience)
  • If your current CTC is lower, emphasize the skills and impact you bring
  • Notice period: be upfront. Zomato typically expects 30-60 days, can accommodate up to 90 days for strong candidates
  • If you have competing offers, mention them professionally — it strengthens negotiation

2️⃣ Why Zomato Specifically?

Framework for answering:
Key points to cover:

1. Scale & Real-time Complexity: "Zomato handles millions of concurrent orders with real-time tracking. The frontend challenges are unique — live maps, dynamic ETAs, instant notifications. Building systems that work reliably at 10,000+ orders per minute during peak dinner time is the kind of engineering that excites me."

2. Hyperlocal Tech: "Hyperlocal delivery is a uniquely complex frontend problem: geolocation accuracy, dynamic routing UI, handling flaky mobile networks during delivery tracking. These are problems I don't get to solve at a typical product company."

3. Consumer Product at Scale: "Zomato's app is used daily by millions. The performance decisions I make directly impact real people ordering food — that immediate, tangible feedback loop is motivating."

4. Growth & Culture: "Zomato's engineering blog and open-source contributions show a culture that values technical excellence. The SDE 2 role offers ownership of significant features, and I see a clear path to senior/staff engineer."

3️⃣ Relocation and Joining Logistics

Points to discuss:
  • Relocation to Gurgaon: If relocating, express willingness and ask about relocation support
  • Hybrid/Remote policy: Zomato typically has a hybrid model — clarify expectations
  • Joining timeline: Be realistic about notice period + relocation time
  • Ask about: Team you'd join, tech stack, on-call expectations, laptop/equipment

💡 HR Round Tips:

  • Be genuine — HR can tell when answers are rehearsed vs authentic
  • Research Zomato's recent launches (Blinkit integration, Zomato Gold, international expansion)
  • Don't badmouth your current employer — focus on what you're moving towards
  • Have questions ready: team size, mentorship culture, performance review cycles
  • Hyperlocal delivery at this scale is a specific engineering challenge — show you understand it

What Actually Helps at Zomato Interviews

Ask clarifying questions before coding

It shows you think like a senior. Jumping straight into code signals junior thinking.

Prepare for follow-ups on everything

Every answer will have a follow up. If you explain SSR, expect "what are the trade-offs?" immediately after.

Don't put anything on your resume you can't defend

Do not write anything you cannot explain under pressure. They will dig into every project listed.

The manager round is the hardest

Most people don't expect that. Prepare system design and architectural defense at a higher bar than the technical rounds.

Ready to Crack Your Zomato Interview?

Join our cohort and get structured preparation with 1-on-1 guidance from a Staff Engineer who has mentored 100+ developers.

Join Next Cohort