WizusLabs Engineering · Craft

The browser tells the truth: a Back-button bug we almost fixed wrong

We had a clean, framework-deep theory for a dead Back button on our web game, a fix designed and QA’d against it, and enough confidence to ship. Then we opened an actual browser — and the theory was wrong. The real cause was three characters in a link.

By WizusLabs Engineering · 2026-07-14 · ~7 min read

A bug report landed that we knew how to fix before we had finished reading it — and that confidence was the actual problem. On our Iron Swarm web build, the browser’s Back button did nothing after you launched the game from the site, and we had a mental model of Flutter web’s history handling precise enough to explain exactly why. We designed a fix against that model. We QA’d it. Every step was coherent, technically literate, and pointed at the wrong cause. Then we did the one thing we should have done first — drove a real browser through the exact steps a player takes — and the real bug turned out to be three characters in a hyperlink. This is a short devlog about the distance between a diagnosis that sounds right and one that has been reproduced, and why, for anything that runs in a browser, you close that distance with the cheapest tool on the bench: an actual browser.

The bug we understood too well

The symptom was clean. Open the site, click through to Iron Swarm, launch the web build, and press the browser’s Back button expecting to land on the page you came from. Nothing happens. The URL does not move; the game just sits there. For a game we ship to the browser precisely so it can be tried without an install, a dead Back button is not cosmetic — it is the exit the player reaches for first, and it is jammed.

Here is where a little framework knowledge turned dangerous. Flutter web has two browser-history modes, and it chooses between them based on how the app’s first frame is built. Mount a classic MaterialApp(home:) — Navigator 1.0 — and the framework calls selectSingleEntryHistory(): it keeps a single entry in the browser’s history and installs what is effectively a trap, so a Back gesture is delivered to the Flutter framework as an internal pop instead of navigating the browser away from the page. Mount a MaterialApp.router instead and it calls selectMultiEntryHistory(), where each route is a real browser history entry and Back behaves the way the browser’s own Back button normally does. Our splash mounted the classic form first. So: single-entry mode, history trap installed, Back swallowed. The theory wrote itself.

Why the theory was so easy to believe

A wrong theory that survives is never obviously wrong; it is plausible, specific, and flattering, and this one was all three. It was plausible because it predicted the exact symptom — a Back button that does nothing is precisely what a history trap produces. It was specific because it named real framework machinery with real method calls, the kind of detail that feels like understanding. And it was flattering, which is the part worth being honest about: knowing an obscure corner of how Flutter web manages history feels like expertise, and a fix that lets you deploy that expertise is more satisfying than one that doesn’t. So we wrote a change to make the splash router-based from the first frame, landing the app in multi-entry mode immediately. It was a real change addressing a real mechanism, and we put it through QA. The one thing we never did was watch the actual failure happen.

Open the browser

So before shipping, we opened one — or rather, we scripted one. We drove a real headless Chromium through Puppeteer along the exact path a player takes: load the site, find the link that launches the web game, click it, then press Back and record what the browser actually did. Not a unit test of our routing logic in isolation — the whole flow, in a real engine, end to end.

The result did not match the theory at all. The link that launched the game carried target="_blank" — it opened the game in a brand-new browser tab. And a fresh tab has no history to go back through: it was created for this page, so there is no previous entry, and Back has nowhere to go. That was the entire bug. Nothing to do with single-entry mode, nothing to do with Navigator 1.0, nothing our splash was doing. The player’s Back button was dead because the player was standing in a new tab that had never been anywhere else.

The part that should have ended the argument earlier: we ran the same script against the candidate ‘fixed’ build — the router-based splash we were about to ship — and it reproduced identically. The Back button was just as dead. Our fix changed the framework’s history mode exactly as designed and changed nothing the player would ever see, because the player’s problem was never in the history mode. When we forced the same link to open in the same tab, Back returned to the referring page every single time, on both builds. The variable that mattered was the link target. The variable we had spent all our confidence on was a spectator.

Two link behaviours and where Back goes A schematic comparing two link behaviours. A ‘Play on web’ link branches two ways. Opening the game in the same tab loads it in place, and the browser Back button returns to the site. Opening it in a new tab with target=_blank creates a fresh tab with empty history, so Back has nowhere to go. ‘Play on web’ link Same tab no target=_blank Game loads in place Back returns to the site New tab target=_blank Fresh tab empty history Back goes nowhere
The same link, two behaviours. Open the game in the same tab and Back walks back to the site; open it in a new tab and Back has no previous page to return to. The bug lived in the link, not the framework. Schematic, not measured data.

