Mert Özen Her Satırda Daha İleri
Frontend JavaScript İleri — #04

JavaScript Event Loop Derinlemesine: Task, Microtask ve Rendering Sırası

Mert Özen 3 Kas 2026 9 dk 3 görüntülenme
JavaScript Event Loop Derinlemesine: Task, Microtask ve Rendering Sırası

Task ve microtask queue'larını, promise ve queueMicrotask'ın yerini, rendering adımında requestAnimationFrame'in ne zaman çalıştığını ve uzun task'ların sayfayı neden dondurduğunu çıktı sırası bulmacalarıyla çözüyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Beş satır, üç farklı zamanlama. Bu kod konsola hangi sırayla ne yazdırır?

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");

Okumaya devam etmeden önce bir tahmin yap ve sırayı bir yere yaz. setTimeout'a sıfır milisaniye verdik; yine de ilk çalışan o olmayacak. Neden olmadığını, motorun queue'larını tek tek açtıktan sonra adım adım göreceğiz.

Büyük Resim: Tek Aşçı, İki Queue

Daha önce gördüğümüz temel tabloyu hatırla: JavaScript ana thread'de tek bir call stack ile çalışır. Zamanlayıcılar, ağ istekleri ve kullanıcı event'leri tarayıcının kendi parçalarında beklenir; hazır olduklarında çalıştırılacak callback'leri sıraya girer. Bu sırayı yöneten mekanizmaya event loop dedik. Orta seride buna "callback queue" diyerek tek bir queue gibi baktık. Gerçekte birden fazla queue var ve aralarındaki öncelik farkı, bu yazının bütün konusu.

Tek aşçılı bir restoran mutfağı düşün. Kapıda sipariş fişleri birikiyor; aşçı bir fişi alır ve o yemeği bitirmeden bir sonrakine geçmez. Bunlar task'lardır. Aşçının bir de tezgâhına yapıştırdığı küçük notlar var: "sosu karıştır", "tabağı ısıt". Bir fişi bitirdiğinde yeni fişe geçmeden önce bütün notları halleder; not hallederken yeni not yazarsa onu da bitirir. Bunlar microtask'lardır. Garson ise tabakları belirli aralıklarla salona taşır, ama aşçı ocağın başındayken servis olmaz. Bu da rendering adımı.

HTML spesifikasyonunun tarif ettiği döngü kabaca şöyle işler:

// event loop'un bir turu (basitleştirilmiş)
// 1. Bir task queue'sundan en eski task'ı al ve sonuna kadar çalıştır.
// 2. Microtask queue'sunu tamamen boşalt (bu sırada eklenenler dahil).
// 3. Ekranı yenileme zamanı geldiyse rendering adımını çalıştır:
//    requestAnimationFrame callback'leri, sonra style, layout, paint.
// 4. Başa dön.

Bu dört satırı aklında tutarsan bu yazıdaki her bulmacayı çözebilirsin.

Task'lar: setTimeout, Event'ler ve Diğerleri

Spesifikasyon bunlara sadece task der; topluluk ise microtask'tan ayırmak için çoğu zaman macrotask der. İkisi aynı şey. Task kaynakları şunlardır: setTimeout ve setInterval callback'leri, tıklama ve tuş basma gibi kullanıcı event'leri, postMessage ile gelen mesajlar, ağdan ya da diskten gelen veri bildirimleri. Bir de en baştaki script'in kendisi: sayfadaki bir <script> ya da konsola yapıştırdığın kod da bir task gibi baştan sona çalışır.

Kritik kural şu: event loop her turda tek bir task çalıştırır ve o task bitmeden araya hiçbir şey giremez. setTimeout(fn, 0) "sıfır milisaniye sonra çalıştır" demek değildir; "en az sıfır milisaniye sonra, queue'ya bir task koy" demektir. Önündeki task'lar ve microtask'lar bitmeden sırası gelmez.

