Skip to content
theakrista.dev
Blog
3 min read

Building a Production PWA: What the Tutorials Don't Tell You

Lessons from shipping nihongojouzu.jp: service worker update traps, Web Push on iOS, offline-first state, and why the PWA parts were harder than the app parts.

PWANext.jsService WorkersWeb Push

I built nihongojouzu.jp because I was studying for the JLPT N2 and every flashcard app failed me on a train. The app itself (spaced repetition, decks, furigana rendering) took about a third of the total effort. The PWA-ness of it took the rest. This post is about that two-thirds.

The service worker lifecycle will hurt you exactly once

Every PWA tutorial shows you how to register a service worker. Almost none of them tell you that your real problem is the second deploy, not the first. A service worker doesn't update when you deploy. It updates when the browser decides to check, and the old worker keeps controlling open tabs until every one of them closes.

My first production incident was exactly this: I shipped a fix, watched a user session in Microsoft Clarity still running the old code an hour later, and learned the lifecycle the honest way.

What I do now:

sw-update.ts: make updates a UX event, not an accident
const registration = await navigator.serviceWorker.register("/sw.js");
 
registration.addEventListener("updatefound", () => {
  const worker = registration.installing;
  worker?.addEventListener("statechange", () => {
    if (worker.state === "installed" && navigator.serviceWorker.controller) {
      // New version waiting: tell the user instead of surprising them
      showToast("Update available", { action: () => worker.postMessage("SKIP_WAITING") });
    }
  });
});

Version transitions are product decisions. A study app can offer a gentle "update now" toast. A checkout flow should never swap code mid-session. Decide, don't default.

Offline-first is a data-model decision

"Works offline" sounds like a caching feature. It's actually a question: where does the truth live during a session? For a study session, my answer was on the device. The review queue loads into a persisted Zustand store, every answer mutates local state instantly, and a sync layer reconciles with Supabase when the network shows up.

The consequence nobody warns you about is that you now own conflict resolution. What happens when reviews sync from two devices? For me, last-write-wins per review was acceptable, since the SRS scheduler self-corrects over time. The point is that I had to decide, and the decision shaped the schema (append-only review events, not mutable counters).

Web Push: the feature that makes SRS work

Spaced repetition without reminders is a graveyard of good intentions. Web Push is what turns "I have an app" into "the app taps me on the shoulder when reviews are due."

Things that cost me real time:

  • iOS requires installation first. Push only works for PWAs added to the home screen, and permission must be requested from a user gesture. Design the onboarding around this or your iPhone users silently get nothing.
  • Subscriptions rot. Endpoints expire and return 410 Gone. If you don't prune them and retry gracefully, your notification job fails in ways that look random.
  • Notifications are a promise. Every push must be worth an interruption. I send exactly one kind: "your reviews are due." No streaks-guilt, no marketing.

Lighthouse is a budget, not a trophy

I run Lighthouse CI on the pipeline (a habit from my day job, where I added it after fixing a marketplace's SSR bottlenecks). The score isn't the point. The regression gate is. A PWA accumulates weight silently: one analytics script, one font, one polyfill at a time. A CI budget makes the accumulation visible at PR time, when it's a one-line revert instead of a performance project.

What I'd tell past me

  1. Read the service worker lifecycle spec before the first deploy, not after the first stale-code incident.
  2. Choose offline semantics per feature. My deck browser is cache-first; my account page is network-only. Blanket strategies are always wrong somewhere.
  3. Test the update path in every release: deploy, open old tab, verify the toast. It's two minutes, and it's the path tutorials never test.
  4. Instrument from day one. GA4 told me what users did. Clarity showed me where they hesitated. Both changed the product more than my own opinions did.

The web platform can absolutely deliver an app-store-quality experience, but the last 20% is engineering, not configuration. That last 20% is also the moat: most people stop before it.

I’m Thea, a senior full-stack engineer in Japan, currently leading architecture for three production products. Work with me or read the case studies.