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

JavaScript Asenkron Programlamaya Giriş: Call Stack, Event Loop ve Callback'ler

Mert Özen 3 Kas 2026 8 dk 6 görüntülenme
JavaScript Asenkron Programlamaya Giriş: Call Stack, Event Loop ve Callback'ler

Tek thread'li JavaScript'in beklemeleri sayfayı dondurmadan nasıl yönettiğini call stack, Web API'ler, callback queue ve event loop ile görüyor; setTimeout(fn, 0) sırrını ve callback hell'i çö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

Üç satır, bir zamanlayıcı. Bekleme süresi sıfır milisaniye. Konsolda harfler hangi sırayla görünür?

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");

Okumaya devam etmeden önce bir tahmin yap. Sıfır milisaniye "hemen" demek gibi duruyor, değil mi? Bu yazının sonunda neden öyle olmadığını, engine'in içinde neler döndüğünü bir resim gibi gözünün önüne getirebileceksin.

Senkron Kod: Tek Kasada Sırayla

Şimdiye kadar yazdığımız kodun neredeyse hepsi senkrondu. Senkron, satırların yazıldığı sırayla, biri bitmeden diğeri başlamadan çalışması demek. Tek kasası olan bir market düşün: kasiyer bir müşterinin ürünlerini okutmayı bitirmeden sıradakine geçmez.

Bu model anlaşılır ve öngörülebilir ama bir zayıflığı var. Müşterilerden biri kasada "bir dakika, cüzdanımı arabada unuttum" derse arkadaki herkes bekler. Kodda bunun karşılığı uzun süren bir işlemdir. Aşağıdaki HTML dosyasını kaydedip tarayıcıda aç ve önce "Freeze" butonuna, hemen ardından "Hello" butonuna tıkla:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Frozen page</title>
</head>
<body>
  <button id="freeze">Freeze (3 seconds)</button>
  <button id="hello">Hello</button>
  <p id="out"></p>

  <script>
    const out = document.querySelector("#out");

    document.querySelector("#freeze").addEventListener("click", () => {
      const end = Date.now() + 3000;
      while (Date.now() < end) {
        // 3 saniye boyunca hiçbir şey yapmadan dön
      }
      out.textContent = "Loop finished";
    });

    document.querySelector("#hello").addEventListener("click", () => {
      out.textContent = "Hello!";
    });
  </script>
</body>
</html>

Üç saniye boyunca sayfa ölü gibi durur. "Hello" butonu tıklamana tepki vermez, metin seçilemez, bazen kaydırma bile takılır. Döngü bitince bekleyen tıklama işlenir. Bunun sebebini anlamak için engine'in nasıl çalıştığına bakmamız gerekiyor.

Tek Thread ve Call Stack

JavaScript, sayfanın ana thread'i üzerinde çalışır ve bu thread tektir. Yani aynı anda yalnızca bir parça JavaScript kodu çalışabilir. Sayfanın çizilmesi, tıklamalara cevap verilmesi de büyük ölçüde aynı thread'i paylaşır. Kasiyer bir tane, ve sen onu bir döngüyle meşgul edersen başka kimseye bakamaz.

JavaScript engine'i o an hangi fonksiyonun içinde olduğunu call stack adında bir yapıda tutar. Üst üste dizilmiş tabaklar gibi düşün: bir fonksiyon çağrıldığında stack'in üstüne bir tabak eklenir, fonksiyon bittiğinde o tabak alınır. Her zaman en üstteki tabakla çalışılır.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(4); // 16

printSquare(4) çağrıldığında stack'e o eklenir. O square'i çağırır, square üstüne konur. O da multiply'ı çağırır, multiply en üste çıkar. multiply sonucu döndürünce stack'ten düşer, sonra square, en son printSquare. Stack boşalınca engine bu kodla işini bitirmiştir.

Bunu aslında daha önce gördün. Hata yönetimi yazısında bir hata fırlatıldığında konsolda gördüğümüz "stack trace", o anki call stack'in fotoğrafıdır: hata hangi fonksiyonda çıktı, o fonksiyonu kim çağırdı, onu kim çağırdı. Yukarıdaki donan sayfa örneğinde de while döngüsü stack'in en üstünde üç saniye boyunca oturdu ve başka hiçbir şeyin sıra almasına izin vermedi.

Tarayıcının Yardımcıları: Web API'ler

Peki tek thread ile bir sunucudan cevap beklemek, beş saniye sonra bir mesaj göstermek nasıl mümkün oluyor? Cevap şu: bekleme işini JavaScript engine'i yapmıyor, tarayıcı yapıyor.

setTimeout, addEventListener, fetch gibi fonksiyonlar dilin kendisinin parçası değildir. Bunlar tarayıcının JavaScript'e sunduğu Web API'lerdir. Sen setTimeout(fn, 2000) dediğinde engine tarayıcıya "iki saniye sonra bu fonksiyonu bana hatırlat" der ve hiç beklemeden bir sonraki satıra geçer. Saati tarayıcı tutar.

Bir restoranı düşün. Garson tektir (JavaScript'in thread'i). Siparişini alır, mutfağa iletir ve yemeğin pişmesini mutfağın kapısında beklemez; diğer masalara bakmaya devam eder. Mutfak (Web API'ler) yemeği hazırlar ve hazır olunca tezgâha koyar. Garson elindeki işi bitirdiğinde tezgâha bakar ve hazır olan tabağı masaya götürür.

Callback Queue ve Event Loop

Bu benzetmede tezgâh, callback queue'dur. Bir zamanlayıcı dolduğunda, bir tıklama olduğunda ya da bir cevap geldiğinde, çalıştırılacak fonksiyon doğrudan çalışmaz; bu queue'nun sonuna eklenir ve sırasını bekler.

Event loop ise garsonun alışkanlığıdır. Kuralı çok basit: call stack boşaldığında queue'daki ilk fonksiyonu alıp stack'e koy ve çalıştır. Bitince tekrar bak. Bu döngü sayfa açık olduğu sürece sürer.

Şimdi baştaki soruya dönelim ve adım adım yürüyelim:

console.log("A");                       // 1) hemen çalışır: A
setTimeout(() => console.log("B"), 0);  // 2) tarayıcıya verilir, süre hemen dolar,
                                        //    fonksiyon queue'ya girer
console.log("C");                       // 3) hemen çalışır: C
// 4) script bitti, stack boş, event loop queue'ya bakar: B

Çıktı A, C, B. Süre sıfır olsa bile callback queue'ya girer ve queue'daki bir fonksiyon, stack boşalmadan çalışamaz. O sırada stack'te hâlâ çalışan senin script'in var. Yani setTimeout(fn, 0), "hemen çalıştır" değil, "elindeki iş bitince, ilk fırsatta çalıştır" demektir.

Bunun bir sonucu daha var: setTimeout'a verdiğin süre bir garanti değil, bir alt sınırdır. Bunu konsolda dene:

const start = Date.now();

setTimeout(() => {
  console.log(`Elapsed: ${Date.now() - start} ms`);
}, 100);

while (Date.now() - start < 1000) {
  // stack'i 1 saniye meşgul et
}

100 milisaniye istedik ama yaklaşık 1000 görürüz. Zamanlayıcı vaktinde doldu, callback de vaktinde queue'ya girdi; ama stack bir saniye boyunca dolu olduğu için garson tezgâha bakamadı.

Burada event loop'un sadece giriş seviyesindeki resmini çizdik. Gerçekte birden fazla queue var, promise'lerin kendine ait bir sırası var ve tarayıcı ekranı çizmeye de bu döngünün belli anlarında fırsat buluyor. Bu ayrıntılara İleri serideki event loop yazısında derinlemesine gireceğiz; şimdilik "stack boşalınca queue'dan sıradaki" kuralı her şeyi açıklamaya yetiyor.

Callback Deseni

Asenkron bir işin sonucunu almanın en eski yolu, işi başlatan fonksiyona "bitince bunu çağır" diye bir fonksiyon vermektir. Daha önce gördüğümüz gibi fonksiyonlar değerdir; başka bir fonksiyona argüman olarak geçilebilir. Buna callback diyoruz.

Bir sunucudan kullanıcı bilgisi çektiğimizi setTimeout ile taklit edelim. Bunu tarayıcının konsoluna yapıştırıp dene:

function getUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid user id"));
      return;
    }
    callback(null, { id, name: "Ellie" });
  }, 500);
}

getUser(1, (error, user) => {
  if (error) {
    console.error(error.message);
    return;
  }
  console.log(`User: ${user.name}`);
});

console.log("Request sent");

Önce "Request sent", yarım saniye sonra "User: Ellie" görürsün. Callback'in ilk parametresinin hata olması yaygın bir gelenektir: hata varsa ilk argüman dolu gelir, yoksa null gelir ve sonuç ikinci argümandadır. Böylece her callback, işe önce "bir şey ters gitti mi?" diye bakarak başlar.

