The failing tests looked like an Ably outage.
Every multiplayer browser test on BingWow started timing out at the same gate: "wait until the realtime channel attaches." The app loaded. The room existed. The players joined. Then the WebSocket layer never became ready.
The root cause was not Ably. It was one Playwright config line.
extraHTTPHeaders: {
'X-BingWow-Automated': '1',
}
That header was supposed to mark test traffic as internal. Instead, it leaked to every cross-origin request the browser made.
Why this broke realtime
Playwright's extraHTTPHeaders is global for the browser context. It does not apply only to requests headed for your app. It applies to third-party calls too.
In this app, a multiplayer room talks to:
-
bingwow.comfor HTML and API routes - Ably for realtime channels
- Supabase for auth and storage paths
- analytics endpoints for product events
The custom X-BingWow-Automated header is not CORS-safelisted. When the browser tried to call Ably with that header, it triggered a preflight request. Ably did not whitelist our private test header. The preflight failed. The actual realtime request never happened.
From the test's point of view, Ably simply never attached.
The misleading symptom
The app code was doing the right thing:
await page.waitForFunction(() => window.__ably_channel_ready === true);
The wait timed out after two minutes. That made the problem look like:
- a flaky WebSocket connection
- a race in the join flow
- a broken token endpoint
- a headless browser limitation
All of those were plausible. None were true.
The header never showed up in the app's own logs as an error because the failing request was cross-origin. The damage happened before Ably's actual attach request could complete.
The fix: use a cookie, not a global header
We still needed to mark test traffic so it would not pollute analytics or product metrics. The replacement was a domain-scoped cookie:
storageState: {
cookies: [{
name: 'bingwow_dev',
value: '1',
domain: '.bingwow.com',
path: '/',
expires: -1,
httpOnly: false,
secure: true,
sameSite: 'Lax',
}],
origins: [],
}
That cookie is sent only to BingWow-owned requests. It never goes to Ably, Supabase, or other third-party origins. The app can still identify internal traffic on its own API routes, and the browser no longer poisons external requests with private headers.
For real-time multiplayer games, this difference matters. The transport stack has to be boring. A test harness that changes cross-origin network behavior is not observing the product anymore. It is creating a second product.
The structural lint
The fix needed a guardrail because the broken line is easy to reintroduce. We added a Jest test that scans Playwright config files for extraHTTPHeaders and fails if a non-allowlisted header appears.
The error message explains why:
extraHTTPHeaders is GLOBAL - it applies to every browser request,
including cross-origin calls to ably.io, posthog, supabase, etc.
Custom non-CORS-safelisted headers trigger preflight failures and
silently break those services.
This is not a style preference. It is a production-shaped invariant for browser tests.
Why headless detection is still useful
The app also skips internal analytics for obvious automation signals:
navigator.webdriver- HeadlessChrome user agents
- a server-side bot detector
- the
bingwow_dev=1cookie
Those checks are safe because they do not alter third-party request headers. They change only how the app classifies traffic after it receives a request.
That distinction is the key lesson: mark traffic where you own the request, not by mutating every request the browser makes.
How I debug this class now
When a browser test involving third-party services fails, I check these first:
- Does the Playwright context set global headers?
- Are those headers CORS-safelisted?
- Do failed requests show
OPTIONSpreflight before the real call? - Does the same flow pass with a clean context plus app-domain cookies?
Most teams only notice this once they add a service that enforces CORS strictly. WebSockets, analytics, file storage, maps, payments, and auth providers all expose the same trap.
The visible feature in my case was a simple bingo card room. The underlying bug was a general browser automation footgun.
If you need internal test classification, prefer these patterns:
- App-domain cookies through
storageState - server-side bot detection
- test-only query params on your own origin
- init scripts that set app-local flags without touching network headers
Avoid global headers unless every origin the browser contacts is under your control.
The same rule now protects the multiplayer flow, the online bingo caller, and the internal QA suite. Tests should make the product easier to trust. They should not create network conditions real users never hit.
That one-line Playwright config was doing exactly that, and deleting it made the test suite both greener and more honest.
For a deeper product-level view of the architecture this affected, I wrote up the broader system in the real-time multiplayer bingo guide.
Top comments (0)