Client-Side Resilience: Membangun Aplikasi Web yang Tangguh dengan Retry, Fallback, dan Timeout
1. Pendahuluan
Pernahkah Anda mengalami aplikasi web yang “hang” atau menampilkan pesan error generik yang membingungkan saat koneksi internet sedikit goyah? Atau mungkin API backend sedang dalam perawatan, dan frontend Anda tiba-tiba lumpuh? Di dunia aplikasi web modern yang semakin terdistribusi, gangguan adalah keniscayaan. Jaringan yang tidak stabil, server yang kelebihan beban, atau bahkan bug sementara di backend bisa merusak pengalaman pengguna dan membuat aplikasi Anda terasa rapuh.
Sebagai developer, kita sering fokus pada sisi backend untuk membangun sistem yang tangguh. Kita menerapkan circuit breaker, idempotency, message queues, dan berbagai pola arsitektur lain. Tapi bagaimana dengan sisi klien? Frontend adalah garda terdepan yang berinteraksi langsung dengan pengguna. Jika frontend tidak siap menghadapi ketidakpastian, semua upaya di backend bisa sia-sia.
Di artikel ini, kita akan menyelami konsep Client-Side Resilience – bagaimana kita bisa membuat aplikasi web di browser menjadi lebih tahan banting terhadap kegagalan. Kita akan membahas tiga pola kunci: Retry, Fallback, dan Timeout, serta bagaimana mengimplementasikannya secara cerdas untuk meningkatkan keandalan dan pengalaman pengguna. Mari kita mulai!
2. Pola #1: Retry – Mencoba Lagi dengan Cerdas
Ketika sebuah request ke backend gagal, apakah itu karena masalah jaringan sementara (misalnya Network Error), timeout, atau server error (seperti 500, 503), respons pertama yang sering terpikir adalah: “coba lagi!”. Namun, mencoba lagi secara membabi buta bisa memperburuk situasi, terutama jika server memang sedang bermasalah atau sumber daya jaringan sedang terbatas. Di sinilah pola Retry dengan Exponential Backoff berperan.
💡 Apa itu Retry dengan Exponential Backoff?
Ini adalah strategi di mana aplikasi mencoba kembali request yang gagal, namun dengan jeda waktu yang semakin lama di antara setiap percobaan. Jeda waktu ini biasanya meningkat secara eksponensial (misalnya 1 detik, lalu 2 detik, 4 detik, dst.). Tujuannya adalah untuk:
- Memberi waktu bagi server untuk pulih jika masalahnya bersifat sementara.
- Mencegah thundering herd (banyak request serentak membanjiri server yang sudah bermasalah).
- Mengurangi beban pada jaringan dan backend.
- Meningkatkan peluang keberhasilan tanpa mengorbankan pengalaman pengguna terlalu banyak.
Contoh Implementasi Sederhana di JavaScript:
async function fetchDataWithRetry(url, options = {}, retries = 3, delay = 1000) {
try {
const response = await fetch(url, options);
if (!response.ok) {
// ✅ Hanya retry untuk error server (5xx) atau masalah jaringan.
// ❌ Jangan retry untuk error klien (4xx) seperti 401, 404, 403.
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status} - ${response.statusText}`);
}
throw new Error(`Server error: ${response.status} - ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`📌 Attempt failed for ${url}: ${error.message}`);
// Periksa apakah ini Network Error atau error server yang bisa di-retry
const shouldRetry = error.message.includes('NetworkError') || error.message.includes('Server error');
if (shouldRetry && retries > 0) {
console.log(`Retrying in ${delay / 1000} seconds... (Remaining retries: ${retries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return fetchDataWithRetry(url, options, retries - 1, delay * 2); // Exponential backoff
}
throw error; // Setelah semua retry habis atau bukan error yang bisa di-retry, lempar error asli
}
}
// Penggunaan praktis:
fetchDataWithRetry('/api/data-penting')
.then(data => console.log('✅ Data fetched successfully:', data))
.catch(error => console.error('❌ Failed to fetch data after multiple retries:', error.message));
✅ Best Practices untuk Retry:
- Identifikasi Jenis Error: Hanya lakukan retry untuk error yang bersifat sementara (misalnya 5xx, koneksi terputus, atau timeout). Jangan retry untuk error 4xx (misalnya 401 Unauthorized, 404 Not Found) karena ini menunjukkan masalah di request atau otorisasi, bukan masalah server sementara.
- Batas Maksimal Retry: Tentukan berapa kali maksimal percobaan ulang. Terlalu banyak retry bisa menghabiskan sumber daya dan membuat pengguna menunggu terlalu lama. Tiga hingga lima kali seringkali cukup.
- Jeda Waktu (Delay): Mulai dengan jeda singkat dan tingkatkan secara eksponensial (misalnya 1s, 2s, 4s, 8s). Anda bisa menambahkan jitter (randomisasi kecil) pada delay untuk mencegah thundering herd jika banyak klien gagal secara bersamaan.
- Informasi ke Pengguna: Tampilkan indikator loading atau pesan “Mencoba kembali…” agar pengguna tahu apa yang sedang terjadi dan tidak merasa aplikasi freeze.
3. Pola #2: Fallback – Menyiapkan Rencana B
Tidak semua masalah bisa diselesaikan dengan retry. Terkadang, sumber daya yang diminta tidak tersedia, layanan inti benar-benar mati, atau request gagal karena alasan yang tidak bisa diatasi dengan mencoba lagi. Di sinilah pola Fallback menjadi penyelamat. Fallback adalah strategi untuk menyediakan alternatif saat fungsi utama gagal. Ini bisa berupa data alternatif, UI yang lebih sederhana, atau bahkan fungsionalitas yang dikurangi. Tujuannya adalah menjaga pengalaman pengguna tetap berjalan meskipun tidak optimal.
🎯 Contoh Penerapan Fallback:
-
Fallback Data: Jika data utama dari API gagal dimuat, Anda bisa menampilkan data cache dari
localStorageatauIndexedDB, atau bahkan data placeholder statis. Ini jauh lebih baik daripada halaman kosong atau pesan error yang merusak pengalaman.async function getUserProfile() { try { const response = await fetchDataWithRetry('/api/user/profile'); // Menggunakan fungsi retry kita return response; } catch (error) { console.warn('⚠️ Failed to fetch user profile, trying to use fallback data.'); // Fallback: Gunakan data dari cache atau default const cachedProfile = localStorage.getItem('user_profile'); if (cachedProfile) { console.log('✅ Using cached profile data.'); return JSON.parse(cachedProfile); } console.log('❌ No cached data, using default profile.'); return { name: 'Pengguna Anonim', email: 'anonim@example.com', avatar: '/default-avatar.png' }; } } -
Fallback UI/Komponen (Skeleton & Error State): Jika sebuah komponen kompleks gagal memuat datanya atau ada error di dalamnya, Anda bisa menampilkan komponen skeleton, loading spinner, atau pesan error yang lebih informatif di tempat komponen tersebut, alih-alih merusak seluruh halaman.
// Contoh di React dengan library data fetching (misalnya React Query) import { useQuery } from '@tanstack/react-query'; function UserDashboardWidget() { const { data, isLoading, isError } = useQuery({ queryKey: ['userProfile'], queryFn: getUserProfile, // Fungsi getUserProfile yang sudah termasuk retry dan fallback data staleTime: 5 * 60 * 1000, // Data dianggap "stale" setelah 5 menit cacheTime: 10 * 60 * 1000, // Data akan dihapus dari cache setelah 10 menit retry: false // Retry sudah ditangani di getUserProfile, jadi matikan di useQuery }); if (isLoading) { return <LoadingSkeleton count={3} />; // Fallback UI: Tampilkan skeleton loader } if (isError) { return <ErrorMessage message="Gagal memuat dashboard. Silakan coba lagi nanti." />; // Fallback UI: Pesan error } if (!data || data.name === 'Pengguna Anonim') { // Periksa juga fallback data return <EmptyState message="Belum ada data untuk ditampilkan atau Anda sedang offline." />; // Fallback UI: Empty state } return ( <div className="user-dashboard-widget"> <h2>Selamat Datang, {data.name}!</h2> <p>Email: {data.email}</p> <img src={data.avatar} alt="Avatar" className="user-avatar" /> {/* ... tampilkan data dashboard lainnya */} </div> ); } -
Fallback Fungsionalitas (Graceful Degradation): Jika fitur real-time tertentu (misalnya chat dengan WebSockets) gagal terhubung, Anda bisa degradasi ke polling sederhana atau bahkan menonaktifkan fitur tersebut sementara dengan pesan yang jelas, daripada membuat aplikasi tidak bisa digunakan.
📌 Kapan Menggunakan Fallback?
- Ketika retry tidak memungkinkan atau tidak efektif (misalnya error 4xx, layanan penting down).
- Untuk menjaga fungsionalitas inti tetap berjalan meskipun fitur sampingan bermasalah.
- Untuk meningkatkan perceived performance dan mencegah blank screen. Pengguna akan lebih menghargai tampilan yang tidak kosong, meskipun datanya belum lengkap atau dari cache.
4. Pola #3: Timeout – Batas Waktu Itu Penting
Bayangkan Anda mengirim request ke server, dan server tidak pernah merespons. Aplikasi Anda akan “terjebak” menunggu selamanya, menghabiskan sumber daya, dan membuat pengguna frustrasi. Pola Timeout adalah kunci untuk mencegah skenario ini. Timeout menetapkan batas waktu maksimal untuk sebuah operasi. Jika operasi tidak selesai dalam batas waktu tersebut, ia akan dibatalkan, dan aplikasi bisa mengambil tindakan lanjutan (retry, fallback, atau menampilkan error).
⚠️ Timeout untuk Request HTTP:
Browser modern memiliki timeout bawaan, tetapi Anda bisa mengontrolnya lebih granular dengan AbortController API yang kuat. Ini memungkinkan Anda untuk membatalkan request yang sedang berjalan.
async function fetchWithTimeout(url, options = {}, timeout = 5000) { // Default timeout 5 detik
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout); // Batalkan request setelah waktu tertentu
try {
const response = await fetch(url, {
...options,
signal: controller.signal // Kaitkan signal controller dengan fetch request
});
clearTimeout(id); // Bersihkan timeout jika request selesai sebelum waktu habis
return response;
} catch (error) {
clearTimeout(id);
if (error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeout}ms.`);
}
throw error;
}
}
// Penggunaan:
fetchWithTimeout('/api/slow-data', {}, 3000) // Timeout 3 detik
.then(response => response.json())
.then(data => console.log('✅ Data fetched:', data))
.catch(error => console.error('❌ Error fetching data with timeout:', error.message));
🎯 Timeout untuk Interaksi Pengguna (Debouncing & Throttling): Meskipun berbeda konteks, Debouncing dan Throttling juga merupakan bentuk timeout yang digunakan untuk mengoptimalkan interaksi pengguna dengan API. Misalnya, saat pengguna mengetik di kolom pencarian, Anda tidak ingin mengirim request ke server setiap kali satu huruf diketik.
- Debouncing: Menunda eksekusi fungsi sampai setelah periode waktu tertentu berlalu tanpa ada input baru. Ini berguna untuk event seperti
keyupatauresize. - Throttling: Membatasi eksekusi fungsi menjadi paling banyak sekali dalam periode waktu tertentu. Ini berguna untuk event yang sering terjadi seperti
scrollataumousemove.
Contoh Debouncing:
function debounce(func, delay) {