Menggunakan pekerja web untuk menjalankan JavaScript di luar thread utama browser

Arsitektur off-main-thread dapat meningkatkan keandalan dan pengalaman pengguna aplikasi Anda secara signifikan.

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

Akibatnya, thread utama menjadi sangat kelebihan beban. Selain itu, seiring 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 pengaruh besar terhadap performa. Ketidakpastian tersebut hanya akan bertambah seiring pengguna mengakses web dari berbagai perangkat yang semakin beragam, mulai dari ponsel fitur yang sangat terbatas hingga perangkat andalan berperforma tinggi dan kecepatan refresh tinggi.

Jika ingin aplikasi web yang canggih memenuhi pedoman 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 pekerja web?

Secara default, JavaScript adalah bahasa dengan thread tunggal yang menjalankan tugas di thread utama. Namun, web worker 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 pekerjaan yang cukup besar yang perlu dilakukan dan akan membebani thread utama.

Sehubungan dengan Core Web Vitals, menjalankan tugas di luar thread utama dapat bermanfaat. Secara khusus, memindahkan 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 pekerjaan yang harus diproses, thread tersebut dapat merespons interaksi pengguna dengan lebih cepat.

Lebih sedikit pekerjaan thread utama—terutama selama startup—juga berpotensi memberikan manfaat untuk Largest Contentful Paint (LCP) dengan mengurangi tugas yang lama. Merender elemen LCP memerlukan waktu thread utama—baik untuk merender teks maupun 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 mahal yang dapat ditangani oleh pekerja web.

Threading dengan pekerja web

Platform lain biasanya mendukung pekerjaan paralel dengan memungkinkan Anda memberikan fungsi ke thread, yang berjalan secara paralel dengan 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 perlombaan.

Di JavaScript, kita bisa mendapatkan fungsi yang kira-kira mirip dari pekerja web, 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 pekerja web, teruskan file ke konstruktor pekerja, yang mulai menjalankan file tersebut di thread terpisah:

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

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

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 postMessage API yang sama di pekerja web 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);
});

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

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

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

Anda menyiapkan Comlink dengan mengimpornya di web worker dan menentukan kumpulan fungsi yang akan ditampilkan ke thread utama. Kemudian, Anda mengimpor Comlink di thread utama, menggabungkan pekerja, dan mendapatkan akses ke fungsi yang ditampilkan:

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 pada thread utama berperilaku sama seperti yang ada di pekerja web, kecuali bahwa setiap fungsi menampilkan promise untuk nilai, bukan nilai itu sendiri.

Kode apa yang harus Anda pindahkan ke pekerja web?

Pekerja web 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 pekerja. Namun, setiap potongan kecil kode yang dipindahkan ke pekerja akan mendapatkan lebih banyak headroom 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 di aplikasi; semuanya adalah komponen framework sehingga secara inheren terikat dengan DOM. Hal ini tampaknya akan mempersulit migrasi ke arsitektur OMT.

Namun, jika kita beralih ke model yang memisahkan masalah UI dari masalah lain, seperti pengelolaan status, pekerja web dapat sangat berguna bahkan dengan aplikasi berbasis framework. Itulah pendekatan yang diambil dengan PROXX.

PROXX: studi kasus OMT

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

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

  • Thread utama menangani rendering animasi dan transisi.
  • Pekerja web menangani logika game, yang murni komputasi.

OMT memiliki efek yang menarik pada performa ponsel fitur PROXX. Dalam versi non-OMT, UI akan 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 di PROXX versi non-OMT.

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

Waktu respons UI di PROXX versi OMT.

Ini adalah kompromi yang disengaja: kami memberi pengguna perangkat yang dibatasi pengalaman yang terasa lebih baik tanpa menghukum pengguna perangkat kelas atas.

Implikasi arsitektur OMT

Seperti yang ditunjukkan 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 pekerja web dan thread utama terkadang dapat memperlambat proses.

Pertimbangkan konsekuensinya

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

Karena performa yang tidak dapat diprediksi di seluruh perangkat, tujuan arsitektur OMT sebenarnya adalah mengurangi risiko—membuat aplikasi Anda lebih andal dalam menghadapi kondisi runtime yang sangat bervariasi—bukan tentang manfaat performa paralelisasi. Peningkatan ketahanan dan peningkatan UX lebih dari sekadar mengimbangi sedikit pengorbanan kecepatan.

Catatan tentang alat

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

Merangkum

Untuk memastikan aplikasi kami dapat diandalkan dan diakses sebanyak mungkin, terutama di pasar yang semakin terglobalisasi, kami perlu mendukung perangkat yang terbatas—perangkat tersebut adalah cara sebagian besar pengguna mengakses web secara global. OMT menawarkan cara yang menjanjikan untuk meningkatkan performa di perangkat tersebut tanpa memengaruhi pengguna perangkat kelas atas.

Selain itu, OMT memiliki manfaat sekunder:

  • Hal ini memindahkan biaya eksekusi JavaScript ke thread terpisah.
  • Hal ini memindahkan biaya pemrosesan, yang berarti UI mungkin akan melakukan 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 perlu menakutkan. Alat seperti Comlink mengurangi beban kerja pekerja dan menjadikannya pilihan yang layak untuk berbagai aplikasi web.