The theory wasn’t wrong — it answered a different question

Here is the subtle part, and the reason this is worth writing down rather than laughing off. The single-entry history trap is real. Flutter web genuinely does install it, Back genuinely is swallowed by the framework in that mode, and our splash genuinely did mount the classic form. We were not wrong about the mechanism. We were wrong about which question it answered. That trap applied only during the roughly 600-millisecond splash before the game’s own router took over — a window a player almost never tries to Back out of — and it had nothing to do with the complaint, which was about a new tab that stayed dead long after the splash was gone. Being right about a mechanism that isn’t the one biting you is one of the most common ways an experienced engineer ships the wrong fix. The more you know, the more convincing the plausible-but-irrelevant story you can tell yourself — and expertise, left unchecked against reproduction, mostly just makes that story better written.

The fix that shipped

The real fix was embarrassingly small, which is the usual shape of these things. One change per link: remove target="_blank" so ‘Play on web’ opens the game in the same tab. Now the game replaces the page it launched from, that page becomes the previous history entry, and Back walks straight back to it — the behaviour the player expected all along. While we were in there with a browser actually open, we caught a second bug the theory would never have surfaced: a ‘Read more’ link that pointed at the game itself rather than the app’s detail page, so a reader looking for more information got dropped into a tank battle. Two site-side, one-line-each corrections. No framework change shipped, because none was needed — and the code fix we had so carefully designed went in the bin, which is exactly where a fix for a cause that doesn’t exist belongs.

The honest close: reproduce before you theorise

The lesson is not ‘we made a silly mistake’ — the theory was good engineering aimed one step too early. The lesson is about order of operations. A passing unit test tells you a unit behaves as written; a confident mental model tells you how you believe the system works. Neither tells you what the user actually experiences at runtime, and for anything that touches a browser — history, tabs, focus, navigation, the back-forward cache — the runtime is full of behaviour no unit test in your codebase will ever exercise, because that behaviour lives in the browser, not in your code. So reproduce first. Drive the real engine through the real steps and watch the real failure before you design a single line of the fix. It is the same discipline we hold everything to — a green test is necessary, never sufficient, and for timing, focus, and navigation bugs the only real proof is the thing happening in front of you. It is also, conveniently, the cheapest tool on the bench: opening a browser costs a few minutes, and here a few minutes of Puppeteer beat a genuinely sophisticated theory outright. We keep a running account of how we build and where it breaks on the rest of the WizusLabs Engineering blog — how these games reach the browser in the first place in shipping web games in 2026, and the QA gate that is meant to catch exactly this class of thing (and the kinds it structurally cannot) in an AI QA gate for games. Or you can just open the game in a browser and press Back; it goes where you would expect now.

Notes

This is a first-hand devlog about a bug we diagnosed and fixed on our own site and web build. It contains no performance metrics or benchmarks: the point of the piece is a debugging discipline, not a measurement, and the only quantity mentioned — the roughly 600-millisecond splash before our router takes over — is an approximate description of our own build, not a claim about anything else. The behaviours described are our own: the wrong first theory, the Puppeteer run that reproduced the real cause identically on both the live and the candidate build, and the one-line-per-link fix that shipped. Iron Swarm is a game this studio builds; it is free and ad-supported, with a single optional Pro purchase that removes ads. The framework and browser mechanisms named above are documented in the references below.

Sources

  • Flutter API Documentation — SystemNavigator (selectSingleEntryHistory() vs selectMultiEntryHistory() — the single- and multi-entry browser-history modes Flutter web selects).
  • MDN Web Docs — The <a> element — target attribute (how target="_blank" opens a link in a new browsing context / tab).
  • MDN Web Docs — History API (session history is per browsing context, so a newly opened tab starts with no previous entry to go back to).
  • Puppeteer — Puppeteer documentation (the headless-Chromium automation we used to drive the real user flow end to end).

These references support only the framework and browser mechanisms named in the post; the diagnosis, the reproduction, and the fix are our own first-hand account.

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog