Asynchronous Content Loading #
Asynchronous content loading adalah teknik memuat konten secara bertahap — tidak semua sekaligus saat halaman pertama dibuka, tapi secara dinamis saat dibutuhkan. Ini adalah salah satu teknik yang paling berpengaruh pada performa dan UX aplikasi web modern: halaman bisa tampil dalam hitungan milidetik dengan shell-nya, sementara konten yang lebih berat dimuat di latar belakang. Tapi teknik ini juga punya jebakan tersendiri: waterfall request yang tidak perlu, fetch yang tidak di-cancel saat komponen unmount, scroll event tanpa throttle yang membebani browser, dan infinite loop request saat error tidak ditangani dengan benar. Artikel ini membahas pola-pola yang benar dan salah, dari parallel fetch sampai Intersection Observer, dari Optimistic UI sampai WebSocket.
Evolusi Asynchronous Loading #
Memahami sejarahnya membantu memahami mengapa tools modern didesain seperti sekarang.
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()
→ Bekerja, tapi verbose dan tidak elegant untuk error handling
fetch API (2015) — Promise-based:
fetch('/api/data')
.then(res => res.json())
.then(data => updateDOM(data))
.catch(err => handleError(err))
→ Lebih bersih, tapi Promise chaining masih bisa berbelit
async/await (2017) — Syntactic sugar di atas Promise:
async function loadData() {
try {
const res = await fetch('/api/data')
const data = await res.json()
updateDOM(data)
} catch (err) {
handleError(err)
}
}
→ Kode terasa sequential tapi tetap asynchronous
React Query / SWR (2019+) — Data fetching library:
const { data, isLoading, error } = useQuery(['data'], fetchData)
→ Caching, deduplication, background refetch, error handling otomatis
→ State management untuk server data sudah termasuk
Pola Waterfall vs Parallel Request #
Waterfall adalah salah satu penyebab terbesar page load yang lambat — setiap request menunggu yang sebelumnya selesai, padahal bisa dijalankan bersamaan.
sequenceDiagram
participant B as Browser
participant API as API Server
Note over B,API: WATERFALL — Sequential (lambat!)
B->>API: GET /api/user
API-->>B: user data (300ms)
B->>API: GET /api/orders (menunggu user selesai!)
API-->>B: orders data (250ms)
B->>API: GET /api/recommendations (menunggu orders selesai!)
API-->>B: recommendations (400ms)
Note over B: Total: 950ms
Note over B,API: PARALLEL — Concurrent (cepat!)
B->>API: GET /api/user
B->>API: GET /api/orders (tidak menunggu!)
B->>API: GET /api/recommendations (tidak menunggu!)
API-->>B: user data (300ms)
API-->>B: orders data (250ms)
API-->>B: recommendations (400ms)
Note over B: Total: 400ms (waktu request terlama)// ANTI-PATTERN: Waterfall request — sequential await
async function loadDashboard() {
const user = await fetchUser() // 300ms
const orders = await fetchOrders() // 250ms (mulai setelah user selesai)
const reco = await fetchRecommendations() // 400ms (mulai setelah orders selesai)
// Total: 950ms
renderDashboard({ user, orders, reco })
}
// BENAR: Parallel request — Promise.all
async function loadDashboard() {
const [user, orders, reco] = await Promise.all([
fetchUser(), // semua dimulai bersamaan
fetchOrders(),
fetchRecommendations(),
])
// Total: ~400ms (hanya menunggu yang terlama)
renderDashboard({ user, orders, reco })
}
// LEBIH BAIK: Promise.allSettled — tidak gagal karena satu request gagal
async function loadDashboard() {
const results = await Promise.allSettled([
fetchUser(),
fetchOrders(),
fetchRecommendations(),
])
const [userResult, ordersResult, recoResult] = results
renderDashboard({
user: userResult.status === 'fulfilled' ? userResult.value : null,
orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [],
reco: recoResult.status === 'fulfilled' ? recoResult.value : [],
// Render dengan data yang tersedia, meski sebagian gagal
})
}
Intersection Observer — Lazy Load yang Efisien #
Intersection Observer adalah API browser yang memungkinkan kamu mendeteksi kapan elemen masuk ke viewport — tanpa scroll event yang mahal.
// ANTI-PATTERN: Scroll event untuk lazy load (sangat boros)
window.addEventListener('scroll', () => {
const elements = document.querySelectorAll('[data-lazy]')
elements.forEach(el => {
const rect = el.getBoundingClientRect()
if (rect.top < window.innerHeight) {
loadContent(el) // getBoundingClientRect dipanggil SETIAP SCROLL EVENT!
}
})
})
// Setiap scroll event = layout recalculation = jank!
// BENAR: Intersection Observer — browser yang handle deteksinya
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadContent(entry.target)
observer.unobserve(entry.target) // berhenti observasi setelah dimuat
}
})
}, {
rootMargin: '200px', // mulai load 200px sebelum masuk viewport
threshold: 0 // trigger saat pixel pertama masuk viewport
})
// Attach ke semua elemen yang perlu lazy load
document.querySelectorAll('[data-lazy]').forEach(el => {
observer.observe(el)
})
// React Hook untuk lazy loading dengan Intersection Observer
import { useEffect, useRef, useState } from 'react'
function useLazyLoad(options = {}) {
const ref = useRef(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.unobserve(el) // hanya perlu trigger sekali
}
}, { rootMargin: '200px', ...options })
observer.observe(el)
return () => observer.unobserve(el)
}, [])
return [ref, isVisible]
}
// Penggunaan:
function ProductComments({ productId }) {
const [ref, isVisible] = useLazyLoad()
return (
<div ref={ref}>
{isVisible ? (
<CommentsSection productId={productId} />
) : (
<CommentsSkeleton />
)}
</div>
)
}
// Comments hanya di-fetch saat user scroll mendekati section ini
Infinite Scroll vs Pagination — Pilihan yang Tepat #
Infinite scroll dan pagination adalah dua pendekatan berbeda untuk memuat data dalam jumlah besar secara inkremental. Keduanya cocok untuk use case yang berbeda.
flowchart LR
subgraph Pagination["Pagination — Load on Click"]
P1["Halaman 1 (20 item)"]
P2["[Klik tombol 'Halaman 2']"]
P3["Halaman 2 (20 item baru)"]
P1 --> P2 --> P3
end
subgraph InfScroll["Infinite Scroll — Load on Scroll"]
I1["20 item pertama"]
I2["User scroll ke bawah..."]
I3["Fetch 20 item berikutnya"]
I4["User scroll lagi..."]
I5["Fetch 20 item berikutnya"]
I1 --> I2 --> I3 --> I4 --> I5
end// Implementasi Infinite Scroll dengan Intersection Observer
function InfiniteProductList() {
const [products, setProducts] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const loadMoreRef = useRef(null)
// Trigger fetch saat "sentinel" element masuk viewport
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
setPage(p => p + 1)
}
}, { rootMargin: '300px' }) // mulai fetch 300px sebelum bottom
if (loadMoreRef.current) observer.observe(loadMoreRef.current)
return () => observer.disconnect()
}, [hasMore, isLoading])
// Fetch saat page berubah
useEffect(() => {
if (page === 1 && products.length > 0) return
setIsLoading(true)
fetchProducts(page).then(newProducts => {
setProducts(prev => [...prev, ...newProducts])
setHasMore(newProducts.length === 20) // ada lebih banyak jika dapat 20 item
setIsLoading(false)
})
}, [page])
return (
<div>
<ProductGrid products={products} />
{/* Sentinel element — ketika ini masuk viewport, trigger fetch */}
<div ref={loadMoreRef}>
{isLoading && <LoadingSpinner />}
{!hasMore && <p>Semua produk sudah ditampilkan</p>}
</div>
</div>
)
}
Kapan menggunakan Infinite Scroll:
✓ Feed yang tidak linear (social media, news feed)
✓ Konten yang user terus consume tanpa tujuan spesifik
✓ Mobile app experience
✗ Tidak cocok untuk konten yang perlu di-navigate ulang
(user tidak bisa kembali ke posisi yang sama setelah reload)
Kapan menggunakan Pagination:
✓ Hasil pencarian (user perlu tahu "ini halaman 3 dari 15")
✓ Daftar yang perlu di-bookmark atau di-share
✓ Admin panel dan data table
✓ Konten yang user mungkin ingin skip langsung ke halaman tertentu
✗ Kurang mulus dari infinite scroll untuk browsing kasual
Debounce dan Throttle untuk Event-Driven Fetch #
Fetch yang dipicu oleh interaksi user — search, scroll, resize — perlu dibatasi agar tidak overload server.
// Debounce: tunggu user berhenti sebelum eksekusi
// Ideal untuk: search input, form auto-save
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer) // reset timer setiap value berubah
}, [value, delay])
return debouncedValue
}
// Penggunaan untuk search:
function SearchBar() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300) // tunggu 300ms setelah user stop typing
const { data: results } = useQuery(
['search', debouncedQuery],
() => searchProducts(debouncedQuery),
{ enabled: debouncedQuery.length > 2 } // fetch hanya jika minimal 3 karakter
)
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Cari produk..."
/>
<SearchResults results={results} />
</>
)
}
// Throttle: batasi frekuensi eksekusi
// Ideal untuk: scroll position tracking, resize handler, mouse move
function throttle(fn, limitMs) {
let lastCall = 0
return function(...args) {
const now = Date.now()
if (now - lastCall >= limitMs) {
lastCall = now
return fn(...args)
}
}
}
// Throttle scroll position untuk fetch konten
const handleScroll = throttle(() => {
const scrollPercent = (window.scrollY / document.body.scrollHeight) * 100
if (scrollPercent > 80) {
fetchMoreContent()
}
}, 200) // maksimal dipanggil sekali setiap 200ms
window.addEventListener('scroll', handleScroll)
Perbedaan debounce vs throttle:
Debounce:
User terus mengetik → tidak ada fetch
User berhenti 300ms → fetch dimulai
Ideal untuk: mencegah fetch saat user masih mengetik
Throttle:
User terus scroll → fetch dipanggil maksimal setiap 200ms
Tidak peduli apakah user berhenti atau tidak
Ideal untuk: update yang perlu berkala selama aksi berlangsung
Cancel Request saat Komponen Unmount #
Di SPA, komponen bisa unmount sebelum request selesai. Jika tidak di-cancel, setState akan dipanggil pada komponen yang sudah tidak ada — menyebabkan memory leak dan error.
// ANTI-PATTERN: Request tidak di-cancel saat unmount
function ProductDetail({ productId }) {
const [product, setProduct] = useState(null)
useEffect(() => {
fetchProduct(productId).then(data => {
setProduct(data) // ERROR: komponen mungkin sudah unmount!
})
}, [productId])
return <div>{product?.name}</div>
}
// BENAR: Gunakan AbortController
function ProductDetail({ productId }) {
const [product, setProduct] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch(`/api/products/${productId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => {
if (err.name !== 'AbortError') {
// AbortError adalah expected — ignore
// Error lain perlu di-handle
console.error('Fetch failed:', err)
}
})
// Cleanup: cancel request saat komponen unmount atau productId berubah
return () => controller.abort()
}, [productId])
return <div>{product?.name}</div>
}
// Menggunakan React Query — AbortController sudah built-in
function ProductDetail({ productId }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: ({ signal }) => fetchProduct(productId, { signal }),
// React Query otomatis pass AbortSignal dan cancel saat query key berubah
})
return <div>{product?.name}</div>
}
Optimistic UI — Update Sebelum Konfirmasi Server #
Optimistic UI adalah teknik di mana UI diupdate segera saat user melakukan aksi, tanpa menunggu response server. Jika server gagal, UI di-revert.
// Contoh: like button dengan Optimistic UI
function LikeButton({ postId, initialLiked, initialCount }) {
const [liked, setLiked] = useState(initialLiked)
const [count, setCount] = useState(initialCount)
const [isLoading, setIsLoading] = useState(false)
async function handleLike() {
const wasLiked = liked
// Optimistic update — ubah UI segera
setLiked(!liked)
setCount(liked ? count - 1 : count + 1)
setIsLoading(true)
try {
await toggleLike(postId)
// Berhasil — UI sudah benar (tidak perlu ubah apa-apa)
} catch {
// Gagal — revert ke state sebelumnya
setLiked(wasLiked)
setCount(initialCount)
showToast('Gagal memperbarui. Coba lagi.')
} finally {
setIsLoading(false)
}
}
return (
<button onClick={handleLike} disabled={isLoading}>
{liked ? '❤️' : '🤍'} {count}
</button>
)
}
Kapan Optimistic UI tepat:
✓ Aksi yang jarang gagal (like, follow, bookmark)
✓ Aksi yang UI-nya sederhana dan mudah di-revert
✓ Aksi yang hasil idealnya sudah jelas sebelum konfirmasi server
Kapan Optimistic UI tidak tepat:
✗ Aksi finansial (payment, transfer) — terlalu berisiko jika revert
✗ Aksi yang bisa berubah di server (harga yang mungkin sudah berubah)
✗ Aksi dengan validasi kompleks di server yang tidak bisa diprediksi
Real-Time Update — Polling vs WebSocket vs SSE #
Untuk konten yang berubah secara real-time, ada tiga pendekatan dengan trade-off berbeda.
// 1. Polling — request berkala ke server
function usePolling(fetchFn, intervalMs) {
const [data, setData] = useState(null)
useEffect(() => {
fetchFn().then(setData) // fetch pertama kali
const interval = setInterval(() => {
fetchFn().then(setData)
}, intervalMs)
return () => clearInterval(interval) // cleanup
}, [])
return data
}
// Gunakan dengan backoff untuk mengurangi beban saat tab tidak aktif
function useSmartPolling(fetchFn) {
useEffect(() => {
let interval = 5000 // mulai dengan 5 detik
function poll() {
fetchFn()
// Slow down polling saat tab tidak aktif
interval = document.hidden ? 30000 : 5000
timeoutId = setTimeout(poll, interval)
}
let timeoutId = setTimeout(poll, interval)
return () => clearTimeout(timeoutId)
}, [])
}
// 2. Server-Sent Events (SSE) — server push satu arah
function useServerSentEvents(url) {
const [data, setData] = useState(null)
useEffect(() => {
const eventSource = new EventSource(url)
eventSource.onmessage = (event) => {
setData(JSON.parse(event.data))
}
eventSource.onerror = () => {
// SSE otomatis reconnect saat koneksi putus
}
return () => eventSource.close()
}, [url])
return data
}
// 3. WebSocket — bidirectional real-time
// Cocok untuk: chat, collaborative editing, live game
// Lihat dokumentasi framework untuk implementasi yang lebih lengkap
Panduan memilih:
Polling:
✓ Data berubah tidak terlalu sering (setiap beberapa detik)
✓ Infrastruktur sederhana — tidak perlu koneksi persistent
✗ Boros request jika data tidak berubah
SSE (Server-Sent Events):
✓ Server perlu push update ke client secara real-time
✓ Satu arah cukup (server → client)
✓ Otomatis reconnect, lebih sederhana dari WebSocket
Contoh: live feed, notifikasi, progress tracking
WebSocket:
✓ Bidirectional: client dan server saling kirim pesan
✓ Low-latency untuk komunikasi intensif
Contoh: chat, collaborative editing, multiplayer game
Error Handling dan Retry #
Error yang tidak ditangani dengan baik menyebabkan spinner infinite atau blank page — pengalaman yang lebih buruk dari loading yang lambat.
// Pattern error handling yang komprehensif
function useDataWithRetry(fetchFn, maxRetries = 3) {
const [state, setState] = useState({
data: null,
error: null,
isLoading: true,
retryCount: 0,
})
async function load(retryCount = 0) {
setState(prev => ({ ...prev, isLoading: true, error: null }))
try {
const data = await fetchFn()
setState({ data, error: null, isLoading: false, retryCount })
} catch (err) {
if (retryCount < maxRetries) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, retryCount) * 1000
setTimeout(() => load(retryCount + 1), delay)
} else {
setState({ data: null, error: err, isLoading: false, retryCount })
}
}
}
useEffect(() => { load() }, [])
return { ...state, retry: () => load() }
}
// Penggunaan:
function ProductList() {
const { data, error, isLoading, retry } = useDataWithRetry(fetchProducts)
if (isLoading) return <ProductSkeleton />
if (error) return (
<div className="error-state">
<p>Gagal memuat produk</p>
<button onClick={retry}>Coba Lagi</button>
</div>
)
return <Grid products={data} />
}
Retry otomatis harus menggunakan exponential backoff — jangan retry dengan interval tetap. Jika semua client melakukan retry pada interval tetap (misal setiap 1 detik), server yang sudah down justru di-hammer dengan ribuan request secara bersamaan saat mulai recovery. Exponential backoff (1s, 2s, 4s, 8s) menyebar beban dan memberikan waktu server untuk recover.
Anti-Pattern Asynchronous Loading #
Request Waterfall yang Tidak Perlu #
// ✗ Anti-pattern: fetch sequential padahal tidak ada dependency
async function loadPage() {
const user = await fetchUser()
const products = await fetchProducts() // tidak butuh user, tapi menunggu!
const categories = await fetchCategories() // tidak butuh keduanya!
}
// ✓ Solusi: parallel ketika tidak ada dependency
const [user, products, categories] = await Promise.all([
fetchUser(), fetchProducts(), fetchCategories()
])
Fetch Tanpa Loading State #
// ✗ Anti-pattern: tidak ada feedback selama loading
function ProductList() {
const [products, setProducts] = useState([])
useEffect(() => {
fetchProducts().then(setProducts)
// Selama fetch: daftar kosong, user tidak tahu apa yang terjadi
}, [])
return <Grid products={products} />
}
// ✓ Solusi: loading, error, dan empty state yang bermakna
function ProductList() {
const { data, isLoading, error } = useQuery(['products'], fetchProducts)
if (isLoading) return <ProductSkeleton count={8} />
if (error) return <ErrorState message="Gagal memuat" onRetry={refetch} />
if (!data?.length) return <EmptyState message="Belum ada produk" />
return <Grid products={data} />
}
Scroll Event tanpa Throttle #
// ✗ Anti-pattern: fetch di setiap scroll event
window.addEventListener('scroll', () => {
fetchMoreIfNearBottom() // bisa dipanggil ratusan kali per detik!
})
// ✓ Solusi: gunakan Intersection Observer atau throttle
const throttledHandler = throttle(fetchMoreIfNearBottom, 200)
window.addEventListener('scroll', throttledHandler)
// Atau lebih baik: gunakan Intersection Observer
Fetch yang Tidak Di-cancel saat Komponen Unmount #
// ✗ Anti-pattern: setState setelah unmount
useEffect(() => {
fetchData().then(data => setData(data)) // komponen mungkin sudah unmount!
}, [])
// ✓ Solusi: AbortController atau React Query
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(e => { if (e.name !== 'AbortError') throw e })
return () => controller.abort()
}, [])
Checklist Asynchronous Content Loading #
REQUEST STRATEGY:
□ Request yang tidak saling bergantung dilakukan secara parallel (Promise.all)
□ Promise.allSettled digunakan jika partial failure acceptable
□ Waterfall request hanya ada jika ada dependency yang nyata
LAZY LOADING:
□ Konten below-the-fold tidak di-fetch saat halaman pertama dimuat
□ Intersection Observer digunakan (bukan scroll event + getBoundingClientRect)
□ rootMargin dikonfigurasi untuk prefetch sebelum masuk viewport
INFINITE SCROLL DAN PAGINATION:
□ Sentinel element ada untuk memicu fetch berikutnya
□ hasMore state ada untuk mencegah fetch saat sudah tidak ada data
□ Loading indicator ada di bawah list saat fetch berlangsung
□ "Sudah semua ditampilkan" state ada saat data habis
DEBOUNCE DAN THROTTLE:
□ Search input menggunakan debounce (bukan fetch setiap keystroke)
□ Scroll handler menggunakan throttle atau Intersection Observer
□ Resize handler menggunakan debounce
CANCEL DAN CLEANUP:
□ AbortController digunakan dan di-abort di useEffect cleanup
□ Timer (setTimeout, setInterval) di-cleanup di useEffect cleanup
□ Subscription di-cleanup di useEffect cleanup
LOADING DAN ERROR STATE:
□ Skeleton atau loading indicator ada untuk setiap async section
□ Error state ada dengan opsi retry
□ Empty state ada untuk daftar yang kosong
□ Retry menggunakan exponential backoff
OPTIMISTIC UI:
□ Optimistic update hanya untuk aksi yang jarang gagal
□ Revert ke state sebelumnya jika server gagal
□ User diberitahu jika revert terjadi (toast notification)
REAL-TIME:
□ Polling menggunakan interval yang wajar (bukan terlalu sering)
□ Polling slowdown saat tab tidak aktif
□ SSE atau WebSocket untuk update yang benar-benar real-time
□ Koneksi dibersihkan saat komponen unmount
Ringkasan #
- Parallel request menggunakan
Promise.allhampir selalu lebih cepat — sequentialawaityang tidak perlu (waterfall) adalah salah satu penyebab paling umum page load yang lambat.- Intersection Observer jauh lebih efisien dari scroll event — browser menghandle deteksi visibilitas secara native tanpa layout recalculation di setiap scroll pixel.
- Debounce untuk input, throttle untuk scroll — debounce menunggu user berhenti sebelum fetch, throttle membatasi frekuensi selama aksi berlangsung.
- Selalu cancel request saat komponen unmount — gunakan AbortController di useEffect cleanup. React Query sudah menangani ini secara otomatis.
- Promise.allSettled untuk partial failure — jika satu dari beberapa request gagal, render dengan data yang tersedia alih-alih gagal total.
- Optimistic UI untuk aksi yang jarang gagal — update UI segera tanpa menunggu server, revert jika gagal. Jauh lebih responsif untuk like, bookmark, dan follow.
- Exponential backoff untuk retry — interval tetap menyebabkan thundering herd saat server recovery. 1s, 2s, 4s, 8s menyebar beban lebih baik.
- Infinite scroll untuk browsing, pagination untuk navigasi — infinite scroll cocok untuk feed; pagination cocok untuk hasil pencarian yang perlu di-navigate atau di-bookmark.
- Loading, error, dan empty state wajib ada — spinner infinite atau blank page tanpa penjelasan adalah UX yang lebih buruk dari loading yang lambat.
- Polling dengan backoff saat tab tidak aktif — jika user tidak melihat tab, polling bisa diperlambat dari 5 detik menjadi 30 detik untuk menghemat bandwidth dan server resources.