Web Development

Service Workers: Offline & PWA

Buat web app yang berfungsi offline dengan lifecycle management, caching strategies, push notifications, dan Progressive Web Apps

1. Pengenalan Service Workers

Service Worker adalah script JavaScript yang berjalan di background, terpisah dari halaman web. Service Worker bertindak sebagai proxy antara browser, jaringan, dan cache. Ini memungkinkan web app Anda berfungsi offline, mengirim push notifications, dan menjadi Progressive Web App (PWA).

Service Worker vs Web Worker

Fitur Web Worker Service Worker
LifecycleHidup selama tab aktifIndependen dari tab (hidup terus)
ScopeSatu halamanSeluruh origin (semua halaman)
Proxy NetworkTidakYa β€” bisa intercept semua fetch
Push NotificationTidakYa
Cache APIBisa aksesBisa akses dan kontrol
Akses DOMTidakTidak
Diagram: Service Worker sebagai Proxy
  Browser (Tab) ──────► Service Worker ──────► Network
                              β”‚                    β”‚
                              β–Ό                    β”‚
                           Cache                   β”‚
                              β”‚                    β”‚
                              └──── Response β—„β”€β”€β”€β”€β”€β”˜

  Alur normal:
  1. Browser meminta resource (halaman, gambar, API)
  2. Service Worker menangkap request (fetch event)
  3. Service Worker memutuskan:
     - Kirim dari cache? (cepat, offline)
     - Kirim dari network? (data terbaru)
     - Gabungan keduanya? (stale-while-revalidate)
  4. Response dikembalikan ke browser

Syarat Service Worker

⚠️ Syarat Wajib

HTTPS β€” Service Worker hanya berfungsi di HTTPS (atau localhost untuk development). File service worker harus berada di root domain agar scope-nya mencakup seluruh halaman. Service Worker tidak bisa mengakses DOM.

2. Lifecycle Service Worker

Service Worker memiliki lifecycle yang unik dan terdiri dari 3 tahap utama: Register β†’ Install β†’ Activate.

Diagram: Lifecycle Service Worker
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ Register  │───►│  Install   │───►│   Waiting     │───►│  Activate  β”‚
  β”‚           β”‚    β”‚            β”‚    β”‚ (jika ada SW  β”‚    β”‚            β”‚
  β”‚ navigator β”‚    β”‚ Pre-cache  β”‚    β”‚  lama aktif)  β”‚    β”‚ Cleanup    β”‚
  β”‚ .service  β”‚    β”‚ assets     β”‚    β”‚              β”‚    β”‚ cache lama  β”‚
  β”‚ Worker    β”‚    β”‚            β”‚    β”‚              β”‚    β”‚            β”‚
  β”‚ .register β”‚    β”‚ skipWaitingβ”‚    β”‚ clients      β”‚    β”‚ Controllingβ”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚ .claim()     β”‚    β”‚ semua tab   β”‚
                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                            β”‚
                                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
                                                    β”‚  Fetch Event   β”‚
                                                    β”‚  Activate Eventβ”‚
                                                    β”‚  (idle/terminated)β”‚
                                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
JavaScript β€” Lifecycle Events
// ===== sw.js β€” Service Worker File =====

// EVENT 1: INSTALL β€” Dipanggil saat pertama kali diinstal
self.addEventListener('install', (event) => {
    console.log('[SW] Installing...');

    // event.waitUntil() β€” tunggu sampai promise selesai
    // Jika gagal, install dianggap gagal
    event.waitUntil(
        caches.open('v1-cache').then((cache) => {
            console.log('[SW] Pre-caching assets...');
            // Pre-cache file-file penting
            return cache.addAll([
                '/',
                '/index.html',
                '/css/style.css',
                '/js/app.js',
                '/images/logo.png',
                '/offline.html'
            ]);
        })
    );

    // skipWaiting() β€” langsung aktif tanpa menunggu tab ditutup
    self.skipWaiting();
});

