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

Sekmeler ve Sayfa Yaşam Döngüsü: BroadcastChannel, Web Locks, visibilitychange ve Navigation API

Mert Özen 15 Kas 2026 15 dk 10 görüntülenme
Sekmeler ve Sayfa Yaşam Döngüsü: BroadcastChannel, Web Locks, visibilitychange ve Navigation API

Birden çok sekmede çalışan bir uygulamada paylaşılan işi Web Locks ile tek sekmeye verir, sonucu ve çıkış gibi olayları BroadcastChannel ile diğer sekmelere yayarsın; kapanışta unload yerine pagehide ve visibilitychange kullanırsın. Bookstore'da outbox'ın iki sekmeden dört yerine iki POST göndermesini, lider sekmenin poll'u tek başına yapmasını, Chromium 158'de unload'ın artık çalışmamasını ve Navigation API ile Angular'ın deneysel desteğini gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), Node.js 24 ve Playwright 1.65 ile denenmiştir; tarayıcı çıktıları Playwright'ın Chromium 158 ve WebKit 27.2 build'lerinden alınmış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ı, Firefox satırları bu verilerden gelir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) IndexedDB yazısında yorumları offline'da bir outbox'a yazmış, bağlantı gelince online event'iyle sırayla göndermiştik. Kullanıcı Bookstore'u iki sekmede açık tutuyor: birinde Dune'a, diğerinde Emma'ya bağlantı yokken yorum yazıyor. Outbox IndexedDB'de, yani iki sekme için de tek. Bağlantı geri gelince sunucu kaç POST alır?

Bu yazı birden çok sekmenin aynı uygulamayı çalıştırmasından doğan sorunlara bakıyor: bir sekmede çıkış yapılınca diğerlerinin bilmemesi, her sekmenin sunucuyu ayrı ayrı yoklaması, aynı işin iki sekmede birden yapılması. Araçlar BroadcastChannel, Web Locks API'si (navigator.locks), sayfa yaşam döngüsü event'leri ve Ocak 2026'da Baseline olan Navigation API.

Web Locks: Bir İşi Tek Sekme Yapsın

Tahmini Playwright ile, aynı tarayıcı context'inde iki sekme açarak denedim; IndexedDB yazısının sonundaki kod:

--- offline: each tab saves one review
pending in tab A: 1 review(s) waiting for a connection
--- back online
[tab A] outbox: 33dac5b9 -> 201
[tab B] outbox: 33dac5b9 -> 201 (replayed)
[tab B] outbox: b8477d8a -> 201
[tab A] outbox: b8477d8a -> 201 (replayed)
server:
[api] #12 POST /api/books/9780441172719/reviews
[api] #12 201 review 501 for 9780441172719 (key 33dac5b9)
[api] #13 POST /api/books/9780441172719/reviews
[api] #13 201 replayed for key 33dac5b9
[api] #14 POST /api/books/9780141439587/reviews
[api] #14 201 review 502 for 9780141439587 (key b8477d8a)
[api] #15 POST /api/books/9780141439587/reviews
[api] #15 201 replayed for key b8477d8a

Sunucu dört POST aldı. İki sekme de online event'ini aldı, ikisi de outbox'ı okudu ve ikisi de iki yorumu gönderdi. Veri bozulmadı, çünkü her yorumun bir idempotency key'i var ve sunucu ikinci isteğe ilk cevabı tekrar verdi (replayed). Ama idempotency key bir güvenlik ağı; iki sekmenin aynı işi yapmasını engellemiyor, yalnız sonucunu zararsız hâle getiriyor. Gereksiz istekler sunucu yükü, log gürültüsü ve rate limit'e takılma riski demek.

