Main Thread Blocking Nedir?
Butona tıklıyorsunuz, hiçbir şey olmuyor. Yarım saniye sonra — bazen daha uzun — sayfa tepki veriyor. Ekran donmuş gibi görünmüyor, tarayıcı çökmüyor; ama o arada hiçbir şey yapamazsınız. Bu deneyim, main thread blocking'in en yaygın belirtisidir ve çoğu kullanıcı neyin döndüğünü bilmeden hisseder.
Tarayıcı, JavaScript yürütme, stil hesaplama, layout, boyama ve birleştirme (compositing) işlemlerinin büyük bölümünü tek bir iş parçacığı üzerinde çalıştırır. Bu iş parçacığına main thread denir. Bir görev bu thread'i uzun süre meşgul tutarken kullanıcı olayları — click, keydown, scroll — gelen bir kuyruğa girer ve bekler. Görev bitene kadar cevap gelmez; thread hem ekranı güncellemekten hem de kullanıcıyla iletişim kurmaktan aynı anda sorumludur.
Sorun, bir sayfanın genel olarak yavaş olmasından farklıdır. Blocking sırasında sayfa teknik olarak çalışmaya devam eder; arka planda işler yürür. Ama kullanıcı tarafı dondurulmuştur. Yavaş bir sayfa yükleme süresiyle mücadele ederken, main thread blocking etkileşim kalitesiyle doğrudan ilgilenir. İki sorun farklı tanı ve farklı çözüm gerektirir.
Long task neden 50 ms eşiğini aşar
Chrome DevTools ve Lighthouse, 50 milisaniyenin üzerinde süren görevleri "long task" olarak işaretler. Bu eşiğin seçimi rastgele değildir. Kullanıcı araştırmaları, 100 ms altındaki gecikmelerin anlık olarak algılandığını gösterir. Tarayıcının bu pencerede hem kullanıcı olaylarını işlemesi hem de bir sonraki frame'i hazırlaması gerekir. 50 ms, kullanılabilir zamanın yaklaşık yarısıdır ve bu eşiği aşan her görev diğer işlerin beklemesine neden olur.
Bir görev 200 ms sürüyorsa, bu süre boyunca kuyruğa girmiş dört ya da beş kullanıcı olayı hiçbir tepki alamaz. Tek bir tıklama işlenmeyi beklerken yanıtın geciktiğini fark eder. Bu gecikme 50-100 ms arasındaysa çoğu kullanıcı fark etmez; 300-500 ms'ye çıktığında fark belirginleşir ve "takılıyor" hissi oluşur. 500 ms üzerinde tepkisizlik sürdüğünde kullanıcıların bir kısmı sayfayı kapatmaya başlar.
Sorun tek bir uzun görevle de sınırlı değildir. Ardışık kısa görevler — her biri 40-45 ms — arasında tarayıcıya nefes alacak boşluk bırakılmazsa etki benzer olur. Main thread için önemli olan anlık süre kadar yoğunlaşma ve sıklıktır. Bir sayfanın başlangıç yüklemesi sırasında çalışan tüm JavaScript birlikte değerlendirildiğinde bu yoğunlaşma çoğunlukla görünür hale gelir; DevTools Performance sekmesindeki "Main" şeridinde kırmızıyla işaretlenmiş bloklar long task'ları gösterir.
Rendering pipeline'ı bloke eden görev türleri
Her JavaScript kodu main thread'i aynı şekilde etkilemez. Ağ isteği bekleme ya da setTimeout'un sayımını beklemek gibi durumlar thread'i serbest bırakır. Asıl bloklama genellikle üç noktada yoğunlaşır.
JavaScript yürütme: Büyük bir modülün initialization metodu ya da kapsamlı bir koleksiyon üzerinde senkron döngü thread'i baştan sona meşgul eder. Sayfa ilk yüklendiğinde tetiklenen ve bölünmemiş kod parçaları en sık kaynak olur. JavaScript parse ve execute maliyeti konusu bu yükün nasıl ölçüleceğini ayrıntılı ele alır.
Style ve layout hesaplama: JavaScript aracılığıyla DOM değiştirildiğinde tarayıcı yeni stil ve layout hesaplar; bu hesaplama da main thread üzerinde çalışır. Bir döngü içinde DOM hem okunup hem yazılıyorsa, her okuma tarayıcıyı yeniden layout hesaplamaya zorlar. Buna "layout thrashing" denir ve kodun kendisi masum göründüğü için fark edilmesi güçtür:
// Her iterasyonda reflow tetikler
const items = document.querySelectorAll('.item');
items.forEach(item => {
const height = item.offsetHeight; // okuma → layout hesapla
item.style.height = height * 2 + 'px'; // yazma → bir sonraki okumayı geçersiz kılar
});
// Toplu okuma, ardından toplu yazma
const heights = Array.from(items).map(item => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = heights[i] * 2 + 'px';
});
İlk versiyon büyük bir listede ciddi blokaj yaratır; her iterasyonda ayrı bir reflow tetiklenir. İkinci versiyon okuma ve yazma adımlarını ayırarak tek bir layout hesaplamasıyla aynı sonuca ulaşır.
Event handler'lar: Scroll ve resize olaylarına bağlı, debounce edilmemiş handler'lar kullanıcı hareket ettikçe sürekli çalışır. Her çalışma küçük görünse de toplamları üst üste binen bir yük oluşturur; üstelik bu yük kullanıcı etkileşiminin tam ortasında gerçekleşir ve gecikme olarak hissedilir.
Scheduler API ile görevleri bölmek
Büyük bir görevi küçük parçalara bölmek, main thread'in arasına nefes alacak alanlar açmak demektir. Klasik yaklaşım setTimeout(fn, 0) kullanmaktır. Bu çağrı görevi makrotask kuyruğuna atar; tarayıcı arada render ve kullanıcı olaylarını işleyebilir:
function processChunk(items, index) {
const end = Math.min(index + 50, items.length);
for (let i = index; i < end; i++) {
process(items[i]);
}
if (end < items.length) {
setTimeout(() => processChunk(items, end), 0);
}
}
Ancak uzun listelerde birikmeli gecikme sorunu çıkabilir. Modern tarayıcılar scheduler.postTask() API'sini sunar; görevlere öncelik atanmasına olanak tanır:
// Kullanıcı girişine tepkisel, yüksek öncelikli iş
scheduler.postTask(() => {
updateUI();
}, { priority: 'user-blocking' });
// Arka planda yapılabilecek, düşük öncelikli iş
scheduler.postTask(() => {
prefetchData();
}, { priority: 'background' });
scheduler.yield() ise görevin ortasında kontrolü tarayıcıya geri vermek için daha sade bir araç sunar:
async function heavyWork(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 100 === 0) {
await scheduler.yield();
}
}
}
Bu yaklaşım özellikle ağır initialization, veri dönüştürme ve liste oluşturma adımları için etkilidir. İlk yüklenen JavaScript miktarını azaltmak bu noktada tamamlayıcı rol oynar; code splitting ile hangi modüllerin ne zaman yükleneceği kontrol altına alındığında görev bölme ihtiyacı da azalır.
requestIdleCallback ve requestAnimationFrame farkı
requestAnimationFrame (rAF), tarayıcı her frame'i çizmeden hemen önce tetiklenir. 60 Hz ekranda bu yaklaşık 16 ms'de bir demektir. Animasyon güncelleme, DOM okuma-yazma, görsel değişiklik — bunların tamamı rAF içinde yapılmalıdır. Bu şekilde değişiklikler tam zamanında frame'e entegre edilir, kare atlanmaz. Kritik nokta şudur: rAF her zaman bir sonraki frame'den önce çalışır, dolayısıyla zamanlaması öngörülebilir ve frame döngüsüyle senkronizedir.
requestIdleCallback ise tarayıcının boşta kaldığı anlarda devreye girer. Callback bir deadline nesnesi alır ve kalan süreyi sorgulayabilir:
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && queue.length > 0) {
processNextItem(queue.shift());
}
}, { timeout: 2000 });
Animasyonla hiçbir ilgisi olmayan, ertelenebilir işler için requestIdleCallback doğru araçtır: analitik gönderme, önbellek doldurma, arka plan log işleme. Görsel değişiklikler bu callback içinde yapılmamalıdır; tetiklenme anı belirsizdir ve frame döngüsüyle senkronize değildir. Bir değişiklik idle anında DOM'a yazılırsa bir sonraki frame'de görünür olur — ama hangi frame olduğu garanti edilmez.
Sık yapılan hata şudur: ağır bir hesaplamayı rAF içine koymak. rAF her 16 ms'de çalıştığı için bu hesaplama her frame'de main thread'i yakalar ve amaçlanan optimizasyon tam tersine döner. rAF hesaplama değil, koordinasyon içindir. Hesaplama gerekiyorsa requestIdleCallback ya da bağımsız bir iş parçacığı tercih edilmelidir.
TBT'yi düşürmenin ölçülebilir yolları
Total Blocking Time (TBT), yükleme sürecindeki tüm long task'ların 50 ms üzerinde harcadığı sürelerin toplamıdır. 200 ms süren bir görev 150 ms, 70 ms süren bir görev 20 ms TBT katkısı yapar. Metrik Core Web Vitals içindeki INP ile doğrudan ilişkilidir: TBT yüksekse kullanıcı etkileşimlerinde gecikme ihtimali artar. Lighthouse lab ortamında TBT değerini hesaplar; gerçek kullanıcı verisinde ise INP takip edilir. İkisi birlikte yorumlandığında sorunun hem ölçümü hem de kaynağı daha net görünür.
TBT'yi düşürmek için üç adımlı bir yaklaşım işe yarar. Önce hangi JavaScript'in başlangıç sırasında çalıştığı belirlenir. Bundle analizi bu adıma zemin hazırlar; hangi modüllerin startup'ta yürütüldüğünü ve toplam boyutlarını gösterir. İkinci adımda long task üreten fonksiyonlar tespit edilir — DevTools Performance sekmesindeki "Bottom-Up" ve "Call Tree" görünümleri neyin nereye mal olduğunu ortaya koyar. Üçüncü adımda tespit edilen görevler bölünür ya da ertelenir: scheduler.yield(), setTimeout, ya da işin yapısına göre Web Worker kullanılır.
Web Worker, main thread'den tamamen bağımsız bir iş parçacığı açar. Hesaplama ağırsa ve DOM'a yazmak gerekmiyorsa bu işi Worker'a taşımak main thread'i tamamen serbest bırakır. Ancak Worker ile ana thread arasındaki mesaj alışverişi de bir maliyet taşır; küçük ve sık görevler için Worker kurmak net kazanç yerine net kayıp üretebilir. Yöntem büyük, izole hesaplama blokları için anlamlıdır: kriptografi, görüntü işleme, veri ayrıştırma gibi işler.
Somut bir hedef: her long task'ı 50 ms altına, tercihen 30-35 ms bandına çekmek. Bu hedefe ulaşıldığında TBT değeri genellikle 200 ms altına düşer; bu Lighthouse'da "iyi" kategorisidir. Daha iddialı bir hedef 150 ms altıdır, ancak bunun için ilk yüklenen JavaScript miktarının azaltılması çoğunlukla kaçınılmaz olur.
Main thread blocking, büyük kod tabanlarında ya da ağır initialization süreçlerinde kendini gösterir; ama küçük bir sayfada bile dikkat dağıtmadan yazılmış tek bir senkron döngü ya da olay dinleyicisi aynı sonucu üretebilir. Sorunun görünürlüğü her zaman boyutuyla orantılı değildir. Yüz satırlık bir initialization fonksiyonu, milyonlarca satırlık bir projede gözden kaçabilir — ama kullanıcı hisseder.
Bloklama kaynağını bulmak, doğru araçları kullanmak anlamına gelir: Performance sekmesi long task'ları kırmızıyla işaretler, Lighthouse TBT değerini üretir, Coverage sekmesi ne kadar kodun gerçekten çalıştığını ortaya koyar. Kaynak görünür olduğunda seçenekler de netleşir. Bölmek mi, ertelemek mi, Worker'a mı taşımak — bu kararların her biri milisaniye cinsinden ölçülür ve kullanıcı doğrudan hisseder.