Asynchronous Content Loading #

Asynchronous content loading is the technique of loading content progressively — not all at once when the page first opens, but dynamically as needed. This is one of the techniques with the biggest impact on modern web app performance and UX: pages can appear in milliseconds with their shells, while heavier content loads in the background. But this technique also has its own traps: unnecessary request waterfalls, fetches not cancelled when components unmount, unthrottled scroll events burdening the browser, and infinite request loops when errors aren’t handled correctly. This article covers the right and wrong patterns, from parallel fetching to Intersection Observer, from Optimistic UI to WebSockets.

The Evolution of Asynchronous Loading #

Understanding the history helps understand why modern tools are designed the way they are.

AJAX (2005) — XMLHttpRequest:
  var xhr = new XMLHttpRequest()
  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      document.getElementById('content').innerHTML = xhr.responseText
    }
  }
  xhr.open('GET', '/api/data', true)
  xhr.send()
  → Works, but verbose and inelegant for error handling

fetch API (2015) — Promise-based:
  fetch('/api/data')
    .then(res => res.json())
    .then(data => updateDOM(data))
    .catch(err => handleError(err))
  → Cleaner, but Promise chaining can still get convoluted

async/await (2017) — Syntactic sugar over Promises:
  async function loadData() {
    try {
      const res = await fetch('/api/data')
      const data = await res.json()
      updateDOM(data)
    } catch (err) {
      handleError(err)
    }
  }
  → Code feels sequential but stays asynchronous

React Query / SWR (2019+) — Data fetching libraries:
  const { data, isLoading, error } = useQuery(['data'], fetchData)
  → Caching, deduplication, background refetch, automatic error handling
  → State management for server data included

Waterfall vs Parallel Request Patterns #

Waterfalls are one of the biggest causes of slow page loads — every request waits for the previous one to finish, even when they could run simultaneously.

sequenceDiagram
    participant B as Browser
    participant API as API Server

    Note over B,API: WATERFALL — Sequential (slow!)
    B->>API: GET /api/user
    API-->>B: user data (300ms)
    B->>API: GET /api/orders (waits for user to finish!)
    API-->>B: orders data (250ms)
    B->>API: GET /api/recommendations (waits for orders to finish!)
    API-->>B: recommendations (400ms)
    Note over B: Total: 950ms

    Note over B,API: PARALLEL — Concurrent (fast!)
    B->>API: GET /api/user
    B->>API: GET /api/orders (doesn't wait!)
    B->>API: GET /api/recommendations (doesn't wait!)
    API-->>B: user data (300ms)
    API-->>B: orders data (250ms)
    API-->>B: recommendations (400ms)
    Note over B: Total: 400ms (the slowest request's time)
// ANTI-PATTERN: Waterfall requests — sequential awaits
func loadDashboard() error {
    user, err := fetchUser()            // 300ms
    if err != nil {
        return err
    }
    orders, err := fetchOrders()        // 250ms (starts after the user finishes)
    if err != nil {
        return err
    }
    reco, err := fetchRecommendations() // 400ms (starts after orders finish)
    if err != nil {
        return err
    }
    // Total: 950ms
    return renderDashboard(user, orders, reco)
}

// CORRECT: Parallel requests — goroutines + WaitGroup
func loadDashboard() error {
    type payload struct {
        user, orders, reco any
    }
    var (
        wg  sync.WaitGroup
        res payload
    )
    wg.Add(3)
    go func() { defer wg.Done(); v, err := fetchUser(); if err == nil { res.user = v } }()            // all start simultaneously
    go func() { defer wg.Done(); v, err := fetchOrders(); if err == nil { res.orders = v } }()        // all start simultaneously
    go func() { defer wg.Done(); v, err := fetchRecommendations(); if err == nil { res.reco = v } }() // all start simultaneously
    wg.Wait()
    // Total: ~400ms (only waits for the slowest)
    return renderDashboard(res.user, res.orders, res.reco)
}

// EVEN BETTER: allSettled equivalent — doesn't fail because one request failed
func loadDashboard() error {
    type result struct {
        value any
        ok    bool
    }
    results := make([]result, 3)

    var wg sync.WaitGroup
    wg.Add(3)
    go func() { defer wg.Done(); v, err := fetchUser(); results[0] = result{v, err == nil} }()
    go func() { defer wg.Done(); v, err := fetchOrders(); results[1] = result{v, err == nil} }()
    go func() { defer wg.Done(); v, err := fetchRecommendations(); results[2] = result{v, err == nil} }()
    wg.Wait()

    user := results[0]
    orders := results[1]
    reco := results[2]

    return renderDashboard(Payload{
        user:   user.value,
        orders: orDefault(orders),
        reco:   orDefault(reco),
        // Render with the available data, even if some failed
    })
}

// Helper: return the value when ok, otherwise a sensible default
func orDefault(r result) any {
    if !r.ok {
        return []any{}
    }
    return r.value
}

Intersection Observer — Efficient Lazy Loading #

Intersection Observer is a browser API letting you detect when elements enter the viewport — without expensive scroll events.

// ANTI-PATTERN: Scroll events for lazy loading (very wasteful)
func onScroll() {
    for _, el := range document.QuerySelectorAll("[data-lazy]") {
        rect := el.GetBoundingClientRect()
        if rect.Top < window.InnerHeight() {
            loadContent(el) // getBoundingClientRect called on EVERY SCROLL EVENT!
        }
    }
}
window.AddEventListener("scroll", onScroll)
// Every scroll event = layout recalculation = jank!

// CORRECT: Intersection Observer — the browser handles detection
var observer *IntersectionObserver
observer = newIntersectionObserver(func(entries []IntersectionEntry) {
    for _, entry := range entries {
        if entry.IsIntersecting {
            loadContent(entry.Target)
            observer.Unobserve(entry.Target) // stop observing after loading
        }
    }
}, ObserverOptions{
    RootMargin: "200px", // start loading 200px before entering the viewport
    Threshold:  0,       // trigger when the first pixel enters the viewport
})

// Attach to all elements needing lazy loading
for _, el := range document.QuerySelectorAll("[data-lazy]") {
    observer.Observe(el)
}
// React hook for lazy loading with Intersection Observer
// (React hooks are a framework API — this shows the equivalent browser logic)

type LazyLoad struct {
    ref       *Element
    isVisible bool
}

func UseLazyLoad(options ObserverOptions) (*LazyLoad, func()) {
    ll := &LazyLoad{ref: &Element{}}

    var observer *IntersectionObserver
    observer = newIntersectionObserver(func(entries []IntersectionEntry) {
        for _, entry := range entries {
            if entry.IsIntersecting {
                ll.isVisible = true
                observer.Unobserve(ll.ref) // only needs to trigger once
            }
        }
    }, options)

    observer.Observe(ll.ref)
    // Cleanup: unobserve when the component unmounts
    return ll, func() { observer.Unobserve(ll.ref) }
}

// Usage:
func ProductComments(productID string) *Element {
    ll, _ := UseLazyLoad(ObserverOptions{RootMargin: "200px"})

    if ll.isVisible {
        return renderCommentsSection(productID)
    }
    return renderCommentsSkeleton()
}
// Comments are only fetched when the user scrolls near this section

Infinite Scroll vs Pagination — Choosing the Right One #

Infinite scroll and pagination are two different approaches to loading large amounts of data incrementally. They suit different use cases.

flowchart LR
    subgraph Pagination["Pagination — Load on Click"]
        P1["Page 1 (20 items)"]
        P2["[Click 'Page 2' button]"]
        P3["Page 2 (20 new items)"]
        P1 --> P2 --> P3
    end

    subgraph InfScroll["Infinite Scroll — Load on Scroll"]
        I1["First 20 items"]
        I2["User scrolls down..."]
        I3["Fetch the next 20 items"]
        I4["User scrolls again..."]
        I5["Fetch the next 20 items"]
        I1 --> I2 --> I3 --> I4 --> I5
    end
// Infinite Scroll implementation with Intersection Observer
// (React state and hooks shown as a plain state struct — framework equivalent)
type InfiniteProductListState struct {
    products  []Product
    page      int
    hasMore   bool
    isLoading bool
    loadMore  *Element // sentinel element
}

// Trigger a fetch when the "sentinel" element enters the viewport
func (s *InfiniteProductListState) setupObserver() {
    observer := newIntersectionObserver(func(entries []IntersectionEntry) {
        for _, entry := range entries {
            if entry.IsIntersecting && s.hasMore && !s.isLoading {
                s.page++ // setPage(p => p + 1)
            }
        }
    }, ObserverOptions{RootMargin: "300px"}) // start fetching 300px before the bottom

    observer.Observe(s.loadMore)
    // cleanup on unmount: observer.Disconnect()
}

// Fetch when the page changes
func (s *InfiniteProductListState) loadPage() {
    if s.page == 1 && len(s.products) > 0 {
        return
    }

    s.isLoading = true
    go func() {
        newProducts := fetchProducts(s.page)
        s.products = append(s.products, newProducts...)
        s.hasMore = len(newProducts) == 20 // more exists if 20 items came back
        s.isLoading = false
    }()
}

func (s *InfiniteProductListState) render() *Element {
    return renderFragment(
        renderProductGrid(s.products),
        // Sentinel element — when this enters the viewport, trigger a fetch
        s.loadMore,
        s.isLoading && renderLoadingSpinner(),
        !s.hasMore && renderParagraph("All products have been displayed"),
    )
}
When to use Infinite Scroll:
  ✓ Non-linear feeds (social media, news feeds)
  ✓ Content users keep consuming without specific goals
  ✓ Mobile app experiences
  ✗ Not suitable for content needing re-navigation
    (users can't return to the same position after reloads)

When to use Pagination:
  ✓ Search results (users need to know "this is page 3 of 15")
  ✓ Lists needing bookmarking or sharing
  ✓ Admin panels and data tables
  ✓ Content users may want to skip directly to specific pages
  ✗ Less smooth than infinite scroll for casual browsing

Debounce and Throttle for Event-Driven Fetching #

Fetches triggered by user interactions — search, scroll, resize — need limiting so the server isn’t overloaded.

// Debounce: wait for the user to stop before executing
// Ideal for: search inputs, form auto-save
type Debounce struct {
    timer  *time.Timer
    value  string
}

func (d *Debounce) Update(value string, delay time.Duration) {
    if d.timer != nil {
        d.timer.Stop() // reset the timer on every value change
    }
    d.timer = time.AfterFunc(delay, func() {
        d.value = value // setDebouncedValue(value)
    })
}

func (d *Debounce) Value() string { return d.value }

// Usage for search:
func searchBar() {
    query := ""
    debounced := &Debounce{}
    debounced.Update(query, 300*time.Millisecond) // wait 300ms after the user stops typing

    // useQuery equivalent — only fetch with at least 3 characters
    var results []Product
    if len(debounced.Value()) > 2 {
        results = searchProducts(debounced.Value())
    }

    // Render: input bound to query + results list
    renderSearchInput(query, func(newQuery string) { query = newQuery })
    renderSearchResults(results)
}
// Throttle: limit execution frequency
// Ideal for: scroll position tracking, resize handlers, mouse moves
func throttle(fn func(), limitMs int64) func() {
    lastCall := int64(0)
    return func() {
        now := time.Now().UnixMilli()
        if now-lastCall >= limitMs {
            lastCall = now
            fn()
        }
    }
}

// Throttle scroll position for content fetching
handleScroll := throttle(func() {
    scrollPercent := (window.ScrollY() / document.Body().ScrollHeight()) * 100
    if scrollPercent > 80 {
        fetchMoreContent()
    }
}, 200) // called at most once every 200ms

window.AddEventListener("scroll", handleScroll)
Debounce vs throttle:

Debounce:
  User keeps typing → no fetch
  User stops for 300ms → the fetch starts
  Ideal for: preventing fetches while the user is still typing

Throttle:
  User keeps scrolling → fetch called at most every 200ms
  Doesn't care whether the user stops or not
  Ideal for: periodic updates during an ongoing action

Cancelling Requests on Component Unmount #

In an SPA, components can unmount before requests finish. If not cancelled, setState gets called on components that no longer exist — causing memory leaks and errors.

// ANTI-PATTERN: Requests not cancelled on unmount
func ProductDetail(productID string) {
    product, err := fetchProduct(productID)
    if err == nil {
        renderProduct(product) // ERROR: the component may already be unmounted!
    }
}

// CORRECT: Use context cancellation (the Go equivalent of AbortController)
func ProductDetail(productID string) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // Cleanup: cancel the request when the component unmounts or productId changes

    product, err := fetchProduct(ctx, productID)
    if err != nil {
        if !errors.Is(err, context.Canceled) {
            // AbortError is expected — ignore
            // Other errors need handling
            log.Error("Fetch failed:", err)
        }
        return
    }
    renderProduct(product)
}

// Using React Query — AbortController is built-in
func ProductDetail(productID string) {
    // React Query equivalent: cache keyed by ['product', productID],
    // automatically passes an AbortSignal and cancels when query keys change
    product := useQuery("product", productID, func(ctx context.Context) (*Product, error) {
        return fetchProduct(ctx, productID)
    })
    renderProduct(product)
}

Optimistic UI — Updating Before Server Confirmation #

Optimistic UI is a technique where the UI updates immediately when users perform an action, without waiting for the server response. If the server fails, the UI is reverted.

// Example: a like button with Optimistic UI
type LikeButtonState struct {
    postID       string
    liked        bool
    count        int
    initialCount int
    isLoading    bool
}

func (s *LikeButtonState) HandleLike() {
    wasLiked := s.liked

    // Optimistic update — change the UI immediately
    s.liked = !s.liked
    if wasLiked {
        s.count--
    } else {
        s.count++
    }

    s.isLoading = true
    if err := toggleLike(s.postID); err != nil {
        // Failed — revert to the previous state
        s.liked = wasLiked
        s.count = s.initialCount
        showToast("Failed to update. Try again.")
    }
    // Succeeded — the UI is already correct (nothing to change)
    s.isLoading = false
}

// Render: button with ❤️/🤍 + count, disabled while loading
func (s *LikeButtonState) render() *Element {
    heart := "🤍"
    if s.liked {
        heart = "❤️"
    }
    return renderButton(heart+" "+strconv.Itoa(s.count), s.isLoading, s.HandleLike)
}
When Optimistic UI is appropriate:
  ✓ Actions that rarely fail (likes, follows, bookmarks)
  ✓ Actions whose UI is simple and easy to revert
  ✓ Actions whose ideal outcome is clear before server confirmation

When Optimistic UI is NOT appropriate:
  ✗ Financial actions (payments, transfers) — too risky to revert
  ✗ Actions that can change server-side (prices that may have changed)
  ✗ Actions with complex server-side validation that can't be predicted

Real-Time Updates — Polling vs WebSocket vs SSE #

For content changing in real time, there are three approaches with different trade-offs.

// 1. Polling — periodic requests to the server
func usePolling(fetchFn func() ([]byte, error), interval time.Duration) []byte {
    var data []byte

    data, _ = fetchFn() // fetch the first time

    ticker := time.NewTicker(interval)
    go func() {
        for range ticker.C {
            data, _ = fetchFn()
        }
    }()

    // Cleanup: ticker.Stop() when the component unmounts
    return data
}

// Use with backoff to reduce load when the tab is inactive
func useSmartPolling(fetchFn func()) {
    interval := 5 * time.Second // start with 5 seconds

    var poll func()
    poll = func() {
        fetchFn()
        // Slow down polling when the tab is inactive
        if document.Hidden {
            interval = 30 * time.Second
        } else {
            interval = 5 * time.Second
        }
        time.AfterFunc(interval, poll)
    }
    time.AfterFunc(interval, poll)
}

// 2. Server-Sent Events (SSE) — one-way server push
func useServerSentEvents(url string) []byte {
    var data []byte
    eventSource := newEventSource(url)

    eventSource.OnMessage = func(event Event) {
        data = json.Unmarshal(event.Data) // setData(JSON.parse(...))
    }

    eventSource.OnError = func() {
        // SSE auto-reconnects when the connection drops
    }

    // Cleanup: eventSource.Close() when the component unmounts
    return data
}

// 3. WebSocket — bidirectional real-time
// Good for: chat, collaborative editing, live games
// See framework documentation for more complete implementations
Selection guide:

Polling:
  ✓ Data changes not too often (every few seconds)
  ✓ Simple infrastructure — no persistent connections needed
  ✗ Wastes requests when data doesn't change

SSE (Server-Sent Events):
  ✓ The server needs to push updates to clients in real time
  ✓ One direction is enough (server → client)
  ✓ Auto-reconnect, simpler than WebSockets
  Examples: live feeds, notifications, progress tracking

WebSocket:
  ✓ Bidirectional: clients and servers send messages to each other
  ✓ Low latency for intensive communication
  Examples: chat, collaborative editing, multiplayer games

Error Handling and Retries #

Errors not handled properly cause infinite spinners or blank pages — an experience worse than slow loading.

// Comprehensive error handling pattern
type DataState struct {
    data       any
    err        error
    isLoading  bool
    retryCount int
}

func useDataWithRetry(fetchFn func() (any, error), maxRetries int) (DataState, func()) {
    state := DataState{isLoading: true}

    var load func(retryCount int)
    load = func(retryCount int) {
        state.isLoading = true
        state.err = nil

        data, err := fetchFn()
        if err != nil {
            if retryCount < maxRetries {
                // Exponential backoff: 1s, 2s, 4s
                delay := time.Duration(math.Pow(2, float64(retryCount))) * time.Second
                time.AfterFunc(delay, func() { load(retryCount + 1) })
            } else {
                state.data = nil
                state.err = err
                state.isLoading = false
                state.retryCount = retryCount
            }
            return
        }
        state.data = data
        state.err = nil
        state.isLoading = false
        state.retryCount = retryCount
    }

    load(0) // initial load
    return state, func() { load(0) } // retry
}

// Usage:
func ProductList() *Element {
    state, retry := useDataWithRetry(fetchProducts, 3)

    if state.isLoading {
        return renderProductSkeleton()
    }
    if state.err != nil {
        return renderErrorState("Failed to load products", retry)
    }
    return renderGrid(state.data)
}
Automatic retries must use exponential backoff — don’t retry at fixed intervals. If all clients retry at a fixed interval (e.g. every 1 second), a downed server gets hammered with thousands of simultaneous requests as it starts recovering. Exponential backoff (1s, 2s, 4s, 8s) spreads the load and gives the server time to recover.

Asynchronous Loading Anti-Patterns #

Unnecessary Request Waterfalls #

// ✗ Anti-pattern: sequential fetching without dependencies
func loadPage() {
    user, _ := fetchUser()
    products, _ := fetchProducts()      // doesn't need the user, but waits!
    categories, _ := fetchCategories()  // doesn't need either!
}

// ✓ Solution: parallel when there are no dependencies
func loadPage() {
    var (
        wg         sync.WaitGroup
        user       any
        products   any
        categories any
    )
    wg.Add(3)
    go func() { defer wg.Done(); user, _ = fetchUser() }()
    go func() { defer wg.Done(); products, _ = fetchProducts() }()
    go func() { defer wg.Done(); categories, _ = fetchCategories() }()
    wg.Wait()
}

Fetches Without Loading States #

// ✗ Anti-pattern: no feedback during loading
func ProductList() *Element {
    products := fetchProducts()
    // During the fetch: an empty list, users don't know what's happening
    return renderGrid(products)
}

// ✓ Solution: meaningful loading, error, and empty states
func ProductList() *Element {
    // useQuery equivalent: cache keyed by ['products']
    data, isLoading, err := useQuery("products", fetchProducts)

    if isLoading {
        return renderProductSkeleton(8)
    }
    if err != nil {
        return renderErrorState("Failed to load", func() { /* refetch */ })
    }
    if len(data) == 0 {
        return renderEmptyState("No products yet")
    }

    return renderGrid(data)
}

Unthrottled Scroll Events #

// ✗ Anti-pattern: fetching on every scroll event
window.AddEventListener("scroll", func() {
    fetchMoreIfNearBottom() // can be called hundreds of times per second!
})

// ✓ Solution: use Intersection Observer or throttle
throttledHandler := throttle(fetchMoreIfNearBottom, 200)
window.AddEventListener("scroll", throttledHandler)
// Or better: use Intersection Observer

Fetches Not Cancelled on Component Unmount #

// ✗ Anti-pattern: setState after unmount
func load() {
    data, _ := fetchData() // the component may already be unmounted!
    setData(data)
}

// ✓ Solution: context cancellation (AbortController equivalent)
func load() error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // cleanup on unmount

    resp, err := fetch(ctx, "/api/data")
    if err != nil {
        if errors.Is(err, context.Canceled) {
            return nil // AbortError is expected — ignore
        }
        return err // other errors propagate
    }
    setData(resp.JSON())
    return nil
}

Asynchronous Content Loading Checklist #

REQUEST STRATEGY:
  □ Independent requests run in parallel (Promise.all)
  □ Promise.allSettled used when partial failures are acceptable
  □ Waterfall requests only exist with real dependencies

LAZY LOADING:
  □ Below-the-fold content not fetched on the initial page load
  □ Intersection Observer used (not scroll events + getBoundingClientRect)
  □ rootMargin configured for prefetching before entering the viewport

INFINITE SCROLL AND PAGINATION:
  □ Sentinel elements exist to trigger the next fetch
  □ hasMore state exists to prevent fetching when no data remains
  □ Loading indicators appear below lists during fetches
  □ "All content displayed" states exist when data runs out

DEBOUNCE AND THROTTLE:
  □ Search inputs use debounce (not fetching on every keystroke)
  □ Scroll handlers use throttle or Intersection Observer
  □ Resize handlers use debounce

CANCEL AND CLEANUP:
  □ AbortController used and aborted in useEffect cleanups
  □ Timers (setTimeout, setInterval) cleaned up in useEffect cleanups
  □ Subscriptions cleaned up in useEffect cleanups

LOADING AND ERROR STATES:
  □ Skeletons or loading indicators exist for every async section
  □ Error states exist with retry options
  □ Empty states exist for empty lists
  □ Retries use exponential backoff

OPTIMISTIC UI:
  □ Optimistic updates only for rarely failing actions
  □ Reverts to previous states when the server fails
  □ Users notified when reverts happen (toast notifications)

REAL-TIME:
  □ Polling uses reasonable intervals (not too frequent)
  □ Polling slows down when tabs are inactive
  □ SSE or WebSocket used for truly real-time updates
  □ Connections cleaned up on component unmount

Summary #

  • Parallel requests with Promise.all are almost always faster — unnecessary sequential awaits (waterfalls) are one of the most common causes of slow page loads.
  • Intersection Observer is far more efficient than scroll events — browsers handle visibility detection natively without layout recalculations on every scroll pixel.
  • Debounce for inputs, throttle for scrolls — debounce waits for users to stop before fetching; throttle limits frequency during ongoing actions.
  • Always cancel requests on component unmount — use AbortController in useEffect cleanups. React Query handles this automatically.
  • Promise.allSettled for partial failures — if one of several requests fails, render with the available data instead of failing entirely.
  • Optimistic UI for rarely failing actions — update the UI immediately without waiting for the server, revert on failures. Far more responsive for likes, bookmarks, and follows.
  • Exponential backoff for retries — fixed intervals cause thundering herds during server recovery. 1s, 2s, 4s, 8s spreads the load better.
  • Infinite scroll for browsing, pagination for navigation — infinite scroll suits feeds; pagination suits search results needing navigation or bookmarking.
  • Loading, error, and empty states are mandatory — infinite spinners or unexplained blank pages are worse UX than slow loading.
  • Polling with backoff when tabs are inactive — if users aren’t viewing a tab, polling can slow from 5 seconds to 30 seconds to save bandwidth and server resources.
#

← Previous: WebP   Next: OWASP

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