Lompat ke konten utama
vourdev
Kembali ke Blog

Dev Notes

Membedah JavaScript Event Loop dan Microtask Queue: Dari Call Stack hingga Starvation

Bedah tuntas mekanisme internal JavaScript Event Loop, Call Stack, Web APIs, Task Queue, dan Microtask Queue beserta studi kasus starvation.

Gambar Cover Membedah JavaScript Event Loop dan Microtask Queue: Dari Call Stack hingga Starvation
Ditulis olehvourdev10 menit baca

Banyak engineer bisa menulis async/await dan Promise setiap hari tanpa masalah, sampai suatu hari mereka ketemu bug aneh: UI browser freeze total padahal tidak ada infinite loop di synchronous code, atau setTimeout(fn, 0) yang dipanggil ternyata baru tereksekusi 100 milidetik kemudian.

JavaScript itu single-threaded. Dia cuma punya satu call stack untuk mengeksekusi instruksi. Pertanyaannya: bagaimana bahasa yang cuma punya satu jalur eksekusi ini bisa menangani HTTP request, user click event, timer, dan rendering 60 FPS tanpa macet?

Jawabannya ada di orkestrasi antara Call Stack, Host APIs (Web APIs / Libuv), Microtask Queue, dan Task Queue (Macrotask) yang diatur oleh Event Loop. Mari kita bedah jeroannya baris demi baris, bukan cuma dari teori permukaan, tapi dari cara engine V8 memproses byte code Anda.

Anatomi Runtime: Siapa Mengerjakan Apa?

Visualisasi arsitektur runtime JavaScript V8
Arsitektur internal runtime JavaScript yang mengatur alur eksekusi asinkron. · V8 JavaScript engine logo 2 — The V8 Project (Public domain)

Sebelum masuk ke urutan eksekusi, kita perlu menyamakan mental model tentang komponen yang terlibat di dalam runtime JavaScript (baik di browser seperti Chrome/V8 maupun di server seperti Node.js).

Lapisan runtime JavaScript dari host sampai eksekusiCall Stack (V8)Frame Eksekusi SynchronousMicrotask QueuePromises, queueMicrotaskTask Queue (Macrotask)setTimeout, DOM EventsHost EnvironmentWeb APIs / Libuv C++
Lapisan runtime JavaScript dari host sampai eksekusi

Ada empat aktor utama di sini:

  1. Call Stack: Struktur data LIFO (Last In, First Out) tempat JavaScript mengeksekusi function frames. Kalau stack ini sedang menjalankan fungsi yang butuh waktu lama, JavaScript tidak bisa melakukan hal lain.
  2. Host Environment (Web APIs / Node C++ Bindings): Tempat task asynchronous sesungguhnya berjalan di latar belakang (misalnya timer hardware OS, network socket untuk fetch, atau file descriptor I/O). Host ini yang nantinya melempar callback ke dalam antrean.
  3. Microtask Queue: Antrean berprioritas tinggi khusus untuk callback Promise (.then(), .catch(), .finally()), queueMicrotask(), MutationObserver, dan di Node.js ada process.nextTick().
  4. Task Queue (Macrotask Queue): Antrean untuk callback timer (setTimeout, setInterval), I/O events, dan UI rendering callbacks.

Aturan emas yang wajib Anda ingat: Event Loop tidak akan pernah menyentuh Task Queue sebelum Call Stack kosong DAN seluruh isi Microtask Queue habis dikuras sampai bersih.

Studi Kasus: Menelusuri Jalur Eksekusi Kode

Mari kita lihat potongan kode klasik yang sering ditanyakan di interview senior engineer, lalu kita bedah apa yang terjadi di balik layar.

typescript
console.log("1: Synchronous start");

setTimeout(() => {
  console.log("2: Timeout callback");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("3: Microtask 1");
  })
  .then(() => {
    console.log("4: Microtask 2");
  });

queueMicrotask(() => {
  console.log("5: Microtask 3 via queueMicrotask");
});

console.log("6: Synchronous end");

Kalau kode di atas dijalankan, output di konsol adalah:

bash
1: Synchronous start
6: Synchronous end
3: Microtask 1
5: Microtask 3 via queueMicrotask
4: Microtask 2
2: Timeout callback

Kenapa 3, 5, dan 4 keluar sebelum 2, padahal setTimeout diset 0ms? Mari kita telusuri siklus hidup eksekusinya per langkah.

Urutan eksekusi callback pada satu tick Event LoopCall StackMicrotask QueueTask Queue1. Enqueues Promise/Microtask2. Enqueues setTimeout callback3. Drain ALL microtasks until em…4. Pick ONE macrotask
Urutan eksekusi callback pada satu tick Event Loop

