Fossils⚛ïļ React PatternsFrontend System Design Interview Questions
ðŸĶ–DinosaurReactSystem DesignInfinite ScrollArchitectureScalability

Frontend System Design Interview Questions

Design infinite scroll, scalable app architecture — the system design questions that test how you think at scale.

Frontend System Design Interview Questions

System design questions are where interviewers stop testing what you know and start testing how you think. There's no single right answer — they want to see your decision-making process, your awareness of trade-offs, and whether you've built real things.


Q17: Design an Infinite Scroll System

Interview Question: "Design an infinite scroll system for a social media feed. Walk me through pagination, caching, error handling, and loading states."

Think of it like a newspaper that magically adds more pages as you reach the bottom. You never click "next page" — the content just keeps appearing. But behind the scenes, someone has to fetch those pages, remember what's already been fetched, handle the case where the printing press breaks, and show a preview while the ink dries.

Step 1: Pagination Strategy — Cursor vs Offset

Before writing any React code, you need to decide how you ask the server for more data.

Offset-based (?page=3&limit=20): Simple, but breaks when new items are added. If someone posts while you're on page 3, page 4 will have a duplicate from page 3.

Cursor-based (?after=abc123&limit=20): You send the ID of the last item you saw. The server returns everything after that. No duplicates, no skipped items.

type PaginationParams = {
  cursor: string | null;
  limit: number;
};
 
type PaginatedResponse<T> = {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
};
 
