Suspense #

Suspense is a UI pattern letting applications show temporary fallbacks — skeletons, spinners, or placeholders — while waiting for something async: lazy-loaded components, fetched data, or content streamed from the server. What makes Suspense powerful isn’t the technology, but the paradigm: loading state logic no longer spreads across every component with if (isLoading) return <Spinner />, but is centralized at higher boundaries. This makes rendering code cleaner and gives better control over content appearance order. This article covers Suspense from its basic concepts, how it works in React and other frameworks, HTML streaming from the server, anti-patterns, and when Suspense provides real benefits.

The Problems Suspense Solves #

Without Suspense, loading state handling is scattered and often inconsistent:

// ANTI-PATTERN: Manual loading state in every component
func Dashboard() *Element {
    user, setUser := useState(nil)
    orders, setOrders := useState(nil)
    stats, setStats := useState(nil)
    userLoading, setUserLoading := useState(true)
    ordersLoading, setOrdersLoading := useState(true)
    statsLoading, setStatsLoading := useState(true)

    useEffect(func() {
        fetchUser().then(func(data any) {
            setUser(data)
            setUserLoading(false)
        })
        fetchOrders().then(func(data any) {
            setOrders(data)
            setOrdersLoading(false)
        })
        fetchStats().then(func(data any) {
            setStats(data)
            setStatsLoading(false)
        })
    })

    return renderFragment(
        renderConditional(userLoading, renderUserSkeleton(), renderUserProfile(user)),
        renderConditional(ordersLoading, renderOrdersSkeleton(), renderRecentOrders(orders)),
        renderConditional(statsLoading, renderStatsSkeleton(), renderStatsCards(stats)),
    )
}

// Problems:
// → Loading state boilerplate in every component
// → Inconsistent — every developer can implement it differently
// → Hard to coordinate content appearance order
// → Data-fetching components and loading UI are mixed

With Suspense, loading state is managed by boundaries, not by individual components:

// CORRECT: The Suspense boundary manages loading state
func Dashboard() *Element {
    return renderFragment(
        renderSuspense(renderUserSkeleton(), func() *Element {
            return renderUserProfile() // Fetches its own data
        }),
        renderSuspense(renderOrdersSkeleton(), func() *Element {
            return renderRecentOrders() // Fetches its own data
        }),
        renderSuspense(renderStatsSkeleton(), func() *Element {
            return renderStatsCards() // Fetches its own data
        }),
    )
}

// Advantages:
// → Every section loads independently
// → Components focus on rendering, not loading management
// → Loading states consistent and centralized

How React Suspense Works #

flowchart TD
    subgraph WithoutSuspense["Without Suspense — Sequential Loading"]
        W1["Fetch all data\n(must wait for everything to finish)"]
        W2["Render the entire page\nwhen all data is ready"]
        WU["User waits\n2-3 seconds blank / spinner"]
        W1 --> W2 --> WU
    end

    subgraph WithSuspense["With Suspense — Progressive Rendering"]
        S1["Start fetching all data\nin parallel"]
        S2["Static parts render immediately"]
        S3["UserProfile ready (200ms)\n→ Appears, replaces the skeleton"]
        S4["StatsCards ready (500ms)\n→ Appears, replaces the skeleton"]
        S5["RecentOrders ready (800ms)\n→ Appears, replaces the skeleton"]
        SU["User sees content\nappearing progressively"]
        S1 --> S2 --> SU
        S1 --> S3 --> SU
        S1 --> S4 --> SU
        S1 --> S5 --> SU
    end

    style WU fill:#E74C3C,color:#fff
    style SU fill:#27AE60,color:#fff

React.lazy — Suspense for Code Splitting #

// React.lazy — Suspense for code splitting
// (React is a framework API — this shows the equivalent lazy-loading logic)
var HeavyChart = lazyLoad(func() *Element { return loadComponent("HeavyChart") })
var DataTable = lazyLoad(func() *Element { return loadComponent("DataTable") })
var RichEditor = lazyLoad(func() *Element { return loadComponent("RichEditor") })