Sıfırın gerçekten sıfır olmadığı başka durumlar da var. Spesifikasyon, iç içe çağrılan zamanlayıcılarda beşinci seviyeden sonra en az 4 milisaniye bekletilmesini ister. Arka plandaki sekmelerde tarayıcılar enerji tasarrufu için zamanlayıcıları saniyede bire kadar yavaşlatır. Bir de farklı task kaynakları arasında sıra garantisi yoktur: tarayıcı, örneğin kullanıcı girdisine bir zamanlayıcıdan daha yüksek öncelik verebilir. Güvenebileceğin şey, aynı süreyle kurulan zamanlayıcıların kuruldukları sırayla çalışmasıdır.

Microtask'lar: Promise Reaksiyonları ve queueMicrotask

Microtask queue'suna giren üç ana şey var. Promise'lerin then, catch ve finally callback'leri, await'ten sonraki kodun devamı ve queueMicrotask ile doğrudan eklediğin fonksiyonlar. DOM değişikliklerini izleyen MutationObserver'ın callback'i de bir microtask'tır; onu ilerleyen yazılarda göreceğiz.

Microtask'ların en önemli özelliği, queue tamamen boşalana kadar çalışmalarıdır. Bir microtask yeni bir microtask eklerse o da aynı turda, bir sonraki task'tan önce çalışır:

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => {
  console.log("micro 1");
  queueMicrotask(() => console.log("micro 2"));
});

// micro 1
// micro 2
// timeout

await da aynı kurala uyar. Bir async fonksiyon ilk await'e kadar tamamen senkron çalışır; await fonksiyonu orada askıya alır ve devamını bir microtask olarak planlar. Bunu konsolda dene:

async function run() {
  console.log("A");
  await null;
  console.log("B");
}

console.log("C");
run();
console.log("D");

// C, A, D, B

run() çağrıldığında A hemen yazılır, çünkü henüz hiçbir şey beklenmedi. await null satırında fonksiyon durur ve kontrol çağırana döner, D yazılır. Script bitince microtask queue'su boşaltılır ve B gelir. await'e bir promise değil null verdik; fark etmez, await her değeri önce bir promise'e sarar.

Bulmacaları Adım Adım Çözmek

Şimdi baştaki soruya dönebiliriz. Doğru sıra: 1, 5, 3, 4, 2. Adım adım gidelim. Script'in kendisi bir task. 1 hemen yazılır. setTimeout callback'ini task queue'suna gönderir. Promise.resolve().then zaten çözülmüş bir promise'e bağlandığı için callback'i hemen microtask queue'suna girer. queueMicrotask da onun arkasına ekler. 5 yazılır ve script biter. Şimdi microtask queue'su boşaltılır: önce 3, sonra 4. Queue boş; event loop sıradaki task'ı alır ve 2 yazılır.

Biraz daha zorunu deneyelim. Önce tahmin et, sonra konsola yapıştır:

console.log("start");

setTimeout(() => {
  console.log("timeout 1");
  Promise.resolve().then(() => console.log("micro in timeout"));
}, 0);

setTimeout(() => console.log("timeout 2"), 0);

Promise.resolve()
  .then(() => console.log("then 1"))
  .then(() => console.log("then 2"));

console.log("end");

Çıktı: start, end, then 1, then 2, timeout 1, micro in timeout, timeout 2. İki nokta önemli. Birincisi, then 2 script sırasında queue'da değildi; then 1 bitince queue'ya girdi ama microtask queue'su boşalana kadar çalışmaya devam edildiği için yine zamanlayıcılardan önce çalıştı. İkincisi, micro in timeout iki zamanlayıcının arasına girdi. timeout 1 bir task'tı; o task bittiği anda microtask queue'su boşaltıldı, timeout 2 ancak ondan sonra sıraya geldi. Yani microtask'lar yalnızca script'in sonunda değil, her task'ın sonunda boşaltılır.

