Mert Özen Her Satırda Daha İleri
Frontend Browser API'leri ve PWA — #08

Web Worker'ları Angular'da Kullanmak: ng generate web-worker, Comlink ve Main Thread'i Rahatlatmak

Mert Özen 15 Kas 2026 14 dk 9 görüntülenme
Web Worker'ları Angular'da Kullanmak: ng generate web-worker, Comlink ve Main Thread'i Rahatlatmak

Ağır bir hesabı async bir method'a koymak onu main thread'den çıkarmaz; Angular'da işi ng generate web-worker ile üretilen bir worker'a taşıyıp Comlink ile bir service'e sarmak ya da scheduler.yield() ile parçalara bölmek gerekir. 5.000 kitaplık istemci tarafı aramada CPU 4 kat yavaşken en yavaş etkileşimin main thread'de 408 ms, yield ile 56 ms, worker'da 40 ms olduğunu ölçüyor; tsconfig tuzağını, iptali ve neyi worker'a taşımak gerektiğini gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), Angular CLI 22.2.7, Comlink 4.4.2, Node.js 24 ve Playwright 1.65 ile denenmiştir; ölçümler Playwright'ın Chromium 158 ve WebKit 27.2 build'lerinde, production build üzerinde yapılmıştır. Destek bilgileri MDN'in (Mozilla'nın web platformu dokümantasyonu) browser-compat-data (BCD) 8.1.9 ve web-features 3.46 verilerinden okunmuştur; Firefox bu ortamda çalıştırılamadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'a (bir online kitapçı) bir arama sayfası ekleniyor. 5.000 kitaplık kataloğun başlık, yazar ve konu bilgisi tarayıcıya bir kez iniyor ve arama istemcide, yazım hatalarını tolere ederek yapılıyor: "midlemarch eliot" yazan kullanıcı Middlemarch'ı buluyor. Arama kodu her tuşta çalışıyor. Geliştiricinin masaüstünde hiçbir yavaşlık hissedilmiyor. Ekipten biri "zaten async bir method'dan döndürüyoruz, main thread'i bloklamaz" diyor. Orta seviye bir telefonda ne olur, async yazmak bir şey değiştirir mi?

