A browser test failed once, with a second guest stuck on the play page. I wanted to know whether joining a room was broken or only slow, and what a player should see while it is slow. This is what 5,297 timed joins said, what we changed, and the one request we decided must never be cancelled.
This follows two earlier posts: How an answer reaches every screen, which describes the room a join enters, and What we got wrong about checks that pass when nothing happened, whose rule, "a check has to state what it examined", shaped the new test helper.
The symptom
Hearso is standup trivia: a host opens a room, and teammates join with a short code. On the evening of 2026-09-26 (UTC), a full run of our Playwright browser tests failed once. The host had a room. The second guest had typed the code, pressed Join room, and was still on /games/trivia/play when the test gave up.
The obvious readings were "join is broken" or "the test is flaky". Neither tells you what to fix. So before touching anything, I timed joins, a lot of them, against the same setup: a local production build (next start) on my laptop, talking to our DEV Supabase database.
What 5,297 joins said
A temporary browser test pressed Join in a loop across six to seven parallel workers and recorded, for each press, the time to the join's answer and its status. I ran it six times between 21:58Z and 22:32Z.
| Run (2026-09-26, UTC) | Shell | Joins | p50 | p99 | Slowest | Over 5 s |
|---|---|---|---|---|---|---|
| 21:58, calibration | classic | 60 | 437 ms | 620 ms | 3,741 ms | 0 |
| 21:59-22:02, with the full suite | classic | 240 | 374 ms | 661 ms | 864 ms | 0 |
| 22:02-22:05 | classic | 600 | 405 ms | 689 ms | 11,870 ms | 3 |
| 22:07-22:14, route timed step by step | classic | 2,000 | 395 ms | 677 ms | 1,649 ms | 0 |
| 22:15-22:32, beside 12 gameplay tests | platform | 2,397 | 978 ms | 1,856 ms | no answer in 60 s | 1 |
Four of 5,297 took longer than 5 seconds. Every join that answered, answered 200. The platform run shared the machine with 12 gameplay tests, so its p50 is not comparable with the classic rows; its one failure is the join that got no answer at all in 60 s.
Figure 1. Every timed join from the six runs on 2026-09-26 (UTC), n = 5,297, both shells together. The four red bars are the only joins over 5 s.
The three slow classic joins took 5,093 ms, 11,870 ms and 11,453 ms. They came from three different workers, and they were sent within 26 seconds of each other. In each, the time to the first byte of the answer was about 99% of the total, so the wait was on the server, not in the browser.
Where the time went
A join is about six database round trips in a row: read the room, look for an existing seat, write the seat, and so on. From my laptop each one took roughly 60 to 70 ms. So I added temporary timing to the join route and logged how long each step had taken.
Every slow join spent its time in one read. At 22:18:37Z a single room read took 11,446 ms. At 22:20:52Z three separate joins each stalled about 61 s, each in a single read, in the same second. In the same episode, around 22:21Z, three room creations failed to reach their room within 20 s. The slow joins came in short episodes that crossed workers, not as a steady tail.
The shapes are suspicious: about 10 s plus 1 s, and about 60 s plus 1 s. Our database client, postgrest-js (2.109.0 in our tree), retries a GET that failed with a network error, and waits 1 s before the first retry. A read whose connection failed at about 10 s, waited 1 s, and then succeeded quickly would produce exactly these totals.
That is likely, not proven. A retried request carries an X-Retry-Count header, and I did not record it. I also measured only DEV, from one laptop; I have no numbers for production joins.
Why the test failed
The lobby moves to the room only after the join's POST answers. The test pressed Join and then asserted the room's URL with Playwright's default 5-second timeout. So 5 s was a budget for six sequential database round trips, and one stalled read spent it.
Before changing the test, I checked that this really was the failure. With the join held for 6 s by route interception, the old three lines failed 5 runs of 5, stuck on /games/trivia/play with "Timeout: 5000ms", which is the original symptom. The new helper passed 5 of 5.
The new helper, joinRoomThrough, waits on the join's own POST with a 20 s budget. When it fails, it says which of three things happened:
- no answer within 20 s, and the page said nothing;
- a refusal, with its status and body;
- an answer of 200, and the page still did not move to the room.
It also presses again when the page says the join is taking longer, as a player would, up to three presses. A failure that names its cause is one you can act on. The old one said only that the URL was still wrong after 5,000 ms.
A limit for players, not only for tests
The test was fixed, but the player's problem was still there. A join could sit on "Joining..." for up to about a minute, because nothing put a deadline on the request.
I chose a 10-second limit with a manual retry, at about 22:45Z. After 10 s the page says "This is taking longer than usual. Try again." and the button works again. Nothing retries by itself. The classic p99 was about 0.7 s, so an ordinary join never meets the limit; only the stalls do.
Every join path now goes through one helper, lib/room-join-request.ts: the lobby's code box, the Live tab's code box, the classic /rooms directory, and the room page's own join for shared links. The deadline is a race, not a cancellation:
const deadline = new Promise<JoinOutcome>((resolve) => {
timer = setTimeout(() => resolve({ kind: "slow" }), deadlineMs);
});
const outcome = Promise.race([send(code, identity), deadline]);
There is no AbortController in that file, on purpose.
The request we must never cancel
Think of a coat check. You hand over your coat, and the attendant hands back a ticket. If you walk away before the ticket reaches you, your coat is still on the rail, but you can no longer prove it is yours.
A seat in a Hearso room works the same way. It belongs to a browser through an httpOnly cookie, a cookie the page's own code cannot read or set, and only the join's answer sets it. A stalled join usually still finishes on the server: the slow read comes back and the seat is written. If the page aborts the request, the browser throws that answer away, cookie included.
We did not assume this. A temporary browser test let the server finish a join, then removed the cookie before the page saw the answer, which is what an abort does. Then it pressed Join again. In 3 runs of 3, the first join got 200 on the server, the retry got 401 "Rejoin this room from the browser you joined with", and the host's roster read "2 joined". The player held a seat and was locked out of it.
Figure 2. What cancelling would do, reproduced 3 times out of 3 on 2026-09-26 at 23:22Z with a temporary browser test against DEV.
So after 10 s the page stops listening, not the request. When the late answer lands, the browser still stores the cookie, and the next press is recognised as the seat's owner. The late answer itself changes nothing on screen.
Why a retry cannot seat anyone twice
A retry sounds like a way to create duplicates. It cannot here, and the reason is in the schema, not in the page: room_players has PRIMARY KEY (room_code, player_id).
The route writes a new seat with a plain INSERT, not an upsert. If two requests race, the key lets exactly one create the row and receive the cookie. The loser gets Postgres error 23505, which the route turns into the same 401. Route tests pin both halves for a guest and for a signed-in player: without the cookie a retry is refused, and with it the retry is admitted to the same row, never a second one.
When to decide what a 401 means
One case was left. A retry sent while the first join is still unanswered carries no cookie. If the first request has already written the seat, the route answers the retry 401.
That 401 does not mean "someone else holds this seat". It means "your own earlier attempt does". The page should say "still slow", and the next press gets in.
My first version decided this when the 401 arrived: is an earlier attempt still unanswered right now? An independent local review, before I opened the pull request, found the ordering that breaks it. The earlier answer can land first: its 200 and cookie at 11.0 s, then the retry's 401 at 11.2 s. By then nothing is unanswered, so the 401 read as a refusal. On the room page that meant a fatal "Rejoin" screen over a seat the browser now held.
Figure 3. The two answers can cross. With the answer-time rule, a browser test rendered the fatal screen in 2 runs of 2; the send-time rule keeps the room.
What makes the 401 harmless is what was in flight when the retry left, so that is what the helper now records:
// Whether an earlier attempt for this room was still unanswered as this one left.
const earlierAtSend = count(code) > 0;
unanswered.set(code, count(code) + 1);
// ...later, when this request's answer arrives:
const earlierStillOut = earlierAtSend || count(code) > 1;
release(code);
if (response.ok) return { kind: "joined" };
if (response.status === 401 && earlierStillOut) return { kind: "slow" };
It stays bounded. A press sent with nothing earlier in flight still gets its 401 as the refusal it is. The browser test that reproduces the reviewer's order is now part of e2e/join-deadline.spec.ts.
An older bug the review found
The automated review on the pull request found one more issue, and it was real. On the room page, a join that failed with a 5xx or no connection was still marked settled. The next poll's 401 then read as "this browser never joined", and the page showed "This room does not exist (or has been cleaned up)." for good.
This bug predated the branch. A newcomer arriving from a shared link whose join failed was told the room did not exist. Now only a successful join or a 4xx refusal settles the join. Anything else says "We could not reach the room just now. Try again." with the same button.
What we left alone on purpose
Room creation has the same exposure: three creations stalled past 20 s in the same episode. It does not get a Try again. A retry there could open a second room, which is not the same kind of mistake as a slow join. The tests' createRoom now waits on the create's own answer and names its failures, but the page is unchanged.
What I would do next
I would record the retry header from the first run. The postgrest-js explanation fits every shape I saw, and I still cannot say it is true.
Next, the same timing against production joins, which I have never measured. A server-side time limit on these reads was the third option I considered; we did not build it. And room creation needs its own answer to "what does the player see while it is slow" that cannot create a second room.
Key takeaways
- Time the thing before you fix the test. "Flaky" was four joins in 5,297, all of them slow, none broken.
- A client deadline can stop waiting without cancelling. If the answer carries state, such as a cookie, a cancelled request throws that state away.
- Let the database make duplicates impossible. A primary key made "try again" safe; the page did not have to.
- Decide what an answer means from what was true when the request was sent. When two answers can cross, the state at arrival time is a guess.
- Reproduce the failure first, then the fix. The old test failed 5 of 5 on a 6 s delay, the lockout 3 of 3, the answer-time rule 2 of 2.
Evidence
All times UTC.
- The first failure and its cause: overnight handoff, 2026-09-26, entry 22:35Z; commit
6eb82928(the old test failed 5/5 on a 6 s delay, "Timeout: 5000ms"; the helper passed 5/5). - The 5,297 joins: six join-timing test logs, 2026-09-26 21:58Z-22:32Z, one line per join with its status, time to answer and time to first byte. Totals: 5,296 answered, all 200, plus one "Timeout 60000ms exceeded" with no answer. Percentiles are nearest-rank over each run's own answers.
- The three slow classic joins: 5,093 ms (first byte 5,038 ms), 11,870 ms (11,822 ms) and 11,453 ms (11,403 ms), sent at 22:04:20.8Z, 22:04:34.1Z and 22:04:46.8Z from workers 0, 4 and 1.
- The one slow read: server logs of the temporarily instrumented join route; room read 11,446 ms at 22:18:37Z; three joins at 22:20:52Z stalled 61,460 ms, 61,433 ms and 61,416 ms in one read each.
- Three stalled creations: platform run log, three "toHaveURL" failures around 22:21Z; the helper of that time waited 15 s plus a default 5 s.
- postgrest-js retry:
node_modules/@supabase/postgrest-js2.109.0,dist/index.mjs(retries GET, HEAD and OPTIONS after a network error; first delay 1 s; setsX-Retry-Count). Not recorded on the stalled requests. - The 10 s limit: overnight handoff, entry 22:35Z (decision at about 22:45Z);
lib/room-join-request.ts(JOIN_ANSWER_DEADLINE_MS = 10_000). - The lockout, 3/3: temporary lockout test, 2026-09-26 23:22Z; commit
2a87544a. - No duplicate seat:
supabase/migrations/20260101000000_base_schema.sql(room_players_pkey);app/api/rooms/[code]/join/route.ts(INSERT,23505to 401); route tests in PR #491. - Send time, not answer time, 2/2: commit
3ee3524d; the browser test in commit4f206a84. - The older room-page bug: review thread on PR #491 (2026-09-27, about 01:10Z); fix in commit
529cc41a. - Merged: PR #491 into the beta.9 release branch, 2026-09-27T01:36:15Z.
Nothing above contains a credential, a token or a player's identity. The room codes in the raw logs belong to test rooms and are not reproduced here.