Menggunakan pekerja web untuk menjalankan JavaScript di luar thread utama browser

Arsitektur di luar thread utama dapat meningkatkan keandalan dan pengalaman pengguna aplikasi Anda secara signifikan.

Dalam 20 tahun terakhir, web telah berkembang secara signifikan dari dokumen statis dengan beberapa gaya dan gambar menjadi aplikasi yang kompleks dan dinamis. Namun, satu hal yang sebagian besar tidak berubah: kita hanya memiliki satu thread per tab browser (dengan beberapa pengecualian) untuk melakukan pekerjaan merender situs dan menjalankan JavaScript.

Akibatnya, thread utama menjadi sangat terbebani. Seiring dengan meningkatnya kompleksitas aplikasi web, thread utama menjadi bottleneck yang signifikan untuk performa. Lebih buruk lagi, jumlah waktu yang diperlukan untuk menjalankan kode di thread utama untuk pengguna tertentu hampir sepenuhnya tidak dapat diprediksi karena kemampuan perangkat memiliki efek yang sangat besar pada performa. Ketidakpastian tersebut akan terus meningkat seiring pengguna mengakses web dari berbagai perangkat yang makin beragam, mulai dari ponsel menengah yang sangat terbatas hingga perangkat unggulan berdaya tinggi dengan kecepatan refresh tinggi.

Jika kita ingin aplikasi web yang canggih memenuhi panduan performa seperti Core Web Vitals—yang didasarkan pada data empiris tentang persepsi dan psikologi manusia—kita memerlukan cara untuk mengeksekusi kode di luar thread utama (OMT).

Mengapa web worker?

JavaScript, secara default, adalah bahasa thread tunggal yang menjalankan tugas di thread utama. Namun, pekerja web menyediakan semacam jalan keluar dari thread utama dengan memungkinkan developer membuat thread terpisah untuk menangani pekerjaan di luar thread utama. Meskipun cakupan web worker terbatas dan tidak menawarkan akses langsung ke DOM, web worker dapat sangat bermanfaat jika ada banyak pekerjaan yang harus dilakukan yang dapat membebani thread utama.

Terkait Core Web Vitals, menjalankan pekerjaan di luar thread utama dapat bermanfaat. Secara khusus, mengalihkan pekerjaan dari thread utama ke web worker dapat mengurangi pertentangan untuk thread utama, yang dapat meningkatkan metrik responsivitas Interaction to Next Paint (INP) halaman. Jika thread utama memiliki lebih sedikit tugas untuk diproses, thread tersebut dapat merespons interaksi pengguna dengan lebih cepat.

Mengurangi pekerjaan thread utama—terutama selama startup—juga memberikan potensi manfaat untuk Largest Contentful Paint (LCP) dengan mengurangi tugas berdurasi lama. Merender elemen LCP memerlukan waktu thread utama—baik untuk merender teks atau gambar, yang merupakan elemen LCP yang sering dan umum—dan dengan mengurangi pekerjaan thread utama secara keseluruhan, Anda dapat memastikan bahwa elemen LCP halaman Anda cenderung tidak diblokir oleh pekerjaan berat yang dapat ditangani oleh web worker.

Threading dengan web worker

Platform lain biasanya mendukung pekerjaan paralel dengan memungkinkan Anda memberikan fungsi ke thread, yang berjalan secara paralel dengan bagian program lainnya. Anda dapat mengakses variabel yang sama dari kedua thread, dan akses ke resource bersama ini dapat disinkronkan dengan mutex dan semaphore untuk mencegah kondisi race.

Di JavaScript, kita bisa mendapatkan fungsi yang kurang lebih sama dari web worker, yang sudah ada sejak tahun 2007 dan didukung di semua browser utama sejak tahun 2012. Web worker berjalan secara paralel dengan thread utama, tetapi tidak seperti threading OS, web worker tidak dapat berbagi variabel.

