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.all hampir selalu lebih cepat — sequential await yang 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.

← Sebelumnya: WebP   Berikutnya: OWASP →

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