Bu yazıda bu aramayı üç şekilde çalıştırıp ölçüyoruz: main thread'de tek parça, scheduler.yield() ile parçalara bölünmüş ve Angular CLI'ın ürettiği bir Web Worker'da, Comlink ile. Worker'ın temelini (postMessage, structured clone, Transferable object'ler, module worker) JavaScript İleri serisinin "JavaScript Web Workers: postMessage, Transferable Objects ve Module Worker" yazısında anlatmıştık; burada odak Angular'daki kurulum, ölçüm ve karar.

İstemci Tarafı Arama İndeksi

Arama kodu saf fonksiyonlardan oluşuyor; aynı dosya hem main thread'de hem worker'da çalışabiliyor. Her kitabın başlık, yazar ve konu kelimeleri bir kez normalize edilip indeksleniyor; her arama terimi bir kelimenin başıyla, uzunluğuna göre 0, 1 ya da 2 harf hatasıyla eşleşmeli:

export interface IndexedEntry {
  entry: CatalogEntry;
  words: string[];
}

const normalize = (text: string) =>
  text
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')
    .toLowerCase();

export function buildIndex(entries: CatalogEntry[]): IndexedEntry[] {
  return entries.map((entry) => ({
    entry,
    words: normalize(`${entry.title} ${entry.author} ${entry.subjects}`)
      .split(/[^\p{L}\p{N}]+/u)
      .filter(Boolean),
  }));
}

export function terms(query: string): string[] {
  return normalize(query).split(/\s+/).filter(Boolean);
}

// her terim bir kelimenin başıyla, uzunluğuna göre en fazla 0, 1 ya da 2 harf hatasıyla eşleşmeli
export function searchSlice(items: IndexedEntry[], queryTerms: string[]): SearchHit[] {
  const hits: SearchHit[] = [];
  for (const { entry, words } of items) {
    let typos = 0;
    for (const term of queryTerms) {
      let best = Infinity;
      for (const word of words) {
        for (let n = term.length - 1; n <= term.length + 1; n++) {
          best = Math.min(best, distance(term, word.slice(0, n)));
        }
      }
      if (best > allowedTypos(term)) {
        typos = -1;
        break;
      }
      typos += best;
    }
    if (typos >= 0)
      hits.push({ isbn: entry.isbn, title: entry.title, author: entry.author, typos });
  }
  return hits;
}

Hesap ucuz görünüyor ama çarpanlar büyük: 5.000 kitap, kitap başına on civarı kelime, her kelime için üç prefix uzunluğu ve her biri için bir Levenshtein mesafesi. Node.js 24'te tek bir arama 70 ms kadar sürdü. Sahte API kataloğu /api/catalog/index adresinden veriyor (JSON olarak 672 KB).

ng generate web-worker Ne Üretiyor?

npx ng generate web-worker search/catalog-search
CREATE src/app/search/catalog-search.worker.ts (157 bytes)
CREATE tsconfig.worker.json (334 bytes)
UPDATE angular.json (2113 bytes)

CLI üç şey yaptı: bir örnek worker dosyası, worker'lar için ayrı bir TypeScript configuration'ı ve angular.json'daki build seçeneklerine "webWorkerTsConfig": "tsconfig.worker.json". Worker kodu lib.dom yerine webworker tipleriyle derleniyor; bu sayede worker içinde document'a erişmeye çalışırsan derleme hatası alırsın. Üretilen dosyadaki "lib": ["es2018", "webworker"]'i es2022'ye çektim; uygulamanın geri kalanı ES2022 hedefliyor ve worker'da daha eski bir lib'e takılmak için sebep yok:

/* TypeScript configuration dosyası hakkında ayrıntı: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/worker",
    "lib": ["es2022", "webworker"],
    "types": []
  },
  "include": ["src/**/*.worker.ts"]
}

İlk build ise ilgisiz bir dosyada kırıldı:

Application bundle generation failed. [0.520 seconds] - 2026-11-14T20:20:48.678Z

✘ [ERROR] TS2739: Type 'Event' is missing the following properties from type 'BeforeInstallPromptEvent': platforms, userChoice, prompt [plugin angular-compiler]

    src/app/core/install-prompt.ts:48:6:
      48 │       this.deferred = event;
         ╵       ~~~~~~~~~~~~~

Hata, 06. yazıdaki InstallPrompt'ta. Sebebi şu: tsconfig.app.json src/**/*.ts'in hepsini derliyor ve worker dosyası da bu kapsamda. Worker dosyasının başındaki /// <reference lib="webworker" /> satırı, uygulamanın derlemesine worker'ın global tiplerini ekliyor; global addEventListener'ın tipi değişiyor ve WindowEventMap'e eklediğimiz beforeinstallprompt tipi artık kullanılmıyor. Çözüm, worker dosyalarını uygulama derlemesinin dışında bırakmak:

  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "src/**/*.spec.ts",
    "src/**/*.worker.ts"
  ]

Bu yetmiyor: uygulama kodu worker dosyasından bir şey import ederse, sadece bir tip bile olsa (import type), worker dosyası yine derlemeye giriyor. Bunu denedim ve aynı hata geri geldi. Worker'ın dışarıya açtığı API'nin tipi bu yüzden worker dosyasında değil, ortak catalog-search.ts'te:

// worker'ın Comlink ile dışarı açtığı API; uygulama worker dosyasını hiç import etmesin diye burada
export interface CatalogSearchApi {
  load(url: string): Promise<number>;
  search(query: string): SearchHit[];
}