Web Locks API'si aynı origin'deki bütün sekmeler, worker'lar ve iframe'ler arasında paylaşılan, isimli kilitler veriyor. navigator.locks.request(name, callback) kilit boşsa callback'i hemen çalıştırıyor, doluysa kilit bırakılana kadar bekletiyor; callback'in döndürdüğü promise bitince kilit bırakılıyor. Outbox'ı boşaltan method'u bir kilidin içine almak yeterli:

  async flush() {
    // bütün sekmelerde tek gönderici: diğerleri kilidi bekler, sonra outbox'ı boş bulur
    await navigator.locks.request('review-outbox', async () => {
      const db = await this.database.connection();
      // önce oku, sonra gönder: transaction ağı beklerken açık kalmamalı
      const items = await db.getAllFromIndex('outbox', 'byCreatedAt');
      this.pending.set(items.length);
      if (!navigator.onLine) return;
      for (const item of items) {
        if (!(await this.send(item))) break; // bunu ve sonrakileri sırayla tut
        await db.delete('outbox', item.idempotencyKey);
        this.pending.update((n) => n - 1);
      }
    });
  }
--- offline: each tab saves one review
pending in tab A: 1 review(s) waiting for a connection
--- back online
[tab A] outbox: d1cce7ae -> 201
[tab A] outbox: 8d42fa6d -> 201
server:
[api] #11 POST /api/books/9780441172719/reviews
[api] #11 201 review 501 for 9780441172719 (key d1cce7ae)
[api] #12 POST /api/books/9780141439587/reviews
[api] #12 201 review 502 for 9780141439587 (key 8d42fa6d)

Kilidi A sekmesi aldı ve iki yorumu da gönderdi; B sekmesi kilidi beklerken A işini bitirdi, B kilide girdiğinde outbox'ı boş buldu. Sunucu iki POST aldı. Bekleyen tarafın atlamasını istersen { ifAvailable: true } seçeneği kilit doluysa callback'i null ile hemen çağırıyor; burada beklemeyi seçtim, çünkü B'nin kilidi beklerken eklenmiş yeni bir yorumu da kaçırmadan göndermesini istiyorum. Web Locks, web-features'a göre Mart 2022'den beri Baseline (Chrome 69, Firefox 96, Safari 15.4); bu yüzden kodda fallback yok. Bu, IndexedDB yazısında "sekmeler arasında tek gönderici" diye bıraktığımız sözün karşılığı.

Lider Sekme: Poll'u Tek Sekme Yapsın

Kurulabilir PWA (Progressive Web App; tarayıcıdan kurulabilen web uygulaması) yazısında okunmamış sipariş güncellemelerini uygulama icon'unda göstermiştik. Sayıyı güncel tutmak için uygulama sunucuyu belirli aralıklarla yokluyor (poll). Beş sekme açıksa beş sekme ayrı ayrı yokluyor. Web Locks burada da işe yarıyor, ama bu kez kısa bir iş için değil, sekmenin bütün ömrü için: kilidi alan sekme lider oluyor ve kilidi hiç bırakmıyor. Sekme kapanınca (ya da çökünce) tarayıcı kilidi bırakıyor ve bekleyen sekmelerden biri onu alıyor:

import { Service, signal } from '@angular/core';

// Bookstore'un bir sekmesi liderdir: yaşadığı sürece bir Web Lock tutar.
// Kapanınca (ya da çökünce) tarayıcı kilidi bırakır ve bekleyen sıradaki sekme onu alır.
@Service()
export class TabLeader {
  readonly isLeader = signal(false);

  constructor() {
    void navigator.locks.request('bookstore-leader', () => {
      this.isLeader.set(true);
      console.log('leader: this tab is the leader');
      return new Promise<never>(() => {}); // hiç bitmez: sekme gidene kadar kilidi tut
    });
  }
}

Hiç bitmeyen bir promise döndürmek tuhaf görünüyor ama kalıp bu: kilidin sahibi olmak, callback'in içinde kalmak demek. Poll'u yapan service yalnız liderde timer kuruyor ve sonucu BroadcastChannel ile diğer sekmelere yayıyor:

import { effect, inject, Service } from '@angular/core';
import { OrderBadge } from './order-badge';
import { TabLeader } from './tab-leader';

type OrderUpdatesMessage = { type: 'count'; unread: number } | { type: 'refresh' };

