
Shipped a React PWA to Google Play as a TWA, offline-first, no backend, and learned some painful Play Billing lessons
I built a small PWA (a 30-minute guided Ashtanga yoga practice generator) and shipped it to the Play Store as a Trusted Web Activity. Sharing the build because it's a fairly "pure" PWA and I hit a few gotchas that might save someone time.
The stack / shape
- React 19 + Vite + TypeScript, no backend at all. Everything (preferences, practice history, entitlement) lives on-device.
vite-plugin-pwa(Workbox) for the service worker; fully offline-capable once loaded, including the audio (voice cues, bell, ambient loops are precached).- State-based navigation wired into the History API so the Android back gesture steps through screens instead of closing the app.
- Pose art is inline SVG, code-split and lazy-loaded so the home screen stays light.
- Same codebase serves the web app and the installed Android app (TWA via Bubblewrap). No separate native code.
Things that went well
- PWA-first meant "write once, ship to web + Play" genuinely worked.
- Offline audio via the Web Audio API (gapless ambient loop with an
AudioBufferSourceNode+GainNode, which<audio loop>couldn't do without an audible seam). - Privacy as a feature: no accounts, no tracking, no ads, no network calls. Easy to reason about, and my favourite selling point.
The painful lessons (Play Billing in a TWA) I added a one-time in-app purchase via the Digital Goods API + Play Billing. This is where TWAs get sharp edges:
- Play automatic protection requires minSdk 24+, my first bundle (minSdk 23) was rejected outright.
- The Digital Goods API is only injected by Chrome-backed TWA providers. My default browser is Brave, which verifies the TWA (so it ran full-screen, no URL bar, looked totally fine) but does not inject
window.getDigitalGoodsService. Result:hasDGService: false,PaymentRequest.show()rejecting instantly, no purchase sheet, and hours of chasing the wrong things. Switching the default browser to Chrome fixed it immediately. - Play's newer "purchase options" product model needs a buy option flagged legacy/backwards-compatible, or the android-browser-helper bridge can't see the product via
getDetails. - Post-install Play Billing client race, right after a fresh install, the service can be not-ready and the first purchase attempt silently fails; force-stop + reopen clears it.
Net takeaway: a TWA is a great way to get a PWA into the Play Store, but billing only works for users whose provider browser supports Digital Goods (effectively Chrome). I handle the rest gracefully, when isBillingAvailable() is false, the app just doesn't offer the purchase and stays fully functional as the free tier.
Disclosure: I made this, so there's a link, but I'm posting mainly for the build discussion. Happy to go deeper on any part (service worker setup, the Web Audio looping, the TWA billing wiring).
Play Store (TWA): https://play.google.com/store/apps/details?id=com.ashtanga30.twa
Anyone else shipped TWAs with billing? Curious how you handle the non-Chrome-provider gap, that feels like the biggest weakness of the TWA-plus-billing path right now.