We shipped a barcode scanner in a word game

For fifteen builds our Android app carried a barcode reader, two unused font families and a GraphQL client. We had written the line that was meant to keep them out.

· The Wordmere team · 5 min read

Our download was 77 MB. It is now 47 MB, and nothing was removed that any player could see.

About five of those megabytes were a development tool that should never have been in a store build at all. It had been there for fifteen consecutive releases. We had written the configuration line that excludes it, we could point at that line, and the line did nothing whatsoever.

What was actually in the download

The thing shipping was Expo's dev launcher — the in-app menu you use while developing, which has no business in a release. It does not travel light. It brought Jetpack Compose, an Apollo GraphQL client, two font families, and Google's ML Kit barcode scanning models.

It also registered an initialiser that ran at every launch. So every player opening a word game was, briefly, starting up a barcode reader.

I want to be clear that this is not an exotic failure. We had done the documented thing. There is an exclude list in the Gradle configuration, the dev launcher was on it, and we had shipped fifteen builds in the comfortable belief that this was handled.

The exclude line that did nothing

Expo's Gradle plugin passes that exclude list to a command-line tool. The helper it uses to build the command takes a key and a list of values, and it joins the list into one argument.

So a list of four package names arrived at the CLI as a single package name with spaces in it. Nothing matches a package called "a b c d", so nothing was excluded, and no error was raised — an exclude list that matches nothing is indistinguishable from an exclude list that had nothing to do.

The fix is four lines in a patch against that package: split on whitespace. We spent considerably longer being sure that was really the cause than fixing it, because the first three theories were wrong. It looked like a Gradle ordering problem, then a stale daemon, then a build-variant mix-up. It was none of those.

A configuration that silently matches nothing looks exactly like a configuration that is working. That is the whole problem with exclusions.

Why our build script never caught it

We did have a check. The release script grepped the compiled dex for the dev launcher's class names, found nothing, and reported the build clean.

It found nothing because R8 had renamed those classes. Our minifier was hiding the thing our check was looking for, every single time, and the check reported success for fifteen builds.

What works is grepping the built artefact for strings that survive minification — the barcode library's native name, a bundled font file name, the initialiser's manifest entry. Those are resources and manifest entries, not classes, so nothing renames them. The release script now hard-fails on any of them, which means this particular mistake cannot ship again even if the exclude breaks a second time.

The other twenty-five megabytes

The dev launcher was the embarrassing find, not the biggest one.

Our world-card artwork shipped four image layers for each of ninety-five worlds, and the component only ever draws one of them. The other three had been designed for a parallax effect that was cut long before launch. That was the single largest item on the list.

Then the fonts. A theme file imported from a font package's top-level barrel, which pulls every weight of every family in it. Fifty-five font files shipped; nine were used.

On iOS the JavaScript bundle carried nearly five megabytes of Hermes debug information, because the build was not told where to write a source map, so it kept the debug data inline instead. And a handful of images were shipping at 1024 px to be drawn at 72 pt.

None of these is clever. They are all the same shape: something was configured once, the reason for it went away, and nothing in the pipeline measures the result.

What we did not take out

We left an ad network in place that costs a couple of megabytes and earns a small single-digit share of revenue on one platform. On paper it is the easiest remaining win. In practice removing a bidder changes the auction for the bidders that stay, and we were not confident enough about the second-order effect to trade it for two megabytes.

We also left a set of unused social-SDK components alone. The patch to strip them was written and then parked, because the gain was about a megabyte and the change touched the login and sharing paths, which are exactly the paths you do not want to debug remotely after a release.

Thirty megabytes was enough. The last five would have cost more than they were worth.

Is it worth doing at all?

Google's own rough guidance is that install conversion moves about a percentage point for every six megabytes. If that holds for us, thirty megabytes is a meaningful change to how many people who tap install actually get the game. We have not been able to measure it cleanly, because the size change shipped in a build that also changed other things, which is a mistake we keep making and keep writing down.

What I am confident about is narrower and duller: a word game should not contain a barcode scanner, and we could not have told you it did.

Grep the artefact, not the config

Every check we had was reading our intentions. The exclude list said the right thing. The build script looked for the right class. Both were describing what we meant rather than what came out.

Unzip the thing you are about to upload and look inside it. Search for strings that minification cannot touch. If you cannot explain why a file is in there, that is the finding — you do not need a size budget or a tool, just the willingness to open the box.