function isOrderUpdatesMessage(data: unknown): data is OrderUpdatesMessage {
  const m = data as OrderUpdatesMessage;
  return (
    (m?.type === 'count' && Number.isInteger(m.unread) && m.unread >= 0) || m?.type === 'refresh'
  );
}

// Sunucuyu yalnız lider sekme yoklar; her sekme onun yayınladığı sayıyı gösterir.
@Service()
export class OrderUpdatesPoller {
  private readonly badge = inject(OrderBadge);
  private readonly leader = inject(TabLeader);
  private readonly channel = new BroadcastChannel('bookstore-order-updates');
  private readonly intervalMs = Number(new URLSearchParams(location.search).get('poll') ?? 30_000);

  constructor() {
    this.channel.onmessage = ({ data }) => {
      if (!isOrderUpdatesMessage(data)) return; // aynı origin, ama yine de güvenilmeyen girdi
      if (data.type === 'count') void this.badge.show(data.unread);
      else if (this.leader.isLeader()) void this.poll('asked by another tab');
    };
    effect((onCleanup) => {
      if (!this.leader.isLeader()) return;
      void this.poll('became leader');
      const timer = setInterval(() => void this.poll('timer'), this.intervalMs);
      onCleanup(() => clearInterval(timer));
    });
    // kullanıcının döndüğü sekme taze bir sayı ister, lider hangisi olursa olsun
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') this.refreshNow();
    });
  }

  refreshNow() {
    if (this.leader.isLeader()) void this.poll('asked by this tab');
    else this.channel.postMessage({ type: 'refresh' } satisfies OrderUpdatesMessage);
  }

  private async poll(reason: string) {
    const unread = await this.badge.refresh();
    console.log(`leader: polled (${reason}), unread ${unread}`);
    if (unread !== null)
      this.channel.postMessage({ type: 'count', unread } satisfies OrderUpdatesMessage);
  }
}

BroadcastChannel aynı origin'de aynı isimle kanal açmış bütün sekmelere, worker'lara ve iframe'lere bir mesaj gönderiyor; veri structured clone ile kopyalanıyor. Gönderen sekme kendi mesajını almıyor. Üç sekmeyle, aralığı ölçüm için 2 saniyeye indirerek (?poll=2000) denedim:

 0.1 s [tab A] leader: this tab is the leader
 0.1 s [tab A] leader: polled (became leader), unread 2
 2.1 s [tab A] leader: polled (timer), unread 2
 4.1 s [tab A] leader: polled (timer), unread 2
 4.8 s tab C nav: "Catalog Search Order report Covers My orders (2) Settings"; order-updates requests so far: 3
 4.8 s --- tab A (the leader) closes
 4.8 s [tab B] leader: this tab is the leader
 4.8 s [tab B] leader: polled (became leader), unread 2
 6.8 s [tab B] leader: polled (timer), unread 2
 7.8 s --- tab C becomes visible again
 7.8 s [tab B] leader: polled (asked by another tab), unread 2
 8.3 s --- sign out in tab B
 8.3 s [tab C] session: signed out in another tab
 8.4 s tab C: "You signed out in another tab.", header shows "Sign in"

İlk 4,8 saniyede sunucu üç sekme için üç istek aldı; her sekme kendisi yoklasaydı dokuz olurdu. C sekmesi hiç istek atmadan header'da "(2)" gösteriyor. Lider A kapanınca kilit aynı anda B'ye geçti ve B hemen yokladı. C tekrar görünür olunca kendi sunucuya gitmedi, lidere "yokla" dedi. WebKit 27.2'de aynı senaryo aynı sırayla çalıştı; A kapandıktan sonra liderliğin B'ye geçmesi orada da aynı anda oldu. Web Push yazısında her sekmenin SwPush.messages'ı ayrı ayrı alıp badge'i ayrı ayrı yenilediğini söylemiştik; artık push gelince her sekme yalnız refreshNow() çağırıyor ve sunucuya yine yalnız lider gidiyor.

