The login bug that only hit people who'd already logged in before
A report came in that didn't make sense on first read: someone with a fully set-up PlayTab account — sport picked, profile complete, weeks of sessions behind them — logged in and landed back on the sport-selection screen, like the app had forgotten who they were. Not an error, not a stuck spinner. A perfectly normal-looking screen, just the wrong one, for an account that had already finished it once.
The confusing part was that their data was fine. The profile really was sitting in the database, complete, untouched. The app just quietly decided to act as if it wasn't there — for that one login, not every time, which is the detail that took the longest to make sense of.
The setup
Auth state in PlayTab lives in one place — a context that every page reads through the same hook, never re-fetched per component. On any auth event, it does two things: record who the user is, and go fetch their profile. A loading flag exists so the rest of the app knows not to make a decision — like "does this account need onboarding" — before that fetch has actually come back.
Why it only hit some logins
Supabase's onAuthStateChange doesn't fire once per login. It fires an initial event while the app is still idle on the login screen, with no session yet — that one sets loading to false, correctly, since there's nothing to wait for. Then a second, real event fires once credentials go through.
Here's the gap: that second event sets the user immediately, synchronously, and then kicks off the async profile fetch. For one version of this code, nothing re-armed loading back to true when that second event fired — it was still sitting at false from the first, idle event. Which meant a route guard checking "does this signed-in user need onboarding?" could run in the sliver of time after the user was set but before their profile had actually loaded, see a signed-in account paired with no-profile-yet, and permanently redirect to sport selection. Permanently, because that redirect doesn't just show a screen — it replaces the current one, so there's no back button walking out of it.
Whether you hit that gap came down to raw timing — how fast the profile request happened to come back relative to how fast the route guard happened to check. Most of the time, the fetch wins comfortably and nobody ever sees the gap. The person who reported this hit it consistently enough to think it was Safari-specific, since that's the browser they always used. It wasn't, as it turned out — Safari didn't do anything Chrome couldn't also do, it just happened to land in that timing window more often on their particular connection.
Why my own testing never caught it
Every existing test in the suite loaded a cached, already-authenticated session and skipped the login form entirely — the normal, sensible thing to do when you don't want to re-type credentials in forty separate tests. That also means none of them ever generated the actual event sequence this bug depends on, because that sequence only happens during a genuinely cold login. An already-authenticated session doesn't replay it.
Confirming this meant deliberately doing the slower thing: a fresh browser context with no stored session, driven through the real login form, and repeated in two different browser engines back to back. It reproduced in both, not just the one the original report blamed — which is what actually ruled out "Safari does something weird" and pointed at plain timing instead.
The fix, and the broader shape of it
The fix is one line: re-arm loading back to true at the start of every profile fetch triggered by a session change, not just once when the component first mounts. Once that gap can't exist, there's nothing left for the race to land in.
The instinct worth keeping isn't "remember this specific flag." It's this: any flag that's supposed to mean "we're not ready to decide yet" has to be re-armed every single time the thing that makes you not-ready happens again — not just the first time. A flag that only resets once quietly stops doing its job the second time the same event fires, and nothing about the code will tell you that until the timing lines up wrong for some real person, at some real moment, that you weren't in the room for.
The takeaway
A bug report that sounds like "my account got reset" is often not about the account at all — it's a timing window that resolves correctly often enough that a warm, already-logged-in test will never walk through it. The only way to actually see it is to go through a cold login yourself and watch what happens in the gap, not to inspect the app after it's already settled.
Next: the story behind Split, PlayTab's own bill-splitting feature — built for exactly the kind of group chat argument this app already existed to avoid — and the small database mistake that outlived three other tables getting it right.
More in this series

Why I built MyPlayTab
A scoring artifact I lost by clicking back turned into the design question this whole app answers.

Who gets to score the match? Keeping every court in sync on PlayTab
Multiple courts, multiple phones, one score that's always right — and the bug that taught me an "obviously unique" sort wasn't.

One rule, forty AI sessions: how PlayTab stayed one app instead of several
A header color got reverted because a technically-correct fix looked worse. That's the point, not a mistake.

Building the bill-splitter I actually needed for a padel group
Two apps in one, so nobody opens Splitwise after a session. What got built in on purpose — and the one bug three sibling tables had already fixed.