// EVENT 2: ACTIVATE β€” Dipanggil setelah install, sebelum fetch
self.addEventListener('activate', (event) => {
    console.log('[SW] Activating...');

    event.waitUntil(
        // Hapus cache lama yang tidak digunakan
        caches.keys().then((cacheNames) => {
            return Promise.all(
                cacheNames
                    .filter(name => name !== 'v1-cache')
                    .map(name => {
                        console.log('[SW] Menghapus cache lama:', name);
                        return caches.delete(name);
                    })
            );
        })
    );

    // Klaim semua klien (tab) yang sudah terbuka
    self.clients.claim();
});

// EVENT 3: FETCH β€” Menangkap semua network request
self.addEventListener('fetch', (event) => {
    console.log('[SW] Fetch:', event.request.url);

    // Intercept request dan kirim response custom
    event.respondWith(
        fetch(event.request).catch(() => {
            // Jika offline, kirim halaman offline
            return caches.match('/offline.html');
        })
    );
});

3. Registrasi & Instalasi

JavaScript β€” Registrasi Service Worker
// ===== app.js β€” Registrasi di halaman web =====

// Feature detection
if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
        try {
            const registration = await navigator.serviceWorker.register('/sw.js', {
                scope: '/'  // Scope: area yang dicakup SW
            });

            console.log('SW registered:', registration.scope);

            // Cek update
            registration.addEventListener('updatefound', () => {
                const newWorker = registration.installing;
                console.log('SW update ditemukan!');

                newWorker.addEventListener('statechange', () => {
                    console.log('SW state:', newWorker.state);
                    // states: installing β†’ installed β†’ activating β†’ activated

                    if (newWorker.state === 'activated') {
                        // Tampilkan notifikasi "Update tersedia"
                        showUpdateNotification();
                    }
                });
            });
        } catch (error) {
            console.error('SW registration failed:', error);
        }
    });
}

// Cek apakah ada update service worker
async function checkForUpdates() {
    if ('serviceWorker' in navigator) {
        const reg = await navigator.serviceWorker.getRegistration();
        if (reg) {
            await reg.update();
        }
    }
}

// Cek update setiap 1 jam
setInterval(checkForUpdates, 60 * 60 * 1000);
πŸ’‘ Tips Update Service Worker

Saat Anda mengubah file sw.js, browser akan mendeteksi perubahan dan menginstal versi baru. Namun, versi baru tidak langsung aktif β€” ia masuk ke status waiting. Gunakan self.skipWaiting() untuk langsung mengaktifkan, atau biarkan user memilih untuk "Refresh untuk update".

4. Fetch Event & Intersep

Event fetch di-trigger setiap kali halaman meminta resource β€” HTML, CSS, JS, gambar, API call, semuanya. Anda bisa menangkap request ini dan memutuskan bagaimana meresponsnya.

JavaScript β€” Fetch Event Intercept
// ===== Berbagai cara menangani fetch event =====

// 1. Passthrough β€” biarkan request ke network (default)
self.addEventListener('fetch', (event) => {
    // Tidak melakukan apa-apa β†’ request ke network biasa
});

// 2. Custom Response β€” buat response sendiri
self.addEventListener('fetch', (event) => {
    if (event.request.url.includes('/api/time')) {
        event.respondWith(
            new Response(
                JSON.stringify({ time: new Date().toISOString() }),
                {
                    headers: { 'Content-Type': 'application/json' }
                }
            )
        );
    }
});

// 3. Redirect β€” arahkan ke URL lain
self.addEventListener('fetch', (event) => {
    if (event.request.url.endsWith('/old-page')) {
        event.respondWith(
            Response.redirect('/new-page', 301)
        );
    }
});

// 4. Modify Response β€” ubah response dari network
self.addEventListener('fetch', (event) => {
    if (event.request.url.includes('/api/')) {
        event.respondWith(
            fetch(event.request).then(async (response) => {
                // Clone response (karena response hanya bisa dibaca sekali)
                const clone = response.clone();
                const data = await clone.json();

                // Tambahkan metadata
                data._cached = false;
                data._timestamp = Date.now();

                return new Response(JSON.stringify(data), {
                    status: response.status,
                    statusText: response.statusText,
                    headers: response.headers
                });
            })
        );
    }
});

5. Caching Strategies

Caching strategy menentukan bagaimana Service Worker menangani request: apakah mengambil dari cache, network, atau kombinasi keduanya.