Langkah 1: Script Evaluation (Synchronous Phase)

  • console.log("1: Synchronous start") masuk ke Call Stack, dieksekusi langsung, lalu di-pop dari stack.
  • setTimeout(..., 0) dipanggil. JavaScript mendelegasikan timer ini ke Host API (Web API/OS timer). Timer langsung selesai (karena delay 0ms), dan Web API mendorong callback () => console.log("2: ...") ke Task Queue.
  • Promise.resolve().then(...) dievaluasi. Karena Promise sudah berstatus fulfilled, engine langsung menjadwalkan handler .then() pertama ke dalam Microtask Queue.
  • queueMicrotask(...) dieksekusi, mendorong fungsinya langsung ke antrean Microtask Queue tepat di belakang Microtask 1.
  • console.log("6: Synchronous end") masuk ke Call Stack, dicetak, lalu Call Stack menjadi kosong (empty stack).

Langkah 2: Microtask Checkpoint

Saat Call Stack kosong, Event Loop langsung melakukan microtask checkpoint. Di fase ini, engine akan mengeksekusi semua task di Microtask Queue secara FIFO (First In, First Out).

  1. Mengambil Microtask 1: console.log("3: Microtask 1") dieksekusi. Handler ini mengembalikan Promise baru yang me-resolve chained .then(), sehingga callback Microtask 2 sekarang didorong ke dalam Microtask Queue.
  2. Mengambil Microtask 3: console.log("5: Microtask 3 via queueMicrotask") dieksekusi.
  3. Mengambil Microtask 2: console.log("4: Microtask 2") dieksekusi.
  4. Engine mengecek kembali Microtask Queue. Karena sudah kosong, fase checkpoint selesai.

Langkah 3: Render & Macrotask Execution

Setelah Microtask Queue bersih, browser (jika di browser) memiliki kesempatan untuk melakukan re-paint / UI rendering jika dibutuhkan. Setelah itu, Event Loop beralih ke Task Queue, mengambil satu task terdepan (callback setTimeout), memasukkannya ke Call Stack, dan mencetak "2: Timeout callback".

Perbedaan Karakteristik: Task vs Microtask

Salah satu miskonsepsi paling fatal adalah menganggap Microtask dan Macrotask bekerja dengan cara yang sama, hanya beda "antrean". Kenyataannya, cara Event Loop menguras kedua antrean ini sangat kontras.

Perbandingan mendasar Microtask Queue dan Task QueueMicrotask QueueDieksekusi segera setelah stackkosongDisedot sampai 100% tuntas persiklusDapat menambahkan microtaskbaru di siklus yang samaBerpotensi memicu UI renderblockingTask Queue (Macrotask)Hanya diambil SATU task per siklusMemberi jeda untuk UI rendersebelum task berikutnyaCallback timer, IO, dan user eventsAman untuk memecah komputasiberat
Perbandingan mendasar Microtask Queue dan Task Queue
Catatan Penting: Pada Task Queue, Event Loop hanya mengambil satu task per tick, lalu kembali memeriksa Microtask Queue dan Render Pipeline. Sebaliknya, pada Microtask Queue, engine akan memproses seluruh item sampai habis, termasuk microtask baru yang dijadwalkan di dalam microtask yang sedang berjalan.

Gotcha: Microtask Starvation dan UI Freeze

Debugging performa dan monitoring event loop di terminal
Profiling event loop latency saat terjadi thread blocking. · Artturi Jalli (Unsplash)

Karena sifat Microtask Queue yang harus dikuras habis sebelum kontrol dikembalikan ke browser atau Task Queue, Anda bisa secara tidak sengaja menyebabkan kondisi yang disebut Microtask Starvation (atau Event Loop Starvation).

Perhatikan pola anti-pattern berikut ini:

typescript
// ANTI-PATTERN: Recursive Microtask Starvation
function infiniteMicrotask() {
  Promise.resolve().then(() => {
    // Menjadwalkan microtask baru tanpa henti
    infiniteMicrotask();
  });
}

// Menjalankan fungsi ini akan membuat browser tab HANG seketika.
// setTimeout di bawah ini TIDAK AKAN PERNAH berjalan, dan UI tidak bisa di-click.
setTimeout(() => {
  console.log("Aku tidak akan pernah dipanggil!");
}, 0);

infiniteMicrotask();

Mengapa ini terjadi?

Meskipun pemanggilan infiniteMicrotask() di atas bersifat asinkron (tidak ada while(true) synchronous yang menahan stack), setiap kali microtask selesai, dia langsung mendaftarkan microtask baru ke dalam queue yang sama.

Event Loop melihat bahwa Microtask Queue tidak pernah kosong, sehingga dia terus-menerus memproses queue tersebut dan tidak pernah memberikan kesempatan bagi browser untuk render UI atau menjalankan Task Queue.

Solusi Praktis: Menggunakan Macrotask untuk Heavy Chunking

Jika Anda harus memproses array besar (misalnya ratusan ribu data) di browser tanpa bantuan Web Worker, jangan lakukan chaining Promise tanpa henti. Pecah komputasi tersebut menggunakan setTimeout atau API modern scheduler.yield().

