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.
#
- Parallel requests with
Promise.allare almost always faster — unnecessary sequentialawaits (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.