Untuk membuat web worker, teruskan file ke konstruktor worker, yang mulai menjalankan file tersebut di thread terpisah:

const worker = new Worker("./worker.js");

Berkomunikasi dengan web worker dengan mengirim pesan menggunakan postMessage API. Teruskan nilai pesan sebagai parameter dalam panggilan postMessage, lalu tambahkan pemroses peristiwa pesan ke worker:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Untuk mengirim pesan kembali ke thread utama, gunakan API postMessage yang sama di web worker dan siapkan pemroses peristiwa di thread utama:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Harus diakui, pendekatan ini agak terbatas. Secara historis, web worker terutama digunakan untuk memindahkan satu tugas berat dari thread utama. Mencoba menangani beberapa operasi dengan satu web worker akan cepat menjadi tidak praktis: Anda harus mengenkode tidak hanya parameter, tetapi juga operasi dalam pesan, dan Anda harus melakukan pencatatan untuk mencocokkan respons dengan permintaan. Kompleksitas tersebut kemungkinan menjadi alasan mengapa web worker belum diadopsi secara lebih luas.

Namun, jika kita dapat menghilangkan beberapa kesulitan komunikasi antara thread utama dan web worker, model ini dapat sangat cocok untuk banyak kasus penggunaan. Untungnya, ada library yang melakukan hal itu.

Comlink adalah library yang bertujuan memungkinkan Anda menggunakan web worker tanpa harus memikirkan detail postMessage. Comlink memungkinkan Anda berbagi variabel antara web worker dan thread utama, hampir seperti bahasa pemrograman lain yang mendukung threading.

Anda menyiapkan Comlink dengan mengimpornya di web worker dan menentukan serangkaian fungsi untuk diekspos ke thread utama. Kemudian, Anda mengimpor Comlink di thread utama, menggabungkan worker, dan mendapatkan akses ke fungsi yang terekspos:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

Variabel api di thread utama berperilaku sama seperti variabel di web worker, kecuali bahwa setiap fungsi menampilkan promise untuk suatu nilai, bukan nilai itu sendiri.

Kode apa yang harus Anda pindahkan ke web worker?

Web worker tidak memiliki akses ke DOM dan banyak API seperti WebUSB, WebRTC, atau Web Audio, sehingga Anda tidak dapat menempatkan bagian aplikasi yang mengandalkan akses tersebut di worker. Namun, setiap bagian kecil kode yang dipindahkan ke pekerja akan memberikan lebih banyak ruang di thread utama untuk hal-hal yang harus ada di sana—seperti memperbarui antarmuka pengguna.

Salah satu masalah bagi developer web adalah sebagian besar aplikasi web mengandalkan framework UI seperti Vue atau React untuk mengatur semuanya dalam aplikasi; semuanya adalah komponen framework sehingga secara inheren terkait dengan DOM. Hal ini tampaknya akan menyulitkan migrasi ke arsitektur OMT.

Namun, jika kita beralih ke model yang memisahkan masalah UI dari masalah lain, seperti pengelolaan status, web worker dapat sangat berguna bahkan dengan aplikasi berbasis framework. Pendekatan itulah yang digunakan PROXX.

PROXX: Studi kasus OMT

Tim Google Chrome mengembangkan PROXX sebagai tiruan Minesweeper yang memenuhi persyaratan Progressive Web App, termasuk berfungsi secara offline dan memiliki pengalaman pengguna yang menarik. Sayangnya, versi awal game ini berperforma buruk di perangkat yang terbatas seperti ponsel menengah, sehingga tim menyadari bahwa thread utama adalah bottleneck.

Tim memutuskan untuk menggunakan web worker guna memisahkan status visual game dari logikanya:

  • Thread utama menangani rendering animasi dan transisi.
  • Web worker menangani logika game, yang murni bersifat komputasional.