Aynı kalıp token yenilemede de kullanılıyor: access token'ın süresi dolunca iki sekme aynı anda refresh token ile yenileme isteği gönderirse ve sunucu refresh token rotation (her kullanımda yeni refresh token verme) uyguluyorsa, ikinci istek eski token'la geldiği için reddedilir ve kullanıcı oturumdan atılır. Yenilemeyi bir navigator.locks.request('token-refresh', …) içine almak, kilidi bekleyen sekmenin kilide girdiğinde önce token'ın zaten yenilenip yenilenmediğine bakması bu yarışı kapatıyor. Token'ın nerede tutulacağı ise Frontend Güvenliği serisinin "Token Nerede Saklanmalı? localStorage, Memory, Cookie, Service Worker ve BFF'in Tehdit Modeli" yazısının konusu.

BroadcastChannel ile Çıkışı Bütün Sekmelere Yaymak

Bu seride gerçek bir oturum açma yok, ama çıkışın bütün sekmelere ulaşması mekanizma olarak gösterilebilir. Session service'i çıkışta yerel veriyi (okuma listesi ve outbox) temizliyor ve kanala bir mesaj gönderiyor:

type SessionMessage = { type: 'signed-out' };

// Bu seride gerçek oturum açma yok; önemli olan yapı: çıkış her sekmeye ulaşır.
@Service()
export class Session {
  private readonly router = inject(Router);
  private readonly database = inject(BookstoreDatabase);
  private readonly channel = new BroadcastChannel('bookstore-session');
  // çıkıştan sonra ''; ilk ziyarette Ada oturum açmış sayılır
  readonly customer = signal(localStorage.getItem('customer') ?? 'ada');
  readonly signedOutElsewhere = signal(false);

  constructor() {
    this.channel.onmessage = ({ data }: MessageEvent<SessionMessage>) => {
      // bir mesaj yalnız "çıkış yap" diyebilir; kimseyi oturuma sokamaz, token taşıyamaz
      if (data?.type !== 'signed-out') return;
      console.log('session: signed out in another tab');
      void this.clear();
      this.signedOutElsewhere.set(true);
    };
  }

  async signOut() {
    await this.clear();
    this.channel.postMessage({ type: 'signed-out' } satisfies SessionMessage);
  }

Yukarıdaki üç sekmeli denemenin son satırları bunu gösteriyor: B'de "Sign out"a basıldıktan 0,1 saniye sonra C "You signed out in another tab." banner'ını gösterdi. Eskiden bu iş localStorage'a bir değer yazıp diğer sekmelerde storage event'ini dinleyerek yapılırdı. Farkı framework'süz bir lab sayfasında (web/tabs/) iki sekmeyle denedim; gönderen sekme hem kanala yazıyor hem localStorage'a aynı değeri iki kez yazıyor:

// BroadcastChannel ve storage event'i: mesajı kim, ne zaman alır?
const channel = new BroadcastChannel("bookstore-lab");
export const received = [];

export function listen() {
  channel.onmessage = (event) => received.push(`BroadcastChannel ${JSON.stringify(event.data)}`);
  addEventListener("storage", (event) => received.push(`storage key=${event.key} newValue=${event.newValue}`));
}

export function announce(text) {
  channel.postMessage({ type: "signed-out", text });
  localStorage.setItem("lab-signal", text); // diğer sekmeler storage event'i alır; bu sekme almaz
  localStorage.setItem("lab-signal", text); // aynı değer tekrar: hiç event yok
}
[chromium] sender received: []
[chromium] other tab received: ["BroadcastChannel {\"type\":\"signed-out\",\"text\":\"bye\"}","storage key=lab-signal newValue=bye"]
[webkit] sender received: []
[webkit] other tab received: ["BroadcastChannel {\"type\":\"signed-out\",\"text\":\"bye\"}"]

İki yöntemde de gönderen sekme kendi mesajını almadı. Chromium'da diğer sekme hem kanal mesajını hem tek bir storage event'ini aldı; aynı değeri ikinci kez yazmak event üretmedi. Playwright'ın WebKit'inde ise diğer sekmeye storage event'i hiç gelmedi (değer o sekmede okunabiliyordu); bunun Playwright'ın WebKit build'ine özgü olup olmadığını gerçek Safari'de doğrulayamadım. BroadcastChannel her iki motorda da çalıştı ve mesaj olarak bir object taşıyabiliyor; storage event'i ise yalnız string taşıyor ve bir değeri kalıcı olarak yazmayı gerektiriyor. Bookstore bu yüzden kanalı kullanıyor.

Kanal mesajına körü körüne güvenme. Kanal yalnız aynı origin'e açık, ama aynı origin'de çalışan her script (bir XSS (Cross-Site Scripting; sayfaya dışarıdan script sokulması) açığından gelen kod ya da üçüncü parti bir script) kanala yazabilir. Session kanaldan yalnız "çıkış yap" emrini kabul ediyor; en kötü ihtimalle kullanıcı istemeden çıkış yapar. Kanaldan asla token, kullanıcı kimliği ya da yetki gelmemeli; OrderUpdatesPoller da gelen sayıyı kullanmadan önce biçimini kontrol ediyor. Bu kuralların genel hâli Frontend Güvenliği serisinin "Üçüncü Parti Script'ler, iframe'ler ve postMessage: sandbox, frame-ancestors, Permissions-Policy ve Güvenli Mesajlaşma" yazısında.

Sayfa Yaşam Döngüsü: visibilitychange, pagehide ve unload

Sekmenin görünür olup olmadığını document.visibilityState ve visibilitychange event'i söylüyor; Page Visibility API'si 2015'ten beri bütün tarayıcılarda var. Poller kullanıcı bir sekmeye döndüğünde taze sayı istemek için bunu kullanıyor. Görünmeyen sekmenin timer'larına güvenme: Chrome'un dokümanına göre arka plandaki sekmelerde timer'lar seyrekleştiriliyor ve uzun süre gizli kalan bir sekmede zincirleme timer'lar dakikada bire kadar düşebiliyor. Lider gizli bir sekmeyse 30 saniyelik poll daha seyrek çalışır; görünür sekmenin refreshNow() çağrısı bu yüzden önemli.

Sayfa kapanırken bir şey göndermek için hangi event'e güvenebilirsin? Lab sayfasında üç event'e birer beacon bağladım ve sekmeyi önce kapattım, sonra başka bir sayfaya gittim:

// unload ve pagehide: sekme kapanırken hangi handler hâlâ çalışır?
export function reportWhenLeaving() {
  addEventListener("unload", () => navigator.sendBeacon("/api/events", "unload fired"));
  addEventListener("pagehide", (event) => navigator.sendBeacon("/api/events", `pagehide fired, persisted=${event.persisted}`));
  addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") navigator.sendBeacon("/api/events", "visibilitychange hidden");
  });
}
[chromium] close: pagehide fired, persisted=false | visibilitychange hidden
[chromium] navigate: pagehide fired, persisted=false | visibilitychange hidden
[webkit] close: pagehide fired, persisted=false | visibilitychange hidden | unload fired
[webkit] navigate: visibilitychange hidden | pagehide fired, persisted=false | unload fired

Chromium 158'de unload hiç çalışmadı. Chrome'un Ekim 2026 duyurusuna göre unload'ın kaldırılması Chrome 155'te tamamlandı: unload handler'ları artık varsayılan olarak çalışmıyor (Permissions Policy ve kurumsal policy ile geri açılabiliyor). WebKit'te unload hâlâ çalışıyor, ama ona bağlanan kod Chromium kullanıcılarında sessizce hiç çalışmaz. Üstelik web.dev'in bfcache rehberine göre unload handler'ı olan sayfalar masaüstü Chrome'da ve Firefox'ta bfcache'e (back/forward cache; geri tuşunda sayfanın bellekten anında açılması) giremiyordu. Her iki motorda güvenilir olanlar pagehide ve visibilitychange; mobilde kullanıcı uygulamayı arka plana attığında sayfa pagehide almadan kapatılabildiği için son kaydetme fırsatı çoğunlukla visibilitychange'in hidden anı. pagehide'ın persisted değeri true ise sayfa bfcache'e giriyor demek; bfcache ve performans etkisi Web Performansı serisinin konusu. Beacon ve fetchLater ile kapanışta veri göndermeyi bu serinin Fetch yazısında anlatmıştık.

