XenneX/LLC
All posts
By Buddy Delaune

The Fake iPhone That Broke the Real Play Button

A small change meant to help me test iOS features in Chrome broke the Play button in Game Night. Two bugs, one rule: analytics should never block the app.

#debugging#game night#engineering#war story
A carved wooden toy telephone beside a real smartphone with a dark blank screen on a worn workbench, with a small screwdriver, a coil of copper wire and a brass desk lamp

In September I made a small change to Game Night so I could test iOS-only features without picking up an iPhone. Half an hour later the Play button stopped working. Not on any real phone, just in my browser, in the exact setup I'd made the change for.

It turned out to be two bugs stacked on top of each other, and the fix came with a rule I now apply to every app I ship.

The Change

Game Night has a handful of remote flags and UI branches that only apply on iOS. Testing them meant building to a simulator or a real device every time, which is slow when all you want to check is whether a flag flips a screen.

Chrome's device toolbar can pretend to be an iPhone. When it does, Ionic's own platform check already reports iOS, even though there's no native bridge at all. My device service just wasn't asking. So I added that check: if there's no real native platform, fall back to what Ionic thinks the device is. A real build still wins when one is present. I wrote tests for both cases, and they passed.

That was v1.2.40.

What Broke

I opened staging in Chrome, emulated an iPhone, set up players, and hit Play. Nothing happened. No navigation, no error on screen. The console said "Firebase analytics is not initialized."

The first bug was in how Firebase starts. On native builds, Firebase configures itself from the config files baked into the app. In a browser, you have to call the web SDK's initialize function yourself. My code decided which path to take by asking the device service, "are we in a browser?"

A week earlier, that exact check had been the subject of a different fix. Analytics had been quietly skipping initialization on native builds, so purchase events never fired. I'd fixed that, and the browser check was part of the fix.

Now I'd changed what "browser" meant. While Chrome was pretending to be an iPhone, the device service said iOS, so the browser check said no, so the web SDK never got initialized. In the one environment where it needed to run, it didn't.

Why It Broke the Button

A Firebase error shouldn't stop someone from playing a game. The second bug is why it did.

The Play button runs a function that saves settings, starts a game session, logs a "play" event, and then navigates to the game screen. The logging step is awaited. Inside the logging method, the call to Firebase was wrapped in a try/catch, but it was returned without being awaited:

try {
  return FirebaseAnalytics.logEvent({ name, params });
} catch (e) {
  console.error(e);
}

That looks safe, and it isn't. Returning a promise from inside a try block doesn't wait for it. The try/catch finishes immediately, and when the promise rejects a moment later, there's nothing left to catch it. The rejection goes back up to the Play function, which was awaiting it, and the rest of the function never runs. No navigation.

The fix is one word:

return await FirebaseAnalytics.logEvent({ name, params });

With the await inside the try, the rejection happens while the catch is still listening, and the error gets logged instead of thrown.

The Two Fixes

For the first bug, I stopped asking "is this a browser?" and started asking the question I actually meant: "is there a native bridge?" That answer doesn't change when Chrome pretends to be a phone. Emulating an iPhone still has no bridge, so the web SDK still gets initialized. A real device has one, so it doesn't.

For the second, I added the await and a test that runs the emulated-iOS case end to end: no native bridge, platform reporting iOS, init, then a play event.

Both went out as v1.2.41, the same day. Neither version reached a store build. Real phones were never affected, because they always have a native bridge and never hit the browser path.

The Rule

The interesting part isn't the missing await. Everyone writes that bug eventually. The part I keep thinking about is that an analytics call was sitting in the middle of the most important button in the app, and it was allowed to stop it.

Analytics is something I want, not something the app needs. If it fails, I lose a data point. If it takes gameplay down with it, I lose a player. So the rule now is that analytics never gets to throw. The logging method catches its own errors, and nothing on a critical path should depend on a log call succeeding.

I wrote in lessons from Game Night that I wish I'd shipped analytics from day one. That's still true. I just didn't expect the bigger risk to be analytics breaking the app, not missing data.

The other lesson is about meaning. "Browser" had been doing two jobs in that codebase: "what platform should the UI act like" and "is there a native bridge." They used to always agree. The day I made them disagree, the code that only cared about the second one broke. When a flag starts meaning something new, it's worth checking who else was reading it.

If you've got an app where a small change keeps breaking something far away, I build and maintain mobile apps and I'm happy to take a look.

Working on something like this? See how XenneX works with clients or what XenneX has built.

Keep reading