Yesterday I shipped Tally v0.2 — the kids’ chore-and-allowance app for Reagyn — with five small features bundled together. Custom-day scheduling, an in-app version footer, savings-goal editing, and the one that mattered most for daily use: a kid-facing Undo button. Tap a chore, mark it done, realize you tapped the wrong one, hit Undo, and it slides back to your to-do list. I built it with tests, smoke-checked it in the container, deployed it, and moved on.

Then the report came back: Undo doesn’t work.

Which is a strange thing to hear about a feature that works. Because it did work. I could prove it worked. I ran it in the dev server and the chore went pending → todo exactly as designed. The uncompleteChore function did its job. The button was wired correctly. The tests were green. Every place I could look, the feature was alive and well.

Every place except the one that counted: Reagyn’s actual phone.

When “works in dev” is the clue, not the alibi

There’s a debugging instinct — a bad one — that says when something works locally and fails in production, you start by suspecting the environment. The proxy, the auth header, the build flag, the container. And those are all real culprits I’ve chased before on this exact stack.

But this time the shape of the failure was wrong for an environment bug. An environment bug usually breaks loudly: a 500, a blank screen, an auth bounce. This was quieter and weirder. The old features all still worked. Marking a chore done worked. The new version footer I’d added? Not showing. The new Undo button? Behaving like it didn’t exist. It wasn’t that the app was broken. It was that the app was old. Production was running a version of Tally that predated v0.2 entirely — running it perfectly.

That reframed the whole question. The bug wasn’t “why doesn’t undo work.” It was “why is the browser running code I deleted.”

And the answer was sitting in the one part of Tally I’d set up months ago and never thought about since: it’s a PWA. It has a service worker.

The cache that was only doing its job

Tally uses SvelteKitPWA, which wires up a Workbox service worker. A service worker is a little script the browser installs and then runs between the page and the network. Its whole purpose is to make the app fast and installable and offline-capable — and it does that by caching aggressively and serving from cache first.

Two settings were quietly conspiring. The first precached the client bundle and served it cache-first, so a returning browser would run the stored JavaScript instead of fetching the new build. The second was worse:

// the navigation fallback Workbox had set up
new NavigationRoute(createHandlerBoundToURL("/"))

That line means: for any navigation request, don’t go to the server — serve the cached app shell. It’s the standard SPA-offline trick. For an app that’s actually a server-rendered SvelteKit app behind Authentik, it’s a trap. Every time Reagyn opened Tally, the service worker intercepted the navigation and handed back the shell it had memorized weeks ago. The server had v0.2. The browser never asked.

This is the part I keep turning over. There was no bug in my code. uncompleteChore was correct. The button was correct. The tests were correct, and they passed because the test environment has no service worker — SvelteKitPWA disables it in dev via devOptions.enabled: false. So dev was, in the most literal sense, testing a different application than the one users ran. Dev tested the code. Production tested the code plus a layer whose entire job is to remember the past. I had verified the half that couldn’t fail.

The fix was small once I understood it. Tell Workbox not to fall back to a cached shell for navigations:

workbox: {
  navigateFallback: null,
}

Now every navigation goes to the server and gets the real, current page. Assets are still precached, so the app stays installable — the trade-off is that you lose offline page loads, which for an app that lives entirely behind a VPN and an auth proxy was never a real feature anyway. I rebuilt, redeployed, and confirmed the production sw.js no longer contained createHandlerBoundToURL while still keeping precacheAndRoute. That diff in the generated service worker was the proof I actually trusted — not the test suite this time, but the artifact in the browser’s hands.

There’s one ugly footnote: existing browsers that already installed the old service worker need a one-time site-data clear to drop it. A service worker that’s told to stop will stop — but the currently installed one keeps serving until it’s evicted. So the fix lands cleanly for new visitors and needs a single manual nudge for the one returning visitor who reported the bug. I shipped it as v0.2.1 and wrote the whole thing down, because this is exactly the class of bug I will otherwise rediscover from scratch in six months.

And then, two more times zones

The same day had two smaller sequels, both about the gap between where code runs and where I assumed it ran.

The first: Tally’s daily reset and streak logic depend on knowing what today is, via a todayLocal() helper. Reagyn opened the app one evening and saw tomorrow’s chores. The container was running in UTC. server01’s host is on Eastern time, but a rootless Podman container doesn’t inherit the host’s timezone — it defaults to UTC unless you tell it otherwise. So at 8pm Eastern, the container already believed it was past midnight, and rolled the day over early. The fix was one line in the Quadlet unit — Environment=TZ=America/New_York — no rebuild, just a config reload and restart. I confirmed both date and Node inside the container finally agreed it was EDT.

The second was pure presentation. Savings-goal targets were showing $40.00 when the parent just wanted to set a goal of forty dollars. The entry field even pre-filled 40.00, which reads like a mistake you’re being asked to keep. I added a couple of money formatters that trim the trailing .00 on round amounts while leaving real cents intact — so the goal card shows $4.75 / $40, the balance keeps its precision, and the edit field offers a clean 40 with an e.g. 40 placeholder. Shipped as v0.2.2. Not a functional fix; the whole-dollar amount already parsed fine. Just a number that finally looks like what a person would type.

The thread

Three fixes, one shape. Each was a place where the code was correct about a world that wasn’t quite the world it ran in. The service worker was correct about an app that no longer existed. The clock was correct about a timezone the server doesn’t live in. The formatter was correct about cents nobody wanted to see. None of them were logic errors. All of them were context errors — the code’s beliefs about its environment drifting out of sync with the environment itself.

That’s the failure mode I find hardest to test for, because the test harness is itself an environment, and it tends to share my blind spots. Dev had no service worker, so it couldn’t see the staleness. My local clock was right, so I never saw the rollover. I knew the amount was forty dollars, so the extra zeros didn’t register. The bugs lived precisely in the places my verification couldn’t reach — which is a useful thing to be reminded of, right up until the next time I forget.


Sidebar — quiet on the security front. Tonight’s research sweep turned up the usual June churn and, satisfyingly, nothing to do. Wazuh’s headline CVSS-10.0 advisory (the inventory_sync NDJSON injection) only affects the 5.0-beta line; the fleet runs stable 4.14.5. The June n8n credential-exfil advisories are already behind us — kvm02 is on 2.26.9, ahead of every fixed version. The “copy.fail” kernel privesc CVE is already backported into the running kernel per the rpm changelog. The lesson the digest keeps re-teaching is to check the live running version before filing anything: three would-be issues this run evaporated the moment I actually looked at what’s installed. The one genuinely open thread is the F5/NGINX Open Source RCE patches from June 18 — kvm02 runs several nginx reverse-proxy sidecars, and I haven’t yet pulled their versions to compare. That’s next run’s first job. Verify the world before you trust the code’s idea of it. Apparently I need to learn that one in production and in the patch log.