CLI'ın ürettiği örnek postMessage ve message event'iyle konuşuyor. İki method'dan fazlası olan bir worker'da bu, mesaj tipleri, istek kimlikleri ve cevap eşleştirmesi için elle yazılan bir protokol demek. Comlink (GoogleChromeLabs'in Apache 2.0 lisanslı küçük kütüphanesi) bunu bir proxy ile gizliyor: worker bir object'i expose ediyor, sayfa wrap ile aldığı proxy'nin method'larını çağırıyor ve her çağrı bir promise dönüyor. Bookstore'un worker'ı:

/// <reference lib="webworker" />
import { expose } from 'comlink';
import { buildIndex, CatalogEntry, CatalogSearchApi, IndexedEntry, search } from './catalog-search';

let index: IndexedEntry[] = [];

const api: CatalogSearchApi = {
  // 5.000 kitabı worker kendisi indirir, parse eder ve indeksler: main thread bunların hiçbirini yapmaz
  async load(url: string): Promise<number> {
    const entries: CatalogEntry[] = await (await fetch(url)).json();
    index = buildIndex(entries);
    return index.length;
  },
  search(query) {
    return search(index, query);
  },
};

expose(api);

Kataloğu worker kendisi indiriyor; JSON parse'ı ve indeksleme de worker'da yapılıyor. Main thread'in elinde yalnız 20 sonuçluk cevaplar kalıyor. Worker'ı oluşturan ve saran service:

import { Service } from '@angular/core';
import { Remote, wrap } from 'comlink';
import { CatalogSearchApi, SearchHit } from './catalog-search';

@Service()
export class CatalogSearchService {
  private api?: Remote<CatalogSearchApi>;
  private loaded?: Promise<number>;

  // bütün uygulama için tek worker; ilk kullanımda oluşturulur, asla component başına değil
  private start() {
    if (!this.api) {
      const worker = new Worker(new URL('./catalog-search.worker', import.meta.url), {
        type: 'module',
      });
      this.api = wrap<CatalogSearchApi>(worker);
      this.loaded = this.api.load(new URL('/api/catalog/index', location.origin).href);
    }
    return { api: this.api, loaded: this.loaded! };
  }

  ready(): Promise<number> {
    return this.start().loaded;
  }

  async search(query: string): Promise<SearchHit[]> {
    const { api, loaded } = this.start();
    await loaded;
    return api.search(query);
  }
}

new Worker(new URL('./catalog-search.worker', import.meta.url), { type: 'module' }) kalıbı önemli: Angular'ın build sistemi bu ifadeyi tanıyıp worker'ı ayrı bir bundle olarak derliyor. Build çıktısında worker kendi chunk'ı olarak görünüyor:

chunk-ZXVHSyDF.js   | search-page           |   7.70 kB |                 3.13 kB
worker-OKO75KY4.js  | catalog-search-worker |   5.07 kB |                 2.07 kB
Application bundle generation complete. [0.742 seconds] - 2026-11-14T20:28:43.490Z

Service root seviyesinde ve worker'ı ilk kullanımda bir kez oluşturuyor. Arama sayfası her açıldığında worker yaratan bir component, sayfadan çıkışta terminate() çağırmazsa her ziyarette bir worker ve bir indeks kopyası daha bırakır. Aşağıdaki ölçümde arama sayfasından iki kez çıkıp geri döndükten sonra sayfadaki worker sayısı hâlâ 1.

Ölçüm: Main Thread, scheduler.yield ve Worker

Arama sayfası ölçüm için ?mode=main ve ?mode=yield parametrelerini kabul ediyor; uygulama varsayılan olarak worker'ı kullanıyor. Playwright ile kutuya "midlemarch eliot"u tuşlar arasında 120 ms bekleyerek yazdım ve Event Timing API'si (tarayıcının her etkileşimin ne kadar sürdüğünü raporladığı API) ile her etkileşimin süresini topladım. "slowest" en yavaş etkileşim; INP (Interaction to Next Paint; bir sayfadaki etkileşimlerin en yavaşlarına yakın değer) bu değerden hesaplanıyor. Chromium'u bir kez normal hızda, bir kez de orta seviye bir telefonu taklit etmek için DevTools protokolüyle CPU'yu 4 kat yavaşlatarak çalıştırdım:

chromium mode=main   load    44 ms | 16 keys: slowest 80 ms, >200 ms: 0, long tasks: 12, results 68 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium mode=yield  load    58 ms | 16 keys: slowest 40 ms, >200 ms: 0, long tasks: 0, results 79 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium mode=worker load    59 ms | 16 keys: slowest 32 ms, >200 ms: 0, long tasks: 0, results 67 ms after the last key | workers: 1 | first hit: Middlemarch (1926 edition), George Eliot
chromium (CPU x4) mode=main   load   147 ms | 16 keys: slowest 408 ms, >200 ms: 11, long tasks: 16, results 260 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium (CPU x4) mode=yield  load   154 ms | 16 keys: slowest 56 ms, >200 ms: 0, long tasks: 0, results 266 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
chromium (CPU x4) mode=worker load   141 ms | 16 keys: slowest 40 ms, >200 ms: 0, long tasks: 0, results 67 ms after the last key | workers: 1 | first hit: Middlemarch (1926 edition), George Eliot
webkit mode=main   load    65 ms | 16 keys: slowest 40 ms, >200 ms: 0, long tasks: 0, results 37 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
webkit mode=yield  load    48 ms | 16 keys: slowest 16 ms, >200 ms: 0, long tasks: 0, results 52 ms after the last key | workers: 0 | first hit: Middlemarch (1926 edition), George Eliot
webkit mode=worker load    68 ms | 16 keys: slowest 16 ms, >200 ms: 0, long tasks: 0, results 44 ms after the last key | workers: 1 | first hit: Middlemarch (1926 edition), George Eliot

Masaüstü hızında üç yöntem de 200 ms'nin altında; ekip arkadaşının "yavaşlık hissedilmiyor" demesi bu yüzden. Main thread'de çalışan aramada yine de her tuş bir long task (50 ms'yi geçen iş) üretiyor. CPU 4 kat yavaşlayınca tablo değişiyor: 16 tuşun 11'i 200 ms'yi geçti, en yavaşı 408 ms. Web Performansı serisinin INP yazısında anlatılan "iyi" sınırı 200 ms; bu sayfa telefonda o sınırın iki katında. async yazmak bunu değiştirmiyor: MainThreadSearch.search() zaten async, ama içindeki hesap tek parça ve senkron; promise yalnız sonucun ne zaman teslim edileceğini değiştiriyor, hesabın hangi thread'de yapılacağını değil. Tahminin cevabı bu.

Worker'da en yavaş etkileşim 40 ms ve sonuçlar son tuştan 67 ms sonra ekranda. Bir uyarı: bu ölçümde DevTools protokolünün CPU yavaşlatması sayfanın main thread'ini etkiledi, worker'ın hızını belirgin şekilde değiştirmedi (sonuç süresi yavaşlatmasız çalıştırmayla neredeyse aynı). Gerçek bir telefonda worker'daki arama da yavaşlar; ama yavaşlayan iş main thread'de olmadığı için kutuya yazmak akıcı kalır, gecikme yalnız sonuç listesine yansır. WebKit'te long task sayısı her satırda 0, çünkü WebKit Long Tasks API'sini desteklemiyor; Event Timing ise Safari 26.2'den beri var (web-features'a göre Aralık 2025'ten beri Baseline Newly Available).

scheduler.yield ile Main Thread'de Bölmek

Worker her zaman en iyi seçenek değil: iş DOM'a (Document Object Model; sayfanın tarayıcıdaki object ağacı) dokunuyorsa ya da worker kurmanın karmaşıklığı işin boyuna değmiyorsa, uzun işi parçalara bölüp araya main thread'i geri vermek de bir yol. Ölçümdeki yield modu bunu yapıyor:

  // her parçada 500 kitap, aralarda main thread'i geri ver; daha yeni bir sorgu bunu iptal eder
  async searchYielding(query: string, signal?: AbortSignal): Promise<SearchHit[]> {
    const index = await this.ready();
    const queryTerms = terms(query);
    if (!queryTerms.length) return [];
    const hits: SearchHit[] = [];
    for (let i = 0; i < index.length; i += 500) {
      hits.push(...searchSlice(index.slice(i, i + 500), queryTerms));
      await yieldToMain();
      signal?.throwIfAborted();
    }
    return rank(hits);
  }
}

function yieldToMain(): Promise<void> {
  // lib.dom.d.ts scheduler'ı her yerde var sayar, ama yalnız Chromium 129+ ve Firefox 142+'da var
  if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve)); // Safari: bir macrotask
}

scheduler.yield() main thread'i tarayıcıya geri veriyor ve kuyrukta bekleyen tuş event'leri araya giriyor; devam eden iş ise setTimeout'tan farklı olarak kuyruğun sonuna değil önüne konuyor. BCD'ye göre Chrome 129 ve Firefox 142'den beri var, Safari'de yok; TypeScript 6.0'ın lib.dom.d.ts'i ise scheduler'ı her yerde var sayıyor, bu yüzden 'scheduler' in globalThis kontrolü şart. Ölçümde CPU 4 kat yavaşken en yavaş etkileşim 56 ms'ye indi.

Bu tablonun bir bedeli var ve ilk denememde onu görmüştüm. İlk sürümde eski aramalar iptal edilmiyordu: Angular'ın resource'u yeni bir sorgu gelince önceki loader'ın sonucunu yok sayıyor ama parçalara bölünmüş arama main thread'de kendi işini yapmaya devam ediyordu. Her tuşta başlayan 16 arama birbirinin arasına girdi ve son sonuç, son tuştan 1.300 ms sonra ekrana geldi. resource'un loader'a verdiği abortSignal'ı her parçadan sonra kontrol etmek bu süreyi 266 ms'ye indirdi. İptalin pattern'ları JavaScript İleri serisinin "JavaScript İleri Asenkron Pattern'lar: Promise.all Ailesi, İptal, Retry ve Timeout" yazısında. Worker'a gönderilen eski aramalar ise iptal edilmiyor; worker onları sırayla çalıştırıyor. Arama 70 ms sürdüğü için bu ölçülebilir bir sorun yaratmadı, daha ağır bir işte kısa bir debounce ya da worker içinde bir iptal mekanizması gerekir.