func ReportPage() *Element {
    return renderFragment(
        renderReportHeader(), // Not lazy — loads immediately

        renderSuspense(renderChartSkeleton(300), func() *Element {
            return renderHeavyChart(reportData)
        }),

        renderSuspense(renderTableSkeleton(10), func() *Element {
            return renderDataTable()
        }),
    )
}

// The framework automatically:
// 1. Starts downloading HeavyChart.chunk.js
// 2. Shows <ChartSkeleton /> during the download
// 3. When the download finishes, replaces the skeleton with the real component
// 4. No if (loading) at all in the parent component

Suspense with Data Fetching (React 18 + React Query) #

// Suspense with data fetching (React 18 + React Query)
// (React is a framework API — this shows the equivalent suspense logic)
func UserProfile(userId string) *Element {
    // No loading state — the component suspends automatically
    user := useSuspenseQuery("user", userId, func() any { return fetchUser(userId) })

    // This code only executes when the user is available
    return renderFragment(
        renderImage(user.Avatar, user.Name),
        renderHeading(user.Name),
        renderParagraph(user.Email),
    )
}

// The parent doesn't need to know about UserProfile's loading state
func ProfilePage(userId string) *Element {
    return renderSuspense(renderProfileSkeleton(), func() *Element {
        return renderUserProfile(userId)
    })
}

Suspense Boundary Granularity #

The position and granularity of Suspense boundaries strongly affect UX. Overly coarse boundaries make lots of content wait for slow content; overly granular boundaries can feel messy.

// ANTI-PATTERN: One boundary for everything → fast content waits for slow content
func Dashboard() *Element {
    return renderSuspense(renderFullPageSkeleton(), func() *Element {
        return renderFragment(
            renderUserProfile(),   // ready in 200ms
            renderRecentOrders(),  // ready in 800ms
            renderHeavyAnalytics(),// ready in 3000ms!
        )
    })
}
// UserProfile and RecentOrders must wait for HeavyAnalytics (3 seconds!)
// The whole page is a skeleton until HeavyAnalytics finishes

// CORRECT: Per-section boundaries → each part appears as soon as it's ready
func Dashboard() *Element {
    return renderFragment(
        // Content that's almost always fast — shared boundary above
        renderSuspense(renderHeaderSkeleton(), func() *Element { return renderDashboardHeader() }),

        // Content with independent speeds — separate boundaries
        renderSuspense(renderUserSkeleton(), func() *Element { return renderUserProfile() }),
        renderSuspense(renderOrdersSkeleton(), func() *Element { return renderRecentOrders() }),

        // Heavy content — its own boundary at the bottom
        renderSuspense(renderAnalyticsSkeleton(), func() *Element { return renderHeavyAnalytics() }),
    )
}
// UserProfile appears at 200ms, RecentOrders at 800ms, HeavyAnalytics at 3000ms
// Users can already see and interact with some content much earlier

Suspense and Error Boundaries #

Suspense handles loading states. Error Boundaries handle error states. The two are often used together.

// Suspense and Error Boundaries
// (React is a framework API — this shows the equivalent error-handling logic)
// Component for showing errors with a retry option
func ErrorFallback(error Error, resetErrorBoundary func()) *Element {
    return renderFragment(
        renderParagraph("Failed to load data: "+error.Message),
        renderButton("Try Again", resetErrorBoundary),
    )
}

// ErrorBoundary + Suspense combination for one section
func OrdersSection() *Element {
    return renderErrorBoundary(
        func() *Element { return renderErrorFallback(error, resetErrorBoundary) },
        renderSuspense(renderOrdersSkeleton(), func() *Element { return renderRecentOrders() }),
    )
}

// Correct order: ErrorBoundary outside Suspense
// If the ErrorBoundary is inside Suspense:
// → Errors from RecentOrders won't be caught by the ErrorBoundary
//   because Suspense intercepts them first

useTransition — Suspense for Navigation #

useTransition is a React 18 hook allowing state updates that can be “interrupted” without immediately showing Suspense fallbacks — very useful for smooth navigation transitions.

