The Great Performance Overhaul: Cutting 300+ Megabytes and Fixing a Hidden Concurrency Ceiling
288MB of images, 160MB of audio, a 6.9MB autoplaying video, and a Cloud Run instance that could only handle one request at a time. Here's the full breakdown of what got fixed, what got found along the way, and what got deliberately left alone.
For a while now, Minnich.Awesome has been quietly getting heavier. Every new game shipped with its own sprite sheets, backgrounds, and soundtrack. Every reskin added a few more megabytes. Nobody was watching the total, because nobody had to—until I finally ran du -sh on the static folder and the number that came back made me sit up straight. This post is the full story of the performance pass that followed: what I found, what I fixed, the bug I stumbled into along the way, and a couple of things I deliberately chose not to change.
The number that started it all
static/images/ was 288 megabytes. Not the whole site—just the images. Digging in, a huge chunk of that turned out to be a single folder: twenty background pieces for Alchemy Drop, sitting at 99 megabytes by themselves, some individual PNGs pushing 6–7MB each. Alongside that, static/audio/ was another 160 megabytes of MP3s, and the homepage's autoplaying hero video was a 6.9MB clip that every single visitor downloaded in full before they'd even decided to stick around.
It's not really a mistake so much as a natural side effect of prioritizing shipping games over auditing file formats every step of the way. But it meant every page load, especially game pages loading dozens of sprites at once, was carrying far more weight than it needed to.
Converting 1,527 images to WebP, without breaking a single one
The plan was straightforward in theory: convert every PNG and JPEG to WebP, which typically shrinks images 70–90% at visually identical quality. In practice, "just convert the images" turned into the hardest part of this whole effort, because changing a file's extension means finding and fixing every place that references it—and this site references images from a lot of places.
I started small on purpose: converted just the Alchemy Drop backgrounds first (that 99MB folder), checked the output at quality 85, and eyeballed one of the converted images before touching anything else. The result was 98.7MB → 8.6MB—a 91% reduction with no visible quality loss. That gave me the confidence to run the same conversion across the rest of the site: 1,507 more files, 184MB → 39.7MB, zero failures, zero collisions.
Then came the actual hard part. Some games don't reference their sprites by a literal file path—they build the filename at runtime. dungeon_crawler.js constructs animation frames like base + 'Idle.png' where base is a separate variable. Top Ace builds cloud and boss sprite paths the same way with template literals. A simple find-and-replace across the codebase couldn't catch these, because the full path never appears as one literal string anywhere in the source. I had to trace each of these patterns by hand, confirm every file in the referenced folder actually got converted, and fix the extension in the handful of places the filename gets assembled.
I also found a whole separate static/games/ directory—self-contained bundled builds for Red Code Quarantine and the pixel cyber run-and-gun game—that my first pass at reference-scanning completely missed, because I'd only been looking in static/js/. Once I added it to the scan, another handful of references turned up and got fixed.
Before calling it done, I wrote a verification pass that actually crawled the live app: every game page, every web tool, plus the homepage—50 pages in total—through Flask's real routing, extracting every image URL each page actually renders and confirming it resolves to a real file. 2,946 distinct image references checked, zero broken. Old files were removed with git rm rather than a raw delete, so the entire migration is sitting in git history if anything ever needs to be recovered.
End result: static/images/ went from 288MB to 53MB—an 82% reduction. Measured on real pages rather than just the folder total, the homepage's actual image payload dropped from 14.5MB to 4.2MB, and Dungeon Crawler's from 17MB to 3.6MB.
The bug the conversion accidentally surfaced
A few hours after the image work, I got a report: the homepage was still trying to load day_rider_thumb.png and a few other now-deleted files. My first assumption was a missed reference somewhere in the migration. It wasn't—every live template checked out clean. The real cause was more interesting.
The homepage's "Jump Back In" strip caches each visited game's thumbnail URL in the visitor's own localStorage, so it can show recently-played games without a server round trip. To handle exactly this kind of asset rename, there was already a self-healing script in templates/index.html that corrects stale cached thumbnails against a known-good lookup table—except that lookup table was a hardcoded list of just fourteen games that I'd written by hand a while back and never revisited as the catalog kept growing. Day Rider, and several other games added since, simply weren't on the list, so their stale cached thumbnails could never self-heal.
Rather than just adding Day Rider to the list and leaving the same trap for the next new game, I replaced the hardcoded object with one generated server-side, straight from the real game and tool catalogs, in webapp/routes.py:
thumbs = {}
for entry in list(GAMES) + list(TOOLS.values()):
if entry.get("endpoint") and entry.get("thumb"):
thumbs[url_for(entry["endpoint"])] = entry["thumb"]
return thumbs
Now every game and tool is covered automatically, forever, with no list to remember to update. A small bug, but exactly the kind that hides for months until someone happens to look.
Audio: same trick, lower risk
With the image lessons fresh, audio was refreshingly simple. Every MP3 on the site was encoded at 256 kbps—overkill for background music and sound effects, where 128 kbps is the standard "nobody will notice" bitrate. Crucially, re-encoding doesn't change the file extension, so none of the reference-hunting from the image work applied here: same filename, same path, just smaller bytes.
I still didn't want to blindly trust my own ears here since I can't exactly "look" at audio quality the way I can inspect an image. So I converted the Dungeon Crawler soundtrack first as a test batch, left it in a separate folder, and waited for a real listen-through before touching anything else. Once that came back clean, the remaining 40 files went through the same treatment. All 56 files were then decode-checked end-to-end for corruption before being called done. static/audio/: 160MB → 82MB, roughly halved.
The homepage video nobody needed to hear
The homepage hero banner autoplays an 8-second looping video at 1280×720, and it's marked muted in the markup—which meant its embedded audio track was pure dead weight, downloaded by every visitor and never once played. Stripping it was free savings with zero behavior change. On top of that, the video was encoded at nearly 7 Mbps, wildly high for a small decorative loop that's partially covered by overlay text anyway. Re-encoding at a much lower bitrate and pulling two comparison frames to check for visible artifacts on a genuinely busy, detail-heavy scene, the difference was imperceptible even zoomed in. 6.9MB → 1.7MB, a 75% cut, for a clip most visitors watch for a couple of seconds before scrolling past.
The bottleneck that had nothing to do with file size
Smaller files help visitors download things faster, but they don't touch a completely different kind of slowness: how many people the server can actually help at once. Minnich.Awesome runs on Google Cloud Run, capped at a single instance on purpose, with one vCPU and 512MB of memory. The Dockerfile was launching the app with plain gunicorn -w 1—one worker, meaning that single instance could only actively process one request at a time. Every other visitor arriving at the same moment simply waited in line, and anything slow—a Firestore read, an email send—blocked every other visitor to the entire site until it finished.
With no second instance to fall back on, that single worker was the entire concurrency budget for the whole site. The fix wasn't simply "add more workers," either—on a single vCPU, separate worker processes just duplicate the app's memory footprint fighting over one core, which is a real risk with only 512MB to work with. The right tool here is threads: they share one process's memory, and since this app is I/O-bound (Firestore calls, mail sending), Python releases its lock during those waits, so threads genuinely add concurrency without meaningfully increasing memory or CPU use. This is also literally Google's own recommended pattern for a Flask app on Cloud Run.
Checking real memory usage first—hovering around 25%, with one spike to 50%—confirmed there was comfortable headroom. The Dockerfile now reads:
That last flag fixed a second, sneakier problem: gunicorn's own default worker timeout is 30 seconds, completely separate from and shorter than Cloud Run's own request timeout, which is set to a full hour on this service. Any request legitimately taking longer than 30 seconds was being silently killed by gunicorn itself the whole time, regardless of what Cloud Run allowed. --timeout 0 disables gunicorn's own clock and lets Cloud Run's much longer one actually govern, as intended.
What I deliberately left alone
Not every idea from this pass made the cut, and I think the reasoning matters as much as the changes that shipped.
- Image/audio cache duration stays at one hour. Extending it to a day or a year would mean faster repeat visits, but also means anyone who updates an image while keeping the same filename won't have that update reach already-cached visitors until the cache expires. With the site still actively changing shape, getting updates out quickly mattered more than shaving round-trips off repeat loads. Worth revisiting once things settle down.
- The 27 unbundled shared JavaScript files stay unbundled, for now. Adding
deferlooked like an easy win until I actually traced it through: those scripts sit at the very bottom of<body>, where deferring buys much less than the textbook case, and worse, at least eleven game templates call shared globals likewindow.GameHUDwithout a ready-guard. Deferring the shared scripts without also fixing those templates would have silently broken the shared HUD overlay and leaderboard score submission on several games. The honest performance upside also turned out to be small given where the scripts already sit—so this got shelved in favor of higher-value fixes, not abandoned.
Update: batching the game-play counter writes
This one didn't stay on the shelf for long. Every single game page load was firing three separate, sequential Firestore calls before the page even rendered—one each to bump the total, daily, and weekly play counters for that game, completely unthrottled, unlike the presence and login-streak trackers elsewhere in the app that only run once per session. Three real network round trips to Firestore's REST API, back to back, purely to record a play count nobody's actually waiting to see.
Firestore's commit endpoint already accepts multiple writes in a single request, so I added increment_fields_batch() to the site's Firestore client and pointed the play-count logic at it: all three counters now go out in one commit instead of three. A commit without an explicit transaction is atomic in Firestore—either every write in it lands or none do—so there's no partial-failure state to reconcile. If the batch fails for any reason, including the one legitimate edge case where a brand-new counter document doesn't exist yet, the code falls back to the original three sequential calls, which already know how to safely create a missing counter. I deliberately didn't try to teach the batched path that same recovery trick, since getting it wrong risked silently resetting an existing counter back to zero.
I can't fire a real request at this site's live Firestore project from a local dev shell, so verifying this one took a different approach: I mocked a response shaped exactly like Firestore's documented commit format and confirmed the parsing pulls out all three incremented values correctly, then forced the batch path to fail and confirmed the fallback kicks in cleanly—which, fittingly, ended up making a real (harmless) authenticated call to Firestore's actual endpoint with a fake token, got a genuine 401 back, and recovered exactly as designed. Every game and tool page still loads clean on top of it. The one thing I couldn't check from here is the live counter itself, so that's a quick spot-check for after this deploys.
The final tally
Across images, audio, and the hero video, this pass cut roughly 313 megabytes down to 137—about a 70% reduction in everything a visitor's browser has to download, verified page-by-page rather than assumed from folder totals. Paired with fixing a hard concurrency ceiling that had nothing to do with file size at all, this was less "one big optimization" and more a dozen small, carefully verified ones. Every image conversion was checked against the live app before old files were removed. Every audio batch was listened to before scaling up. Every claim about "how much faster" in this post is backed by a number I actually measured, not a guess—because half the point of doing this properly was refusing to trade a real regression for an imaginary speedup.
The games load lighter, the homepage loads lighter, the server can finally walk and chew gum at the same time, and the highest-traffic pages on the site no longer wait on three Firestore round trips just to log that someone hit play. Still more on the list for another day—but for now, this is the good kind of boring: nothing broke, and everything's just faster.
More from the Devlog