
Real Zomato Interview Experience
4 Rounds: Technical Deep Dive, Hands-on Coding, System Design & HR
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
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 (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.
// 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
- 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
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²)
// ❌ 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?
| 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 |
// 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
// 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 tokensSecurity 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
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.
• Words: ["pizza", "pasta", "paneer", "burger", "biryani"]
• Prefix "p" → ["pizza", "pasta", "paneer"]
• Prefix "pa" → ["pasta", "paneer"]
• Prefix "bi" → ["biryani"]
• Prefix "z" → []
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
Given an array of strings, group the anagrams together. Return the answer in any order.
• Input: ["eat", "tea", "tan", "ate", "nat", "bat"]
• Output: [["eat","tea","ate"], ["tan","nat"], ["bat"]]
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
Implement a simplified version of React's
useState hook that maintains state across re-renders, supports functional updates, and batches state changes.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!).
// 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
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
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, 24️⃣ let vs var: Where It Actually Matters
| 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
- Only call hooks at the top level — no loops, conditions, or nested functions
- Only call hooks from React functions — components or other custom hooks
- Custom hooks must start with "use" — naming convention for lint tooling
// 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
✅ 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
✅ 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)
// "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
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
| 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 |
// 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?
// 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 scroll5️⃣ How Do You Handle a Frontend Performance Regression in Production?
• 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
// 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
- 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?
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
- 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
It shows you think like a senior. Jumping straight into code signals junior thinking.
Every answer will have a follow up. If you explain SSR, expect "what are the trade-offs?" immediately after.
Do not write anything you cannot explain under pressure. They will dig into every project listed.
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.