Cache API

JavaScript β€” Cache API Dasar
// ===== Cache API =====
// Cache API tersedia di Service Worker dan window context

// Buka cache bernama 'v1'
const cache = await caches.open('v1');

// Tambahkan satu item
await cache.add('/page/about.html');

// Tambahkan banyak item sekaligus
await cache.addAll([
    '/css/style.css',
    '/js/app.js',
    '/images/logo.png'
]);

// Simpan request-response pair
const response = await fetch('/api/data');
await cache.put('/api/data', response.clone());

// Ambil dari cache
const cachedResponse = await cache.match('/css/style.css');
if (cachedResponse) {
    console.log('Ditemukan di cache!');
}

// Hapus item dari cache
await cache.delete('/old-file.js');

// List semua cache names
const cacheNames = await caches.keys();
console.log('Semua cache:', cacheNames);

// Hapus cache tertentu
await caches.delete('v1');

// Cek apakah request ada di cache
const request = new Request('/page/about.html');
const matched = await caches.match(request); // Cek semua cache
console.log(matched ? 'Ada!' : 'Tidak ada');

6. Cache-First & Network-First

Strategy 1: Cache-Only

JavaScript β€” Cache-Only
// ===== Cache-Only: Selalu dari cache, tidak ada network =====
// Cocok untuk: pre-cached assets yang tidak berubah
self.addEventListener('fetch', (event) => {
    event.respondWith(caches.match(event.request));
});

Strategy 2: Network-Only

JavaScript β€” Network-Only
// ===== Network-Only: Selalu dari network =====
// Cocok untuk: POST requests, realtime data
self.addEventListener('fetch', (event) => {
    event.respondWith(fetch(event.request));
});

Strategy 3: Cache-First (with Network Fallback)

JavaScript β€” Cache-First
// ===== Cache-First: Cek cache dulu, kalau tidak ada ke network =====
// Cocok untuk: static assets (gambar, CSS, JS, font)
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((cachedResponse) => {
            if (cachedResponse) {
                return cachedResponse; // βœ… Dari cache
            }

            // Tidak ada di cache β†’ fetch dari network
            return fetch(event.request).then((networkResponse) => {
                // Simpan ke cache untuk next time
                const responseClone = networkResponse.clone();
                caches.open('v1').then((cache) => {
                    cache.put(event.request, responseClone);
                });
                return networkResponse;
            });
        })
    );
});

Strategy 4: Network-First (with Cache Fallback)

JavaScript β€” Network-First
// ===== Network-First: Coba network dulu, fallback ke cache =====
// Cocok untuk: halaman HTML, data yang sering berubah
self.addEventListener('fetch', (event) => {
    event.respondWith(
        fetch(event.request)
            .then((networkResponse) => {
                // Update cache dengan data terbaru
                const responseClone = networkResponse.clone();
                caches.open('v1').then((cache) => {
                    cache.put(event.request, responseClone);
                });
                return networkResponse;
            })
            .catch(() => {
                // Network gagal β†’ ambil dari cache
                return caches.match(event.request);
            })
    );
});

Strategy 5: Stale-While-Revalidate

JavaScript β€” Stale-While-Revalidate
// ===== Stale-While-Revalidate: Cache dulu, update di background =====
// Cocok untuk: API data yang perlu cepat tapi juga terbaru
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.open('v1').then((cache) => {
            return cache.match(event.request).then((cachedResponse) => {
                // Fetch dari network SELALU (untuk update cache)
                const fetchPromise = fetch(event.request).then((networkResponse) => {
                    cache.put(event.request, networkResponse.clone());
                    return networkResponse;
                });

                // Kembalikan cache langsung jika ada, atau tunggu network
                return cachedResponse || fetchPromise;
            });
        })
    );
});

Ringkasan Strategi

StrategyCepat?Fresh?Offline?Cocok untuk
Cache-Onlyβœ…βŒβœ…Pre-cached static
Network-OnlyβŒβœ…βŒPOST, realtime
Cache-Firstβœ…βŒβœ…Static assets
Network-FirstβŒβœ…βœ…HTML, API data
Stale-While-Revalidateβœ…βœ…*βœ…API data, feed

7. Push Notifications