SPA'lar (sayfa geçişlerini tarayıcıda yapan tek sayfalık uygulamalar) yıllardır History API'si (history.pushState ve popstate) üzerinde çalışıyor; link tıklamalarını ayrıca yakalamak, geri tuşunu ayrıca dinlemek gerekiyor. Navigation API bunların hepsini tek bir navigate event'inde topluyor: link, form, geri/ileri ve navigation.navigate() aynı event'i tetikliyor; event.intercept() ile navigasyonu document yüklemeden kendin yapıyorsun. web-features'a göre Firefox 147 ve Safari 26.2 ile Ocak 2026'da Baseline Newly Available oldu. Lab sayfasındaki örnek:

// Navigation API: link, form, geri/ileri ve navigation.navigate() için tek bir navigate event'i
export const log = [];

export function interceptNavigations() {
  navigation.addEventListener("navigate", (event) => {
    const url = new URL(event.destination.url);
    log.push(`navigate ${url.pathname}${url.search} type=${event.navigationType} canIntercept=${event.canIntercept}`);
    // yalnız /tabs/ altındaki kendi "sayfalarımız"; geri kalanı normal document yüklemesi
    if (!event.canIntercept || url.pathname !== "/tabs/" || event.downloadRequest !== null) return;
    event.intercept({
      async handler() {
        await new Promise((resolve) => setTimeout(resolve, 50)); // sanki veri yükleniyormuş gibi
        document.querySelector("#view").textContent = `Page: ${url.searchParams.get("page") ?? "home"}`;
      },
    });
  });
}

export async function goTo(page) {
  const { committed, finished } = navigation.navigate(`/tabs/?page=${page}`);
  await committed;
  await finished;
  return { url: navigation.currentEntry.url.replace(location.origin, ""), entries: navigation.entries().length };
}
[chromium] after the link: view "Page: orders", url ?page=orders
[chromium] navigation.navigate: {"url":"/tabs/?page=catalog","entries":3}
[chromium] after back: view "Page: orders"
[chromium] document requests to the server during these three navigations: 0
[chromium] log: ["navigate /tabs/?page=orders type=push canIntercept=true","navigate /tabs/?page=catalog type=push canIntercept=true","navigate /tabs/?page=orders type=traverse canIntercept=true"]

Düz bir <a href> tıklaması, navigation.navigate() ve geri tuşu aynı handler'dan geçti ve sunucuya hiç document isteği gitmedi. navigationType geri tuşunda traverse; navigation.entries() bu sekmenin aynı origin'deki geçmişini veriyor. WebKit 27.2'de çıktı satır satır aynıydı.

Angular Router bugün varsayılan olarak History API üzerinde çalışıyor. Angular 21.1'den beri withExperimentalPlatformNavigation() adında deneysel bir router feature'ı var; dokümantasyonu bunun production için olmadığını açıkça söylüyor. Ne değiştirdiğini görmek için Settings sayfasına geçici olarak routerLink'siz, düz bir link (<a class="plain" href="/orders">) koyup iki build aldım:

[chromium] default: plain link -> /orders, GET /orders documents: 1, window.marker: gone (new document)
[webkit] default: plain link -> /orders, GET /orders documents: 1, window.marker: gone (new document)
[chromium] withExperimentalPlatformNavigation: plain link -> /orders, GET /orders documents: 0, window.marker: same document
[webkit] withExperimentalPlatformNavigation: plain link -> /orders, GET /orders documents: 0, window.marker: same document

Varsayılan router'da düz link sayfayı baştan yükledi: sunucu bir document isteği aldı ve sayfadaki JavaScript state'i gitti. Deneysel feature açıkken aynı link Navigation API üzerinden yakalandı ve SPA navigasyonu oldu. Bu, CMS'ten (içerik yönetim sistemi) gelen HTML içindeki linkler gibi routerLink koyamadığın yerler için ilginç, ama bugün için bir deney; bu değişiklik Bookstore'un koduna girmedi. Navigation API'yi bugün doğrudan kullanmanın mantıklı olduğu yer, Angular dışındaki küçük sayfalar ya da navigasyonları gözlemlemek (örneğin analytics için navigatesuccess).

