I Shipped an App Store App That Updates Itself, Because There's Nothing in It to Update
How MyVitals reaches the Play Store with almost no native UI — a thin WebView shell pointed at a live URL — and the pile of small native tricks it takes to make a website stop feeling like one.

com.myvitals.app, installed from the Play Store, icon and splash screen and everything, has almost no native UI. Open the source: a splash screen, an error screen, one WebView pointed at https://myvitals.co.in. That's the app.
export const WEBVIEW_URL = 'https://myvitals.co.in';
That string is functionally the entire product. Ship a fix to the web frontend — a normal deploy — and every installed app gets it next open. No review queue, no staged rollout, no "update available" nag. The native shell itself changes maybe a few times a year (a permission, an icon); everything users experience as "the app" ships at web speed, to 100% of installs, at once.
The trade
- No true offline bundle — if the WebView can't reach the internet on first paint, there's nothing local to fall back to. (The actual offline story lives entirely on the web side — service worker + IndexedDB, covered separately.)
- You inherit WebView quirks —
env(safe-area-inset-*)often resolves to0even withviewport-fit=coverset. Fix: stack a fixed minimum padding on top of the CSS value instead of trusting it alone.
"Finished loading" ≠ "actually works"
onLoadEnd means "navigation finished," not "a human would call this working" — for a React SPA, the shell can be blank while JS boots. So there's a second signal: a 15-second timer, and if still loading when it fires, that's a failure regardless of what the WebView reported.
The real gotcha: onLoadEnd fires after onError too — it's "did-finish," not success-only. Wire it naively and a failed load flashes an error screen then un-fails itself a moment later. Fix is one ref, set the instant an error fires, checked before allowing success to override it.
Retry doesn't call .reload() — it bumps a key prop and lets React fully remount the WebView, after checking real connectivity first. A stale WebView instance can wedge itself into states a soft reload won't clear.
Bridging the back button
Android's hardware back button should navigate the website's own history. Track canGoBack from WebView nav state; on back-press, call goBack() if there's history — and explicitly do nothing (let the OS exit the app) if there isn't. Swallowing back-press unconditionally traps users on a screen with a dead back button, which is worse than not intercepting it.
Making a website stop feeling clickable
Injected JS disables text selection and long-press callouts globally (except real inputs), suppresses the copy/share/lookup context menu, kills link previews, and turns off bounce/overscroll. Same instinct on the web side via -webkit-tap-highlight-color: transparent and overscroll-behavior-y: none — belt and suspenders across the native/web boundary.
Camera permission for a plain HTML input
The upload flow is a bare <input type="file"> — the web app has no idea it's in a WebView. But camera capture from a web file picker needs an OS permission that behaves inconsistently if only requested lazily mid-flow. So the shell proactively requests camera/photo permissions once the page loads, ahead of the user ever tapping upload.
The one screen the theming audit never reached
Small confession: the error screen's retry button is a hardcoded #2E7D5B green. Brand teal everywhere else is #0d9488. Not the same color — and this is the one screen a 21-page design consolidation pass never touched, because it's native code that only renders when something's already gone wrong.
👉 Try MyVitals now — same app, same day, web or home screen icon.


