Web Worker di React: Pindahkan Main Thread dengan Comlink

Foto oleh Auledas via Wikimedia Commons (CC BY-SA 4.0)
Hanya setelah profiling menunjukkan satu tugas sinkron yang secara konsisten melampaui satu frame 16ms. Jika memoisasi, virtualisasi, atau melakukan lebih sedikit pekerjaan per render memperbaiki jank, lakukan itu dulu. Worker menambah overhead serialisasi dan cold-start, jadi ia hanya berbuah untuk pekerjaan yang benar-benar CPU-bound seperti mem-parsing dataset besar, pemrosesan gambar, atau matematika berat.
Comlink adalah pustaka berukuran sekitar satu kilobyte dari Google Chrome Labs yang menggantikan pipa postMessage manual dengan ES6 Proxy. Anda mengekspos objek dari worker dan membungkusnya di main thread, lalu memanggil metodenya seolah-olah fungsi async lokal. Setiap panggilan menjadi pesan di balik layar dan mengembalikan promise, sehingga worker terasa seperti kode async biasa.
Ketika datanya sudah ada di browser dan satu-satunya biaya adalah CPU, bukan database atau rahasia. Worker menghindari latensi jaringan, bekerja offline, dan berjalan di perangkat pengguna alih-alih anggaran server Anda. Dorong pekerjaan ke server ketika ia butuh state otoritatif, kredensial, atau harus divalidasi demi keamanan.
Kemungkinan besar biaya structured clone. Argumen dan hasil disalin secara mendalam melintasi batas thread, dan menyalin typed array besar sendiri bisa menghabiskan anggaran frame Anda. Gunakan transferable object — serahkan kepemilikan ArrayBuffer ke worker dengan helper transfer dari Comlink — agar data dipindahkan alih-alih disalin.
Tidak. Worker berjalan di thread terpisah tanpa akses ke DOM atau state React Anda. Worker harus mengembalikan data biasa yang bisa diserialisasi, dan main thread memperbarui state serta merender. Jaga semua pekerjaan UI di main thread dan gunakan worker murni untuk komputasi.