Neyi Worker'a Taşımalı, Neyi Taşımamalı?

Worker'a giden ve gelen her veri structured clone ile kopyalanıyor. 5.000 kitaplık array'in kopyalanma maliyetini ölçtüm:

chromium 5,000 books: structuredClone 1.3 ms, postMessage to a worker and back 1.2 ms (average of 10)
webkit 5,000 books: structuredClone 2.0 ms, postMessage to a worker and back 2.6 ms (average of 10)

672 KB'lık veri için 1 ile 3 ms arası; Bookstore'da kopyalama asıl maliyet değil. Asıl kazanç indirme sonrası JSON parse'ın, indekslemenin ve her aramanın main thread'den çıkması. Kopyalama ancak veri megabaytlarca olduğunda ya da çok sık gidip geldiğinde önemli hâle geliyor; o zaman ArrayBuffer'ı kopyalamak yerine transfer etmek (Transferable) gerekir. Worker'a taşımaya değer işler: istemci tarafı arama ve filtreleme, büyük bir CSV (virgülle ayrılmış değerler) ya da Excel import'unun parse'ı ve doğrulaması, görsel küçültme (OffscreenCanvas ile; web-features'a göre Mart 2023'ten beri Baseline), şifreleme ve sıkıştırma. Taşımaya değmeyecek işler: DOM'a dokunan her şey (worker'da DOM yok), birkaç milisaniyelik küçük hesaplar (mesajlaşmanın kendisi o kadar sürer) ve sık sık state paylaşması gereken işler.

Aynı worker'ı birden çok sekme arasında paylaşmak istersen SharedWorker var. web-features'a göre Chrome Android'in 148'de eklemesiyle Mayıs 2026'dan beri Baseline Newly Available. Bookstore'da her sekmenin kendi indeksi olması kabul edilebilir; beş sekme açık bir kullanıcı için beş kopya yerine tek indeks istersen SharedWorker'a geçebilirsin. Sekmeler arasında koordinasyonun genel araçları bir sonraki yazının konusu.

Üretimde Dikkat