async function fetchFeed(params: PaginationParams): Promise<PaginatedResponse<Post>> {
  const url = new URL('/api/feed', window.location.origin);
  if (params.cursor) url.searchParams.set('after', params.cursor);
  url.searchParams.set('limit', String(params.limit));
 
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Feed fetch failed: ${res.status}`);
  return res.json();
}

Step 2: The Core Hook

function useInfiniteScroll<T>(
  fetcher: (params: PaginationParams) => Promise<PaginatedResponse<T>>,
  limit = 20
) {
  const [items, setItems] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
  const hasMore = useRef(true);
  const isFetching = useRef(false);
 
  const loadMore = useCallback(async () => {
    if (isFetching.current || !hasMore.current) return;
    isFetching.current = true;
    setStatus('loading');
 
    try {
      const response = await fetcher({ cursor, limit });
      setItems(prev => [...prev, ...response.items]);
      setCursor(response.nextCursor);
      hasMore.current = response.hasMore;
      setStatus('idle');
    } catch {
      setStatus('error');
    } finally {
      isFetching.current = false;
    }
  }, [cursor, fetcher, limit]);
 
  return { items, status, loadMore, hasMore: hasMore.current };
}

Why useRef for isFetching instead of state? Because changing a ref doesn't trigger a re-render. We only need this flag to prevent duplicate API calls — we don't need to show it in the UI.

Step 3: Detecting When to Load More — IntersectionObserver

Think of it like a security camera watching a tripwire near the bottom of your page. When the user scrolls close enough, the camera triggers and says "time to load more."

function useIntersectionTrigger(
  onIntersect: () => void,
  options?: IntersectionObserverInit
) {
  const triggerRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const el = triggerRef.current;
    if (!el) return;
 
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) onIntersect();
    }, { rootMargin: '200px', ...options });
 
    observer.observe(el);
    return () => observer.disconnect();
  }, [onIntersect, options]);
 
  return triggerRef;
}

The rootMargin: '200px' means "start loading 200px before the user actually reaches the bottom." This makes it feel instant.

Step 4: Skeleton Loading States

Never show a blank space. Show the user that content is coming:

function FeedSkeleton({ count = 3 }: { count?: number }) {
  return (
    <>
      {Array.from({ length: count }, (_, i) => (
        <div key={i} className="animate-pulse space-y-3 p-4 border rounded">
          <div className="h-4 bg-gray-200 rounded w-3/4" />
          <div className="h-4 bg-gray-200 rounded w-1/2" />
          <div className="h-32 bg-gray-200 rounded" />
        </div>
      ))}
    </>
  );
}

Step 5: Error Handling and Retry

function Feed() {
  const { items, status, loadMore, hasMore } = useInfiniteScroll(fetchFeed);
  const triggerRef = useIntersectionTrigger(loadMore);
 
  return (
    <div>
      {items.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
 
      {status === 'loading' && <FeedSkeleton />}
 
      {status === 'error' && (
        <div className="text-center p-4">
          <p>Something went wrong loading the feed.</p>
          <button onClick={loadMore}>Try again</button>
        </div>
      )}
 
      {hasMore && status !== 'error' && (
        <div ref={triggerRef} aria-hidden="true" />
      )}
 
      {!hasMore && (
        <p className="text-center text-gray-500 p-4">You've reached the end!</p>
      )}
    </div>
  );
}

Step 6: Caching (Bonus — The Senior Touch)

In production, you'd use React Query's useInfiniteQuery which handles caching, background refetching, and stale data for you:

function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam, limit: 20 }),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 60_000,
  });
}

This gives you cache invalidation, background refetching, and optimistic updates — all the things you'd otherwise build by hand.

The One-Liner That Impresses: "Infinite scroll is a state machine with four states — idle, loading, error, and exhausted — backed by cursor-based pagination and IntersectionObserver, where the hardest part isn't loading data but preventing duplicate loads and handling the edge cases."

Common Follow-Up Questions

"How do you handle items being added to the top of the feed while scrolling?"

"With cursor-based pagination, new items at the top don't affect your position. Your cursor says 'give me everything after item X,' and that doesn't change when new items are added above X. For real-time updates, you'd show a 'New posts available' banner that prepends items when clicked."

"How would you add virtualization?"

"For feeds with thousands of items, rendering all DOM nodes kills performance. Libraries like react-window or @tanstack/virtual only render items currently in the viewport plus a small overscan buffer. Combined with infinite scroll, you get infinite data with a fixed DOM size."

"What about scroll position restoration?"

"When the user navigates away and comes back, you need to restore their position. Cache the scroll position in a ref or session storage, and after items re-render, scroll to the saved position. React Router and Next.js have built-in scroll restoration, but with virtualization you may need manual handling."

Red Flags in Your Answer

  • Jumping straight to code without discussing pagination strategy
  • Using offset-based pagination without mentioning the duplicate item problem
  • Using scroll events instead of IntersectionObserver (scroll events fire hundreds of times per second)
  • No error handling or retry mechanism
  • Not mentioning skeleton loading (showing a blank space or a spinner is bad UX)
  • Forgetting to prevent duplicate API calls when the user scrolls quickly

Q18: How Would You Design a Scalable React App?

Interview Question: "You're building a React app that will be maintained by 15 developers and serve millions of users. How would you structure it?"

Think of it like designing a city. You wouldn't build every house, office, and park on one giant block. You'd create neighborhoods (features), roads between them (shared code), and rules about what can be built where (boundaries). A well-designed city can grow without tearing everything down.

The Three Pillars

Every scalable app needs three things:

  1. Folder structure — where code lives
  2. State management strategy — how data flows
  3. Separation of concerns — what each piece is responsible for

Pillar 1: Feature-Based Folder Structure

src/
├── app/                        # Routes / pages
│   ├── (marketing)/
│   │   ├── page.tsx
│   │   └── layout.tsx
│   ├── (dashboard)/
│   │   ├── products/
│   │   ├── analytics/
│   │   └── settings/
│   └── api/
│
├── features/                   # Domain modules — the core
│   ├── products/
│   │   ├── components/         # ProductCard, ProductGrid
│   │   ├── hooks/              # useProducts, useProductFilters
│   │   ├── api/                # fetchProducts, createProduct
│   │   ├── types.ts
│   │   └── index.ts            # Public API
│   ├── cart/
│   ├── auth/
│   └── notifications/
│
├── shared/                     # Cross-feature code
│   ├── components/             # Button, Input, Modal
│   ├── hooks/                  # useDebounce, useMediaQuery
│   ├── lib/                    # Formatters, validators
│   └── types/
│
└── config/                     # Environment, constants

The golden rule: Features never import from other features. If two features need to share something, it goes in shared/.

features/products → shared/components   ✅ Products uses shared Button
features/products → features/cart       ❌ Cross-feature import
features/cart → shared/hooks            ✅ Cart uses shared useDebounce

Pillar 2: State Management Strategy

Don't pick one tool for everything. Different kinds of state need different solutions:

URL State (search params, route)          → React Router / Next.js
    ↓
Server State (API data)                   → React Query / SWR
    ↓
Global App State (auth, theme)            → Context or Zustand
    ↓
Feature State (cart, form wizard)         → Zustand or useReducer
    ↓
Component State (dropdown open, hover)    → useState

The rule of thumb: Start at the bottom. Only move state higher when you have a concrete reason.

QuestionWhere to Put State
Can I reconstruct it from the URL?URL params
Does it come from an API?React Query
Does the whole app need it?Global store or Context
Do multiple components in one feature need it?Feature-level store
Is it just for this one component?useState

Pillar 3: Separation of Concerns

Every component should have one job. Separate the three concerns:

// api/products.ts — DATA LAYER
export async function fetchProducts(filters: Filters) {
  const res = await fetch(`/api/products?${new URLSearchParams(filters)}`);
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}
 
// hooks/useProducts.ts — LOGIC LAYER
export function useProducts(filters: Filters) {
  return useQuery({
    queryKey: ['products', filters],
    queryFn: () => fetchProducts(filters),
  });
}
 
// components/ProductGrid.tsx — UI LAYER
export function ProductGrid({ filters }: { filters: Filters }) {
  const { data, isLoading, error } = useProducts(filters);
 
  if (isLoading) return <GridSkeleton />;
  if (error) return <ErrorMessage error={error} />;
 
  return (
    <div className="grid grid-cols-3 gap-4">
      {data.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Each layer can be tested independently. Swap the API without touching the UI. Change the UI without touching the data logic.

The Component Hierarchy

Design System (Button, Input, Card)           → shared/components/
    ↓
Composed Patterns (SearchBar, DataTable)      → shared/components/ or feature/
    ↓
Feature Components (ProductCard, CartItem)    → features/{name}/components/
    ↓
Page Components (ProductsPage, CartPage)      → app/ routes

Scaling Rules for 15+ Developers

RuleWhy
Feature folders with barrel exportsEach feature has a public API — internals are private
No cross-feature importsPrevents spaghetti dependencies
Shared code requires team agreementPrevents "shared" becoming a dumping ground
Colocate tests with sourceProductCard.test.tsx next to ProductCard.tsx
Performance budgets in CICatch bundle bloat before it merges

The One-Liner That Impresses: "A scalable React app isn't about picking the right library — it's about creating boundaries that let 15 developers work on different features without stepping on each other, backed by a state strategy that matches each type of data to the right tool."

Common Follow-Up Questions

"How do features communicate if they can't import each other?"

"Three patterns: (1) Events — one feature publishes, another subscribes. (2) Shared state — both features read from a global store. (3) Routing — navigate with URL params that the other feature reads. The choice depends on whether communication is synchronous or asynchronous."

"How do you prevent shared components from becoming a junk drawer?"

"Require team approval for anything added to shared/. Use the Rule of Three — code stays in a feature until three different features need it. And barrel files with explicit exports make the public API visible."

"How do you handle a monorepo vs single repo?"

"For one team, a single repo with feature folders is simpler. For multiple teams owning different domains, a monorepo with tools like Turborepo or Nx gives independent builds, caching, and clear ownership. The feature-based structure maps directly to monorepo packages."

Red Flags in Your Answer

  • Describing only folder structure without discussing state management or separation of concerns
  • Saying "we put everything in Redux" without explaining why different state types need different tools
  • No mention of feature boundaries or module independence
  • Organizing by file type (components/, hooks/, utils/) instead of by feature at scale
  • Not mentioning performance budgets, code splitting, or CI guardrails
  • Forgetting that architecture is about people — 15 developers need boundaries to work independently