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

JavaScript Performans İpuçları: Debounce, Throttle ve Layout Thrashing

Mert Özen 3 Kas 2026 10 dk 4 görüntülenme
JavaScript Performans İpuçları: Debounce, Throttle ve Layout Thrashing

performance.now, mark ve measure ile ölçmeyi, kendi debounce ve throttle fonksiyonumuzu, requestAnimationFrame'i, layout thrashing'i, lazy loading'i ve DevTools Performance sekmesini örneklerle öğreniyoruz.

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

Önce Tahmin Et

Aşağıda on satırlık küçük bir yardımcı fonksiyon var. Ne işe yaradığını henüz söylemeyelim. Bu kod konsola ne yazdırır, kaç satır ve hangi harfler?

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

const log = debounce(console.log, 200);
log("a");
log("b");
log("c");
setTimeout(() => log("d"), 300);

Okumaya devam etmeden önce bir tahmin yap. Dört çağrı var ama dört çıktı olduğundan emin misin? Cevabı debounce'un mantığını kurarken vereceğiz.

Önce Ölç: performance.now, mark ve measure

Performans işinde ilk kural, tahminle optimizasyon yapmamak. "Şu döngü yavaştır" hissiyle kod değiştirmek karanlıkta ok atmaya benzer; çoğu zaman yavaşlık hiç beklemediğin bir yerdedir. O yüzden önce ölçeriz.

performance.now(), sayfanın açıldığı andan beri geçen süreyi milisaniye cinsinden, ondalıklı olarak verir. Date.now()'dan iki farkı var: daha hassastır ve monotondur, yani bilgisayarın saati ileri geri alınsa bile geriye gitmez. Süre ölçmek için doğru araç budur. Daha düzenli ölçümler için mark ve measure var. Bunu tarayıcının konsoluna yapıştırıp dene:

performance.mark("sort-start");

const numbers = Array.from({ length: 200_000 }, () => Math.random());
numbers.toSorted((a, b) => a - b);

performance.mark("sort-end");

const result = performance.measure("sorting", "sort-start", "sort-end");
console.log(`${result.name}: ${result.duration.toFixed(1)} ms`);

mark zaman çizgisine isimli bir işaret koyar, measure iki işaret arasındaki süreyi hesaplar ve sonucu bir object olarak döndürür. Bu ölçümlerin bir güzelliği de DevTools'un Performance kaydında kendi satırlarında görünmeleri. İki not düşelim: tarayıcılar güvenlik nedeniyle bu zamanlayıcıların hassasiyetini biraz düşürür, bu yüzden mikro saniyelik farklara güvenme. Ayrıca ilk çalıştırma genelde yavaştır, çünkü motor kodu henüz optimize etmemiştir. Bir işi birkaç kez çalıştırıp ortalamaya bak.

Debounce: Sakinleşince Çalış

Bir asansör kapısını düşün. Biri girer, kapı bekler. Bir başkası daha girer, kapı yine bekler. Kapı ancak birkaç saniye boyunca kimse gelmezse kapanır. Debounce tam olarak budur: art arda gelen çağrıları yutar ve sadece ortalık sakinleşince, en son çağrıyı çalıştırır.

Baştaki koda bak. Her log çağrısı önce bekleyen zamanlayıcıyı iptal eder, sonra yenisini kurar. "a", "b" ve "c" aynı anda geldiği için ilk iki zamanlayıcı daha çalışamadan iptal edilir; 200 ms sonra yalnızca c yazılır. 300. milisaniyede gelen "d" ise ortalık sakinleştikten sonra geldiği için kendi turunu alır ve 500. milisaniye civarında d yazılır. Yani dört çağrıdan iki çıktı: c ve d.

En tipik kullanım alanı arama kutusu. Kullanıcı "javascript" yazarken her harfte sunucuya istek atmak yerine, yazmayı bıraktığında tek bir istek atarsın. Bir HTML dosyasına kaydedip dene:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Search with debounce</title>
</head>
<body>
  <input id="search" placeholder="Type something...">
  <p id="status"></p>

  <script>
    function debounce(fn, ms) {
      let timer;
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), ms);
      };
    }

    let keyCount = 0;
    let searchCount = 0;
    const status = document.querySelector("#status");

    function update() {
      status.textContent = `${keyCount} keys, ${searchCount} searches`;
    }

    const search = debounce((text) => {
      searchCount++;
      console.log("Searching:", text);
      update();
    }, 300);

    document.querySelector("#search").addEventListener("input", (e) => {
      keyCount++;
      update();
      search(e.target.value);
    });
  </script>