Service Worker memungkinkan web app mengirim push notifications bahkan ketika browser ditutup. Ini sangat mirip dengan notifikasi dari aplikasi native.

JavaScript β€” Push Notification
// ===== 1. Minta Izin Notifikasi =====
async function requestNotificationPermission() {
    // Cek support
    if (!('Notification' in window)) {
        console.log('Browser tidak support notifikasi');
        return false;
    }

    // Cek permission
    if (Notification.permission === 'granted') {
        return true;
    }

    if (Notification.permission !== 'denied') {
        const permission = await Notification.requestPermission();
        return permission === 'granted';
    }

    return false;
}

// ===== 2. Subscribe ke Push =====
async function subscribeToPush() {
    const registration = await navigator.serviceWorker.ready;

    // VAPID key β€” di-generate dari server
    const vapidPublicKey = 'BEl62iUYgUivxIkv69yVi...';

    const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
    });

    console.log('Push subscription:', JSON.stringify(subscription));

    // Kirim subscription ke server
    await fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(subscription)
    });
}

// Helper: convert VAPID key
function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
        .replace(/-/g, '+')
        .replace(/_/g, '/');
    const rawData = window.atob(base64);
    return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}

// ===== 3. Handle Push Event di Service Worker =====
self.addEventListener('push', (event) => {
    let data = { title: 'Notifikasi Baru', body: 'Ada info baru!' };

    if (event.data) {
        data = event.data.json();
    }

    const options = {
        body: data.body,
        icon: '/images/icon-192.png',
        badge: '/images/badge-72.png',
        image: data.image,
        vibrate: [100, 50, 100],
        data: { url: data.url || '/' },
        actions: [
            { action: 'open', title: 'Buka', icon: '/images/open.png' },
            { action: 'close', title: 'Tutup', icon: '/images/close.png' }
        ]
    };

    event.waitUntil(
        self.registration.showNotification(data.title, options)
    );
});

// ===== 4. Handle Notification Click =====
self.addEventListener('notificationclick', (event) => {
    event.notification.close();

    const action = event.action;
    const url = event.notification.data.url;

    if (action === 'close') return;

    event.waitUntil(
        clients.matchAll({ type: 'window' }).then((windowClients) => {
            // Cek apakah sudah ada window yang terbuka
            for (const client of windowClients) {
                if (client.url === url && 'focus' in client) {
                    return client.focus();
                }
            }
            // Buka window baru
            return clients.openWindow(url);
        })
    );
});

8. Background Sync

Background Sync memungkinkan Anda menunda operasi (seperti mengirim form) sampai koneksi internet tersedia kembali β€” bahkan jika user menutup tab!

JavaScript β€” Background Sync
// ===== Background Sync =====

// 1. Di halaman web β€” daftarkan sync
async function submitForm(data) {
    try {
        // Coba kirim langsung
        await fetch('/api/submit', {
            method: 'POST',
            body: JSON.stringify(data)
        });
        showSuccess('Data berhasil dikirim!');
    } catch (error) {
        // Gagal (offline?) β€” simpan ke IndexedDB dan daftarkan sync
        await saveToIndexedDB('pending-submissions', data);

        const registration = await navigator.serviceWorker.ready;
        await registration.sync.register('sync-submissions');

        showInfo('Data akan dikirim saat online kembali');
    }
}

// 2. Di Service Worker β€” handle sync event
self.addEventListener('sync', (event) => {
    if (event.tag === 'sync-submissions') {
        event.waitUntil(syncSubmissions());
    }
});

async function syncSubmissions() {
    const db = await openDB();
    const submissions = await db.getAll('pending-submissions');

    for (const submission of submissions) {
        try {
            await fetch('/api/submit', {
                method: 'POST',
                body: JSON.stringify(submission)
            });

            // Hapus dari IndexedDB setelah berhasil
            await db.delete('pending-submissions', submission.id);
            console.log('[SW] Sync berhasil:', submission.id);
        } catch (error) {
            console.log('[SW] Sync gagal, akan dicoba lagi:', error);
            throw error; // Throw agar sync dicoba lagi nanti
        }
    }
}

