Skip to content

01 - React Internals & Rendering


1. Hydration

What: The process where React takes server-rendered HTML and attaches event listeners

  • makes it interactive on the client. The HTML is already visible — hydration "wakes it up."

How it works:

Server: renderToString(<App />) → static HTML string → sent to browser
Browser: displays HTML immediately (fast First Contentful Paint)
Client JS loads → hydrateRoot(document.getElementById('root'), <App />)
React walks the existing DOM, attaches event handlers, builds its internal tree

The problem: Hydration is ALL or NOTHING. React must process the ENTIRE component tree before anything becomes interactive. On a large page, this can block interactivity for seconds.

Hydration mismatch: If server HTML differs from what the client renders, React throws a warning and re-renders from scratch (destroying the performance benefit). Common causes:

  • Date.now() or Math.random() in render
  • Browser-only APIs (window.innerWidth) used during SSR
  • Different data on server vs client
tsx
// BAD: Hydration mismatch
function Greeting() {
  return <p>Current time: {Date.now()}</p>; // different on server vs client
}

// GOOD: Use useEffect for client-only values
function Greeting() {
  const [time, setTime] = useState<number | null>(null);
  useEffect(() => setTime(Date.now()), []);
  return <p>Current time: {time ?? 'Loading...'}</p>;
}

2. Partial Hydration

What: Instead of hydrating the entire page, only hydrate the interactive parts. Static content (headers, footers, text) stays as plain HTML — never loaded as JS.

┌────────────────────────────────────┐
│ Header (static HTML - no JS)       │
├────────────────────────────────────┤
│ Search Bar (HYDRATED - interactive)│
├────────────────────────────────────┤
│ Article Body (static HTML - no JS) │
├────────────────────────────────────┤
│ Comments (HYDRATED - interactive)  │
├────────────────────────────────────┤
│ Footer (static HTML - no JS)       │
└────────────────────────────────────┘

Key insight: Most of a page is static. Why ship JS for a paragraph of text?

Implementations:

  • Astro's client:* directives (client:load, client:visible, client:idle)
  • Qwik's resumability (doesn't even hydrate — serializes component state to HTML)

3. Islands Architecture

What: The page is a "sea" of static HTML with "islands" of interactive components. Each island hydrates independently — one island crashing doesn't affect others.

┌──────────────────────────────────────────┐
│            Static HTML (sea)             │
│                                          │
│   ┌──────────┐         ┌──────────┐     │
│   │ Island 1 │         │ Island 2 │     │
│   │ (React)  │         │ (Svelte) │     │
│   └──────────┘         └──────────┘     │
│                                          │
│   ┌──────────────────────┐               │
│   │     Island 3         │               │
│   │     (Vue)            │               │
│   └──────────────────────┘               │
│                                          │
└──────────────────────────────────────────┘

Key difference from partial hydration: Islands can use DIFFERENT frameworks. Each island is isolated, has its own state, and its own JS bundle.

Framework: Astro is the poster child. Fresh (Deno) also uses this.

Trade-off:

  • Pro: Ship near-zero JS for static content, each island is independent
  • Con: Communication between islands is complex (no shared React tree/context)
  • Con: Not great for highly interactive apps where everything is connected

4. Streaming SSR

What: Instead of generating the ENTIRE HTML string then sending it, the server sends HTML in chunks as components finish rendering. The browser renders chunks as they arrive.

Without streaming (traditional SSR):

Server: [render ALL components] → [send complete HTML] → browser paints
User sees: nothing .......... then EVERYTHING at once

With streaming:

Server: [render header] → send → [render body] → send → [render comments] → send
User sees: header first .... then body .... then comments

React API:

tsx
// React 18+
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/html');
      pipe(res); // starts streaming
    },
  });
});

Combined with Suspense:

tsx
function Page() {
  return (
    <Layout>
      <Header />                          {/* sent immediately */}
      <Suspense fallback={<Spinner />}>
        <SlowDataComponent />             {/* streamed when ready */}
      </Suspense>
    </Layout>
  );
}

The server sends the <Spinner /> HTML first, then when SlowDataComponent finishes, it sends a <script> tag that replaces the spinner in the DOM. No full page reload.


5. Concurrent Rendering

What: React can work on multiple state updates at the same time and decide which one to prioritize. It can pause rendering one tree to work on a more urgent update, then come back to it.

Before (synchronous rendering):

User types → React renders entire tree → blocks → user sees result
If tree is large, typing feels laggy because React can't be interrupted.

After (concurrent):

User types → React starts rendering → new keystroke arrives →
React ABANDONS current render → starts fresh with new input → fast response

Key mental model: Concurrent rendering makes renders INTERRUPTIBLE and PRIORITIZED.

APIs that enable concurrent rendering:

  • startTransition() — mark an update as non-urgent
  • useDeferredValue() — defer a value until urgent work is done
  • useTransition() — track pending state of a transition
