Fase-fase event loop menjelaskan kapan sebuah callback dijemput, tetapi belum menjelaskan apa yang terjadi ketika callback itu sedang berjalan. Di dalamnya, mesin V8 memakai dua struktur data yang mengatur segalanya: call stack yang mengeksekusi kode secara sinkron, dan microtask queue yang menyisipkan pekerjaan kecil di sela-sela tugas besar. Chapter ini menelusuri keduanya dengan eksperimen langsung, lalu menyusun gambar lengkap urutan eksekusi dari stack hingga antrean.
Call Stack sebagai Tempat Eksekusi Sinkron #
Call stack adalah tumpukan bingkai (frame) eksekusi milik V8. Setiap kali sebuah function dipanggil, V8 mendorong satu frame berisi parameter dan variable lokalnya ke puncak tumpukan; setiap kali function tersebut selesai, frame-nya dikeluarkan. Sifatnya LIFO (last in, first out) — function yang terakhir dipanggil selalu selesai lebih dulu. Program kecil berikut membuat pola ini terlihat lewat pencatatan masuk dan keluar di tiap kedalaman:
function telusuri(kedalaman) {
console.log(`masuk kedalaman ${kedalaman}`);
if (kedalaman < 3) {
telusuri(kedalaman + 1);
}
console.log(`keluar kedalaman ${kedalaman}`);
}
telusuri(1);
console.log("call stack kembali kosong");Output yang Dihasilkan:
masuk kedalaman 1
masuk kedalaman 2
masuk kedalaman 3
keluar kedalaman 3
keluar kedalaman 2
keluar kedalaman 1
call stack kembali kosongMari kita telaah baris-baris kode di atas:
- Baris 1-7 (
function telusuri(...)): function mencatat kedatangannya, memanggil dirinya sendiri satu tingkat lebih dalam selama kedalaman masih di bawah 3, lalu mencatat kepergiannya. - Baris 2: pesan
masuktercetak tiga kali berturut-turut — satu untuk setiap frame yang didorong ke stack: kedalaman 1, 2, lalu 3. - Baris 4: pada kedalaman 3 pemanggilan rekursif berhenti, sehingga frame terdalam selesai lebih dulu. Pesan
keluarmuncul dalam urutan terbalik: 3, 2, 1. - Baris 9: baris terakhir hanya berjalan setelah seluruh frame keluar — bukti bahwa kode level teratas menunggu call stack benar-benar kosong.
Call stack juga punya batas ukuran. Rekursi tanpa henti akan memenuhi tumpukan dan memicu RangeError, bukan proses yang menggantung tanpa akhir:
function ulang() {
return ulang();
}
try {
ulang();
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}Output yang Dihasilkan:
RangeError: Maximum call stack size exceededFunction ulang() memanggil dirinya tanpa kondisi berhenti, sehingga V8 kehabisan ruang frame dan melempar RangeError yang kita tangkap dengan try/catch. Batas persisnya berbeda-beda sesuai platform dan ukuran tiap frame, tetapi gejala dan pesan errornya konsisten. Pesan ini juga sering muncul ketika struktur data rekursif (misalnya JSON bersarang sangat dalam) diproses dengan algoritma rekursif.
Microtask Queue dan Penyisipan di Antara Tugas #
Call stack hanya menampung eksekusi yang sedang berjalan. Sementara itu, hasil Promise dan callback queueMicrotask() menunggu di microtask queue, sebuah antrean FIFO yang dikosongkan seluruhnya setiap kali call stack kembali kosong — sebelum event loop melanjutkan ke tugas berikutnya, apa pun fase tugas berikutnya itu. Aturan pengosongan total inilah yang membuat microtask selalu mendahului timer maupun callback I/O berikutnya.
Kedua cara berikut memasukkan pekerjaan ke antrean yang sama: Promise.resolve().then(fn) dan queueMicrotask(fn):
setTimeout(() => {
console.log("Timer A selesai (tunda 10 ms)");
Promise.resolve().then(() => console.log("Microtask dari Timer A"));
queueMicrotask(() => console.log("queueMicrotask dari Timer A"));
}, 10);
setTimeout(() => {
console.log("Timer B selesai (tunda 20 ms)");
}, 20);Output yang Dihasilkan:
Timer A selesai (tunda 10 ms)
Microtask dari Timer A
queueMicrotask dari Timer A
Timer B selesai (tunda 20 ms)Mari kita ikuti kronologi eksekusinya:
- Baris 1-6 (Timer A): ketika tunda 10 milidetik terlampaui, callback Timer A didorong ke call stack dari fase timers. Console pertama tercetak, lalu dua microtask didaftarkan —
Promise.thenlebih dulu,queueMicrotaskkemudian. - Antara Timer A dan Timer B: begitu callback Timer A selesai dan call stack kosong, seluruh microtask queue dikosongkan lebih dulu. Karena itu kedua microtask tercetak sebelum Timer B, meskipun jeda antar keduanya hanya sesaat.
- Baris 8-10 (Timer B): setelah antrean microtask bersih, event loop baru menjemput Timer B pada fase timers berikutnya. Urutan
Promise.thensebelumqueueMicrotaskmengikuti urutan pendaftaran — keduanya berbagi satu antrean FIFO.
Tiga Struktur dalam Satu Gambar #
| Struktur | Dikelola oleh | Kapan dieksekusi | Isi tipikal |
|---|---|---|---|
| Call stack | V8 | Segera, sinkron, satu per satu | Function yang sedang berjalan |
| Microtask queue | V8 | Setiap call stack kosong; dikosongkan seluruhnya | Callback Promise.then, queueMicrotask |
| Antrean macrotask | libuv (per fase event loop) | Dijemput sesuai fase | Callback timer, I/O, setImmediate |
Urutan lengkapnya untuk satu putaran kerja: kode sinkron berjalan sampai call stack kosong, microtask queue dikosongkan seluruhnya (microtask baru yang lahir saat pengosongan ikut dieksekusi), lalu event loop menjemput macrotask berikutnya dari fase yang sedang aktif, dan siklus berulang.
Pemahaman stack dan antrean ini menjadi fondasi untuk membaca kode asynchronous lintas generasi. Generasi pertama Node.js bahkan belum punya Promise — hasil asynchronous dikirim lewat function callback yang diteruskan sebagai argumen. Chapter Pola Callback membahas konvensi tersebut beserta jebakan-jebakannya.