Rippling

Real Rippling Interview Experience

3 Rounds: Technical Screen, Machine Coding & Frontend System Design

3
Rounds
Async
JS Deep
28 LPA
Package
Senior
Remote
Vasanth

By Vasanth Bhat

Staff Software Engineer @ Walmart Global Tech

Mentored 100+ frontend developers through successful interviews

Round 1: Technical Screen (60 mins)

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.

1️⃣ Flatten Array in Layered Order (BFS)

Difficulty: Medium | Time: 15 minutes

Problem Statement:
Flatten a nested array but in a specific layered (level-by-level) order — not depth-first.
Input: [1, 2, [3, [4], 5], 6, [7]]
Output: [1, 2, 6, 3, 5, 7, 4]
Key Insight:
This is NOT regular flatten. It's BFS (Breadth-First) — process all elements at the current depth before going deeper. Think of it like tree level-order traversal applied to arrays.
🎯 Solution — BFS with Queue:
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)?
  • Regular .flat(Infinity) uses DFS: [1, 2, 3, 4, 5, 6, 7] — goes deepest first
  • This problem wants level-order: process all level-0 items, then level-1, then level-2...
  • Queue (FIFO) naturally gives level-order — arrays encountered are deferred to the end
  • Follow-up: "What if the array has 1 million elements?" — Use index-based iteration instead of shift() to avoid O(n) shift cost. Or use a deque.

2️⃣ Output-Based Question on this Binding

Difficulty: Medium | Time: 10 minutes

Problem:
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:
  • "Why did this lose context?" — Assigning method to variable strips implicit binding. The function doesn't "remember" its object.
  • Arrow functions don't have their own this — they inherit from the enclosing lexical scope
  • bind vs call/apply: bind returns a new function (doesn't execute). call/apply execute immediately.
  • Common pitfall: Arrow function as object method = wrong this

3️⃣ Polyfill for Function.prototype.bind

Difficulty: Medium-Hard | Time: 15 minutes

🎯 Complete Implementation:
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:
  • Partial application: Args passed to bind are prepended to later call args
  • new operator handling: When used as constructor, bound context is ignored — this is the new instance
  • Prototype chain: Must preserve original function's prototype for instanceof to work
  • Why not arrow function? Arrow functions don't have this or arguments — can't be used as the returned bound function if you need new support

4️⃣ All CSS Position Types + Flex Item Widths

Time: 10 minutes

🎯 CSS Position Types:
/* 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: 1 is shorthand for flex-grow: 1; flex-shrink: 1; flex-basis: 0%
  • flex-basis vs width: flex-basis is the "ideal" size before grow/shrink applies. width is a hard constraint (unless flex-shrink overrides it)
  • sticky gotcha: Won't work if any ancestor has overflow: hidden or overflow: auto

💡 Technical Screen Tips:

  • They test if you can explain WHY this lost context — not if you know the fix
  • BFS array flatten is a common Rippling question — recognize the pattern quickly
  • bind polyfill: handle new operator case — most candidates miss this
  • CSS positions: know sticky's parent-bound behavior and overflow gotcha
  • Reason from fundamentals — never memorize patterns

Rippling 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 →

Round 2: Machine Coding (90 mins)

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.

1️⃣ Memoized Function for Async Callback Tasks

Difficulty: Hard | Time: 25 minutes

Requirements:
✓ Same arguments should NOT re-fire the API call
✓ In-flight requests should make later callers wait on the SAME promise
✓ Decide a cache invalidation strategy
✓ Handle errors (don't cache failed results)
Key Insight:
The trick is: cache the PROMISE, not the resolved value. This automatically deduplicates in-flight requests — all callers await the same promise.
🎯 Complete Implementation:
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):
  • Why cache the promise (not the value)? — In-flight deduplication happens automatically. Multiple callers all await the same promise object.
  • Error handling: Delete from cache on error so next call retries. Don't cache failures.
  • Cache invalidation strategies: TTL (maxAge), LRU (maxSize), manual invalidation, event-based (WebSocket notifies stale data)
  • Scaling concern: JSON.stringify for keys is slow for large objects. Use a custom keyResolver for performance.
  • Memory leak: Without maxSize, cache grows unbounded. Map preserves insertion order — easy LRU approximation.

2️⃣ React Hook for Infinite Scroll with Cursor-Based Pagination

Difficulty: Medium-Hard | Time: 20 minutes

Requirements:
✓ IntersectionObserver (not scroll listeners)
✓ Cursor-based pagination (not page numbers)
✓ Prevent duplicate fetches
✓ Cleanup on unmount
🎯 Complete Implementation:
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?
  • Cursor-based: Stable under insertions/deletions. No duplicates if data changes between requests.
  • Offset/page-based: If a new item is inserted at the top, page 2 will repeat the last item of page 1.
  • Cursor is usually: An encoded timestamp, ID, or composite key (e.g., base64(createdAt + id))

3️⃣ Publish-Subscribe Pattern (Redux-like useSelector + dispatch)

Difficulty: Medium-Hard | Time: 15 minutes

Problem:
Implement a pub-sub state store that works like Redux — with 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:
  • Object.is comparison: Prevents unnecessary re-renders when the selected value hasn't changed
  • Selector must be pure: If selector creates a new array/object each time, it always "changes". Use useMemo or reselect.
  • Scaling concern: With 1000 subscribers, every dispatch iterates all listeners. Solution: batch notifications with microtask queue.
  • vs Redux: Redux adds middleware (thunk/saga), DevTools, time-travel. This is the core pattern stripped down.

💡 Machine Coding Tips:

  • Rippling is famous for async questions — Promises must be second nature
  • Cache the PROMISE, not the resolved value — this deduplicates in-flight requests automatically
  • 30-minute defense is where you win or lose — know scaling limits of your solution
  • IntersectionObserver + cursor pagination is the standard — know why it beats scroll + offset
  • Pub-sub: selective re-renders (Object.is comparison) is what separates senior from mid

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 →

Round 3: Frontend System Design (60 mins)

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.

🏗️ Problem: Design a Notifications System for an HR Dashboard

Time: 60 minutes

Areas to Cover:
1. WebSocket vs Server-Sent Events for live push
2. Offline queue using IndexedDB
3. Cross-tab synchronization with BroadcastChannel
4. Optimistic mark-as-read with rollback
5. Notification batching to avoid spam
6. Accessibility with ARIA live regions
1️⃣ WebSocket vs Server-Sent Events (SSE):
// === 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 Tips:

  • They do not care what you pick — they care WHY (SSE vs WS: justify with uni-directionality)
  • "What breaks at scale?" follows every answer — prepare scaling limits for each decision
  • Offline-first: IndexedDB queue + drain on reconnect shows production thinking
  • Cross-tab sync: BroadcastChannel is the simplest — mention SharedWorker as the scaled alternative
  • Batching: prevents notification spam AND reduces re-renders
  • Accessibility is NOT optional — ARIA live regions show you build for everyone

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 →

What Stands Out at Rippling Interviews

Async JS tested deeply in every round

Promises, in-flight deduplication, race conditions, and cache invalidation. If async isn't second nature, you won't clear.

Machine coding is build AND defend

60 mins to code, 30 mins to justify. "What's the time complexity? What breaks at 100K entries?" Be ready.

Scaling question follows every answer

After every design choice: "What breaks at higher scale?" Know the limits of your solution before they ask.

CSS still matters in the screen

Position types, flex layouts, sticky gotchas. Don't skip CSS prep — it's tested alongside JS fundamentals.

Ready to Crack Your Rippling 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