OMT memiliki efek menarik pada performa ponsel menengah PROXX. Dalam versi non-OMT, UI dibekukan selama enam detik setelah pengguna berinteraksi dengannya. Tidak ada masukan, dan pengguna harus menunggu selama enam detik penuh sebelum dapat melakukan hal lain.

Waktu respons UI dalam versi PROXX non-OMT.

Namun, dalam versi OMT, game memerlukan waktu dua belas detik untuk menyelesaikan update UI. Meskipun tampaknya menurunkan performa, hal ini sebenarnya meningkatkan masukan kepada pengguna. Perlambatan terjadi karena aplikasi mengirimkan lebih banyak frame daripada versi non-OMT, yang tidak mengirimkan frame sama sekali. Oleh karena itu, pengguna tahu bahwa sesuatu sedang terjadi dan dapat terus bermain saat UI diperbarui, sehingga game terasa jauh lebih baik.

Waktu respons UI dalam versi OMT PROXX.

Ini adalah konsekuensi yang disengaja: kami memberikan pengalaman yang terasa lebih baik kepada pengguna perangkat yang terbatas tanpa merugikan pengguna perangkat kelas atas.

Implikasi arsitektur OMT

Seperti yang ditunjukkan oleh contoh PROXX, OMT membuat aplikasi Anda berjalan dengan andal di berbagai perangkat, tetapi tidak membuat aplikasi Anda lebih cepat:

  • Anda hanya memindahkan pekerjaan dari thread utama, bukan mengurangi pekerjaan.
  • Overhead komunikasi tambahan antara web worker dan thread utama terkadang dapat membuat proses menjadi sedikit lebih lambat.

Mempertimbangkan dampaknya

Karena thread utama bebas memproses interaksi pengguna seperti men-scroll saat JavaScript berjalan, jumlah frame yang hilang akan lebih sedikit meskipun total waktu tunggu mungkin sedikit lebih lama. Membuat pengguna menunggu sebentar lebih baik daripada melewatkan frame karena margin error lebih kecil untuk frame yang dilewatkan: melewatkan frame terjadi dalam milidetik, sedangkan Anda memiliki waktu ratusan milidetik sebelum pengguna merasakan waktu tunggu.

Karena performa di berbagai perangkat tidak dapat diprediksi, tujuan arsitektur OMT sebenarnya adalah mengurangi risiko—membuat aplikasi Anda lebih tangguh dalam menghadapi kondisi runtime yang sangat bervariasi—bukan tentang manfaat performa paralelisasi. Peningkatan ketahanan dan peningkatan UX sangat sepadan dengan sedikit penurunan kecepatan.

Catatan tentang alat

Web worker belum menjadi mainstream, sehingga sebagian besar alat modul—seperti webpack dan Rollup—tidak mendukungnya secara langsung. (Namun, Parcel melakukannya.) Untungnya, ada plugin untuk membuat web worker berfungsi dengan webpack dan Rollup:

Kesimpulan

Untuk memastikan aplikasi kita seandal dan semudah mungkin diakses, terutama di pasar yang makin mengglobal, kita perlu mendukung perangkat yang terbatas—perangkat tersebut adalah cara sebagian besar pengguna mengakses web secara global. OMT menawarkan cara yang menjanjikan untuk meningkatkan performa pada perangkat tersebut tanpa berdampak buruk pada pengguna perangkat kelas atas.

Selain itu, OMT memiliki manfaat sekunder:

  • Library ini memindahkan biaya eksekusi JavaScript ke thread terpisah.
  • Hal ini memindahkan biaya penguraian, yang berarti UI mungkin akan di-booting lebih cepat. Hal ini dapat mengurangi First Contentful Paint atau bahkan Time to Interactive, yang pada akhirnya dapat meningkatkan skor Lighthouse Anda.

Web worker tidak harus menakutkan. Alat seperti Comlink memudahkan penggunaan worker dan menjadikannya pilihan yang layak untuk berbagai aplikasi web.