</body>
</html>

Hızlıca bir kelime yaz: tuş sayısı onlarca, arama sayısı bir olacak. Aynı fikir pencere boyutlandırma bittiğinde yeniden hesaplamak ya da bir formu otomatik kaydetmek için de kullanılır.

Throttle: Belli Aralıkla Çalış

Debounce bazen fazla sabırlıdır. Kullanıcı sayfayı on saniye boyunca durmadan kaydırırsa, debounce'lu bir fonksiyon on saniye boyunca hiç çalışmaz. Kaydırma sırasında ekrandaki bir şeyi güncellemek istiyorsan başka bir şeye ihtiyacın var: throttle. Bunu metro seferleri gibi düşün. Peronda ne kadar insan birikirse biriksin, tren beş dakikada bir kalkar. Throttle da fonksiyonu belli bir aralıkta en fazla bir kez çalıştırır.

function throttle(fn, ms) {
  let last = 0;
  let timer = null;
  let lastArgs;

  return (...args) => {
    lastArgs = args;
    const remaining = ms - (performance.now() - last);

    if (remaining <= 0) {
      clearTimeout(timer);
      timer = null;
      last = performance.now();
      fn(...args);
    } else if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        last = performance.now();
        fn(...lastArgs);
      }, remaining);
    }
  };
}

const save = throttle((x) => console.log("ran:", x), 500);
let i = 0;
const ticker = setInterval(() => save(++i), 50);
setTimeout(() => clearInterval(ticker), 2000);

İki saniye boyunca saniyede yirmi çağrı yaptık, yani kırk çağrı. Konsolda ise yaklaşık beş satır göreceksin. İlk çağrı hemen çalışır. Aralık dolmadan gelenler için tek bir zamanlayıcı kurulur ve aralık dolduğunda en son gelen argümanlarla çalışır. Böylece son değeri hiçbir zaman kaçırmazsın. Kısaca: debounce "durunca bir kez", throttle "devam ettikçe düzenli aralıklarla" demek.

requestAnimationFrame: Ekranla Aynı Ritimde

Daha önce event loop'u anlatırken requestAnimationFrame'in yerini görmüştük: callback'i, tarayıcı bir sonraki kareyi çizmeden hemen önce çalışır. Ekran saniyede 60 kez yenileniyorsa saniyede 60 kez, 120 Hz bir ekranda 120 kez. Görsel güncellemeler için setInterval(fn, 16) yerine bunu kullanmanın sebebi şu: zamanlayıcı ekranın ritmini bilmez, bazen bir karede iki kez, bazen hiç çalışmaz ve titreme olur. Üstelik requestAnimationFrame, sekme arka plandayken durur ve pil harcamaz.

Kaydırmaya bağlı bir okuma ilerleme çubuğu, iki fikri bir araya getiren güzel bir örnek. Scroll event'i bir kare içinde birkaç kez gelebilir; biz her kare için yalnızca bir güncelleme yapacağız:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Reading progress</title>
  <style>
    #bar { position: fixed; top: 0; left: 0; height: 6px; width: 0; background: tomato; }
    main { height: 400vh; padding: 20px; }
  </style>
</head>
<body>
  <div id="bar"></div>
  <main>Scroll down...</main>

  <script>
    const bar = document.querySelector("#bar");
    let scheduled = false;

    function updateBar() {
      scheduled = false;
      const total = document.documentElement.scrollHeight - innerHeight;
      bar.style.width = `${(scrollY / total) * 100}%`;
    }

    window.addEventListener("scroll", () => {
      if (scheduled) return;
      scheduled = true;
      requestAnimationFrame(updateBar);
    }, { passive: true });
  </script>
</body>
</html>

scheduled bayrağı, bir kare için zaten bir güncelleme planlandıysa yenisini istemememizi sağlar. Bu, ekranın ritmine kilitlenmiş bir throttle. Daha önce event'leri anlatırken gördüğümüz passive: true da tarayıcıya "bu listener kaydırmayı engellemeyecek" sözü verir, böylece kaydırma bizi beklemeden akar.

Layout Thrashing: Oku, Yaz, Oku, Yaz...

Daha önce reflow ve repaint'e giriş yapmıştık. Tarayıcı tembeldir ve bu aslında iyi bir şey: bir element'in stilini değiştirdiğinde sayfanın yerleşimini (layout) hemen hesaplamaz, "kirli" diye işaretler ve bir sonraki çizimden önce hepsini birden hesaplar. Ama ardından offsetWidth, getBoundingClientRect() ya da scrollTop gibi bir ölçü okursan tarayıcının elinde güncel bir cevap yoktur. Seni bekletip layout'u o anda, senkron olarak hesaplamak zorunda kalır. Buna forced reflow denir.

Bunu bir döngünün içinde yaparsan, yani her turda önce okuyup sonra yazarsan, tarayıcı her turda layout'u yeniden hesaplar. İşte layout thrashing budur. Her dikişten sonra kumaşı yeniden ölçen bir terzi gibi. Farkı kendi gözünle gör:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Layout thrashing</title>
  <style>
    #box-container { display: flex; flex-wrap: wrap; gap: 2px; }
    .box { width: 40px; height: 20px; background: steelblue; }
  </style>
</head>
<body>
  <button id="bad">Mixed reads and writes</button>
  <button id="good">Read first, then write</button>
  <p id="result"></p>
  <div id="box-container"></div>

  <script>
    const container = document.querySelector("#box-container");
    for (let i = 0; i < 1500; i++) {
      const box = document.createElement("div");
      box.className = "box";
      container.append(box);
    }
    const boxes = [...container.children];

    function bad() {
      for (const box of boxes) {
        const width = box.offsetWidth;
        box.style.width = `${(width % 60) + 1}px`;
      }
    }

    function good() {
      const widths = boxes.map((b) => b.offsetWidth);
      boxes.forEach((b, i) => {
        b.style.width = `${(widths[i] % 60) + 1}px`;
      });
    }

    function measure(fn) {
      const t0 = performance.now();
      fn();
      document.body.offsetHeight; // son layout'u da ölçüme dahil et
      document.querySelector("#result").textContent =
        `${fn.name}: ${(performance.now() - t0).toFixed(1)} ms`;
    }

    document.querySelector("#bad").addEventListener("click", () => measure(bad));
    document.querySelector("#good").addEventListener("click", () => measure(good));
  </script>
</body>
</html>

İki fonksiyon aynı sonucu üretiyor; tek fark işlemlerin sırası. Benim denememde karışık sürüm yüzlerce milisaniye sürerken diğeri birkaç milisaniyede bitti; senin sayıların farklı olabilir ama fark yine açık olacak. Kural basit: önce bütün okumaları yap, sonra bütün yazmaları. Okumayla yazmanın iç içe geçmek zorunda olduğu durumlarda yazmaları requestAnimationFrame ile bir sonraki kareye ertelemek de işe yarar.

Lazy Loading: İhtiyaç Olunca Yükle

En hızlı kod, hiç indirilmeyen koddur. Sayfa açılırken kullanıcının görmediği resimleri, açmadığı panelleri yüklemek ilk açılışı yavaşlatır. Resimler ve iframe'ler için iş tek bir attribute: <img src="large.jpg" loading="lazy" width="800" height="600" alt="...">. Tarayıcı, resim ekrana yaklaşana kadar onu indirmez. Bu özellik Haziran 2026'dan beri Baseline'da geniş çapta destekleniyor. width ve height vermeyi unutma; yoksa resim geldiğinde sayfa aşağı kayar.

Kod için de aynı fikir geçerli. Daha önce gördüğümüz dynamic import() ile bir module'ü ancak gerektiğinde yüklersin. Module kullandığı için bu örnek, iki dosyayı aynı klasörden sunan yerel bir sunucuda çalışır. index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Load when needed</title>
</head>
<body>
  <button id="report">Show report</button>
  <script type="module">
    document.querySelector("#report").addEventListener("click", async () => {
      const { drawReport } = await import("./report.js");
      drawReport(document.body);
    });
  </script>
</body>
</html>

Ve aynı klasördeki report.js:

export function drawReport(target) {
  const p = document.createElement("p");
  p.textContent = "The report module has just loaded.";
  target.append(p);
}

