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 — await berurutan
func loadDashboard() error {
user, err := fetchUser() // 300ms
if err != nil {
return err
}
orders, err := fetchOrders() // 250ms (mulai setelah user selesai)
if err != nil {
return err
}
reco, err := fetchRecommendations() // 400ms (mulai setelah orders selesai)
if err != nil {
return err
}
// Total: 950ms
return renderDashboard(user, orders, reco)
}
// BENAR: Parallel request — goroutine + 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 } }() // semua dimulai bersamaan
go func() { defer wg.Done(); v, err := fetchOrders(); if err == nil { res.orders = v } }() // semua dimulai bersamaan
go func() { defer wg.Done(); v, err := fetchRecommendations(); if err == nil { res.reco = v } }() // semua dimulai bersamaan
wg.Wait()
// Total: ~400ms (hanya menunggu yang terlama)
return renderDashboard(res.user, res.orders, res.reco)
}
// LEBIH BAIK: padanan allSettled — tidak gagal karena satu request gagal
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 dengan data yang tersedia, meski sebagian gagal
})
}
// Helper: kembalikan value saat ok, selain itu default yang masuk akal
func orDefault(r result) any {
if !r.ok {
return []any{}
}
return r.value
}
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)
func onScroll() {
for _, el := range document.QuerySelectorAll("[data-lazy]") {
rect := el.GetBoundingClientRect()
if rect.Top < window.InnerHeight() {
loadContent(el) // getBoundingClientRect dipanggil SETIAP SCROLL EVENT!
}
}
}
window.AddEventListener("scroll", onScroll)
// Setiap scroll event = layout recalculation = jank!
// BENAR: Intersection Observer — browser yang handle deteksinya
var observer *IntersectionObserver
observer = newIntersectionObserver(func(entries []IntersectionEntry) {
for _, entry := range entries {
if entry.IsIntersecting {
loadContent(entry.Target)
observer.Unobserve(entry.Target) // berhenti observasi setelah dimuat
}
}
}, ObserverOptions{
RootMargin: "200px", // mulai load 200px sebelum masuk viewport
Threshold: 0, // trigger saat pixel pertama masuk viewport
})
// Attach ke semua elemen yang perlu lazy load
for _, el := range document.QuerySelectorAll("[data-lazy]") {
observer.Observe(el)
}
// React Hook untuk lazy loading dengan Intersection Observer
// (React hooks adalah framework API — ini menunjukkan logika browser yang setara)
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) // hanya perlu trigger sekali
}
}
}, options)
observer.Observe(ll.ref)
// Cleanup: berhenti observasi saat komponen unmount
return ll, func() { observer.Unobserve(ll.ref) }
}
// Penggunaan:
func ProductComments(productID string) *Element {
ll, _ := UseLazyLoad(ObserverOptions{RootMargin: "200px"})
if ll.isVisible {
return renderCommentsSection(productID)
}
return renderCommentsSkeleton()
}
// 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
// (state dan hooks React ditampilkan sebagai struct state biasa — padanan framework)
type InfiniteProductListState struct {
products []Product
page int
hasMore bool
isLoading bool
loadMore *Element // sentinel element
}
// Trigger fetch saat "sentinel" element masuk 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"}) // mulai fetch 300px sebelum bottom
observer.Observe(s.loadMore)
// cleanup saat unmount: observer.Disconnect()
}
// Fetch saat page berubah
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 // ada lebih banyak jika dapat 20 item
s.isLoading = false
}()
}
func (s *InfiniteProductListState) render() *Element {
return renderFragment(
renderProductGrid(s.products),
// Sentinel element — ketika ini masuk viewport, trigger fetch
s.loadMore,
s.isLoading && renderLoadingSpinner(),
!s.hasMore && renderParagraph("Semua produk sudah ditampilkan"),
)
}
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
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 timer setiap value berubah
}
d.timer = time.AfterFunc(delay, func() {
d.value = value // setDebouncedValue(value)
})
}
func (d *Debounce) Value() string { return d.value }
// Penggunaan untuk search:
func searchBar() {
query := ""
debounced := &Debounce{}
debounced.Update(query, 300*time.Millisecond) // tunggu 300ms setelah user stop typing
// Padanan useQuery — fetch hanya jika minimal 3 karakter
var results []Product
if len(debounced.Value()) > 2 {
results = searchProducts(debounced.Value())
}
// Render: input terikat ke query + daftar hasil
renderSearchInput(query, func(newQuery string) { query = newQuery })
renderSearchResults(results)
}
// Throttle: batasi frekuensi eksekusi
// Ideal untuk: scroll position tracking, resize handler, mouse move
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 untuk fetch konten
handleScroll := throttle(func() {
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
func ProductDetail(productID string) {
product, err := fetchProduct(productID)
if err == nil {
renderProduct(product) // ERROR: komponen mungkin sudah unmount!
}
}
// BENAR: Gunakan context cancellation (padanan Go dari AbortController)
func ProductDetail(productID string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Cleanup: cancel request saat komponen unmount atau productId berubah
product, err := fetchProduct(ctx, productID)
if err != nil {
if !errors.Is(err, context.Canceled) {
// AbortError adalah expected — ignore
// Error lain perlu di-handle
log.Error("Fetch failed:", err)
}
return
}
renderProduct(product)
}
// Menggunakan React Query — AbortController sudah built-in
func ProductDetail(productID string) {
// Padanan React Query: cache dengan key ['product', productID],
// otomatis pass AbortSignal dan cancel saat query key berubah
product := useQuery("product", productID, func(ctx context.Context) (*Product, error) {
return fetchProduct(ctx, productID)
})
renderProduct(product)
}
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
type LikeButtonState struct {
postID string
liked bool
count int
initialCount int
isLoading bool
}
func (s *LikeButtonState) HandleLike() {
wasLiked := s.liked
// Optimistic update — ubah UI segera
s.liked = !s.liked
if wasLiked {
s.count--
} else {
s.count++
}
s.isLoading = true
if err := toggleLike(s.postID); err != nil {
// Gagal — revert ke state sebelumnya
s.liked = wasLiked
s.count = s.initialCount
showToast("Gagal memperbarui. Coba lagi.")
}
// Berhasil — UI sudah benar (tidak perlu ubah apa-apa)
s.isLoading = false
}
// Render: button dengan ❤️/🤍 + count, disabled saat loading
func (s *LikeButtonState) render() *Element {
heart := "🤍"
if s.liked {
heart = "❤️"
}
return renderButton(heart+" "+strconv.Itoa(s.count), s.isLoading, s.HandleLike)
}
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
func usePolling(fetchFn func() ([]byte, error), interval time.Duration) []byte {
var data []byte
data, _ = fetchFn() // fetch pertama kali
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
data, _ = fetchFn()
}
}()
// Cleanup: ticker.Stop() saat komponen unmount
return data
}
// Gunakan dengan backoff untuk mengurangi beban saat tab tidak aktif
func useSmartPolling(fetchFn func()) {
interval := 5 * time.Second // mulai dengan 5 detik
var poll func()
poll = func() {
fetchFn()
// Slow down polling saat tab tidak aktif
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) — server push satu arah
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 otomatis reconnect saat koneksi putus
}
// Cleanup: eventSource.Close() saat komponen unmount
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
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) // load awal
return state, func() { load(0) } // retry
}
// Penggunaan:
func ProductList() *Element {
state, retry := useDataWithRetry(fetchProducts, 3)
if state.isLoading {
return renderProductSkeleton()
}
if state.err != nil {
return renderErrorState("Gagal memuat produk", retry)
}
return renderGrid(state.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
func loadPage() {
user, _ := fetchUser()
products, _ := fetchProducts() // tidak butuh user, tapi menunggu!
categories, _ := fetchCategories() // tidak butuh keduanya!
}
// ✓ Solusi: parallel ketika tidak ada dependency
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()
}
Fetch Tanpa Loading State #
// ✗ Anti-pattern: tidak ada feedback selama loading
func ProductList() *Element {
products := fetchProducts()
// Selama fetch: daftar kosong, user tidak tahu apa yang terjadi
return renderGrid(products)
}
// ✓ Solusi: loading, error, dan empty state yang bermakna
func ProductList() *Element {
// Padanan useQuery: cache dengan key ['products']
data, isLoading, err := useQuery("products", fetchProducts)
if isLoading {
return renderProductSkeleton(8)
}
if err != nil {
return renderErrorState("Gagal memuat", func() { /* refetch */ })
}
if len(data) == 0 {
return renderEmptyState("Belum ada produk")
}
return renderGrid(data)
}
Scroll Event tanpa Throttle #
// ✗ Anti-pattern: fetch di setiap scroll event
window.AddEventListener("scroll", func() {
fetchMoreIfNearBottom() // bisa dipanggil ratusan kali per detik!
})
// ✓ Solusi: gunakan Intersection Observer atau throttle
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
func load() {
data, _ := fetchData() // komponen mungkin sudah unmount!
setData(data)
}
// ✓ Solusi: context cancellation (padanan AbortController)
func load() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // cleanup saat unmount
resp, err := fetch(ctx, "/api/data")
if err != nil {
if errors.Is(err, context.Canceled) {
return nil // AbortError adalah expected — ignore
}
return err // error lain diteruskan
}
setData(resp.JSON())
return nil
}
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.