Event Loop Deep Dive — Node.js

Semua pelajaran Node.js sebelumnya bilang "Node.js non-blocking" tanpa menjelaskan bagaimana. Jawabannya: event loop. Pondasi: call stack + callback queue…

Event loop adalah putaran yang memilih kode mana yang dijalankan berikutnya, setiap kali JavaScript di Node selesai mengerjakan sesuatu. Bayangin kantor pos dengan satu petugas. Dia selalu menyelesaikan orang yang sedang di depannya dulu. Setelah itu dia menghabiskan tumpukan memo kecil yang mendesak, sampai kosong. Baru dia keliling ke baki-baki dengan urutan tetap: baki "alarm" (timer), baki "paket datang" (I/O), baki "setelah paket" (setImmediate), lalu baki "tutup". Setiap selesai satu urusan dari baki, tumpukan memo dihabiskan lagi. Memo itu namanya microtask.

🧪 Tebakan cepat: kode ini mencetak "1 sync", menjadwalkan timer 0 ms yang mencetak "4 timer", menjadwalkan queueMicrotask yang mencetak "2 microtask", menjadwalkan Promise.resolve().then yang mencetak "3 promise", lalu mencetak "1b sync". Apa urutan output-nya? Tebak dulu, jawabannya ada di bagian "Microtask dulu, baru timer".

Satu call stack, kode sinkron dulu

JavaScript di Node punya satu call stack: daftar function yang sedang jalan, cuma bisa mengerjakan satu hal dalam satu waktu. Kode sinkron, baris demi baris di file-mu, jalan sampai habis lebih dulu. Callback yang dijadwalkan (timer, Promise, I/O) gak pernah memotong di tengah. Mereka antre, dan event loop baru mengambilnya saat call stack kosong.

Microtask dulu, baru timer

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
A
D
C
B

A dan D sinkron, jadi duluan. .then masuk ke microtask queue, antrean yang berisi lanjutan Promise (.then, .catch, sisa function setelah await) dan queueMicrotask(fn). Microtask queue dihabiskan sampai kosong sebelum event loop pindah ke fase berikutnya, termasuk sebelum timer apa pun. Jadi C sebelum B, walau timernya 0 ms.

Di dalam microtask queue, urutannya sesuai urutan dijadwalkan (yang pertama masuk, pertama keluar). Untuk tebakannya: 1 sync dan 1b sync dulu karena sinkron, lalu 2 microtask (dijadwalkan duluan), 3 promise, dan terakhir 4 timer. Itu jawaban tebakannya.

Fase-fase event loop Node

Node memakai libuv, library bahasa C yang menjalankan event loop dan mengurus I/O. Satu putaran melewati fase-fase ini, berurutan:

Fase Yang dijalankan di situ
timers callback setTimeout dan setInterval yang waktunya sudah lewat
pending callbacks beberapa callback error sistem yang ditunda dari putaran sebelumnya
poll callback I/O: file selesai dibaca, data atau koneksi baru datang. Kalau gak ada yang lain, Node menunggu di sini
check callback setImmediate
close callbacks callback penutupan, misalnya socket.on("close")

Setiap selesai satu callback, Node menghabiskan antrean process.nextTick dulu, lalu microtask queue, baru lanjut. Pekerjaan berat seperti membaca file, dns.lookup, crypto.pbkdf2, dan kompresi zlib dikerjakan thread pool milik libuv (default 4 thread) di luar JavaScript, sedangkan koneksi jaringan ditunggu lewat notifikasi sistem operasi. JavaScript-mu sendiri tetap jalan di satu thread.

process.nextTick dan setImmediate

console.log("sync");
setImmediate(() => console.log("setImmediate"));
setTimeout(() => console.log("setTimeout 0"), 0);
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("end sync");

Hasil di Node 24, file app.js biasa (CommonJS):

sync
end sync
nextTick
promise
setImmediate
setTimeout 0

process.nextTick(fn) punya antrean sendiri yang diproses sebelum microtask lain, jadi nextTick tercetak sebelum promise. Dua baris terakhir gak dijamin urutannya: di kode utama, setTimeout(fn, 0) baru siap kalau 1 ms sudah lewat saat putaran pertama dimulai. Kami jalankan 100 kali di Node 24: 99 kali setImmediate duluan, sekali setTimeout 0 duluan. Jangan bergantung pada urutan itu.

Di dalam callback I/O, urutannya pasti:

fs.readFile("app.js", () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});

Selalu immediate lalu timeout. Callback readFile jalan di fase poll, dan fase sesudah poll adalah check, tempat setImmediate. Timer baru dapat giliran di putaran berikutnya.

Memblokir event loop

const mulai = Date.now();
setTimeout(() => console.log("timer 10 ms jalan setelah " + (Date.now() - mulai) + " ms"), 10);
const t = Date.now();
while (Date.now() - t < 300) {} // kerja sinkron 300 ms

Output-nya timer 10 ms jalan setelah 301 ms. Selama loop sinkron itu jalan, event loop gak bisa berputar: timer, I/O, dan semua request lain menunggu. Hal yang sama terjadi kalau process.nextTick atau microtask terus menjadwalkan dirinya sendiri: antreannya gak pernah kosong, dan timer gak pernah dapat giliran (starvation). Kerja CPU yang berat dipindah ke Worker Threads, yang dibahas di pelajaran nanti.

Di latihan: buktikan urutannya

Kode awal sudah mencetak 1 sync dan 1b sync. Di antaranya, jadwalkan tiga hal, masing-masing mencetak dari dalam penjadwalnya sendiri: setTimeout(() => { console.log("4 timer"); }, 0);, lalu queueMicrotask(() => { console.log("2 microtask"); });, lalu Promise.resolve().then(() => { console.log("3 promise"); });. Output-nya harus persis lima baris, urut: 1 sync, 1b sync, 2 microtask, 3 promise, 4 timer.

⚠️ Jebakan yang sering ketemu

TL;DR: JavaScript di Node punya satu call stack: kode sinkron jalan sampai habis dulu. Lalu antrean process.nextTick, lalu microtask queue (.then, await, queueMicrotask) dihabiskan sampai kosong, baru fase berikutnya: timers, pending callbacks, poll (I/O), check (setImmediate), close. Jadi A D C B, dan 1 sync, 1b sync, 2 microtask, 3 promise, 4 timer. process.nextTick jalan sebelum microtask lain. Urutan setTimeout 0 dan setImmediate di kode utama gak dijamin; di dalam callback I/O, setImmediate selalu duluan. libuv mengerjakan I/O dan thread pool-nya (4 thread) di luar JavaScript. Kode sinkron yang berat atau nextTick yang berulang terus memblokir event loop untuk semua request.

Yang akan kamu pelajari