Rendering Adımı ve requestAnimationFrame'in Yeri

Tarayıcı ekranı her task'tan sonra çizmez. Ekranın yenileme hızına bağlı olarak bir rendering fırsatı bekler; 60 Hz bir ekranda bu yaklaşık 16,7 milisaniyede birdir. O an geldiğinde, iki task arasında, rendering adımını çalıştırır: önce requestAnimationFrame ile kaydedilmiş callback'ler, ardından stil hesaplama, layout ve paint. Sekme arka plandaysa bu adım genellikle hiç çalışmaz, dolayısıyla requestAnimationFrame callback'leri de bekler.

Bunun ilk sonucu şaşırtıcı: bir task devam ettiği sürece yaptığın görsel değişiklikler ekrana yansımaz. Bunu konsolda dene ve ekrana dikkatle bak:

document.body.style.background = "red";

const end = performance.now() + 2000;
while (performance.now() < end) {}

document.body.style.background = "";

İki saniye boyunca sayfa donar ama kırmızıyı hiç görmezsin. Arka plan kırmızıya döndü, iki saniye öyle kaldı, sonra eski hâline geldi; ama bütün bunlar tek bir task'ın içinde oldu ve garsonun salona çıkma fırsatı olmadı. Rendering adımı geldiğinde arka plan zaten eski rengine dönmüştü.

requestAnimationFrame ne task'tır ne microtask; bir sonraki rendering adımında, paint'ten hemen önce çalışan ayrı bir listedir. Bu yüzden animasyon kodunun doğru yeri odur: her karede tam bir kez, çizimden hemen önce çalışır. Ama bu "çizimden önce" ayrıntısı bir tuzak da kurar:

document.body.style.background = "red";
requestAnimationFrame(() => {
  document.body.style.background = ""; // kırmızı hiç çizilmez
});

// Bir kare çizildikten sonra çalışmak için iki kez iç içe:
// requestAnimationFrame(() => requestAnimationFrame(() => { ... }));

İlk callback paint'ten önce çalıştığı için kırmızı yine hiç ekrana çıkmaz. Bir karenin gerçekten çizildiğinden emin olup ondan sonra bir şey yapmak istiyorsan requestAnimationFrame'i iç içe iki kez çağırırsın: birincisi bu kareye, ikincisi bir sonraki kareye düşer.

Son bir not: setTimeout(fn, 0) ile requestAnimationFrame(fn)'i aynı anda kurarsan hangisinin önce çalışacağının garantisi yoktur. Bu, o an karenin neresinde olduğuna bağlıdır. Böyle bir sıraya güvenen kod yazma.

Uzun Task'lar UI'yi Neden Dondurur?

Event loop'un kuralını ters çevirince UI donmalarının sebebi ortaya çıkar: bir task çalışırken ne başka bir task, ne rendering, ne de kullanıcının tıklamasına cevap mümkündür. Tarayıcılar 50 milisaniyeden uzun süren task'lara long task der; kullanıcı bu kadar gecikmeyi hissetmeye başlar. Aşağıdaki dosyayı freeze.html adıyla kaydedip tarayıcıda aç:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Long task</title>
</head>
<body>
  <p>Counter: <span id="counter">0</span></p>
  <button id="block">Freeze for 3 seconds</button>
  <button id="chunked">Do the same work in chunks</button>
  <script>
    const counter = document.querySelector("#counter");
    let ticks = 0;
    setInterval(() => {
      ticks += 1;
      counter.textContent = ticks;
    }, 100);

    function busy(ms) {
      const end = performance.now() + ms;
      while (performance.now() < end) {}
    }

    document.querySelector("#block").addEventListener("click", () => {
      busy(3000);
    });

    document.querySelector("#chunked").addEventListener("click", async () => {
      for (let i = 0; i < 60; i++) {
        busy(50);
        await new Promise((resolve) => setTimeout(resolve, 0));
      }
    });
  </script>