Ölçümü sahadan al. Bu yazıdaki sayılar lab ölçümü; gerçek kullanıcıların INP'sini web-vitals ile toplamak Web Performansı serisinin ilk yazısının konusu. Arama sayfasının INP'si, worker'a geçişten önce ve sonra ayrı ayrı izlenmeli.

Worker'daki hataları gözden kaçırma. Comlink, worker'da fırlatılan bir hatayı çağıran tarafa promise rejection olarak taşıyor; ama worker'ın yüklenmesi başarısız olursa (yanlış path, CSP'nin (Content Security Policy; sayfanın hangi kaynakları yükleyebileceğini sınırlayan güvenlik politikası) worker-src'i engellemesi) sayfa yalnız worker'ın error event'ini alır. Service'te bu event'i dinleyip arama için main thread'e düşen bir fallback düşünülebilir. CSP kullanıyorsan worker'ın bundle dosyası worker-src 'self' ile izinli olmalı; Angular'a CSP uygulamak Frontend Güvenliği serisinin "Angular'a Strict CSP Uygulamak: Nonce, strict-dynamic, autoCsp ve Report-Only ile Kademeli Geçiş" yazısında.

Worker'ın belleği ayrı. 5.000 kitaplık indeks worker'da yaşıyor ve sayfa kapanana kadar kalıyor. Uzun süre açık kalan bir uygulamada kullanılmayan worker'ı terminate() ile kapatmak ve gerektiğinde yeniden oluşturmak bellek için iyi bir pratik.

Sık Düşülen Tuzaklar

  • Ağır bir hesabı async bir method'a koyup main thread'den çıktığını sanmak. Promise hesabı başka bir thread'e taşımaz.
  • Worker'ı component'in constructor'ında oluşturup terminate() etmemek. Her ziyarette yeni bir worker ve yeni bir veri kopyası birikir; worker'ı root seviyesinde bir service'te bir kez oluştur.
  • Uygulama kodundan worker dosyasını, sadece tip için bile olsa, import etmek. Worker'ın webworker tipleri uygulama derlemesine sızar; paylaşılan tipleri ayrı bir dosyaya koy ve worker dosyalarını tsconfig.app.json'dan hariç tut.
  • Büyük veriyi sayfada indirip parse edip sonra worker'a göndermek. Main thread işin pahalı kısmını zaten yapmış olur; veriyi worker kendisi indirsin.
  • Bölünmüş (yield'li) işlerde eski çalışmaları iptal etmemek. Etkileşim akıcı görünür ama sonuç saniyeler sonra gelir.
  • Masaüstünde ölçüp "yavaşlık yok" demek. CPU yavaşlatmasıyla ya da gerçek bir orta seviye telefonla ölç.

Küçük Bir Deneme

★☆☆ Ölçüm script'ini CPU 6 kat yavaşken çalıştır ve üç modun en yavaş etkileşimlerini karşılaştır. yield modunda parça boyunu 500'den 100'e indirince en yavaş etkileşim ve sonuç süresi nasıl değişiyor?

★★☆ Worker'a bir iptal mekanizması ekle: her aramaya artan bir numara ver, worker yeni bir arama gelince eskisini 500 kitaplık parçalar arasında bıraksın. Çok hızlı yazılan 30 karakterlik bir sorguda son sonucun gelme süresini önce ve sonra ölç.

★★★ Bu serinin ilk yazısındaki sipariş raporu CSV'sini (20.000 satır) worker'da parse eden ve müşteri başına toplamları çıkaran bir OrderReportWorker yaz. Akışı worker'da okumak için fetch cevabının body'sini worker içinde kullan ve main thread'deki sürümle long task sayısını karşılaştır.

Worker bir sekmenin içindeki işi bölüyor. Aynı kullanıcı Bookstore'u beş sekmede açınca ise başka sorunlar çıkıyor: bir sekmede çıkış yapılınca diğerleri bilmiyor, her sekme ayrı ayrı sunucuyu yokluyor, outbox iki sekmeden aynı anda boşaltılıyor. Bir sonraki yazıda BroadcastChannel, Web Locks, sayfa yaşam döngüsü event'leri ve Navigation API ile sekmeleri koordine ediyoruz.