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.
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()vsselectMultiEntryHistory()— the single- and multi-entry browser-history modes Flutter web selects). - MDN Web Docs —
The
<a>element —targetattribute (howtarget="_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.