Üretimde Dikkat

Lider seçimi bir optimizasyon, doğruluk garantisi değil. Lider sekme dondurulabilir (Chrome arka plandaki sekmeleri bellek baskısında dondurup atabiliyor) ve kilit ancak sekme gerçekten kapanınca bırakılıyor; bu süre boyunca poll yapılmaz. Kritik bir iş (ödeme durumu gibi) liderin ayakta olmasına bağlı olmamalı; kullanıcının baktığı sekme gerekirse kendisi sorabilmeli.

Kanal mesajlarını sürümle. Yeni bir deploy sonrası bir sekme yeni sürümle, diğeri eski sürümle çalışabilir (Angular Service Worker yazısındaki VERSION_READY durumu). Mesajlara bir type ve gerekiyorsa bir sürüm alanı koy, tanımadığın mesajı sessizce yok say; isOrderUpdatesMessage bu yüzden var.

Çıkışta yerel veriyi temizle. Ortak kullanılan bir bilgisayarda çıkış yapan kullanıcının okuma listesi ve gönderilmemiş yorumları IndexedDB'de kalmamalı; Session.clear() bunu yapıyor. Cookie tabanlı oturumda asıl çıkış sunucuda olur; kanal mesajı yalnız diğer sekmelerin ekranını güncel tutar.

Sık Düşülen Tuzaklar

  • Her sekmenin kendi timer'ıyla sunucuyu yoklaması. Sekme sayısı kadar istek gider; poll'u Web Locks ile seçilen tek bir lidere ver.
  • Paylaşılan bir kuyruğu (outbox) birden çok sekmeden aynı anda boşaltmak. Idempotency key veriyi korur ama istekleri çoğaltır; boşaltmayı bir kilidin içine al.
  • BroadcastChannel mesajına güvenip yetki ya da token taşımak. Aynı origin'deki her script kanala yazabilir; mesajın biçimini doğrula ve kanaldan yalnız zararsız emirler kabul et.
  • Kapanışta veri göndermek için unload kullanmak. Chromium'da artık çalışmıyor; pagehide ve visibilitychange kullan.
  • Gizli sekmedeki timer'ın zamanında çalışacağını varsaymak. Tarayıcılar arka plandaki timer'ları seyrekleştirir; görünür olunca tazele.
  • Deneysel withExperimentalPlatformNavigation()'ı production'a almak. Angular dokümantasyonu yalnız deney için olduğunu söylüyor.

Küçük Bir Deneme

★☆☆ ReviewOutbox.flush()'ta kilidi { ifAvailable: true } ile iste. Bu yazıdaki iki sekmeli denemeyi tekrarla ve B sekmesinin kilidi beklemek yerine nasıl davrandığını konsoldan izle.

★★☆ TabLeader'a, lider olan sekmenin başlığına (document.title) bir işaret ekleyen bir effect yaz. Playwright'ta dört sekme açıp liderleri sırayla kapat ve liderliğin hangi sırayla geçtiğini kaydet; Web Locks'un bekleyenleri hangi sırayla sıraya aldığını MDN'deki LockManager.request açıklamasıyla karşılaştır.

★★★ Kurulabilir PWA yazısındaki InstallPrompt'un "Not now" kararını bütün sekmelere yay: bir sekmede "Not now" denince diğer sekmelerdeki teklif de hemen kaybolsun. Önce storage event'iyle, sonra BroadcastChannel ile yaz ve iki motorda karşılaştır.

Bu yazıyla Bookstore'un tarayıcı tarafı tamamlandı: offline çalışıyor, kurulabiliyor, bildirim alıyor, ağır işi worker'da yapıyor ve sekmeleri koordine ediyor. Serinin son yazısında geriye bakıyoruz: 2020'den 2026'ya tarayıcı API'lerinde ve Angular Service Worker'da neler değişti, Baseline nasıl okunur ve eski bir Angular PWA'sı nasıl denetlenir.