Callback Hell: Sağa Doğru Kayan Kod

Tek bir asenkron iş için callback gayet iyi. Sorun, işler birbirine bağlandığında başlar. Önce kullanıcıyı, sonra onun siparişlerini, sonra ilk siparişin detayını almak istediğini düşün. Yukarıdaki getUser konsolda tanımlıyken bunu da yapıştır:

function getOrders(userId, callback) {
  setTimeout(() => callback(null, [{ id: 101 }, { id: 102 }]), 500);
}

function getOrderDetail(orderId, callback) {
  setTimeout(() => callback(null, { id: orderId, total: 349.9 }), 500);
}

getUser(1, (error, user) => {
  if (error) return console.error(error.message);

  getOrders(user.id, (error, orders) => {
    if (error) return console.error(error.message);

    getOrderDetail(orders[0].id, (error, detail) => {
      if (error) return console.error(error.message);

      console.log(`${user.name} paid ${detail.total} on their first order`);
    });
  });
});

Kod her adımda biraz daha sağa kayıyor ve bir piramide dönüşüyor. Buna callback hell ya da "kıyamet piramidi" denir. Üç adımda bile okumak zorlaşıyor; beş altı adımda ve araya döngüler, koşullar girdiğinde iyice içinden çıkılmaz bir hâl alıyor.

Görünüşten daha önemli iki sorun var. Birincisi, hata kontrolü her seviyede tekrar ediyor ve birini unutmak çok kolay. İkincisi, kontrolü başkasına devrediyorsun: callback'ini verdiğin fonksiyon onu hiç çağırmayabilir, iki kez çağırabilir ya da yanlış argümanlarla çağırabilir. Kodunun akışı artık senin elinde değil. Promise'ler tam olarak bu iki derde çare olarak doğdu.

Sık Düşülen Tuzaklar

En yaygın hata, asenkron bir sonucu fonksiyondan return ile döndürmeye çalışmaktır:

function getUserName() {
  let name;
  setTimeout(() => {
    name = "Ellie";
  }, 500);
  return name;
}

console.log(getUserName()); // undefined

return name satırı, zamanlayıcının callback'i daha queue'ya bile girmeden çalışır. Asenkron sonuca ancak callback'in içinde, ya da ilerleyen yazılarda göreceğimiz promise ve async/await ile ulaşabilirsin.

İkincisi, setTimeout'un süresini kesin sanmak. Gördüğümüz gibi süre bir alt sınır; stack meşgulse callback geç çalışır. Saniyesi saniyesine zamanlama gereken işler için ona güvenme.

Üçüncüsü, uzun senkron işleri ana thread'de yapmak. Büyük bir array'i işlemek, ağır bir hesap yapmak sayfayı dondurur, çünkü o sırada event loop queue'ya bakamaz. Bu tür işleri parçalara bölmek ya da başka bir thread'e taşımak gerekir; bunun yollarını İleri seride göreceğiz.

Son olarak, daha önce closure yazısında konuştuğumuz döngü tuzağı burada da karşına çıkar. var ile kurulan bir for döngüsünde setTimeout kullanırsan, callback'ler çalıştığında döngü çoktan bitmiş olur ve hepsi aynı son değeri görür. let kullan.

Küçük Bir Deneme

★☆☆ Çıktıyı tahmin et, sonra konsolda dene:

console.log(1);
setTimeout(() => console.log(2), 100);
setTimeout(() => console.log(3), 0);
console.log(4);

★★☆ getOrderDetail'i, sipariş id'si 102 olduğunda "Order not found" hatası verecek şekilde değiştir. Piramitteki üçüncü adımı orders[1].id ile çağır ve hatanın doğru seviyede yakalandığını gör.

★★★ Donan sayfa örneğindeki 3 saniyelik döngüyü, her biri 100 milisaniye süren 30 parçaya böl ve her parçadan sonra setTimeout(nextChunk, 0) ile bir sonrakini planla. "Hello" butonu artık arada tepki veriyor mu? Neden?

Callback'ler işi görüyor ama sağa kayan piramit ve dağınık hata kontrolü büyüdükçe can sıkıyor. Bir sonraki yazıda asenkron bir sonucu bir object olarak elimizde tutmamızı sağlayan promise'lerle tanışacağız ve bu piramidi düz bir zincire çevireceğiz.