// useTransition — Suspense for navigation
// (React is a framework API — this shows the equivalent transition logic)
func ProductFilter() *Element {
    category, setCategory := useState("all")
    isPending, startTransition := useTransition()

    handleCategoryChange := func(newCategory string) {
        startTransition(func() {
            // This state update is marked as "non-urgent"
            // React keeps showing the old content until the update finishes
            // without showing skeletons/fallbacks
            setCategory(newCategory)
        })
    }

    return renderFragment(
        renderCategoryButtons(
            category,
            handleCategoryChange,
            isPending, // show a light loading indicator on the button
                       // without replacing the entire content with a skeleton
        ),
        renderConditional(isPending, renderLoadingBar(), nil), // Thin progress bar
        renderSuspense(renderProductSkeleton(), func() *Element {
            return renderProductList(category)
        }),
    )
}
useTransition vs without useTransition:

Without useTransition:
  User clicks a filter → skeleton appears instantly → new data appears
  Old content disappears, users lose context

With useTransition:
  User clicks a filter → old content stays visible (slightly dimmed)
                   → a thin progress bar appears
                   → new content appears, the progress bar disappears
  Users can still see the page structure during loading

HTML Streaming — Suspense on the Server Side #

The Suspense concept isn’t limited to React. Servers can apply the same pattern: send static content first via HTTP streaming, then send slower content when it’s ready.

sequenceDiagram
    participant B as Browser
    participant S as Go Server

    B->>S: GET /dashboard

    S-->>B: HTML: Header + Nav (immediately)
    Note over B: The browser renders the header right away

    S-->>B: HTML: Skeleton placeholder for products
    Note over B: The browser shows the products skeleton

    Note over S: Query the products database (200ms)
    S-->>B: HTML: Script injecting products content
    Note over B: The browser replaces the skeleton with real data

    Note over S: Fetch recommendations from the ML service (1500ms)
    S-->>B: HTML: Script injecting recommendations content
    Note over B: Recommendations appear

    S-->>B: HTML: Footer (immediately)
    Note over B: The page is complete
// Go backend — Streaming HTML with placeholders + injection
package main

import (
    "fmt"
    "net/http"
    "time"
)

func dashboardHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Header().Set("X-Content-Type-Options", "nosniff")
    // Transfer-Encoding: chunked happens automatically when using a Flusher

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    // === STEP 1: Send the page shell immediately ===
    fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head>
  <title>Dashboard</title>
  <link rel="stylesheet" href="/app.css">
</head>
<body>
  <nav>Dashboard Nav</nav>
  <main>
    <h1>Dashboard</h1>

    <!-- Placeholder for products — shows immediately as a skeleton -->
    <div id="products-container">
      <div class="skeleton skeleton-list"></div>
    </div>

    <!-- Placeholder for recommendations -->
    <div id="reco-container">
      <div class="skeleton skeleton-grid"></div>
    </div>
  </main>
  <footer>Footer</footer>
  <script src="/app.js"></script>
`)
    flusher.Flush()  // Send immediately to the browser

    // === STEP 2: Fetch products data (simulated 200ms) ===
    time.Sleep(200 * time.Millisecond)
    products := fetchProducts()  // actual DB query

    // Inject content into the placeholder via a script
    fmt.Fprintf(w, `<script>
document.getElementById('products-container').innerHTML = %q;
</script>`, renderProductsHTML(products))
    flusher.Flush()

    // === STEP 3: Fetch recommendations (simulated 1.5s) ===
    time.Sleep(1300 * time.Millisecond)  // total 1.5s
    recommendations := fetchRecommendations()

    fmt.Fprintf(w, `<script>
document.getElementById('reco-container').innerHTML = %q;
</script>`, renderRecoHTML(recommendations))
    flusher.Flush()

    // Close the connection
    fmt.Fprint(w, `</body></html>`)
}
This server-side HTML streaming approach is the simplest way to apply Suspense-style loading without React or any frontend framework — just Go/Node.js/Python with chunked responses. Pages still feel fast because the shell is visible immediately, while slow parts follow. This is a technique Facebook used before React existed, and it remains highly relevant for server-rendered applications.

React Server Components + Suspense (Next.js App Router) #

React Server Components (RSC) allow server components to be rendered and streamed progressively — this is the modern way to use Suspense with server-side data fetching.

// React Server Components + Suspense (Next.js App Router)
// (React is a framework API — this shows the equivalent logic in Go)
// Server Component — fetches data directly in the component
func UserProfile(userId string) *Element {
    user := dbFindUserByID(userId)  // queries the DB directly on the server
    return renderFragment(
        renderImage(user.Avatar, user.Name),
        renderHeading(user.Name),
    )
}

func RecentOrders(userId string) *Element {
    orders := dbFindOrdersByUser(userId)  // can be slower
    return renderOrdersList(orders)
}

func Analytics() *Element {
    stats := fetchExternalAnalytics()  // can be very slow
    return renderStatsCards(stats)
}

// The page is a server component composing the components above
func DashboardPage(params Params) *Element {
    return renderFragment(
        // UserProfile renders on the server, streamed to the client when ready
        renderSuspense(renderProfileSkeleton(), func() *Element {
            return renderUserProfile(params.ID)
        }),

        renderSuspense(renderOrdersSkeleton(), func() *Element {
            return renderRecentOrders(params.ID)
        }),

        // Analytics can be slow — place it below with a separate boundary
        renderSuspense(renderAnalyticsSkeleton(), func() *Element {
            return renderAnalytics()
        }),
    )
}

// Next.js / React automatically:
// 1. Renders the page shell immediately
// 2. Streams each section when its data is ready on the server
// 3. The browser receives and displays content progressively
// → No client-to-API request waterfalls
// → TTFB stays low because the shell is sent immediately

Skeleton Screens — Good Fallbacks #

A good Suspense fallback is a skeleton screen resembling the real content layout, not a generic spinner.

// ANTI-PATTERN: Generic spinners aren't informative
renderSuspense(renderDiv("Loading..."), func() *Element { return renderProductList() })
// Users don't know what structure will appear
// Severe layout shifts when content appears (high CLS)

// CORRECT: Skeletons resembling the real content
func ProductListSkeleton() *Element {
    return renderDivClass("product-grid",
        renderCards(8, func(i int) *Element {
            return renderDivClass("product-card-skeleton",
                renderSkeleton("skeleton-image", 200),
                renderSkeletonText("80%"),
                renderSkeletonText("60%"),
                renderSkeletonText("40%"),
            )
        }),
    )
}

renderSuspense(renderProductListSkeleton(), func() *Element { return renderProductList() })
// Advantages:
// → Users know there will be 8 product cards
// → No layout shifts when content appears (low CLS)
// → Feels "loading", not "broken"

Suspense Anti-Patterns to Avoid #

One Boundary for the Entire App #

// ✗ Anti-pattern: one global boundary
func App() *Element {
    return renderSuspense(renderFullPageSpinner(), func() *Element {
        return renderRouter(
            renderRoute("/", renderHome()),
            renderRoute("/dashboard", renderDashboard()),
        )
    })
}
// If one small component in Dashboard suspends → the entire app becomes a spinner!

// ✓ Solution: Suspense boundaries at the right levels
func App() *Element {
    return renderRouter(
        renderRoute("/", func() *Element {
            return renderSuspense(renderHomeSkeleton(), func() *Element { return renderHome() })
        }),
        renderRoute("/dashboard", func() *Element {
            return renderSuspense(renderDashboardSkeleton(), func() *Element { return renderDashboard() })
        }),
    )
}

Suspense Without Error Boundaries #

// ✗ Anti-pattern: Suspense without an Error Boundary
renderSuspense(renderSkeleton(), func() *Element {
    return renderDataComponent() // If the fetch fails → the error isn't caught!
})
// Errors from DataComponent propagate up and crash

// ✓ Solution: Always pair with an Error Boundary
renderErrorBoundary(
    func() *Element { return renderErrorFallback() },
    renderSuspense(renderSkeleton(), func() *Element { return renderDataComponent() }),
)

Fallbacks Causing Layout Shifts #

// ✗ Anti-pattern: fallbacks with wrong dimensions
renderSuspense(renderDivStyle("height: 50px", "Loading..."), func() *Element {
    return renderProductList() // ProductList is actually 800px tall
})
// When content appears: layout shifts from 50px to 800px = high CLS!

// ✓ Solution: Skeletons with dimensions close to the real content
renderSuspense(renderProductListSkeleton(), func() *Element { return renderProductList() }) // ~800px, 8 cards

When Suspense Provides Real Benefits #

Suspense is very beneficial when:
  ✓ Pages consist of several independent sections with different fetch speeds
  ✓ There are heavy components that can be lazy-loaded (chart libraries, rich editors, maps)
  ✓ SSR with streaming (Next.js App Router, React Server Components)
  ✓ Cross-page navigation needing smooth loading indications

Suspense is less beneficial when:
  ✗ Simple components with a single data fetch
     → Manual loading states are still more straightforward
  ✗ Data that must all exist before anything can be displayed
     → A single loading boundary is more appropriate
  ✗ Server rendering without streaming support
     → Suspense boundaries in SSR without streaming = blocking renders

Frameworks supporting Suspense:
  React:    Suspense + React.lazy + React 18 concurrent features
  Next.js:  App Router with Server Components + streaming
  Vue:      <Suspense> component (experimental but stable)
  Nuxt:     useLazyFetch + <NuxtLazyHydration>
  Solid.js: <Suspense> built-in with the resource API

Suspense Checklist #

BOUNDARY PLACEMENT:
  □ Per-section boundaries for independent content, not one global boundary
  □ Components that can be slow have their own boundaries
  □ Navigation-level (route changes) has per-route boundaries
  □ An Error Boundary always exists outside every Suspense boundary

FALLBACK QUALITY:
  □ Skeleton screens resemble the real content layout (not generic spinners)
  □ Skeleton dimensions close to real content dimensions (prevents CLS)
  □ Skeletons not too detailed — enough to communicate structure
  □ Skeleton colors consistent with the design system

PERFORMANCE:
  □ React.lazy used for components not needed on the initial load
  □ Data fetched in parallel (all starting together, not sequential)
  □ useTransition for filters/sorts/navigations wanting non-blocking behavior
  □ Suspense boundaries not overly granular (not per tiny component)

SERVER STREAMING:
  □ Page shells (headers, navs, footers) sent immediately without waiting for data
  □ Slow sections streamed separately with placeholders
  □ Error handling for streaming failing mid-way
  □ Transfer-Encoding: chunked configured correctly

TESTING:
  □ Skeleton screens tested under slow network conditions (Network Throttling in DevTools)
  □ Error states tested (turn off the API, see the Error Boundary work)
  □ CLS checked (no layout shifts when content appears)

Summary #

  • Suspense moves loading state responsibility from components to boundaries — components focus on rendering existing data; boundaries manage what’s shown while data isn’t available.
  • Boundary granularity determines UX quality — overly coarse boundaries make fast content wait for slow content; overly granular ones show many skeletons at once. Find the right balance per page.
  • Always pair Suspense with an Error Boundary — Suspense handles loading states, Error Boundaries handle error states. They complement each other and are both needed.
  • Skeleton screens must resemble the real content — dimensions close to the actual content prevent CLS (Cumulative Layout Shift) and give users accurate expectations of what will appear.
  • HTML streaming from the server is Suspense without a framework — chunked HTTP responses with placeholders and script injection provide the same experience as React Suspense, without needing any framework.
  • useTransition for non-blocking navigation — instead of showing skeletons when filters change, old content stays visible with a light progress indicator. Much smoother UX.
  • React Server Components + Suspense are the strongest combination — fetch data directly in server components, stream to the client when ready, without client-side API call waterfalls.
  • Don’t use Suspense for every small component — Suspense boundaries have overhead. Use them for components that can genuinely be slow or heavy, not as a default for everything.
  • Data fetching must be parallel, not sequential — separate Suspense boundaries let every section start its fetch simultaneously. This is one of the biggest benefits of proper boundary granularity.
  • Suspense isn’t a replacement for manual loading states — for simple use cases with one data fetch and one component, manual loading states with if (isLoading) are still easier to read and maintain.
#

← Previous: PWA   Next: WebP

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact