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

JavaScript Web Workers: postMessage, Transferable Objects ve Module Worker

Mert Özen 3 Kas 2026 10 dk 5 görüntülenme
JavaScript Web Workers: postMessage, Transferable Objects ve Module Worker

Ağır işleri Web Worker ile ana thread'den çıkarıyor; postMessage ve structured clone'u, tek dosyada Blob URL worker'ı, transferable objects'i, module worker'ı ve worker'da DOM olmamasını öğ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

Henüz hiçbir şey anlatmadan küçük bir worker kuralım ve ona bir object gönderelim. Bu kod konsola hangi sırayla ne yazdırır?

const code = `onmessage = (e) => postMessage(e.data.number * 2);`;
const blob = new Blob([code], { type: "text/javascript" });
const worker = new Worker(URL.createObjectURL(blob));

worker.onmessage = (e) => console.log("from worker:", e.data);

const data = { number: 21 };
worker.postMessage(data);
data.number = 100;
console.log("main thread:", data.number);

Okumaya devam etmeden önce bir tahmin yap. İki şeye dikkat et: hangi satır önce yazılır ve worker 21'i mi görür, 100'ü mü? Kodu denemek istersen boş bir sekmenin (about:blank) konsoluna yapıştır; bazı siteler güvenlik kurallarıyla bu tür worker'ları engeller.

Neden Worker? Tek Thread'in Sınırı

Daha önce event loop'u anlatırken gördük: sayfanın JavaScript'i tek bir thread'de, ana thread'de çalışır. Aynı thread stil hesabını, layout'u, çizimi ve kullanıcının tıklamalarını da yönetir. Bir önceki yazıda da bazı işlerin ne kadar planlarsan planla ağır olduğunu söylemiştik. İki saniye süren bir hesap başladığında o iki saniye boyunca butonlar tepki vermez, animasyonlar durur, sayfa donmuş gibi görünür.

Bir restoran mutfağını düşün. Şef pasta tezgâhında durur, siparişleri alır, tabakları çıkarır. Ona bir de yüz kilo soğan doğratırsan servis durur. Akıllıca olanı, ağır hazırlığı arka mutfaktaki bir yardımcıya vermektir. Yardımcı salona çıkmaz, müşteriyle konuşmaz; şefle yalnızca servis penceresinden not alıp verir. Web Worker tam olarak o yardımcıdır: kendi thread'inde, kendi event loop'uyla çalışan ayrı bir JavaScript ortamı. Ana thread'le ortak değişkeni yoktur, DOM'a dokunamaz ve bütün iletişim mesajlarla olur.

İlk Worker: İki Dosya ve postMessage

Klasik kurulumda worker'ın kodu ayrı bir dosyada yaşar. Aynı klasöre iki dosya koy. Önce worker.js:

self.onmessage = (e) => {
  const { start, end } = e.data;
  let total = 0;
  for (let i = start; i <= end; i++) total += i;
  self.postMessage(total);
};

Sonra index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>First worker</title>
</head>
<body>
  <script>
    const worker = new Worker("worker.js");

    worker.onmessage = (e) => console.log("Total:", e.data);
    worker.onerror = (e) => console.error("Worker error:", e.message);

    worker.postMessage({ start: 1, end: 1_000_000 });
  </script>
</body>
</html>

Bu sayfayı dosyaya çift tıklayarak (file://) açarsan Chrome gibi tarayıcılar worker dosyasını SecurityError ile reddeder; klasörü editörünün canlı önizleme özelliği ya da python3 -m http.server gibi herhangi bir basit yerel statik sunucuyla http üzerinden açman gerekir.

İki taraf da aynı iki aracı kullanıyor: postMessage ile gönderir, message event'i ile alır. Gelen veri her zaman e.data'dadır. Worker'ın içindeki self, sayfadaki window'un karşılığıdır; worker'ın kendi global object'i. new Worker'a verilen adres sayfanın adresine göre çözülür ve aynı origin'den gelmek zorundadır.

Tek Dosyada Worker: Blob URL

Küçük denemeler için iki dosya ve bir sunucu fazla gelebilir. Worker'ın kodunu bir string olarak bir Blob'a koyup ondan bir blob: URL üretirsen tek bir HTML dosyası yeter, çift tıklayıp açabilirsin. Blob URL, onu oluşturan sayfanın origin'ini taşır, bu yüzden aynı origin kuralına takılmaz. Aşağıdaki sayfa farkı da gösteriyor: aynı ağır hesabı bir ana thread'de, bir de worker'da çalıştırıyoruz, bu sırada bir sayaç her karede ilerliyor.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>A worker in a single file</title>
</head>
<body>
  <p>Frame counter: <span id="frame">0</span></p>
  <button id="main">Calculate on the main thread</button>
  <button id="background">Calculate in a worker</button>
  <p id="result"></p>

  <script>
    function countPrimes(limit) {
      let count = 0;
      for (let n = 2; n <= limit; n++) {
        let prime = true;
        for (let d = 2; d * d <= n; d++) {
          if (n % d === 0) { prime = false; break; }
        }
        if (prime) count++;
      }
      return count;
    }

    const code = `
      ${countPrimes.toString()}
      self.onmessage = (e) => self.postMessage(countPrimes(e.data));
    `;
    const url = URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
    const worker = new Worker(url);
    const result = document.querySelector("#result");
    const LIMIT = 10_000_000;

    let frame = 0;
    (function loop() {
      document.querySelector("#frame").textContent = ++frame;
      requestAnimationFrame(loop);
    })();

    document.querySelector("#main").addEventListener("click", () => {
      result.textContent = "Calculating...";
      setTimeout(() => {
        const t0 = performance.now();
        const count = countPrimes(LIMIT);
        result.textContent = `Main thread: ${count} primes, ${(performance.now() - t0).toFixed(0)} ms`;
      }, 50);
    });

    document.querySelector("#background").addEventListener("click", () => {
      result.textContent = "Calculating...";
      const t0 = performance.now();
      worker.onmessage = (e) => {
        result.textContent = `Worker: ${e.data} primes, ${(performance.now() - t0).toFixed(0)} ms`;
      };
      worker.postMessage(LIMIT);
    });
  </script>
</body>
</html>

"Calculate on the main thread" butonuna bastığında kare sayacı donar; hesap bitene kadar sayfa ölü gibidir. "Calculate in a worker" butonunda ise sayaç akmaya devam eder, sonuç hazır olunca ekrana düşer. Süre neredeyse aynı; worker işi hızlandırmaz, sadece ana thread'in yolundan çeker. Worker'ın kodunu ayrıca yazmadık: countPrimes.toString() fonksiyonun kaynak kodunu string olarak verir, biz de onu worker koduna ekledik. Böylece aynı fonksiyon iki tarafta da çalışıyor.

postMessage ve Structured Clone

Şimdi baştaki tahmine dönelim. Önce main thread: 100 yazılır, sonra from worker: 42. Mesaj asenkron gider, bu yüzden ana thread'deki satırlar önce biter. Worker'ın 21'i görmesinin sebebi ise şu: postMessage object'i paylaşmaz, kopyalar. Kopya, postMessage çağrıldığı anda alınır; sonradan data.number'ı değiştirmen worker'daki kopyayı etkilemez.

Kopyalama, daha önce gördüğümüz structuredClone'un kullandığı algoritmayla yapılır. Aynı kuralları konsolda doğrudan structuredClone ile deneyebilirsin:

class Point {
  constructor(x) { this.x = x; }
  length() { return Math.abs(this.x); }
}

const copy = structuredClone({
  date: new Date(0),
  tags: new Set(["a"]),
  point: new Point(3)
});

console.log(copy.date instanceof Date);   // true
console.log(copy.tags.has("a"));          // true
console.log(copy.point instanceof Point); // false, düz bir object: { x: 3 }

try {
  structuredClone({ compute: () => 1 });
} catch (e) {
  console.log(e.name); // DataCloneError
}

Primitive'ler, düz object'ler, array'ler, Date, Map, Set, RegExp, ArrayBuffer, typed array'ler ve Blob sorunsuz geçer. Class örnekleri prototype'larını kaybeder ve düz object'e döner. Fonksiyonlar ve DOM node'ları ise hiç gönderilemez; postMessage o anda DataCloneError fırlatır.

İşi bitmiş bir worker'ı kapatmanın iki yolu var: dışarıdan worker.terminate() onu anında durdurur, içeriden self.close() ise worker'ın kendini kapatmasını sağlar. Worker'da yakalanmayan bir hata ana thread'e error event'i olarak gelir; onerror bağlamazsan sadece konsolda görürsün.

Transferable Objects: Kopyalamak Yerine Devretmek

Küçük mesajlar için kopyalamak sorun değil. Ama 64 MB'lık bir görüntü verisini kopyalamak hem zaman hem bellek demek. Bunun için transferable objects var: bazı object'leri kopyalamak yerine sahipliğini devredebilirsin. postMessage'ın ikinci argümanı bir transfer listesidir:

const helper = new Worker(URL.createObjectURL(new Blob([`
  onmessage = (e) => {
    const bytes = new Uint8Array(e.data);
    postMessage("Worker received " + bytes.length + " bytes");
  };
`], { type: "text/javascript" })));

helper.onmessage = (e) => console.log(e.data);

const buffer = new ArrayBuffer(64 * 1024 * 1024);
helper.postMessage(buffer, [buffer]);
console.log("Size on the main thread:", buffer.byteLength); // 0

Transfer neredeyse anlıktır, çünkü veri kopyalanmaz; bellek parçası olduğu gibi worker'a geçer. Bedeli şu: ana thread'deki buffer artık "detached", yani boş bir kabuk. byteLength değeri 0 olur, üstünde typed array kurmaya kalkarsan TypeError alırsın. Bir anahtarı birine verdiğinde artık kapıyı sen açamazsın. Worker işini bitirince aynı yöntemle buffer'ı geri gönderebilir.

Dikkat edilecek bir nokta: Uint8Array gibi typed array'lerin kendisi transfer edilemez, altlarındaki buffer edilir; yani postMessage(array, [array.buffer]) yazarsın. ArrayBuffer dışında MessagePort, ImageBitmap ve OffscreenCanvas da transfer edilebilir. Aynı transfer listesini structuredClone(value, { transfer: [...] }) ile de kullanabilirsin. Gerçekten paylaşılan bellek için bir de SharedArrayBuffer var, ama sayfanın sunucudan gelen özel header'larla "cross-origin isolated" olmasını gerektirdiği için bu yazının konusu dışında.

Module Worker: { type: "module" }

Klasik bir worker'da başka bir dosyayı yüklemek için importScripts("helper.js") kullanılır. Daha modern yolu, worker'ı bir module olarak başlatmak: new Worker("worker.js", { type: "module" }). Böylece worker'ın içinde daha önce gördüğümüz import ve export çalışır. Module worker'lar Chrome 80, Firefox 114 ve Safari 15'ten beri var; Aralık 2025'ten beri Baseline'da geniş çapta destekleniyor. Üç dosyalık küçük bir örnek, yine yerel bir sunucudan açılmak üzere. math.js:

export function average(numbers) {
  return numbers.reduce((a, b) => a + b, 0) / numbers.length;
}

worker.js:

import { average } from "./math.js";

self.onmessage = (e) => {
  self.postMessage(average(e.data));
};

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Module worker</title>
</head>
<body>
  <script>
    const worker = new Worker("worker.js", { type: "module" });
    worker.onmessage = (e) => console.log("Average:", e.data);
    worker.postMessage([3, 5, 10]);
  </script>
</body>
</html>

Module worker'lar module script'leri gibi otomatik olarak strict mode'da çalışır ve içlerinde importScripts yoktur. Bağımlılıklarını kod çalışmaya başlamadan önce yüklerler. Yeni bir kodda module worker'ı varsayılan seçim olarak düşünebilirsin.

Worker'ın Dünyası: Neler Var, Neler Yok

Worker ayrı bir dünyada yaşar. Ne var ne yok, en kolay worker'a sorarak görülür. Bunu konsolda dene:

const probe = new Worker(URL.createObjectURL(new Blob([`
  postMessage({
    document: typeof document,
    window: typeof window,
    localStorage: typeof localStorage,
    fetch: typeof fetch,
    setTimeout: typeof setTimeout,
    indexedDB: typeof indexedDB
  });
`], { type: "text/javascript" })));

probe.onmessage = (e) => console.table(e.data);

document, window ve localStorage için undefined göreceksin. Worker'da DOM yoktur; bir element'in rengini değiştirmek, bir input'u okumak istiyorsan sonucu ana thread'e gönderirsin, DOM'u o günceller. alert gibi arayüz fonksiyonları da yok. Buna karşılık fetch, zamanlayıcılar, promise'ler, WebSocket, IndexedDB, crypto ve performance var. Canvas'ı arka planda çizmeye yarayan OffscreenCanvas'a canvas yazısında değineceğiz.

Bu yazıda anlattığımız tür, dedicated worker denen, tek bir sayfaya ait worker. İki akrabasını da bil. SharedWorker, aynı origin'deki birden fazla sekme ya da iframe tarafından paylaşılan tek bir worker'dır; masaüstü tarayıcılarda uzun zamandır vardı, Android için Chrome'a da gelince Mayıs 2026'da Baseline oldu. Service Worker ise bambaşka bir rol oynar: sayfa ile ağ arasında duran bir vekil gibi çalışır, istekleri yakalayıp önbellekten cevap verebilir ve sitenin çevrimdışı çalışmasını sağlar. Kendine ait bir yaşam döngüsü olan ayrı ve geniş bir konu.

Sık Düşülen Tuzaklar

Worker'ı her küçük iş için yeniden oluşturmak. Bir worker'ı başlatmanın da bir maliyeti var: yeni bir JavaScript ortamı kurulur ve kod yüklenir. Bir kez oluştur, tekrar tekrar kullan, işin tamamen bitince terminate() et.

Çok küçük, çok sık mesajlar. Her mesaj kopyalanır ve bir event olarak sıraya girer. Saniyede binlerce küçük mesaj göndermek yerine işleri toplayıp daha az, daha büyük mesajlar gönder.

Callback'te boğulmak. onmessage'la çalışmak, daha önce gördüğümüz callback dünyasına benzer. Tek seferlik istekler için küçük bir promise sarmalayıcı kodu çok rahatlatır:

function runInWorker(worker, data) {
  return new Promise((resolve, reject) => {
    worker.addEventListener("message", (e) => resolve(e.data), { once: true });
    worker.addEventListener("error", reject, { once: true });
    worker.postMessage(data);
  });
}

// Kullanımı: const result = await runInWorker(worker, 5_000_000);

Bu sürüm aynı anda tek bir istek için güvenli. Aynı worker'a aynı anda birden fazla iş gönderiyorsan her mesaja bir kimlik numarası koyup cevabı o numarayla eşleştirmen gerekir.

Transfer ettiğin şeyi kullanmaya devam etmek. Transfer listesine koyduğun bir ArrayBuffer'ı sonra ana thread'de okumaya çalışırsan boş bir kabuk bulursun. Bir de MIME tipi meselesi var: sunucu worker dosyasını text/javascript olarak göndermezse tarayıcı onu çalıştırmayı reddeder.

Küçük Bir Deneme

★☆☆ Çıktıyı tahmin et: baştaki worker'a worker.postMessage({ number: 5, tags: new Map([["a", 1]]) }) ve ardından worker.postMessage({ number: 5, compute() {} }) gönderiyorsun. Hangisi worker'a ulaşır, hangisi hata verir ve hata hangi thread'de fırlar?

★★☆ Tek dosyalık asal sayı örneğini, worker her yüzde onluk ilerlemede { type: "progress", percent }, en sonda { type: "result", count } gönderecek şekilde değiştir ve ilerlemeyi bir <progress> element'inde göster.

★★★ Asal sayma işini navigator.hardwareConcurrency kadar worker'a böl; her worker bir aralığı saysın. Sonuçları runInWorker ve Promise.all ile topla, tek worker'a göre ne kadar hızlandığını performance.now() ile ölç.

Ana thread'i rahatlatmanın bir yolu işi başka bir thread'e vermekse, diğer yolu işi hiç yapmamak. Kaydırma boyunca sürekli "şu element ekranda mı, boyutu değişti mi" diye sormak yerine, bunu tarayıcının sana haber vermesini sağlayabilirsin. Bir sonraki yazıda IntersectionObserver, MutationObserver ve ResizeObserver ile tam olarak bunu yapacağız.