// Periodic Background Sync (Chrome only)
self.addEventListener('periodicsync', (event) => {
    if (event.tag === 'update-content') {
        event.waitUntil(updateContentFromServer());
    }
});

async function updateContentFromServer() {
    const cache = await caches.open('v1');
    const response = await fetch('/api/latest-content');
    await cache.put('/api/latest-content', response);
    console.log('[SW] Content updated via periodic sync');
}

9. Membangun PWA Lengkap

Progressive Web App (PWA) membutuhkan 3 komponen: Service Worker, Web App Manifest, dan HTTPS.

JSON β€” manifest.json
{
    "name": "BeebaneLabs Tutorial",
    "short_name": "Beebane",
    "description": "Portal tutorial teknologi Indonesia",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#08090a",
    "theme_color": "#08090a",
    "orientation": "portrait-primary",
    "icons": [
        {
            "src": "/images/icon-72.png",
            "sizes": "72x72",
            "type": "image/png"
        },
        {
            "src": "/images/icon-96.png",
            "sizes": "96x96",
            "type": "image/png"
        },
        {
            "src": "/images/icon-128.png",
            "sizes": "128x128",
            "type": "image/png"
        },
        {
            "src": "/images/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/images/icon-512.png",
            "sizes": "512x512",
            "type": "image/png",
            "purpose": "any maskable"
        }
    ],
    "categories": ["education", "technology"],
    "lang": "id",
    "dir": "ltr"
}
HTML β€” Tambahkan di <head>
<!-- Tambahkan di <head> halaman Anda -->
<link rel="manifest" href="/manifest.json">

<!-- Theme color untuk browser chrome -->
<meta name="theme-color" content="#08090a">

<!-- iOS support -->
<link rel="apple-touch-icon" href="/images/icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="BeebaneLabs">

<!-- Windows -->
<meta name="msapplication-TileColor" content="#08090a">
<meta name="msapplication-TileImage" content="/images/icon-144.png">

Install Prompt (Add to Home Screen)

JavaScript β€” Install Prompt
// ===== Handle Install Prompt =====
let deferredPrompt;

// Tangkap event 'beforeinstallprompt'
window.addEventListener('beforeinstallprompt', (event) => {
    event.preventDefault(); // Cegah auto-prompt
    deferredPrompt = event;

    // Tampilkan tombol "Install" custom
    const installBtn = document.getElementById('installBtn');
    installBtn.style.display = 'block';

    installBtn.addEventListener('click', async () => {
        installBtn.style.display = 'none';
        deferredPrompt.prompt();

        const { outcome } = await deferredPrompt.userChoice;
        console.log('User choice:', outcome); // 'accepted' atau 'dismissed'
        deferredPrompt = null;
    });
});

// Deteksi saat app berhasil di-install
window.addEventListener('appinstalled', () => {
    console.log('PWA berhasil di-install!');
    deferredPrompt = null;
});

// Deteksi apakah app sedang berjalan sebagai PWA
function isRunningAsPWA() {
    return window.matchMedia('(display-mode: standalone)').matches
        || window.navigator.standalone === true;
}

10. Quiz: Uji Pemahamanmu!

Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut:

Pertanyaan 1: Apa syarat wajib agar Service Worker bisa berfungsi?

a) HTTP saja cukup
b) HTTPS (atau localhost)
c) Domain .com
d) Port 443 saja

Pertanyaan 2: Apa urutan lifecycle Service Worker yang benar?

a) Activate β†’ Install β†’ Register
b) Register β†’ Activate β†’ Install
c) Register β†’ Install β†’ Activate
d) Install β†’ Register β†’ Activate

Pertanyaan 3: Strategi caching mana yang cocok untuk static assets (gambar, CSS, JS)?

a) Network-Only
b) Cache-Only
c) Cache-First
d) Network-First

Pertanyaan 4: Apa fungsi dari self.skipWaiting()?

a) Melewati registrasi
b) Melewati tahap install
c) Langsung mengaktifkan SW baru tanpa menunggu
d) Menghapus cache lama

Pertanyaan 5: Apa nama file manifest yang dibutuhkan PWA?

a) pwa.json
b) app.json
c) manifest.json
d) config.json
πŸ” Zoom
100%
🎨 Tema