Foto oleh Auledas via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Pindahkan JavaScript sinkron apa pun yang berjalan lebih lama dari satu frame 16ms keluar dari main thread ke dalam Web Worker. Bungkus worker itu dengan Comlink agar pemanggilannya terlihat seperti fungsi async biasa alih-alih pipa postMessage. Pilih worker sebelum round-trip server ketika datanya sudah ada di browser dan biayanya adalah CPU, bukan jaringan.
Setiap aplikasi React punya satu thread yang menggambar UI, menjalankan event handler, dan mengeksekusi render. Ketika satu fungsi di thread itu berjalan selama 200ms, halaman membeku selama 200ms: klik menumpuk, animasi tersendat, dan skor Interaction to Next Paint yang sudah Anda atur dengan cermat jatuh bebas. Saya mengalaminya pada dashboard padat data di mana mem-parsing dan membentuk ulang CSV besar di browser membekukan tab hampir setengah detik pada setiap unggahan.
Solusinya tidak selalu berupa algoritma yang lebih cepat atau endpoint backend. Kadang pekerjaan itu memang milik browser, dan langkah yang tepat adalah menjalankannya di thread lain. Itulah yang diberikan Web Worker, dan Comlink membuatnya nyaris tak terlihat. Berikut cara saya memutuskan kapan menggunakannya, cara memasangnya dalam build Vite, dan jebakan yang diam-diam merenggut frame Anda.
Pada 60fps browser punya sekitar 16 milidetik untuk memproduksi setiap frame. Dalam jendela itu ia harus menjalankan JavaScript yang tertunda, menghitung ulang style dan layout, lalu menggambar. Apa pun yang kode Anda lakukan secara sinkron memakan anggaran itu. Lewati batasnya dan Anda kehilangan satu frame; jalankan loop 200ms dan Anda kehilangan selusin frame berturut-turut sementara UI membeku. Karena Interaction to Next Paint kini menjadi Core Web Vital, dan skor bagus berarti 200ms atau kurang pada persentil ke-75 dari interaksi nyata, tugas panjang ini bukan lagi sekadar gangguan, melainkan sinyal peringkat.
Web Worker adalah thread JavaScript terpisah dengan event loop-nya sendiri. Ia tidak bisa menyentuh DOM dan tidak berbagi memori dengan komponen Anda, tetapi ia bisa berjalan selama yang dibutuhkan tanpa memblokir satu frame pun di main thread. Main thread tetap bebas untuk menggambar dan merespons sementara pekerjaan berat terjadi di tempat lain.
Jangan letakkan worker di depan pekerjaan murah. Jika tugas selesai dalam kurang dari satu frame, biaya menserialisasi argumen, mengirim pesan, dan mendeserialisasi hasil biasanya lebih besar daripada keuntungannya. Aturan praktis saya: profil dulu, dan hanya gunakan worker ketika satu pemanggilan sinkron secara konsisten melampaui 16ms.
Worker mentah berkomunikasi melalui postMessage dan handler onmessage. Anda mengirim pesan, mencocokkan respons secara manual, dan akhirnya membangun protokol kecil setiap kali. Comlink, pustaka berukuran sekitar satu kilobyte dari Google Chrome Labs, menggantikan semua itu dengan ES6 Proxy. Anda mengekspos sebuah objek dari worker, membungkus worker di sisi utama, lalu memanggil metodenya seolah-olah fungsi async lokal. Proxy mengubah setiap panggilan menjadi pesan di balik layar dan mengembalikan sebuah promise kepada Anda.
Berikut contoh lengkap dalam proyek Vite plus TypeScript. Worker mengekspos fungsi berat; main thread memanggilnya dan tidak pernah terblokir. Perhatikan pola instansiasi worker, yang Vite butuhkan dalam kondisi utuh untuk membundel worker sebagai chunk terpisah.
// heavy.worker.ts — runs on its own thread
import * as Comlink from 'comlink';
const api = {
// Simulated CPU-bound work: parse + aggregate a large dataset
aggregate(rows: number[]): { sum: number; mean: number } {
let sum = 0;
for (let i = 0; i < rows.length; i++) sum += rows[i];
return { sum, mean: sum / rows.length };
},
};
export type HeavyApi = typeof api;
Comlink.expose(api);// useHeavyWorker.ts — main-thread hook
import * as Comlink from 'comlink';
import { useEffect, useMemo } from 'react';
import type { HeavyApi } from './heavy.worker';
export function useHeavyWorker() {
const worker = useMemo(
() =>
new Worker(new URL('./heavy.worker.ts', import.meta.url), {
type: 'module',
}),
[],
);
const api = useMemo(() => Comlink.wrap<HeavyApi>(worker), [worker]);
// Terminate on unmount so you do not leak threads
useEffect(() => () => worker.terminate(), [worker]);
return api;
}// Component.tsx — the call site looks synchronous, but never blocks
const api = useHeavyWorker();
async function onUpload(rows: number[]) {
// UI stays at 60fps while this runs on the worker thread
const result = await api.aggregate(rows);
setStats(result);
}Naluri ketika sesuatu lambat di browser adalah mendorongnya ke backend. Itu benar ketika pekerjaan butuh database, rahasia, atau state bersama. Itu salah ketika datanya sudah ada di browser dan satu-satunya biaya adalah CPU. Mengirim array besar ke server, menunggu jaringan, dan mem-parsing respons bisa dengan mudah memakan waktu lebih lama daripada komputasinya sendiri, dan itu membakar bandwidth serta satu request server untuk pekerjaan yang bisa dilakukan mesin pengguna secara gratis.
Worker menjaga data tetap lokal, menghindari latensi round-trip sepenuhnya, bekerja offline, dan berskala dengan perangkat pengguna alih-alih anggaran server Anda. Tabel di bawah adalah keputusan yang benar-benar saya buat.
| Faktor | Web Worker | Round-trip server |
|---|---|---|
| Di mana biayanya | Murni CPU, data sudah di browser | Butuh DB, rahasia, atau state bersama |
| Latensi | Tanpa lompatan jaringan | Round-trip plus antrean |
| Offline | Bekerja penuh offline | Gagal tanpa konektivitas |
| Biaya penskalaan | Memakai CPU pengguna, gratis bagi Anda | Menghabiskan kapasitas server |
| Batas kepercayaan | Sisi klien, jangan percaya hasilnya | Otoritatif, divalidasi sisi server |
Worker berjalan di klien, jadi keluarannya tidak pernah otoritatif. Memfilter, mengurutkan, dan pratinjau dalam worker tidak masalah, tetapi apa pun yang mengatur uang, izin, atau state persisten tetap harus divalidasi di server. Perlakukan worker sebagai akselerator untuk UI, bukan pengganti kepercayaan backend.
Kejutan paling umum adalah biaya serialisasi. Argumen dan nilai kembalian melintasi batas thread lewat algoritma structured clone, yang menyalin data secara mendalam. Untuk typed array besar, salinan itu sendiri bisa menghabiskan anggaran frame Anda, jadi Anda sudah menyerahkan komputasi tetapi memperkenalkan kembali kemacetan pada salinan. Solusinya adalah transferable object: serahkan kepemilikan ArrayBuffer ke worker alih-alih menyalinnya. Comlink mengeksposnya melalui helper transfer.
import * as Comlink from 'comlink';
// Transfer the underlying buffer instead of cloning it.
// After transfer, `bytes` is neutered on the main thread — do not reuse it.
const bytes = new Uint8Array(hugeArrayBuffer);
await api.process(Comlink.transfer(bytes, [bytes.buffer]));Saya tidak menggunakan worker di setiap proyek. Sebagian besar jank React diperbaiki dengan memoisasi, virtualisasi, atau sekadar melakukan lebih sedikit pekerjaan per render. Tetapi ketika profiling menunjukkan tugas CPU-bound sejati yang harus berjalan di browser, worker yang dibungkus Comlink adalah alat terbersih yang saya kenal: call site-nya terbaca seperti kode async biasa, pekerjaan berat meninggalkan main thread, dan UI menahan 60fps sepanjang operasi. Kombinasi itulah yang menjaga Interaction to Next Paint tetap hijau di bawah beban nyata.