DevTools'un Network sekmesine bakarsan report.js'in butona tıklayana kadar istenmediğini görürsün. Bir element'in ekrana girdiğini kendin yakalayıp ona göre yükleme yapmayı ilerleyen yazılarda, observer API'lerini anlatırken göreceğiz.

DevTools Performance Sekmesine Giriş

performance.now() bir işin ne kadar sürdüğünü söyler; Performance sekmesi ise o süre boyunca tarayıcının ne yaptığını gösterir. Layout thrashing sayfasını aç, DevTools'ta Performance sekmesine geç ve kayıt butonuna bas. "Mixed reads and writes" butonuna birkaç kez tıkla, sonra kaydı durdur.

Ortadaki Main satırı ana thread'in zaman çizgisi. Üst üste binen bloklar call stack'i gösterir: üstte event, altında onun çağırdığı fonksiyonlar. Sarı bloklar JavaScript'in çalışmasıdır, mor bloklar stil ve layout hesabı, yeşiller çizim. Karışık sürümde sarı bloğun içine sıkışmış sayısız mor Layout bloğu göreceksin; bunlardan birine tıkladığında DevTools forced reflow uyarısı verir ve seni koddaki satıra götürür. 50 ms'den uzun süren işler kırmızı bir üçgen ve kırmızı taralı bir uçla "long task" olarak işaretlenir; bunlar kullanıcının takılma hissettiği anlardır. Alttaki Bottom-Up sekmesi ise süreyi en çok hangi fonksiyonların yediğini sıralar.

İki küçük ipucu. Panelin ayarlarındaki CPU throttling ile işlemciyi yavaşlatıp sayfanı zayıf bir telefondaymış gibi deneyebilirsin. Kodundaki performance.mark ve measure çağrıları da kayıtta ayrı bir satırda görünür. Firefox'ta benzer bir profiler, Safari'de ise Timelines sekmesi var.

Sık Düşülen Tuzaklar

Debounce'u her event'te yeniden oluşturmak. input.addEventListener("input", (e) => debounce(search, 300)(e.target.value)) yazarsan her tuşta yepyeni bir debounce'lu fonksiyon, yani yepyeni bir zamanlayıcı üretirsin; hiçbiri diğerini iptal etmez. Debounce'lu fonksiyonu bir kez oluştur ve hep onu çağır.

Tek ölçüme güvenmek. İlk çalıştırma, motorun kodu henüz optimize etmediği turdur. DevTools açıkken de sayfa biraz yavaşlar. Birkaç kez ölç ve iki sürümü aynı koşullarda karşılaştır.

Süreyi Date.now() ile ölçmek. Sistem saati senkronize edilirken değer zıplayabilir, ölçümün negatif bile çıkabilir. Süre için performance.now() kullan.

requestAnimationFrame'i sihirli değnek sanmak. Ağır bir işi rAF'in içine koymak onu hafifletmez; sadece kareyi geciktirir. 200 ms süren bir hesap, nereye koyarsan koy ana thread'i 200 ms kilitler. Böyle işler ya parçalara bölünmeli ya da ana thread'den tamamen çıkarılmalı.

Küçük Bir Deneme

★☆☆ Bir milyon element'li bir array'in toplamını önce for döngüsüyle, sonra reduce ile hesapla. İkisini de performance.mark ve measure ile ölç, sonuçları Performance kaydındaki ölçüm satırında da bul.

★★☆ Çıktıyı tahmin et: yazıdaki throttle ile const t = throttle(console.log, 500); tanımla ve t("a")'yı hemen, t("b")'yi 100 ms, t("c")'yi 200 ms, t("d")'yi 600 ms sonra çağır. Hangi harfler yazılır ve yaklaşık ne zaman? setTimeout ile kurup dene.

★★★ Layout thrashing sayfasındaki bad fonksiyonunu, okumaları aynı turda yapıp yazmaları requestAnimationFrame içinde toplu yapan bir sürüme çevir. Performance kaydında forced reflow uyarılarının kaybolduğunu doğrula.

Bu yazıdaki tekniklerin hepsi ana thread'deki işi azaltmaya ya da daha akıllıca zamanlamaya dayanıyor. Ama bazı işler ne kadar iyi planlarsan planla ağırdır: büyük bir veriyi işlemek, bir görüntüyü filtrelemek, karmaşık bir hesap. Bir sonraki yazıda bu işleri ana thread'den tamamen çıkarıp arka planda çalıştırmayı göreceğiz: Web Workers.