typescript
// BEST PRACTICE: Time-slicing menggunakan async scheduler
async function processLargeArray<T>(
  items: T[], 
  processor: (item: T) => void, 
  chunkSize = 100
): Promise<void> {
  let index = 0;

  async function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      processor(items[index]);
    }

    if (index < items.length) {
      // Gunakan modern scheduler API jika didukung, atau fallback ke setTimeout
      if ('scheduler' in window && 'yield' in (window as any).scheduler) {
        await (window as any).scheduler.yield();
      } else {
        await new Promise((resolve) => setTimeout(resolve, 0));
      }
      await processChunk();
    }
  }

  await processChunk();
}

// Penggunaan: UI tetap responsif 60fps saat memproses 100.000 data
const largeDataset = Array.from({ length: 100000 }, (_, i) => ({ id: i }));
processLargeArray(largeDataset, (item) => {
  // Operasi per item
});

Baris kode await new Promise((resolve) => setTimeout(resolve, 0)) di atas secara sengaja melempar eksekusi langkah berikutnya ke Task Queue. Hal ini memberi jeda bagi browser untuk membersihkan Microtask, melakukan layouting, dan me-render frame visual baru ke layar sebelum melanjutkan chunk berikutnya.

Nuansa di Node.js: process.nextTick vs setImmediate

Di lingkungan Node.js, terdapat layer tambahan di luar spesifikasi Web Browser standar. Node.js menggunakan library Libuv untuk mengelola event loop-nya, yang memiliki fase-fase tersendiri:

  1. Timers Phase: Menjalankan callback dari setTimeout dan setInterval.
  2. Pending Callbacks: Menjalankan I/O callbacks yang tertunda dari sistem operasi.
  3. Poll Phase: Mengambil event I/O baru (file system, network).
  4. Check Phase: Khusus untuk mengeksekusi callback setImmediate().
  5. Close Callbacks: Membersihkan koneksi (socket.on('close')).

Di Node.js, process.nextTick() memiliki antrean tersendiri (nextTick Queue) yang dieksekusi bahkan sebelum Microtask Queue Promise standar:

typescript
// Contoh di Node.js runtime
setTimeout(() => console.log("1: Timers phase"), 0);
setImmediate(() => console.log("2: Check phase (setImmediate)"));

Promise.resolve().then(() => console.log("3: Microtask Promise"));
process.nextTick(() => console.log("4: NextTick"));

console.log("5: Sync");

// Output di Node.js:
// 5: Sync
// 4: NextTick
// 3: Microtask Promise
// 1: Timers phase (atau 2 tergantung bootstrap I/O context)
// 2: Check phase

process.nextTick() secara historis dibuat untuk mengizinkan developer melakukan sinkronisasi state atau pembersihan error handling tepat setelah sinkronus selesai, sebelum I/O atau microtask lain berjalan. Namun di modern JavaScript, direkomendasikan untuk lebih mengutamakan queueMicrotask() agar kode Anda portabel antara Node.js, Deno, Bun, dan Browser.

Tips Praktis & Best Practices

  1. Jangan Gunakan `setTimeout(fn, 0)` untuk Urutan State: Jika Anda ingin memastikan state terupdate setelah aksi sinkronus selesai, gunakan queueMicrotask() atau Promise.resolve().then(). Menggunakan setTimeout(..., 0) menambahkan overhead latency minimal 4ms (sesuai spesifikasi HTML HTML5 timer clamping saat bersarang) dan masuk ke task queue yang berprioritas rendah.
  2. Hati-hati dengan Async Lifecycle di Frontend Framework: Di framework seperti React atau Vue, batch update DOM sering kali dijadwalkan di dalam microtask. Menyelipkan queueMicrotask sembarangan bisa menyebabkan state inconsistency jika tidak sinkron dengan cycle component render.
  3. Pindahkan Heavy Computation ke Worker: Event Loop adalah mekanisme untuk concurrency, bukan parallelism. Komputasi matematika berat, kompresi gambar, atau enkripsi tetap akan memblokir thread utama. Gunakan Worker (Browser) atau worker_threads (Node.js) untuk komputasi CPU-bound sejati.

Kesimpulan & Action Items

Memahami Event Loop bukan sekadar bekal untuk lulus technical interview, melainkan fondasi penting saat Anda mendesain arsitektur aplikasi berskala besar yang performan dan bebas dari glitch responsivitas.

Langkah konkret yang bisa Anda terapkan di codebase sekarang:

  • Audit perulangan berat: Cari fungsi-fungsi pengolahan data array besar di frontend dan pastikan mereka menggunakan time-slicing atau dipindahkan ke Web Worker.
  • Hindari recursive promise loop tanpa boundary: Pastikan tidak ada chaining Promise tanpa exit condition yang bisa memicu starvation.
  • Standardisasi asynchronous helper: Mulai ganti penggunaan trik setTimeout(..., 0) yang tersebar di codebase lama dengan queueMicrotask() atau scheduler.yield() untuk menjaga prediktabilitas prioritas eksekusi.

Butuh penyesuaian khusus untuk project Anda?

Jika situasi operasional atau arsitektur sistem bisnis Anda membutuhkan solusi kustom, diskusikan langsung bersama tim engineer kami. Anda juga bisa melihat rincian layanan vour.dev atau menghitung estimasi biaya project lebih dulu.

Mulai Project