3 Rounds: Technical Screen, Machine Coding & Frontend System Design
Staff Software Engineer @ Walmart Global Tech
Mentored 100+ frontend developers through successful interviews
JavaScript fundamentals, CSS, and problem solving on CodePair. They are not testing if you know bind — they are testing if you can explain why this lost context.
Difficulty: Medium | Time: 15 minutes
Input: [1, 2, [3, [4], 5], 6, [7]]Output: [1, 2, 6, 3, 5, 7, 4]
function flattenLayered(arr) {
const result = [];
const queue = [...arr]; // Start with top-level elements
while (queue.length > 0) {
const item = queue.shift();
if (Array.isArray(item)) {
// Don't process nested array now — push its children to back of queue
queue.push(...item);
} else {
// Non-array element — add to result immediately
result.push(item);
}
}
return result;
}
// Test:
console.log(flattenLayered([1, 2, [3, [4], 5], 6, [7]]));
// Step by step:
// Queue: [1, 2, [3,[4],5], 6, [7]]
// Process 1 → result: [1], queue: [2, [3,[4],5], 6, [7]]
// Process 2 → result: [1,2], queue: [[3,[4],5], 6, [7]]
// Process [3,[4],5] → spread into queue: [6, [7], 3, [4], 5]
// Process 6 → result: [1,2,6], queue: [[7], 3, [4], 5]
// Process [7] → spread: [3, [4], 5, 7]
// Process 3 → result: [1,2,6,3], queue: [[4], 5, 7]
// Process [4] → spread: [5, 7, 4]
// Process 5 → result: [1,2,6,3,5], queue: [7, 4]
// Process 7 → result: [1,2,6,3,5,7], queue: [4]
// Process 4 → result: [1,2,6,3,5,7,4]
// Output: [1, 2, 6, 3, 5, 7, 4] ✓
// Time: O(n) where n = total elements (each processed once)
// Space: O(n) for queue + result
💡 Why BFS (not DFS)?
this BindingDifficulty: Medium | Time: 10 minutes
const obj = { value: 6, print() { console.log(this.value); } };
// What does each log?
obj.print(); // ?
const fn = obj.print;
fn(); // ?
setTimeout(obj.print, 100); // ?
// How to preserve context for delayed call?
🎯 Complete Answer:
const obj = { value: 6, print() { console.log(this.value); } };
// 1. Method call — this = obj
obj.print(); // 6
// 2. Indirect call — this = window (or undefined in strict mode)
const fn = obj.print;
fn(); // undefined (in non-strict) / TypeError (in strict)
// WHY: When you assign a method to a variable, you lose the object context.
// fn is now just a plain function reference — no implicit binding.
// 3. setTimeout — this = window (setTimeout calls the callback loosely)
setTimeout(obj.print, 100); // undefined
// WHY: setTimeout stores the function reference internally and calls it
// without any object context. Same as fn() above.
// === HOW TO PRESERVE CONTEXT ===
// Fix 1: Arrow function wrapper (lexical this)
setTimeout(() => obj.print(), 100); // 6
// Arrow function captures `this` from surrounding scope — not its own.
// But here we're explicitly calling obj.print(), so method call rules apply.
// Fix 2: bind (creates a new function with locked this)
setTimeout(obj.print.bind(obj), 100); // 6
// bind returns a NEW function where this is permanently set to obj.
// Fix 3: Arrow function in the object definition
const obj2 = {
value: 6,
print: () => console.log(this.value)
};
// ⚠️ TRAP: Arrow function here captures this from MODULE scope,
// not obj2. This would log undefined! NOT a fix.
// === THE 4 RULES OF this (priority order) ===
// 1. new binding → this = newly created object
// 2. explicit binding → call/apply/bind → this = specified object
// 3. implicit binding → obj.method() → this = obj
// 4. default binding → fn() → this = window (or undefined in strict)
💡 Interview Points:
this — they inherit from the enclosing lexical scopethisDifficulty: Medium-Hard | Time: 15 minutes
Function.prototype.myBind = function(context, ...boundArgs) {
// 'this' here is the function being bound
const originalFn = this;
// Validate: bind can only be called on functions
if (typeof originalFn !== 'function') {
throw new TypeError('Bind must be called on a function');
}
// Return a new function that:
// 1. Sets `this` to context
// 2. Prepends boundArgs before any new arguments
// 3. Supports being called with `new` (constructor usage)
function boundFunction(...callArgs) {
// If called with `new`, `this` should be the new instance
// not the bound context
const isNewCall = this instanceof boundFunction;
return originalFn.apply(
isNewCall ? this : context,
[...boundArgs, ...callArgs]
);
}
// Preserve prototype chain for `new` operator support
if (originalFn.prototype) {
boundFunction.prototype = Object.create(originalFn.prototype);
}
return boundFunction;
};
// === TEST CASES ===
// Basic binding
const obj = { value: 42 };
function getValue(prefix) {
return `${prefix}: ${this.value}`;
}
const bound = getValue.myBind(obj, 'Result');
console.log(bound()); // "Result: 42"
// Partial application (currying)
function add(a, b, c) {
return a + b + c;
}
const add5 = add.myBind(null, 5);
console.log(add5(3, 2)); // 10
// Works with setTimeout
const timer = {
name: 'Timer',
greet() { return `Hello from ${this.name}`; }
};
setTimeout(timer.greet.myBind(timer), 100); // "Hello from Timer"
// Works with new (constructor)
function Person(name) {
this.name = name;
}
const BoundPerson = Person.myBind({ ignored: true });
const p = new BoundPerson('John');
console.log(p.name); // "John" (not affected by bound context)
console.log(p instanceof BoundPerson); // true
💡 Key Points to Explain:
new operator handling: When used as constructor, bound context is ignored — this is the new instanceinstanceof to workthis or arguments — can't be used as the returned bound function if you need new supportTime: 10 minutes
/* 1. static (default) */
/* Element in normal document flow. top/left/right/bottom have no effect. */
.default { position: static; }
/* 2. relative */
/* Stays in normal flow but offset from its ORIGINAL position. */
/* Other elements don't move — space is preserved. */
.nudged {
position: relative;
top: 10px; /* Moves 10px DOWN from where it would be */
left: 20px; /* Moves 20px RIGHT from where it would be */
}
/* 3. absolute */
/* Removed from flow. Positioned relative to nearest positioned ancestor. */
/* If no positioned ancestor → positioned relative to viewport (initial containing block). */
.tooltip {
position: absolute;
top: 0;
right: 0;
/* Sits at top-right of nearest ancestor that has position: relative/absolute/fixed */
}
/* 4. fixed */
/* Removed from flow. Positioned relative to VIEWPORT. */
/* Does NOT move on scroll. Always visible. */
.sticky-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1000;
}
/* 5. sticky */
/* Hybrid: behaves like relative UNTIL a scroll threshold, then becomes fixed. */
/* Stays within its parent container bounds. */
.section-header {
position: sticky;
top: 0; /* Sticks when scrolled to top of viewport */
background: white;
z-index: 10;
/* Stops sticking when parent scrolls out of view */
}
🎯 Flex Item Different Widths:
/* Method 1: flex property (recommended) */
.container { display: flex; }
.sidebar { flex: 0 0 250px; } /* Fixed 250px, won't grow or shrink */
.main { flex: 1; } /* Takes remaining space */
.aside { flex: 0 0 200px; } /* Fixed 200px */
/* Method 2: flex-basis + flex-grow */
.container { display: flex; }
.item-1 { flex: 1; } /* 1 part */
.item-2 { flex: 2; } /* 2 parts (twice as wide as item-1) */
.item-3 { flex: 1; } /* 1 part */
/* Ratio: 25% | 50% | 25% */
/* Method 3: width + flex-shrink: 0 (prevent shrinking) */
.container { display: flex; }
.logo { width: 100px; flex-shrink: 0; }
.nav { width: 300px; flex-shrink: 0; }
.space { flex: 1; } /* Fill remaining */
/* Method 4: min-width / max-width constraints */
.container { display: flex; }
.sidebar {
flex: 1;
min-width: 200px;
max-width: 350px;
}
💡 Common Follow-up:
flex-grow: 1; flex-shrink: 1; flex-basis: 0%overflow: hidden or overflow: autoRippling values reasoning over memorization. If you can't explain WHY this lost context, knowing the fix doesn't matter. Build deep JS fundamentals with us →
60 mins to build. 30 mins to defend trade-offs and scale. Rippling is famous for async questions — if Promises are not second nature, this round is hard to clear.
Difficulty: Hard | Time: 25 minutes
function asyncMemoize(fn, options = {}) {
const {
maxAge = Infinity, // TTL in ms (cache invalidation)
maxSize = 100, // Max cache entries (LRU eviction)
keyResolver = (...args) => JSON.stringify(args) // Custom key
} = options;
const cache = new Map(); // key → { promise, timestamp }
function isExpired(entry) {
return Date.now() - entry.timestamp > maxAge;
}
function evictIfNeeded() {
if (cache.size > maxSize) {
// Remove oldest entry (Map preserves insertion order)
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
}
async function memoized(...args) {
const key = keyResolver(...args);
// Check cache: return if exists and not expired
if (cache.has(key)) {
const entry = cache.get(key);
if (!isExpired(entry)) {
// Return the SAME promise — deduplicates in-flight requests
return entry.promise;
}
// Expired — remove and re-fetch
cache.delete(key);
}
// Create the promise and cache it IMMEDIATELY
// (before awaiting — this is what deduplicates in-flight calls)
const promise = fn(...args).catch((error) => {
// Don't cache errors — remove from cache so retry works
cache.delete(key);
throw error;
});
cache.set(key, { promise, timestamp: Date.now() });
evictIfNeeded();
return promise;
}
// Manual cache control
memoized.invalidate = (...args) => {
const key = keyResolver(...args);
cache.delete(key);
};
memoized.invalidateAll = () => cache.clear();
memoized.has = (...args) => {
const key = keyResolver(...args);
return cache.has(key) && !isExpired(cache.get(key));
};
return memoized;
}
// === USAGE ===
const fetchUser = asyncMemoize(
async (userId) => {
console.log(`Fetching user ${userId}...`);
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
{ maxAge: 60000, maxSize: 50 } // Cache for 1 min, max 50 entries
);
// First call — makes API request
const user1 = await fetchUser('123'); // Logs: "Fetching user 123..."
// Second call (same args) — returns cached promise (no API call!)
const user2 = await fetchUser('123'); // No log — cache hit
// Simultaneous calls — both get the SAME in-flight promise
const [a, b] = await Promise.all([
fetchUser('456'), // Logs: "Fetching user 456..."
fetchUser('456'), // No log — shares the in-flight promise
]);
// Only ONE API call was made!
// After TTL expires (60s) — refetches
// Or manually invalidate:
fetchUser.invalidate('123');
💡 Defense Points (for 30-min discussion):
Difficulty: Medium-Hard | Time: 20 minutes
import { useState, useEffect, useRef, useCallback } from 'react';
function useInfiniteScroll(fetchFn, options = {}) {
const { threshold = 0.1 } = options;
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
// Use refs for values that shouldn't trigger re-renders
const cursorRef = useRef(null);
const isFetchingRef = useRef(false); // Prevent duplicate fetches
const observerRef = useRef(null);
const loadMore = useCallback(async () => {
// Guard: prevent duplicate fetches
if (isFetchingRef.current || !hasMore) return;
isFetchingRef.current = true;
setLoading(true);
setError(null);
try {
const { data, nextCursor } = await fetchFn(cursorRef.current);
setItems(prev => [...prev, ...data]);
cursorRef.current = nextCursor;
// No next cursor = no more pages
if (!nextCursor || data.length === 0) {
setHasMore(false);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
isFetchingRef.current = false;
}
}, [fetchFn, hasMore]);
// Sentinel ref — attach to last element
const sentinelRef = useCallback((node) => {
// Disconnect previous observer
if (observerRef.current) {
observerRef.current.disconnect();
}
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingRef.current && hasMore) {
loadMore();
}
},
{ threshold }
);
observerRef.current.observe(node);
}, [loadMore, hasMore, threshold]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
// Initial fetch
useEffect(() => {
loadMore();
}, []);
// Reset function (for filters/search changes)
const reset = useCallback(() => {
setItems([]);
cursorRef.current = null;
setHasMore(true);
setError(null);
isFetchingRef.current = false;
}, []);
return {
items,
loading,
error,
hasMore,
sentinelRef,
reset,
retry: loadMore
};
}
// === USAGE ===
function EmployeeList() {
const fetchEmployees = useCallback(async (cursor) => {
const url = cursor
? `/api/employees?cursor=${cursor}&limit=20`
: `/api/employees?limit=20`;
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch');
const json = await res.json();
return {
data: json.employees,
nextCursor: json.nextCursor // null if no more pages
};
}, []);
const { items, loading, error, hasMore, sentinelRef, retry } =
useInfiniteScroll(fetchEmployees);
return (
<div className="employee-list">
{items.map((employee, index) => (
<EmployeeCard key={employee.id} data={employee} />
))}
{/* Sentinel element — triggers next page when visible */}
{hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
{loading && <Spinner />}
{error && (
<div className="error">
<p>{error}</p>
<button onClick={retry}>Retry</button>
</div>
)}
{!hasMore && items.length > 0 && (
<p>All employees loaded</p>
)}
</div>
);
}
📊 IntersectionObserver vs Scroll Listeners:
Criteria | IntersectionObserver | scroll event
──────────────────────┼────────────────────────┼─────────────────────
Performance | Async, off main thread | Fires 60x/sec (throttle needed)
Throttle needed? | No (built-in) | Yes (requestAnimationFrame)
Cleanup | disconnect() | removeEventListener
Nested containers | Works with root option | Manual offset calculation
Browser support | 95%+ (2024) | 100%
Code complexity | Lower | Higher (getBoundingClientRect)
💡 Why Cursor > Page Numbers?
base64(createdAt + id))Difficulty: Medium-Hard | Time: 15 minutes
dispatch to update state, and useSelector that only re-renders when the selected slice changes.
🎯 Complete Implementation:
// === STORE IMPLEMENTATION ===
function createStore(reducer, initialState) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function dispatch(action) {
const prevState = state;
state = reducer(state, action);
// Notify all subscribers
listeners.forEach(listener => listener(state, prevState));
}
function subscribe(listener) {
listeners.add(listener);
// Return unsubscribe function
return () => listeners.delete(listener);
}
return { getState, dispatch, subscribe };
}
// === useSelector HOOK (with selective re-render) ===
function useSelector(store, selector) {
// Use useState to trigger re-renders
const [selectedState, setSelectedState] = useState(
() => selector(store.getState())
);
const selectorRef = useRef(selector);
selectorRef.current = selector;
useEffect(() => {
const unsubscribe = store.subscribe((newState, prevState) => {
const newSelected = selectorRef.current(newState);
const prevSelected = selectorRef.current(prevState);
// Only re-render if selected slice actually changed
if (!Object.is(newSelected, prevSelected)) {
setSelectedState(newSelected);
}
});
return unsubscribe; // Cleanup on unmount
}, [store]);
return selectedState;
}
// === useDispatch HOOK ===
function useDispatch(store) {
return store.dispatch;
}
// === USAGE EXAMPLE ===
// Reducer
function appReducer(state, action) {
switch (action.type) {
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload]
};
case 'MARK_READ':
return {
...state,
notifications: state.notifications.map(n =>
n.id === action.payload ? { ...n, read: true } : n
)
};
case 'SET_USER':
return { ...state, user: action.payload };
default:
return state;
}
}
// Create store
const store = createStore(appReducer, {
notifications: [],
user: null
});
// Component: Only re-renders when notifications change
function NotificationBadge() {
const unreadCount = useSelector(store,
state => state.notifications.filter(n => !n.read).length
);
return <span className="badge">{unreadCount}</span>;
}
// Component: Only re-renders when user changes
function UserAvatar() {
const user = useSelector(store, state => state.user);
const dispatch = useDispatch(store);
return <img src={user?.avatar} onClick={() => dispatch({ type: 'LOGOUT' })} />;
}
// Dispatching from anywhere:
store.dispatch({
type: 'ADD_NOTIFICATION',
payload: { id: 1, text: 'New employee added', read: false }
});
💡 Discussion Points:
Machine coding at Rippling is "build AND defend." 60 mins to code, 30 mins to justify every decision under scale pressure. Practice defending under pressure →
Design a notifications system for an HR dashboard. After every choice: "What breaks at higher scale?" They do not care what you pick — they care WHY.
Time: 60 minutes
// === DECISION FRAMEWORK ===
//
// WebSocket:
// ✅ Bi-directional (client can send mark-as-read instantly)
// ✅ Binary data support
// ✅ Lower latency for high-frequency updates
// ❌ More complex server infrastructure (stateful connections)
// ❌ Harder to scale (each connection is persistent)
// ❌ No automatic reconnection (must implement manually)
//
// Server-Sent Events (SSE):
// ✅ Simpler server (just HTTP with streaming response)
// ✅ Auto-reconnection built into browser
// ✅ Works through HTTP/2 multiplexing (no extra connection)
// ✅ EventSource API is dead simple
// ❌ Uni-directional (server → client only)
// ❌ Text only (no binary)
// ❌ 6 connection limit per domain (HTTP/1.1)
//
// CHOICE FOR NOTIFICATIONS: SSE
// WHY: Notifications are server → client only (uni-directional).
// mark-as-read can use regular POST (doesn't need WebSocket).
// Auto-reconnection saves us 50 lines of reconnect logic.
// HTTP/2 eliminates the 6-connection limit.
// Implementation:
function useNotificationStream(userId) {
const [notifications, setNotifications] = useState([]);
useEffect(() => {
const eventSource = new EventSource(
`/api/notifications/stream?userId=${userId}`
);
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
setNotifications(prev => [notification, ...prev]);
};
// Named events for different types
eventSource.addEventListener('batch', (event) => {
const batch = JSON.parse(event.data);
setNotifications(prev => [...batch, ...prev]);
});
eventSource.onerror = () => {
// Browser auto-reconnects with last-event-id header
// Server resumes from where client left off
console.log('SSE disconnected, auto-reconnecting...');
};
return () => eventSource.close();
}, [userId]);
return notifications;
}
// "What breaks at scale?"
// → 10K concurrent SSE connections per server instance
// → Solution: Horizontal scaling with sticky sessions or pub/sub
// (Redis Pub/Sub fans out to the SSE server holding that connection)
// → Client: Use visibility API to pause when tab is hidden
2️⃣ Offline Queue with IndexedDB:
// When user is offline, queue actions (mark-as-read, dismiss)
// Replay them when connection is restored
class OfflineQueue {
constructor(dbName = 'notifications-queue') {
this.dbName = dbName;
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('actions')) {
db.createObjectStore('actions', {
keyPath: 'id',
autoIncrement: true
});
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = () => reject(request.error);
});
}
async enqueue(action) {
const tx = this.db.transaction('actions', 'readwrite');
const store = tx.objectStore('actions');
store.add({ ...action, timestamp: Date.now() });
return new Promise((res, rej) => {
tx.oncomplete = res;
tx.onerror = rej;
});
}
async drain() {
const tx = this.db.transaction('actions', 'readwrite');
const store = tx.objectStore('actions');
const actions = await new Promise((res, rej) => {
const request = store.getAll();
request.onsuccess = () => res(request.result);
request.onerror = rej;
});
// Replay in order
for (const action of actions) {
try {
await fetch(action.url, {
method: action.method,
body: JSON.stringify(action.body),
headers: { 'Content-Type': 'application/json' }
});
store.delete(action.id); // Remove after success
} catch (err) {
break; // Stop replaying if still offline
}
}
}
}
// Usage:
const queue = new OfflineQueue();
await queue.init();
async function markAsRead(notificationId) {
const action = {
url: `/api/notifications/${notificationId}/read`,
method: 'PATCH',
body: { read: true }
};
if (!navigator.onLine) {
await queue.enqueue(action);
return; // Optimistic UI update happens separately
}
await fetch(action.url, { method: action.method, body: JSON.stringify(action.body) });
}
// Drain queue when back online
window.addEventListener('online', () => queue.drain());
3️⃣ Cross-Tab Sync with BroadcastChannel:
// Problem: User has 3 tabs open. Marks notification as read in tab 1.
// Tabs 2 and 3 should reflect this immediately WITHOUT separate API calls.
const channel = new BroadcastChannel('notifications-sync');
// When a notification is read in this tab → broadcast to others
function markAsReadAndSync(notificationId) {
// Update local state
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
);
// Broadcast to other tabs
channel.postMessage({
type: 'MARK_READ',
payload: { id: notificationId }
});
// Persist to server
fetch(`/api/notifications/${notificationId}/read`, { method: 'PATCH' });
}
// Listen for updates from other tabs
channel.onmessage = (event) => {
const { type, payload } = event.data;
switch (type) {
case 'MARK_READ':
setNotifications(prev =>
prev.map(n => n.id === payload.id ? { ...n, read: true } : n)
);
break;
case 'NEW_NOTIFICATION':
setNotifications(prev => [payload, ...prev]);
break;
case 'DISMISS_ALL':
setNotifications([]);
break;
}
};
// Cleanup
useEffect(() => {
return () => channel.close();
}, []);
// "What breaks at scale?"
// → BroadcastChannel is same-origin only (fine for single app)
// → With 20+ tabs, message storms possible → throttle/debounce broadcasts
// → SharedWorker alternative: single SSE connection shared across all tabs
// (only ONE connection to server instead of one per tab)
4️⃣ Optimistic Mark-as-Read with Rollback:
async function optimisticMarkAsRead(notificationId) {
// 1. Save previous state for rollback
const previousNotifications = [...notifications];
// 2. Optimistic update (instant UI feedback)
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
);
try {
// 3. Persist to server
const response = await fetch(
`/api/notifications/${notificationId}/read`,
{ method: 'PATCH' }
);
if (!response.ok) throw new Error('Server rejected');
} catch (error) {
// 4. Rollback on failure
setNotifications(previousNotifications);
// 5. Show error toast
showToast('Failed to mark as read. Please try again.', 'error');
}
}
// "What breaks at scale?"
// → Rapid clicks: user marks 10 notifications read in 1 second
// → Solution: batch the PATCH requests (debounce 500ms, send array of IDs)
// → Rollback becomes complex with batching — use version vectors or
// reconcile with server state after batch completes
5️⃣ Notification Batching (Anti-Spam):
// Problem: 50 new hire notifications arrive in 2 seconds.
// Showing 50 toasts = spam. Batch them.
class NotificationBatcher {
constructor(options = {}) {
this.batchWindow = options.batchWindow || 2000; // 2 seconds
this.maxBatchSize = options.maxBatchSize || 10;
this.buffer = [];
this.timer = null;
this.onBatch = options.onBatch || (() => {});
}
add(notification) {
this.buffer.push(notification);
// Flush immediately if buffer is full
if (this.buffer.length >= this.maxBatchSize) {
this.flush();
return;
}
// Otherwise, wait for batch window
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.batchWindow);
}
}
flush() {
if (this.buffer.length === 0) return;
clearTimeout(this.timer);
this.timer = null;
const batch = [...this.buffer];
this.buffer = [];
this.onBatch(batch);
}
}
// Usage:
const batcher = new NotificationBatcher({
batchWindow: 2000,
maxBatchSize: 10,
onBatch: (batch) => {
if (batch.length === 1) {
showToast(batch[0].message);
} else {
showToast(`${batch.length} new notifications`);
}
// Add all to notification list
setNotifications(prev => [...batch, ...prev]);
}
});
// SSE handler feeds into batcher instead of directly updating state
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
batcher.add(notification);
};
6️⃣ Accessibility — ARIA Live Regions:
// Screen readers need to announce new notifications without focus change
// In JSX:
function NotificationPanel() {
return (
<div>
{/* Invisible live region — screen reader announces additions */}
<div
role="status"
aria-live="polite"
aria-atomic="false"
className="sr-only" /* visually hidden */
>
{latestNotification && (
<span>New notification: {latestNotification.message}</span>
)}
</div>
{/* Notification list */}
<ul role="list" aria-label="Notifications">
{notifications.map(n => (
<li
key={n.id}
role="listitem"
aria-label={`${n.read ? '' : 'Unread: '}${n.message}`}
>
<button
onClick={() => markAsRead(n.id)}
aria-pressed={n.read}
aria-label={`Mark ${n.message} as ${n.read ? 'unread' : 'read'}`}
>
{n.message}
</button>
</li>
))}
</ul>
</div>
);
}
// CSS for visually hidden but accessible:
// .sr-only {
// position: absolute;
// width: 1px; height: 1px;
// padding: 0; margin: -1px;
// overflow: hidden;
// clip: rect(0, 0, 0, 0);
// border: 0;
// }
// aria-live values:
// "polite" — announces when user is idle (non-intrusive)
// "assertive" — interrupts immediately (use for urgent alerts)
// "off" — no announcement
// "What breaks at scale?"
// → 50 notifications = 50 announcements → overwhelming
// → Solution: batch announcements too. "You have 5 new notifications"
// → Use aria-atomic="true" on the container to re-read the whole region
🏗️ Architecture Diagram:
┌─────────────────────────────────────────────────────────────┐
│ HR DASHBOARD — NOTIFICATION SYSTEM │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ SSE Stream ┌──────────────────┐ │
│ │ Server │─────────────────▸│ NotificationBatcher│ │
│ │ (Redis PubSub) │ (2s window) │ │
│ └─────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌────────────────────────┼────┐ │
│ │ ▼ │ │
│ ┌─────────────┐ │ ┌──────────────────────┐ │ │
│ │ BroadcastCh │◀─┼──│ Notification Store │ │ │
│ │ (cross-tab) │──┼─▸│ (React state) │ │ │
│ └─────────────┘ │ └──────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ ┌─────────────┐ │ ┌──────────────────────┐ │ │
│ │ IndexedDB │◀─┼──│ Optimistic Updates │ │ │
│ │ (offline Q) │ │ │ + Rollback │ │ │
│ └─────────────┘ │ └──────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ UI: Bell Icon + Panel │ │ │
│ │ │ + ARIA Live Region │ │ │
│ │ └──────────────────────┘ │ │
│ │ │ │
│ │ Tab 1 (Active) │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Tab 2, 3... (via Broadcast) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
System design at Rippling is about defending under scale pressure. Every answer gets a "what breaks if we 10x this?" follow-up. Practice system design with scaling drills →
Promises, in-flight deduplication, race conditions, and cache invalidation. If async isn't second nature, you won't clear.
60 mins to code, 30 mins to justify. "What's the time complexity? What breaks at 100K entries?" Be ready.
After every design choice: "What breaks at higher scale?" Know the limits of your solution before they ask.
Position types, flex layouts, sticky gotchas. Don't skip CSS prep — it's tested alongside JS fundamentals.
Join our cohort and get structured preparation with 1-on-1 guidance from a Staff Engineer who has mentored 100+ developers.