← All Posts

September 8, 2026

Rebuilding the Twitch Flow

By

45 people reached the submit page. 6 submitted. No match ever played. Nothing was broken server-side, which made it entirely my problem.

Rebuilding the Twitch Flow card from Aux Battles, showing 45 reached the page, 6 submitted, 0 matches played
Rebuilding the Twitch Flow

On 2 September a streamer ran a bracket tournament for his chat. 35 people opened the link he posted within about 100 seconds. 45 reached the submit page, across 105 pageviews, so a lot of them reloaded it hoping something would change. 24 finished logging in with Twitch. 10 got as far as pasting a song and running the lookup. 6 submitted. Not one match ever played.

The part I keep coming back to is that nothing was broken. There is no 5xx in the Supabase edge log across the entire two hours. One failed track lookup and one mistyped room code are the only errors recorded anywhere. Every write that was attempted succeeded. The bracket logic, the part I had actually worried about, was never even reached.

That is the uncomfortable version of a bad night. The software did exactly what it was told. What it was told to do lost the room in about four minutes, and reading the rows back afterwards it is obvious that every one of those four minutes was a decision I made months earlier.

The two dead minutes

The audience arrived two full minutes before the submission window opened. 35 people landed inside 100 seconds of the first link going out, and the door opened at the end of the second minute. Around 22 of them were already signed in and ready, looking at a page telling them the bracket had not opened yet.

Those two minutes went on the prompt wheel. Three spins, two rerolls, then a prompt the audience openly disliked the moment it landed. So the wheel cost him twice: once for the wait, and again for the answer it gave. PostHog recorded 23 dead clicks from 15 different people on that waiting page in the same window, including dead swipes on the waiting message itself. A dead click is someone poking at a screen that has stopped explaining itself.

The wheel is a good feature. Putting it between an arriving audience and the thing they came to do was the mistake, and it was mine, not his.

The only link on screen was the wrong one

He posted his own address bar. Twice. The actual viewer link went out three minutes after the first one, a minute after the window had already opened, by which point chat had turned.

So all 35 people arrived at the host's lobby URL. That route told them they could not join with a room code and offered a manual Continue button that chained through two more redirects. On a desktop that reads as one extra click. On a phone it reads as being thrown out, which is precisely what chat said: "it says im in it but also not in it", and "It just kicked me from it".

Here is the part that is not a user error. The setup screen had no share affordance on it at all. No copy button, no viewer link, nothing but a small room-code pill in the header. The copy-link button existed, but only on a dashboard he would not reach until the window was already open. Through the entire period in which he was actually trying to send people somewhere, his address bar was the only thing on screen to copy. He used the only door I left open.

And he could not see any of it happening

35 people were sitting on his link while he was rerolling prompts, and nothing anywhere on his screen mentioned them. The three counters that did exist counted songs in, people submitting and duplicate picks. All three count people who already got through. None of them counted the crowd waiting on a door that had not opened.

That is the number that would have told him to stop spinning and press the button, and it was the one number the app did not have.

What actually changed

The waiting page now tells the truth and lets you get ready. Instead of a grey line saying the bracket has not opened, it names the streamer, says the prompts land on the wheel as soon as the battle opens, and invites you to log in with Twitch while it spins so you are ready to paste the second the door opens. The two dead minutes are now the two minutes in which an audience authenticates. That card had actually been written before the stream, but it sat after an early return that bracket rooms took, so bracket viewers never saw it. It was dead code for the exact mode the incident happened in.

The link and the crowd are both on the setup screen now. There is a Send chat the link block with a copy button, and next to it a live count: three people are already on it, waiting for you to open submissions. Both are on screen while the host is still choosing, which is the moment they matter.

The URL stops moving. Picking a prompt and collecting songs used to be two pages, so pressing Open submissions visibly threw the host onto a different screen mid-stream and tore down the wheel he had just spun on camera. Those phases now share one screen and, more importantly, one mounted component, so pressing the button changes exactly two things: the button's label, and a paste box appearing. The address bar does not move, because the address bar is the one thing a streamer cannot hide from an audience. It also stopped naming the phase and started naming the battle, so a URL read off a stream says bracket or chat or friends rather than whichever row of the state machine the host happens to be on.

The room is not created until you press go. Setup used to run against a live room row, so a half-configured room was on the air from the first click and every abandoned setup left a row behind. The host now fills in a draft and the room is inserted once, at the end, carrying the whole decision in a single write. Nothing about the room is ever observed in a state the host did not choose.

Setup is remembered between streams, per format. A bracket setup and a chat-battle setup are different setups, so they are stored separately: offering someone their bracket settings for a chat battle is worse than offering nothing. Running the same 32-entry tournament every week is now one button.

Friends battles got an actual door. The room code goes out on stream while the host is still choosing a format, so there is now an explicit open-the-room step, and anyone who arrives early gets an honest "not seating yet" screen that puts them in the queue automatically the moment the host opens up, rather than stranding them in a queue nobody was rendering yet.

The bugs underneath all that

Rebuilding the flow surfaced a much worse class of bug that had been quietly running the whole time. Postgres stores an oversized column out of line once a row gets big enough, and an update that does not touch such a column sends no value for it in the replication stream. The tournament is one 3 to 5KB column, so it was always out of line, and the writes that move a bracket along never touch it. Every client therefore lost the entire tournament the instant the streamer picked the next round's prompt, and stayed that way until a poll happened to do a real read a minute later.

On stream that looked like a resubmission window with no survivors in it, and a Start button that silently did nothing. The classic battle had the same wound in a different place: the song queue vanished on every song change, which is the Analyzing Vibes stall. Worst of all, one page inferred which round it was from whether the tournament happened to be loaded, and that inference is what let two knocked-out viewers submit into round 2 of a real stream. A missing column now means unchanged rather than gone, and the page that decides who is allowed in reads the round number and fails closed if the tree is missing.

Two smaller ones in the same family. Four different things could overwrite room state on their own schedule, and nothing stopped an older fetch landing after a newer one and rewinding every screen by one step. And because a realtime channel name is a shared key rather than a handle, two components on the same room could collide on one and throw, which from inside a React effect means the error boundary, mid-stream. Both now have a single guarded path in.

What is still not fixed

Pasting a link is still the default thing a viewer is asked to do. "I'm not doing auxbattles I'm not copying links" came from someone who had already authenticated successfully, which makes it the most expensive sentence in the whole log: he did the hard part and quit at the easy one. Submitting straight from chat exists and is now one visible toggle rather than something buried in a modal, but a room created with defaults still asks for a pasted link, and on a phone that is a real ask.

The host also still has to press a button to open submissions. The better answer is probably that a bracket's first round is open from the moment the room exists, because the prompt only has to be decided before the first match plays, not before anyone can paste anything. That one is still on the list.

None of the above is the bracket logic, which has been solid since DaddyP's tournament in August. What lost that room was the four minutes before a single song played, and almost none of it was code that was wrong. It was code that was right about the wrong thing.