At 2 AM, I was jolted awake by a flurry of DingTalk messages from our ops team. Several users were reporting that their game progress had rolled back — after grinding all the way to level 12, a simple refresh sent them back to level 8. My first instinct was that the backend hadn't persisted the data, but checking the logs revealed the backend never even received their save requests. Frontend logs showed localStorage writes were happening, yet after a refresh the value reverted to an earlier state. My gut said: multiple tabs were overwriting the storage. I manually opened five tabs in the browser and tried to reproduce it for half an hour, but couldn't trigger the race condition. Finally, I wrote a Playwright script to simulate concurrent reads and writes, and that's when I caught the gremlin. Six hours — from debugging to fixing, plus setting up an automated regression suite. This post is a retrospective and a ready‑to‑use E2E storage consistency verification template.
Breaking Down the Problem: Why Your Memory Storage Always Fails
Modern frontend apps — especially games, collaborative editors, and configuration centers — heavily use localStorage or IndexedDB as "memory storage" to save user preferences, drafts, and game states right in the browser. The trouble is, if a user opens two tabs of the same origin simultaneously (something many people do without thinking), each tab holds its own reference to the same storage. Write operations are not atomic, and there is no cross‑tab locking mechanism. By default, localStorage.setItem simply overwrites — the last writer stomps on the earlier one, with no merging or conflict resolution whatsoever.
Take our incident: Tab A reads progress {level: 10}. The user plays through level 11 in Tab A and is about to write. Meanwhile, Tab B wakes up from the background, still holding {level: 10}. The user performs an action in Tab B that writes level 9 (say, hitting reset). Tab B’s write finishes 60ms later than Tab A’s. The result? Tab A’s level‑11 write is completely overwritten by Tab B’s level‑9. And the browser raises no error.
A typical manual testing workflow looks like this: QA opens two tabs, clicks around in both, and checks whether the stored value ends up consistent. Setting aside how random the timing is in manual simulation, the sheer ovehead of “opening two tabs and coordinating actions” makes regression testing so expensive that nobody wants to do it frequently. This is why E2E automation is essential — it’s not a nice‑to‑have; without automation, you essentially have no test at all.
Solution Design: Playwright + GitHub Actions for Deterministic Concurrent E2E
The tool choice wasn’t really a debate. To simulate multi‑tab concurrency, the core requirements are:
- Multiple same‑origin “tabs” sharing storage, just like real browser tabs.
- Precise control over read/write timing, so that the race condition reproduces reliably instead of appearing only “when the stars align”.
- CI‑friendliness — runs automatically on every push.
Cypress historically had poor support for multiple tabs; later it introduced experimental APIs like cy.origin, but they remain limited, especially for same‑origin tab scenarios where localStorage is shared. Puppeteer lets you create multiple contexts with browser.createIncognitoBrowserContext(), but the behavior of multiple pages within one context shares storage the same way as Playwright. However, Puppeteer falls short in terms of waiting, network interception, and browser context management finesse, leading to higher script maintenance costs.
Playwright’s BrowserContext provides storage isolation by default, while multiple Page instances within the same context share localStorage / IndexedDB — this perfectly mirrors the real “same‑origin, multiple tabs” environment. Combine that with page.evaluate to directly read/write storage and page.waitForFunction to observe value changes, and you can turn an asynchronous race condition into a deterministic assertion.
The overall architecture is straightforward: test scripts (TypeScript) → a concurrent operation utility → executed by @playwright/test → GitHub Actions runs them automatically on every PR, outputting a report. No additional services, zero dependency burden.
Core Implementation: 3 Pieces of Code That Nail Consistency Verification
Installation & Basic Setup
First, install Playwright:
npm init -y
npm i -D @playwright/test typescript ts-node
npx playwright install --with-deps chromium
Nothing special here; all the following code runs on this environment.
Code Snippet #1: A Concurrent Storage Race Utility
What it solves: Simulates two tabs (pageA, pageB) performing interleaved reads and writes on localStorage under the same origin, and asserts that the final value matches expectations.
// utils/storage-race.ts
import { Page, BrowserContext } from '@playwright/test';
/**
* 在同一个 context 下创建两个页面,同时执行各自的 storage 操作,
* 最后验证键 key 的值是否等于 expectedValue。
*/
export async function simulateRace(
context: BrowserContext,
origin: string,
key: string,
operationA: (value: string | null) => string,
operationB: (value: string | null) => string,
expectedValue: string
) {
const pageA = await context.newPage();
const pageB = await context.newPage();
// 确保两个页面都已经加载同源页面,localStorage 可读
await Promise.all([pageA.goto(origin), pageB.goto(origin)]);
// 获取当前存储的初始值(可能为空)
const getCurrent = (page: Page) =>
page.evaluate((k: string) => localStorage.getItem(k), key);
const initial = await getCurrent(pageA);
// 同时触发 A 和 B 的写入操作,模拟竞态
// 注意:这里不 await 单个操作,而是让两个操作并发飞行
const writeA = pageA.evaluate(
({ k, op, init }) => localStorage.setItem(k, op(init)),
{ k: key, op: operationA, init: initial }
);
const writeB = pageB.evaluate(
({ k, op, init }) => localStorage.setItem(k, op(init)),
{ k: key, op: operationB, init: initial }
);
// 等待两个写操作都完成
await Promise.all([writ
Top comments (0)