At 1 a.m., the CI bot pinged me in our team chat for the tenth time: “Frontend multi-tab sync test failed.” This was already the third time this test case failed for our collaborative whiteboard project, and all I wanted was to sleep. After repeatedly digging through Playwright’s docs, I finally realized I had fallen into a particularly stupid trap—browser context isolation. I’ll lay out the whole debugging journey so you can save yourself some extra work.
Problem breakdown
Our frontend uses IndexedDB for offline data persistence. After data is written in one page, it notifies other open tabs via BroadcastChannel to refresh the UI. The testing goal is clear: use Playwright to simulate two tabs and verify that data syncs in real time.
The typical approach: open two Page objects, one writes to IndexedDB and broadcasts, the other listens on BroadcastChannel and asserts that it receives the message. My initial pseudo-test looked something like this:
tab1 -> write to IndexedDB -> send “sync” message via BroadcastChannel
tab2 -> listen for BroadcastChannel beforehand -> on message, read from IndexedDB -> assert data is up to date
It seemed harmless, but when running with Playwright, the second page never received the broadcast message. Not occasionally — 100% failure.
What’s the root cause? I used two browser.newContext() calls, creating two completely isolated browser contexts. In Chromium, different BrowserContexts not only isolate IndexedDB storage, but also isolate BroadcastChannel — messages sent in contextA are entirely invisible to contextB. This is a classic mistake of “simulating multi-tab” scenarios with the wrong API.
Solution design
To test true multi-tab data sync, you must open multiple Pages within the same BrowserContext. This way, they share the same origin’s storage (IndexedDB, localStorage), and BroadcastChannel works correctly.
Why not Cypress? Cypress doesn’t natively support multiple tabs. Although you can simulate it with cy.origin, it’s awkward for verifying sync at the storage layer like IndexedDB.
Why not Puppeteer? Early versions of Puppeteer lacked elegant multi-page management, and Playwright is clearly more mature in waiting for async events, network idle, and locator assertions, saving you from writing a ton of waitForTimeout.
Why not use two real browser windows? Automated tests run in headless CI environments — no desktop.
The architecture is simple: one BrowserContext, two Pages, same-origin URLs. The core logic uses page.evaluate() to manipulate IndexedDB and BroadcastChannel within the browser, and assertions rely on Playwright’s waitForFunction to poll the page state.
Core implementation
This code solves the problem of creating two pages within the same storage context and verifying that, after one page writes data, the other page perceives the change through BroadcastChannel.
Here is the full runnable test (requires installing playwright and the idb frontend library, and a local static server):
import { test, expect, BrowserContext } from '@playwright/test';
import http from 'http';
import fs from 'fs';
import path from 'path';
// A minimal HTML page with built-in idb operations and BroadcastChannel listening
const PAGE_HTML = `
<!DOCTYPE html>
<html>
<body>
<div id="status">idle</div>
<script type="module">
import { openDB } from 'https://unpkg.com/idb?module';
const channel = new BroadcastChannel('sync-demo');
const statusEl = document.getElementById('status');
async function initDB() {
const db = await openDB('sync-db', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('items')) {
db.createObjectStore('items', { keyPath: 'id' });
}
}
});
window._db = db;
}
async function writeItem(id, value) {
const db = await openDB('sync-db', 1);
await db.put('items', { id, value });
channel.postMessage({ type: 'changed', id, value });
statusEl.textContent = 'written';
}
async function readItem(id) {
const db = await openDB('sync-db', 1);
return await db.get('items', id);
}
// Expose to Playwright for direct calls
window._writeItem = writeItem;
window._readItem = readItem;
channel.onmessage = async (event) => {
if (event.data.type === 'changed') {
const item = await readItem(event.data.id);
statusEl.textContent = 'synced:' + JSON.stringify(item);
}
};
initDB();
</script>
</body>
</html>
`;
let server: http.Server;
const PORT = 4567;
test.beforeAll(async () => {
// Start a local static server, returning the above HTML
Top comments (0)