</body>
</html>

İlk butona bastığında sayaç üç saniye boyunca durur, sonra birden atlar. O sırada sayfadaki metni seçmeye ya da ikinci butona basmaya çalış; hiçbir şey olmaz, event'ler ancak task bitince işlenir. İkinci buton toplamda aynı üç saniyelik işi yapar ama 50 milisaniyelik parçalara bölerek. Her parçadan sonra setTimeout ile yeni bir task'a geçtiği için aradaki boşluklarda tarayıcı ekranı çizebilir, sayacı güncelleyebilir ve tıklamalara cevap verebilir. İş biraz daha uzun sürer, ama sayfa canlı kalır.

Sık Düşülen Tuzaklar

Microtask ile UI'ye nefes aldırmaya çalışmak. Yukarıdaki parçalı döngüde setTimeout yerine await Promise.resolve() yazarsan sayfa yine donar. Microtask'lar task bitmeden, rendering'den önce boşaltılır; yani araya hiç boşluk açılmaz. Ana thread'i tarayıcıya geri vermek için yeni bir task gerekir.

Microtask'larla sonsuz döngü kurmak. Kendini tekrar tekrar setTimeout ile planlayan bir fonksiyon sayfayı dondurmaz, çünkü her tur ayrı bir task'tır ve aralarda rendering yapılabilir. Kendini queueMicrotask ile planlayan bir fonksiyon ise queue'yu hiç boşaltmaz; sekme tamamen kilitlenir.

Kodla tetiklenen event'lerin gerçek event'lerle aynı sırada çalıştığını sanmak. Bunu konsolda dene:

const button = document.createElement("button");
button.textContent = "Click me";
document.body.append(button);

button.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("micro 1"));
  console.log("listener 1");
});
button.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("micro 2"));
  console.log("listener 2");
});

button.click();
// listener 1, listener 2, micro 1, micro 2

Şimdi sayfanın en altına eklenen butona fareyle tıkla. Bu kez sıra listener 1, micro 1, listener 2, micro 2 olur. Gerçek tıklamada tarayıcı her listener'ı ayrı çağırır ve her birinin ardından call stack boşaldığı için microtask'lar hemen çalışır. button.click() ise senin script'inin içinden çağrılır; stack hiç boşalmadığından microtask'lar script bitene kadar bekler. Event'leri kodla tetikleyerek yazdığın testlerin gerçek kullanımdan farklı davranabilmesinin sebebi bu.

Küçük Bir Deneme

★☆☆ Çalıştırmadan önce tahmin et: Promise.resolve().then(() => console.log(1)).then(() => console.log(3)); Promise.resolve().then(() => console.log(2)).then(() => console.log(4)); İki zincir sırayla mı çalışır, iç içe mi geçer?

★★☆ Çıktının sırasını tahmin et, sonra dene: setTimeout(() => console.log("A"), 0); (async () => { console.log("B"); await null; console.log("C"); setTimeout(() => console.log("D"), 0); })(); Promise.resolve().then(() => console.log("E")); console.log("F"); Her satır için hangi queue'ya girdiğini yanına not et.

★★★ freeze.html dosyasında parçalı döngüdeki setTimeout satırını await Promise.resolve() ile değiştir ve ikinci butona bas. Sayaç neden yine donuyor? Sonra aynı satırı await new Promise((resolve) => requestAnimationFrame(resolve)) yap; iş ne kadar sürüyor, sayfa nasıl davranıyor ve neden?

Artık bir promise'in callback'inin tam olarak ne zaman çalıştığını biliyoruz. Bir sonraki yazıda bu bilgiyi gerçek problemlere uyguluyoruz: birden fazla promise'i birleştirmenin dört yolu, ES2024 ve ES2025 ile gelen yeni yardımcılar, iptal edilebilen fonksiyonlar, retry, timeout ve aynı anda çalışan iş sayısını sınırlamak.