tsx
function Search() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  // Input is responsive (query updates immediately)
  // List filters on deferredQuery (lower priority, can be interrupted)
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <HeavyFilteredList filter={deferredQuery} />
    </>
  );
}

6. Time Slicing

What: Breaking a large render task into small chunks ("slices") that execute during idle browser frames, yielding back to the browser between slices so it can handle user input, paint, etc.

Without time slicing:
[======== render 50ms ========] → browser blocked, janky

With time slicing:
[== 5ms ==] yield [== 5ms ==] yield [== 5ms ==] yield ...
             ↑ browser can handle clicks, paint, etc.

React's implementation: Fiber architecture enables this. Each fiber node is a unit of work. React's scheduler processes fibers one at a time, checking after each if it should yield to the browser (shouldYield()). If a higher-priority update arrives, it pauses current work.

The 5ms budget: React's scheduler aims to yield every ~5ms, keeping frames at 60fps (16ms per frame).


7. Reconciliation Algorithm

What: The algorithm React uses to determine what changed between the previous render and the current render, so it can apply MINIMAL DOM updates.

Two key heuristics (O(n) instead of O(n³)):

  1. Different type = destroy and rebuild

    tsx
    // Previous: <div><Counter /></div>
    // Current:  <span><Counter /></span>
    // React: destroys <div> and Counter entirely, rebuilds <span> + new Counter
  2. Keys identify elements across renders

    tsx
    // Without key: React doesn't know which item is which
    // It updates ALL items when one is added/removed
    
    // With key: React matches items by key
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
    // Adding an item → React only creates ONE new DOM node

What happens during reconciliation:

  1. React builds a new "virtual DOM" (fiber tree) from your JSX
  2. Compares new tree with previous tree (diffing)
  3. Generates a list of DOM mutations (inserts, updates, deletes)
  4. Applies mutations to the real DOM in the "commit" phase

8. Fiber Architecture

What: React's internal reimplementation (React 16+) that replaced the old synchronous, recursive "stack reconciler" with an incremental, pausable architecture.

A Fiber is a JavaScript object representing a unit of work:

js
{
  type: 'div',            // component type or HTML tag
  key: null,
  stateNode: domNode,     // reference to actual DOM node
  child: firstChildFiber, // linked list, not array
  sibling: nextFiber,     // next sibling
  return: parentFiber,    // parent
  pendingProps: {},
  memoizedState: {},       // hooks are stored here as a linked list
  effectTag: 'UPDATE',     // what DOM operation to perform
  alternate: prevFiber,    // points to the "current" version (double buffering)
}

Double buffering: React maintains TWO fiber trees:

  • Current tree: What's currently on screen
  • Work-in-progress (WIP) tree: What React is building for the next render

When WIP is complete, React swaps the pointer. The old current becomes the new WIP for the next render.

Why Fiber matters:

  • Enables concurrent rendering (each fiber is a unit of work that can be paused)
  • Enables priorities (urgent vs deferred updates)
  • Enables Suspense (can "suspend" a fiber and show fallback)
  • Hooks work because state is stored as a linked list on the fiber node

9. Virtual DOM Diffing Complexity

The theoretical problem: Comparing two arbitrary trees requires O(n³) — for each node, compare with every node in the other tree, then compute optimal transformations.

React's approach: O(n) React cheats with two assumptions:

  1. Elements of different types produce different trees → don't bother diffing subtrees
  2. Keys hint at which children are stable across renders

How it diffs children (list diffing):

Old: [A, B, C, D]
New: [A, C, D, B]

Without keys: React updates B→C, C→D, D→B (3 updates)
With keys:    React sees B moved to end (1 move operation)

Edge case — index as key:

tsx
// BAD: Using index as key breaks when order changes
{items.map((item, i) => <Item key={i} data={item} />)}
// If you delete first item, React thinks first item's CONTENT changed,
// second item's content changed, and last item was deleted.
// Every component gets wrong props → bugs + wasted re-renders.

10. Suspense Boundaries

What: A component that catches "suspensions" (async operations) in its subtree and shows a fallback until they resolve.

tsx
<Suspense fallback={<Skeleton />}>
  <UserProfile />    {/* may suspend while fetching data */}
  <UserPosts />      {/* may also suspend */}
</Suspense>

How it works internally:

  1. Component "suspends" by throwing a Promise
  2. React catches it at the nearest Suspense boundary
  3. Renders the fallback
  4. When the Promise resolves, React re-renders the suspended component
  5. Replaces fallback with actual content

Nested boundaries:

tsx
<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<PostsSkeleton />}>
    <Posts />  {/* this suspending only shows PostsSkeleton, not PageSkeleton */}
  </Suspense>
</Suspense>

With streaming SSR: Server sends fallback HTML first, then swaps in real content via inline <script> when the suspended component finishes rendering on the server.


11. Selective Hydration

What: React 18 feature where the client prioritizes hydrating components that the user is interacting with, rather than hydrating in document order.

Page loads with server HTML:
[Header] [SearchBar] [ArticleBody] [Comments]

Normal hydration: hydrates Header → SearchBar → Article → Comments (in order)

