1. Pendahuluan
Pernahkah Anda menggunakan aplikasi web yang terasa “macet” atau tidak responsif saat Anda mengklik sesuatu atau saat data sedang dimuat? Rasanya seperti aplikasi itu “berpikir keras” dan mengabaikan masukan Anda. Nah, itu adalah masalah umum yang sering dihadapi developer: main thread blocking.
Di dunia web modern yang serba cepat, ekspektasi pengguna terhadap aplikasi web yang responsif dan mulus sangat tinggi. Aplikasi yang lambat atau sering macet tidak hanya membuat frustrasi, tetapi juga bisa merugikan bisnis. Apalagi jika aplikasi Anda harus menangani data dalam jumlah besar, melakukan kalkulasi kompleks di sisi klien, atau bahkan beroperasi secara offline.
Untungnya, browser modern telah menyediakan “jurus rahasia” yang bisa kita gunakan: Web Workers dan IndexedDB. Dalam artikel ini, kita akan menyelami bagaimana kedua teknologi ini dapat digabungkan untuk menciptakan aplikasi web yang tetap responsif, bahkan saat melakukan tugas-tugas berat di latar belakang. Mari kita mulai! 🚀
2. Memahami Masalah: Main Thread Blocking
Untuk memahami solusi, kita harus paham dulu masalahnya. Bayangkan browser Anda seperti sebuah restoran. Di restoran ini, ada satu koki utama yang menangani semua pesanan penting: menerima input dari pelanggan (klik, ketikan), menggambar makanan di piring (update UI), dan melayani makanan (menjalankan JavaScript utama aplikasi). Koki ini adalah Main Thread.
Jika ada pesanan yang sangat rumit atau membutuhkan waktu lama (misalnya, memotong 100 kg bawang atau memasak hidangan 5-course), koki utama akan sibuk dan tidak bisa melayani pelanggan lain. Pelanggan yang datang akan menunggu, dan bahkan pelanggan yang sudah memesan mungkin tidak bisa mendapatkan makanan mereka karena koki terlalu sibuk.
Dalam konteks aplikasi web:
- Koki Utama (Main Thread) bertanggung jawab untuk:
- Merender UI (gambar elemen HTML, CSS).
- Menangani event pengguna (klik, scroll, ketikan).
- Menjalankan sebagian besar kode JavaScript aplikasi Anda.
- Pesanan Rumit/Lama (Tugas Berat) bisa berupa:
- Pemrosesan data JSON yang sangat besar.
- Kalkulasi matematis yang intensif.
- Filter atau transformasi gambar.
- Enkripsi/dekripsi data.
- Interaksi dengan IndexedDB untuk data dalam jumlah besar.
Ketika tugas-tugas ini berjalan di main thread, browser tidak bisa melakukan hal lain. UI akan “freeze”, animasi akan tersendat, dan input pengguna tidak akan direspons. Ini adalah pengalaman yang buruk! ❌
3. Web Workers: Otak Kedua untuk Browser Anda
Untuk mengatasi masalah koki utama yang terlalu sibuk, kita bisa menyewa koki tambahan! Koki tambahan ini bekerja di dapur terpisah dan tidak bisa langsung berinteraksi dengan pelanggan (UI), tetapi dia bisa melakukan tugas berat dan melaporkan hasilnya ke koki utama. Inilah analogi untuk Web Workers.
Web Workers memungkinkan kita menjalankan skrip JavaScript di background thread yang terpisah dari main thread. Ini berarti tugas komputasi intensif dapat dilakukan tanpa memblokir UI.
Kelebihan Web Workers:
- Responsivitas UI Terjaga: Main thread bebas untuk menangani event dan update UI.
- Paralelisme: Mampu melakukan beberapa tugas secara bersamaan.
- Peningkatan Performa: Mempercepat eksekusi tugas berat.
Batasan Web Workers:
- Tidak Bisa Mengakses DOM Langsung: Worker tidak bisa memanipulasi elemen HTML secara langsung. Semua komunikasi harus melalui pesan.
- Tidak Bisa Mengakses Window Object: Worker tidak memiliki akses ke
window,document, atauparent. - Komunikasi Asynchronous: Komunikasi antara main thread dan worker selalu bersifat asynchronous menggunakan
postMessage()danonmessage.
Contoh Sederhana Web Worker:
// main.js (di main thread)
const worker = new Worker('worker.js');
// Mengirim pesan ke worker
worker.postMessage({ operation: 'hitungFaktorial', number: 10000 });
console.log('Main thread bebas, menunggu hasil dari worker...');
// Menerima pesan dari worker
worker.onmessage = function(event) {
console.log('Hasil dari worker:', event.data.result);
// Update UI di sini
};
worker.onerror = function(error) {
console.error('Ada error di worker:', error);
};
// worker.js (di background thread)
function hitungFaktorial(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
// Hindari angka terlalu besar untuk demo, bisa jadi Infinity
if (result === Infinity) return Infinity;
}
return result;
}
self.onmessage = function(event) {
const { operation, number } = event.data;
if (operation === 'hitungFaktorial') {
const result = hitungFaktorial(number);
self.postMessage({ result: result });
}
};
📌 Catatan: Web Workers berkomunikasi melalui pesan. Data yang dikirim akan disalin (structured clone algorithm). Untuk data yang sangat besar, pertimbangkan Transferable Objects agar tidak ada proses penyalinan data.
4. IndexedDB: Gudang Data Lokal yang Tangguh
Sekarang, bagaimana jika tugas berat kita melibatkan data yang harus disimpan atau diambil secara lokal dalam jumlah besar? Di sinilah IndexedDB berperan sebagai “gudang penyimpanan” yang jauh lebih canggih daripada localStorage atau sessionStorage.
IndexedDB adalah sistem database transaksional NoSQL berbasis objek yang terintegrasi di browser. Ini dirancang untuk menyimpan data terstruktur dalam jumlah besar di sisi klien.
Kelebihan IndexedDB:
- Penyimpanan Data Besar: Mampu menyimpan gigabyte data, jauh lebih besar dari
localStorage(hanya beberapa MB). - Terstruktur & Terindeks: Data disimpan sebagai objek JavaScript dan Anda bisa membuat indeks untuk query yang efisien.
- Asynchronous: Semua operasi bersifat asynchronous, sehingga tidak memblokir main thread.
- Transaksi: Mendukung transaksi untuk memastikan integritas data.
- Dapat Diakses Worker: Web Workers dapat berinteraksi langsung dengan IndexedDB! Ini adalah kunci dari strategi kita.
Kenapa tidak localStorage?
localStorage bersifat synchronous dan terbatas pada string. Membaca atau menulis data besar ke localStorage akan memblokir main thread. IndexedDB dirancang untuk skenario data kompleks dan besar secara asynchronous.
5. Strategi Integrasi: Web Workers + IndexedDB untuk UI Responsif
Sekarang kita akan menggabungkan kekuatan Web Workers dan IndexedDB. Idenya adalah:
- Offload Tugas Data ke Worker: Ketika aplikasi perlu melakukan pemrosesan data yang berat (misalnya, mengunduh, mengurai, atau menganalisis data besar), tugas tersebut didelegasikan ke Web Worker.
- Worker Berinteraksi dengan IndexedDB: Web Worker, yang bekerja di background, dapat secara langsung membaca atau menulis data ke IndexedDB tanpa membebani main thread.
- Main Thread Tetap Responsif: Sementara worker sibuk, main thread tetap bebas untuk merespons input pengguna, menjalankan animasi, atau menampilkan loading indicator.
- Komunikasi Hasil: Setelah worker selesai memproses data atau mengambil hasil dari IndexedDB, ia mengirimkan hasilnya kembali ke main thread untuk diperbarui ke UI.
Skenario Penggunaan Nyata:
🎯 Mengelola Dataset Besar dari API: Bayangkan Anda memiliki aplikasi dashboard yang perlu mengunduh dan menampilkan ribuan record data dari API.
- Main thread memicu worker untuk fetch data.
- Worker melakukan HTTP request ke API.
- Setelah data diterima, worker mengurai JSON, melakukan validasi, dan menyimpannya ke IndexedDB.
- Worker memberi tahu main thread bahwa data sudah siap di IndexedDB.
- Main thread kemudian dapat mengambil sebagian data (misalnya, untuk pagination) dari IndexedDB (atau membiarkan worker mengambilnya dan mengirimkan subsetnya) dan menampilkannya di UI.
🎯 Kalkulasi Data Offline: Anda membangun aplikasi pengolah gambar sederhana yang bisa bekerja offline.
- Pengguna memilih beberapa gambar.
- Main thread mengirimkan data gambar ke worker.
- Worker melakukan komputasi intensif (misalnya, menerapkan filter, mengubah ukuran) pada gambar-gambar tersebut.
- Hasil gambar yang sudah diproses disimpan ke IndexedDB oleh worker.
- Worker memberitahu main thread saat selesai, dan main thread menampilkan thumbnail atau opsi untuk mengunduh gambar yang sudah diproses.
Contoh Kode: Memproses dan Menyimpan Data Produk dalam Jumlah Besar
Kita akan membuat contoh di mana main thread memicu worker untuk mengambil daftar produk dari API, memprosesnya (misalnya, menambahkan ID unik atau melakukan kalkulasi), dan menyimpannya ke IndexedDB.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Responsif dengan Web Workers & IndexedDB</title>
<style>
body { font-family: sans-serif; margin: 20px; }
button { padding: 10px 15px; font-size: 16px; cursor: pointer; }
#status { margin-top: 20px; font-weight: bold; }
#product-count { margin-top: 10px; }
.loading { color: orange; }
.success { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h1>Demo UI Responsif</h1>
<p>Klik tombol untuk memproses dan menyimpan data produk berat di background.</p>
<button id="processButton">Proses & Simpan Produk (50.000 data)</button>
<p id="status"></p>
<p id="product-count"></p>
<script>
const processButton = document.getElementById('processButton');
const statusDiv = document.getElementById('status');
const productCountDiv = document.getElementById('product-count');
let worker;
processButton.addEventListener('click', () => {
if (worker) {
worker.terminate(); // Pastikan worker sebelumnya dihentikan
}
worker = new Worker('worker.js');
statusDiv.textContent = 'Memulai pemrosesan data...';
statusDiv.className = 'loading';
processButton.disabled = true;
worker.postMessage({ action: 'processAndStoreProducts' });
worker.onmessage = (event) => {
const { status, message, count } = event.data;
if (status === 'progress') {
statusDiv.textContent = `Progress: ${message}`;
} else if (status === 'completed') {
statusDiv.textContent = `Selesai: ${message}`;
statusDiv.className = 'success';
productCountDiv.textContent = `Total produk di IndexedDB: ${count}`;
processButton.disabled = false;
} else if (status === 'error') {
statusDiv.textContent = `Error: ${message}`;
statusDiv.className = 'error';
processButton.disabled = false;
}
};
worker.onerror = (error) => {
statusDiv.textContent = `Error tak terduga: ${error.message}`;
statusDiv.className = 'error';
processButton.disabled = false;
console.error('Worker error:', error);
};
});
// Cek jumlah produk saat halaman dimuat (opsional, bisa juga di worker)
async function getProductCount() {
const request = indexedDB.open('ProductDB', 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(['products'], 'readonly');
const objectStore = transaction.objectStore('products');
const countRequest = objectStore.count();
countRequest.onsuccess = () => {
productCountDiv.textContent = `Total produk di IndexedDB: ${countRequest.result}`;
};
};
request.onerror = (event) => {
console.error('Failed to open IndexedDB:', event.target.errorCode);
productCountDiv.textContent = 'Tidak dapat mengakses IndexedDB.';
};
}
getProductCount();
</script>
</body>
</html>
worker.js
// worker.js (di background thread)
let db;
// Fungsi untuk membuka atau membuat database IndexedDB
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ProductDB', 1);
request.onupgradeneeded = (event) => {
db = event.target.result;
if (!db.objectStoreNames.contains('products')) {
const objectStore = db.createObjectStore('products', { keyPath: 'id' });
objectStore.createIndex('name', 'name', { unique: false });
objectStore.createIndex('price', 'price', { unique: false });
}
};
request.onsuccess = (event) => {
db = event.target.result;
resolve(db);
};
request.onerror = (event) => {
reject(`Database error: ${event.target.errorCode}`);
};
});
}
// Fungsi untuk menghasilkan data produk dummy
function generateDummyProducts(count) {
self.postMessage({ status: 'progress', message: 'Membuat data dummy...' });
const products = [];
for (let i = 0; i < count; i++) {
products.push({
id: `prod-${i}-${Date.now()}`,
name: `Produk Dummy ${i}`,
price: Math.floor(Math.random() * 1000) + 10,
description: `Deskripsi untuk Produk Dummy ${i}`,
category: `Kategori ${(i % 5) + 1}`
});
}
return products;
}
// Fungsi untuk menyimpan produk ke IndexedDB
async function storeProducts(products) {
if (!db) {
db = await openDatabase();
}
return new Promise((resolve, reject) => {
const transaction = db.transaction(['products'], 'readwrite');
const objectStore = transaction.objectStore('products');
let addedCount = 0;
const totalProducts = products.length;
transaction.oncomplete = () => {
resolve(addedCount);
};
transaction.onerror = (event) => {
reject(`Transaction error: ${event.target.error}`);
};
products.forEach((product, index) => {
objectStore.add(product);
addedCount++;
if (index % Math.floor(totalProducts / 10) === 0 && index > 0) { // Update progress setiap 10%
self.postMessage({ status: 'progress', message: `Menyimpan ${index}/${totalProducts} produk...` });
}
});
});
}
// Fungsi untuk mendapatkan jumlah produk di IndexedDB
async function getProductCount() {
if (!db) {
db = await openDatabase();
}
return new Promise((resolve, reject) => {
const transaction = db.transaction(['products'], 'readonly');
const objectStore = transaction.objectStore('products');
const countRequest = objectStore.count();
countRequest.onsuccess = () => resolve(countRequest.result);
countRequest.onerror = (event) => reject(`Count error: ${event.target.error}`);
});
}
self.onmessage = async (event) => {
if (event.data.action === 'processAndStoreProducts') {
try {
await openDatabase(); // Pastikan DB terbuka
const dummyProducts = generateDummyProducts(50000); // 50.000 produk
self.postMessage({ status: 'progress', message: 'Data dummy berhasil dibuat, memulai penyimpanan...' });
const countStored = await storeProducts(dummyProducts);
const totalCount = await getProductCount();
self.postMessage({ status: 'completed', message: `Berhasil memproses dan menyimpan ${countStored} produk.`, count: totalCount });
} catch (error) {
self.postMessage({ status: 'error', message: error