Tabs and the Page Lifecycle: BroadcastChannel, Web Locks, visibilitychange and the Navigation API
In an app running in several tabs, you give shared work to a single tab with Web Locks, spread the result and events such as sign-out to the other tabs with BroadcastChannel, and use pagehide and visibilitychange instead of unload on close. In Bookstore we show the outbox sending two POSTs from two tabs instead of four, a leader tab doing the polling alone, unload no longer running in Chromium 158, and the Navigation API with Angular's experimental support.
Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), Node.js 24 and Playwright 1.65; browser output comes from Playwright's Chromium 158 and WebKit 27.2 builds. Support data was read from MDN browser-compat-data 8.1.9 and web-features 3.46; Firefox could not be run in this environment, so Firefox statements come from that data.
Predict First
In the IndexedDB post of Bookstore, the online bookstore used as the shared example across this site's series, we wrote reviews into an outbox while offline and sent them in order on the online event when the connection came back. The user keeps Bookstore open in two tabs: in one they review Dune, in the other Emma, both without a connection. The outbox lives in IndexedDB, so it is one outbox for both tabs. How many POSTs does the server receive when the connection comes back?
This post looks at the problems that come from several tabs running the same app: when one tab logs out the others do not know, every tab polls the server separately, the same work is done in two tabs at once. The tools are BroadcastChannel, the Web Locks API (navigator.locks), page lifecycle events and the Navigation API, which became Baseline in January 2026.
Web Locks: Let One Tab Do the Job
I tried the prediction with Playwright, opening two tabs in the same browser context, with the code from the end of the IndexedDB post:
--- 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
The server received four POSTs. Both tabs got the online event, both read the outbox and both sent both reviews. No data was corrupted, because every review has an idempotency key and the server returned the first answer again for the second request (replayed). But the idempotency key is a safety net; it does not stop two tabs from doing the same work, it only makes the result harmless. Unnecessary requests mean server load, log noise and the risk of hitting a rate limit.
The Web Locks API provides named locks shared by all tabs, workers and iframes of the same origin. navigator.locks.request(name, callback) runs the callback right away if the lock is free and waits until it is released if not; the lock is released when the promise returned by the callback settles. Wrapping the method that empties the outbox in a lock is enough:
async flush() {
// one sender across all tabs: the others wait for the lock, then find the outbox empty
await navigator.locks.request('review-outbox', async () => {
const db = await this.database.connection();
// read first, send afterwards: a transaction must not stay open while waiting for the network
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; // keep this and the later ones, in order
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)
Tab A took the lock and sent both reviews; while tab B was waiting for the lock, A finished, and when B entered the lock it found the outbox empty. The server received two POSTs. If you want the waiting side to skip instead, the { ifAvailable: true } option calls the callback immediately with null when the lock is taken; here I chose to wait, because I want B to also send a new review added while it was waiting for the lock. According to web-features, Web Locks has been Baseline since March 2022 (Chrome 69, Firefox 96, Safari 15.4); that is why the code has no fallback. This keeps the promise we left in the IndexedDB post: "a single sender across tabs".
A Leader Tab: Let One Tab Poll
In the installable PWA post we showed unread order updates on the app icon. To keep the number current, the app polls the server at intervals. With five tabs open, five tabs poll separately. Web Locks helps here too, but this time not for a short job but for the tab's whole lifetime: the tab that takes the lock becomes the leader and never releases it. When the tab closes (or crashes), the browser releases the lock and one of the waiting tabs gets it:
import { Service, signal } from '@angular/core';
// One tab of Bookstore is the leader: it holds a Web Lock for as long as it lives.
// When it closes (or crashes), the browser releases the lock and the next waiting tab gets it.
@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>(() => {}); // never resolves: keep the lock until the tab goes away
});
}
}
Returning a promise that never settles looks odd, but that is the pattern: owning the lock means staying inside the callback. The polling service sets up a timer only in the leader and broadcasts the result to the other tabs with BroadcastChannel:
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'
);
}
// Only the leader tab polls the server; every tab shows the count it broadcasts.
@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; // same origin, but still untrusted input
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));
});
// a tab the user comes back to wants a fresh number, whichever tab is the leader
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 sends a message to every tab, worker and iframe of the same origin that has opened a channel with the same name; the data is copied with structured clone. The sending tab does not receive its own message. I tried it with three tabs, lowering the interval to 2 seconds for the measurement (?poll=2000):
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"
In the first 4.8 seconds the server received three requests for three tabs; had every tab polled on its own it would have been nine. Tab C shows "(2)" in the header without sending a single request. When leader A closed, the lock passed to B at the same moment and B polled right away. When C became visible again it did not go to the server itself; it asked the leader to poll. In WebKit 27.2 the same scenario ran in the same order; leadership passed to B at the same moment after A closed there too. In the Web Push post we noted that every tab received SwPush.messages separately and refreshed the badge separately; now every tab only calls refreshNow() when a push arrives, and still only the leader goes to the server.
The same pattern is used for token refresh: when the access token expires and two tabs send a refresh request with the refresh token at the same time, and the server applies refresh token rotation, the second request arrives with the old token, gets rejected, and the user is logged out. Wrapping the refresh in navigator.locks.request('token-refresh', …), with the waiting tab first checking whether the token has already been refreshed when it enters the lock, closes this race. Where to keep the token is the topic of the Frontend Security series post "Where Should Tokens Live? The Threat Model of localStorage, Memory, Cookies, Service Workers and the BFF".
Spreading Sign-Out to Every Tab with BroadcastChannel
This series has no real sign-in, but the mechanism of a sign-out reaching every tab can still be shown. The Session service clears local data (the reading list and the outbox) on sign-out and sends a message to the channel:
type SessionMessage = { type: 'signed-out' };
// No real sign-in in this series; the shape is what matters: sign-out reaches every tab.
@Service()
export class Session {
private readonly router = inject(Router);
private readonly database = inject(BookstoreDatabase);
private readonly channel = new BroadcastChannel('bookstore-session');
// '' after a sign-out; on the very first visit Ada is signed in
readonly customer = signal(localStorage.getItem('customer') ?? 'ada');
readonly signedOutElsewhere = signal(false);
constructor() {
this.channel.onmessage = ({ data }: MessageEvent<SessionMessage>) => {
// a message can only say "sign out"; it can never sign anyone in or carry a token
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);
}
The last lines of the three-tab run above show it: 0.1 seconds after "Sign out" was clicked in B, C showed the "You signed out in another tab." banner. This used to be done by writing a value to localStorage and listening for the storage event in the other tabs. I tried the difference with two tabs in a framework-free lab page (web/tabs/); the sending tab writes to the channel and writes the same value to localStorage twice:
// BroadcastChannel vs. the storage event: who gets the message, and when?
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); // other tabs get a storage event; this tab does not
localStorage.setItem("lab-signal", text); // same value again: no event at all
}
[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\"}"]
With both methods the sending tab did not receive its own message. In Chromium the other tab got both the channel message and a single storage event; writing the same value a second time produced no event. In Playwright's WebKit, however, no storage event reached the other tab at all (the value could be read there); I could not verify on real Safari whether this is specific to Playwright's WebKit build. BroadcastChannel worked in both engines and can carry an object as the message; the storage event only carries strings and requires writing a value permanently. That is why Bookstore uses the channel.
Do not trust a channel message blindly. The channel is open only to the same origin, but any script running on that origin (code from an XSS hole or a third-party script) can write to it. Session accepts only the "sign out" command from the channel; in the worst case the user is signed out unintentionally. A token, a user identity or a permission must never come from the channel; OrderUpdatesPoller also checks the shape of the number before using it. The general form of these rules is in the Frontend Security series post "Third-Party Scripts, iframes and postMessage: sandbox, frame-ancestors, Permissions-Policy and Safe Messaging".
The Page Lifecycle: visibilitychange, pagehide and unload
document.visibilityState and the visibilitychange event tell whether a tab is visible; the Page Visibility API has been in every browser since 2015. The poller uses it to ask for a fresh number when the user comes back to a tab. Do not rely on a hidden tab's timers: according to Chrome's documentation, timers in background tabs are throttled, and in a tab hidden for a long time chained timers can drop to once per minute. If the leader is a hidden tab, the 30-second poll runs less often; that is why the visible tab's refreshNow() call matters.
Which event can you rely on to send something when a page closes? In the lab page I attached a beacon to each of three events and first closed the tab, then navigated to another page:
// unload vs. pagehide: which handler still runs when the tab closes?
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
In Chromium 158 unload never ran. According to Chrome's October 2026 announcement, the removal of unload was completed in Chrome 155: unload handlers no longer run by default (they can be turned back on with Permissions Policy and enterprise policy). In WebKit unload still runs, but code attached to it silently never runs for Chromium users. On top of that, according to web.dev's bfcache guide, pages with an unload handler could not enter the bfcache in desktop Chrome and Firefox. The reliable ones in both engines are pagehide and visibilitychange; on mobile, a page can be killed without getting pagehide after the user sends the app to the background, so the last chance to save is usually the hidden moment of visibilitychange. If pagehide's persisted is true, the page is entering the bfcache; the bfcache and its performance impact are a topic of the Web Performance series. We covered sending data on close with beacons and fetchLater in this series' Fetch post.
The Navigation API and the Angular Router
SPAs have run on the History API (history.pushState and popstate) for years; you have to catch link clicks separately and listen for the back button separately. The Navigation API gathers all of these into a single navigate event: links, forms, back/forward and navigation.navigate() trigger the same event, and with event.intercept() you perform the navigation yourself without loading a document. According to web-features it became Baseline Newly Available in January 2026 with Firefox 147 and Safari 26.2. The example in the lab page:
// The Navigation API: one navigate event for links, forms, back/forward and navigation.navigate()
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}`);
// only our own "pages" under /tabs/; anything else is a normal document load
if (!event.canIntercept || url.pathname !== "/tabs/" || event.downloadRequest !== null) return;
event.intercept({
async handler() {
await new Promise((resolve) => setTimeout(resolve, 50)); // as if data were loading
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"]
A plain <a href> click, navigation.navigate() and the back button went through the same handler, and no document request reached the server. navigationType is traverse for the back button; navigation.entries() returns this tab's same-origin history. In WebKit 27.2 the output was identical line by line.
The Angular Router runs on the History API by default today. Since Angular 21.1 there is an experimental router feature called withExperimentalPlatformNavigation(); its documentation states clearly that it is not for production. To see what it changes, I temporarily put a plain link without routerLink (<a class="plain" href="/orders">) on the Settings page and made two builds:
[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
With the default router the plain link reloaded the page: the server received a document request and the page's JavaScript state was gone. With the experimental feature on, the same link was caught through the Navigation API and became an SPA navigation. That is interesting for places where you cannot put a routerLink, such as links inside HTML that comes from a CMS, but for now it is an experiment; this change did not go into Bookstore's code. Where using the Navigation API directly makes sense today is small pages outside Angular, or observing navigations (for example navigatesuccess for analytics).
In Production
Leader election is an optimization, not a correctness guarantee. The leader tab can be frozen (Chrome can freeze and discard background tabs under memory pressure), and the lock is released only when the tab actually closes; no polling happens in the meantime. Critical work (such as payment status) must not depend on the leader being alive; the tab the user is looking at must be able to ask on its own when needed.
Version your channel messages. After a new deploy, one tab can run the new version and another the old one (the VERSION_READY state from the Angular Service Worker post). Give messages a type and, if needed, a version field, and silently ignore messages you do not recognize; that is why isOrderUpdatesMessage exists.
Clear local data on sign-out. On a shared computer, the reading list and unsent reviews of a user who signed out must not stay in IndexedDB; Session.clear() does that. With a cookie-based session the real sign-out happens on the server; the channel message only keeps the other tabs' screens up to date.
Common Pitfalls
- Every tab polling the server with its own timer. You send as many requests as there are tabs; give polling to a single leader chosen with Web Locks.
- Emptying a shared queue (an outbox) from several tabs at once. An idempotency key protects the data but multiplies the requests; empty it inside a lock.
- Trusting a
BroadcastChannelmessage to carry permissions or a token. Any script on the same origin can write to the channel; validate the message's shape and accept only harmless commands from it. - Using
unloadto send data on close. It no longer runs in Chromium; usepagehideandvisibilitychange. - Assuming a hidden tab's timer runs on time. Browsers throttle background timers; refresh when the tab becomes visible.
- Taking the experimental
withExperimentalPlatformNavigation()to production. The Angular documentation says it is for experimentation only.
A Small Experiment
★☆☆ Request the lock in ReviewOutbox.flush() with { ifAvailable: true }. Repeat this post's two-tab run and watch in the console how tab B behaves instead of waiting for the lock.
★★☆ Write an effect in TabLeader that adds a marker to the leader tab's title (document.title). Open four tabs in Playwright, close the leaders one by one and record the order in which leadership passes; compare it with MDN's description of LockManager.request to see in which order Web Locks queues the waiting requests.
★★★ Spread the "Not now" decision of InstallPrompt from the installable PWA post to every tab: when "Not now" is chosen in one tab, the offer in the other tabs should disappear right away too. Write it first with the storage event, then with BroadcastChannel, and compare them in both engines.
With this post Bookstore's browser side is complete: it works offline, can be installed, receives notifications, does heavy work in a worker and coordinates its tabs. In the series' last post we look back: what changed in browser APIs and the Angular Service Worker from 2020 to 2026, how to read Baseline and how to audit an old Angular PWA.