Selective hydration:
User clicks on Comments while React is hydrating SearchBar →
React PAUSES SearchBar hydration →
Hydrates Comments FIRST (because user is interacting with it) →
Then goes back to finish SearchBar

Requirements:

  • Streaming SSR (renderToPipeableStream)
  • Suspense boundaries around sections
  • React 18+

Key insight: Hydration becomes event-driven, not sequential.


12. Server Components (RSC)

What: Components that run ONLY on the server. They never ship JS to the client. They can directly access databases, file systems, and APIs.

Server Components          Client Components
─────────────────          ─────────────────
Run on server only         Run on client (and server for SSR)
No JS sent to client       JS bundle sent to client
Can access DB directly     Cannot access server resources
No state (no useState)     Can have state and effects
No event handlers          Can handle user events
Can import client comps    Cannot import server comps

How RSC works:

  1. Server renders Server Components into a special serializable format (RSC payload)
  2. RSC payload is streamed to the client
  3. Client renders Client Components normally, but uses server-rendered data for Server Components (no JS needed for those)
tsx
// Server Component (default in Next.js App Router)
async function ProductPage({ id }: { id: string }) {
  const product = await db.products.findById(id); // direct DB access!
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton product={product} /> {/* Client Component */}
    </div>
  );
}

// Client Component
'use client';
function AddToCartButton({ product }: { product: Product }) {
  const [added, setAdded] = useState(false);
  return <button onClick={() => setAdded(true)}>Add to Cart</button>;
}

When to use which:

  • Product pages, dashboards with data-heavy content → Server Components
  • Interactive forms, checkout flows → Client Components
  • Result: dramatically smaller JS bundles

13. Tearing in Concurrent UI

What: A visual inconsistency where different parts of the UI show different versions of the same data, because a state update happened mid-render.

Imagine: external store has value = 1
Component A reads store → sees 1
                                     ← store updates to 2 (mid-render!)
Component B reads store → sees 2

Screen shows: A=1, B=2 → TORN (inconsistent)

Why it happens: Concurrent rendering can PAUSE and RESUME. If an external store (Redux, Zustand, module-level variables) changes between pauses, different components read different snapshots.

Solution: useSyncExternalStore

tsx
import { useSyncExternalStore } from 'react';

function useStore<T>(store: Store<T>, selector: (state: State) => T): T {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),  // getSnapshot
    () => selector(store.getInitialState()),  // getServerSnapshot
  );
}

React guarantees consistent reads across the entire render when using this hook. Zustand and Redux Toolkit already use this internally.


14. Scheduler Priorities

What: React's internal scheduler assigns priorities to updates so urgent work (user input) runs before less urgent work (data fetching results).

Priority levels (from React's scheduler):

1. Immediate    — synchronous, blocks everything (legacy)
2. UserBlocking — clicks, input (must respond in ~250ms)
3. Normal       — data fetching results, setState from useEffect
4. Low          — analytics, prefetching
5. Idle         — work that can wait indefinitely

How you influence priority:

tsx
// Normal priority (default)
setCount(count + 1);

// Low priority (transition)
startTransition(() => {
  setSearchResults(filterHugeList(query));
});
// The setSearchResults update is interruptible and won't block input

15. Render Waterfalls

What: When data fetching is sequential because each component fetches after its parent renders, creating a chain of loading states.

BAD (waterfall):
ParentPage renders → starts fetching user data
  ... waiting ...
ParentPage re-renders with user → ChildProfile renders → starts fetching posts
  ... waiting ...
ChildProfile re-renders with posts → GrandchildComments renders → starts fetching comments

Timeline: ===fetch user===  ===fetch posts===  ===fetch comments===
                                                              ↑ everything visible
GOOD (parallel):
ParentPage renders →  starts ALL fetches in parallel
                      ===fetch user===
                      ===fetch posts===
                      ===fetch comments===
                                       ↑ everything visible

Solutions:

  1. Fetch in parent, pass down: Parent fetches all data, children receive via props
  2. Parallel queries: TanStack Query's useQueries or multiple useQuery in same component
  3. Route-level fetching: Next.js loader / Remix loader — fetch before rendering
  4. Server Components: Fetch on server, no client waterfall at all

16. Edge Rendering

What: Running server-side rendering at CDN edge locations (close to the user) instead of a central origin server.

Traditional SSR:
User (Mumbai) → CDN → Origin Server (US) → render → HTML → CDN → User
Latency: ~200ms

Edge Rendering:
User (Mumbai) → Edge (Mumbai) → render → HTML → User
Latency: ~20ms

Platforms: Cloudflare Workers, Vercel Edge Functions, Deno Deploy

Constraints:

  • Limited runtime (V8 isolates, not full Node.js)
  • No file system, limited memory
  • Cold starts must be <5ms
  • Can't use all npm packages (no native modules)

When to use: Personalized content (user-specific dashboards), A/B testing, geo-specific content. Static content is better served from CDN cache directly.

Frontend interview preparation reference.