PWA vs Native vs Hybrid: A Technical Comparison¶
A Progressive Web App runs inside a browser engine the user already has and is delivered by URL; a native app is compiled against the operating system's SDK and delivered as a signed package; cross-platform frameworks (React Native, Flutter), WebView wrappers (Cordova, Capacitor) and desktop shells (Electron, Tauri) sit at different points between those two poles. The differences are not about "web is slow, native is fast" — they come from concrete mechanisms: who owns the runtime, which sandbox the code runs in, how bytes reach the device, which background and hardware APIs the platform exposes, and which store rules apply. This page compares all of them dimension by dimension, with verified per-platform limits, so you can pick an architecture on evidence rather than folklore.
Key takeaways
- The deciding factors are capability gaps, distribution channel and update latency — not raw rendering speed, which is adequate on all stacks for typical app UIs when engineered well.
- PWAs win on reach, linkability, zero-install first use and instant updates; they lose on background execution, deep OS integration (widgets, Live Activities, assistant integrations) and several hardware APIs, especially on iOS where every browser uses WebKit.
- Storage is not a web weakness any more: Chromium and Safari/WebKit let an origin use up to roughly 60% of disk, but eviction rules differ, and WebView-based hybrid apps on Apple platforms get a much smaller quota (about 15% per origin).
- Web Push works in every major engine, but on iOS and iPadOS only for web apps added to the Home Screen, and silent (invisible) pushes are blocked in Chromium and Safari; only Firefox accepts
userVisibleOnly: false, with a quota on background messages. - Hybrid is often the right answer: ship one PWA, then wrap it with a Trusted Web Activity for Google Play or Capacitor for the App Store, adding native code only where a capability gap forces it.
- Store policies are technical constraints: Google Play billing rules shape how a TWA sells digital goods, and Apple's guideline 4.2 rejects apps that are merely a repackaged website.
The six architectures being compared¶
Before comparing dimensions, it helps to be precise about what each label means, because "hybrid" and "cross-platform" are used loosely in the industry.
| Approach | UI is drawn by | App logic runs in | Engine owned and updated by | Delivered as | Typical stacks |
|---|---|---|---|---|---|
| PWA | The browser engine (Blink, WebKit, Gecko) | Browser JavaScript engine + service worker | Browser vendor | URL; optional install from the browser | Any web framework, Workbox |
| Native | Platform UI toolkit | Compiled machine code / managed runtime | OS vendor (SDK) + you | Signed store package | Swift + SwiftUI/UIKit; Kotlin + Jetpack Compose/Views |
| Cross-platform native | Native views (React Native) or the framework's own renderer (Flutter) | Embedded JS engine (Hermes) or AOT-compiled Dart | You (bundled in the binary) | Signed store package | React Native, Flutter (also Kotlin Multiplatform, .NET MAUI) |
| Hybrid WebView | The system WebView inside a native shell | WebView JavaScript engine + native plugins | OS vendor (WebView) + you (shell) | Signed store package | Capacitor, Cordova |
| Store-wrapped PWA | The user's browser (TWA) or a system WebView (iOS wrappers) | Browser/WebView engine | Browser/OS vendor | Signed store package that points at your origin | Trusted Web Activity via Bubblewrap/PWABuilder; Microsoft Store PWA packages |
| Desktop wrapper | Bundled Chromium (Electron) or the OS WebView (Tauri) | Chromium renderer + Node.js main process (Electron) or Rust core (Tauri) | You (Electron) or OS vendor (Tauri's WebView) | Installer or desktop store package | Electron, Tauri |
The key question in every row is who owns the runtime. When the browser owns it (PWA, TWA), you inherit the browser's security fixes and new APIs for free but cannot extend the runtime. When you own it (native, React Native, Flutter, Electron), you can do anything the OS allows but must ship every runtime update yourself. WebView-based approaches split the difference: the OS owns the rendering engine, you own the shell and its native bridge.
flowchart TB
subgraph PWA["PWA"]
P1["Your HTML, CSS, JS"] --> P2["Browser engine and service worker"]
P2 --> P3["Operating system"]
end
subgraph NAT["Native"]
N1["Swift or Kotlin code"] --> N2["Platform UI toolkit and SDK"]
N2 --> N3["Operating system"]
end
subgraph XP["React Native or Flutter"]
X1["JS or Dart code"] --> X2["Framework runtime in your binary"]
X2 --> X3["Native views or own renderer"]
X3 --> X4["Operating system"]
end
subgraph HYB["Capacitor or Cordova"]
H1["Your HTML, CSS, JS"] --> H2["System WebView"]
H1 --> H3["Native plugins via bridge"]
H2 --> H4["Operating system"]
H3 --> H4
end Runtime and rendering architecture¶
PWAs: the browser engine is the runtime¶
A PWA is a website with a manifest and usually a service worker; it has no runtime of its own. On Chromium-based browsers the page runs in a renderer process that is sandboxed from the OS; a separate browser process owns the window, network stack and storage backends, and a GPU process composites frames. The rendering pipeline is the standard one — style, layout, paint, then compositing on a dedicated compositor thread — so animations of transform and opacity can continue while the main thread is busy. The service worker runs on its own thread in a renderer process and is started on demand for each event.
JavaScript is executed by a tiered JIT engine. V8 (Chromium) moves hot code from the Ignition interpreter through the Sparkplug baseline compiler and the Maglev mid-tier compiler to the TurboFan optimizing compiler; JavaScriptCore (WebKit) uses LLInt, Baseline, DFG and FTL tiers; SpiderMonkey (Gecko) uses a baseline interpreter, baseline compiler and the Warp/Ion optimizing tier. The practical effect: the first run of any function is interpreted, and steady-state performance depends on type-stable hot paths reaching the optimizing tier.
How the installed app is hosted differs per platform:
- Android (Chrome, Samsung Internet): installation mints a WebAPK, a small APK generated and signed by a server that the browser vendor runs. The WebAPK holds the icon, name and intent filters, but the web app itself runs in the browser's process and shares the browser profile's storage. MDN notes that only Chrome on devices with Google Mobile Services and Samsung Internet on Samsung devices install WebAPKs; other browsers create browser-badged shortcuts (MDN: Making PWAs installable).
- iOS and iPadOS: every browser must use WebKit (outside the EU and Japan alternative-engine programs), and Home Screen web apps run in a WebKit-hosted standalone container that is separate from the Safari app, with its own cookie jar and storage. Since iOS 26, any site added to the Home Screen opens as a web app by default (WebKit: Safari 26.0).
- Desktop Chromium (Chrome, Edge): the installed app is a browser window without tabs or omnibox, registered with the OS (Start menu, Dock, app launcher) and sharing the browser profile.
- macOS Safari: since Safari 17 on macOS Sonoma, Add to Dock creates a standalone web app for any site.
- Firefox: on Windows, Firefox's "Web Apps" (Taskbar Tabs) feature is enabled by default; Linux support exists behind a preference and macOS is not supported (Firefox source docs). Mozilla describes it as bringing app-like features rather than implementing the full manifest feature set.
The detailed install mechanics per platform are covered in Installation by Platform.
Native: platform toolkits compiled ahead of time¶
On iOS, Swift is compiled by LLVM to ARM64 machine code. UIKit and SwiftUI build a layer tree that Core Animation commits to an out-of-process render server, which composites at the display's refresh rate. There is no interpreter warm-up and no JIT; apart from Apple's own WebKit/JavaScriptCore (and, in the EU and Japan, browser engines granted Apple's alternative-engine entitlement), iOS apps cannot generate executable code at runtime — which is also why Hermes on iOS runs precompiled bytecode in an interpreter rather than JIT-compiling.
On Android, Kotlin compiles to JVM bytecode and then DEX. The Android Runtime (ART) combines ahead-of-time compilation at install, JIT compilation at runtime and profile-guided recompilation in the background; apps can ship Baseline Profiles so that hot startup paths are compiled before the first launch. Jetpack Compose and the classic View system render through the hardware-accelerated pipeline on a dedicated RenderThread.
Native code calls platform APIs directly: there is no permission-less bridge to cross and no capability the OS offers that a native app cannot request (subject to entitlements and store review).
Cross-platform native: React Native and Flutter¶
React Native runs your JavaScript in an embedded engine — Hermes by default — whose compiler turns JavaScript into bytecode at build time, so the app does not parse source on startup. Since React Native 0.82 (October 2025) the New Architecture is the only supported architecture: JSI exposes C++ host objects directly to JavaScript (synchronous calls, no JSON-serialized bridge), the Fabric renderer maintains a C++ shadow tree and mounts real platform views, and TurboModules load native modules lazily. The same release shipped an experimental opt-in to Hermes V1 (React Native 0.82 release notes). Because the UI consists of genuine UIView/android.view.View instances, accessibility, text input and platform look-and-feel come from the OS.
Flutter compiles Dart ahead of time to native machine code for release builds (JIT is used only in debug builds for hot reload) and draws every pixel itself with its Impeller renderer, which is the only renderer on iOS and the default on Android API 29+ with a fallback for older or non-Vulkan devices (Flutter docs: Impeller). Flutter does not use platform widgets; it reimplements Material and Cupertino designs and exposes a semantics tree to platform accessibility services. Embedding a true native view (a map, a WebView) uses "platform views", which have extra composition cost.
Hybrid WebView wrappers: Capacitor and Cordova¶
Capacitor and Cordova ship your web build inside a native app and render it in the system WebView — WKWebView on iOS, Android System WebView on Android. Capacitor serves bundled assets from a local origin rather than file://: by default capacitor://localhost on iOS and https://localhost on Android, configurable through server.iosScheme, server.androidScheme and server.hostname (Capacitor config reference). Native functionality comes from plugins that the web layer calls through a message-passing bridge.
Three WebView facts shape hybrid apps more than any framework choice:
- Engine version is not yours. Android System WebView is Chromium-based and updated through Google Play independently of OS updates, so most devices run a recent engine.
WKWebViewis the system WebKit and only updates with iOS itself, so you support whatever WebKit your oldest supported iOS version shipped. - Service workers are mostly absent on iOS. In practice
WKWebViewexposes service workers only to apps that opt into App-Bound Domains — theWKAppBoundDomainsInfo.plist key (up to 10 domains, iOS 14+) pluslimitsNavigationsToAppBoundDomains = YES, which restricts that WebView's top-level navigations to those domains (WebKit: App-Bound Domains). Apple does not document service workers inWKWebViewas a general feature, and Capacitor's config reference warns that withWKAppBoundDomainspresent "some features won't work" unless the flag is set (Capacitor config reference). Android WebView supports service workers; Capacitor routes their requests through its bridge by default (resolveServiceWorkerRequests). - Storage quota is smaller on Apple platforms. WebKit gives each origin in a non-browser app about 15% of total disk (20% overall across origins), versus about 60% for browser apps and Home Screen web apps (WebKit: Updates to Storage Policy).
Desktop wrappers: Electron and Tauri¶
Electron bundles a full Chromium and Node.js into every app. It has one main process (Node.js, owns windows through BrowserWindow and the app lifecycle), one renderer process per window (web content, no Node.js by default), preload scripts that run before page scripts with a limited privileged API and expose functions through contextBridge, and optional utility processes for CPU-heavy or crash-prone work (Electron process model). Context isolation is the default, and since Electron 20 renderers are sandboxed without extra configuration (Electron sandbox docs). Electron ships a new major version every 8 weeks, tracking Chromium, and supports the three most recent majors (Electron timelines) — so an Electron app that is not re-released regularly runs an unpatched browser engine.
Tauri does not bundle an engine. It renders with the OS WebView — WebView2 (Chromium, self-updating) on Windows, WKWebView on macOS and iOS, WebKitGTK on Linux, Android System WebView on Android — and runs app logic in a Rust core, with Swift and Kotlin available for mobile plugins (Tauri webview versions). Tauri's documentation states that a minimal app can be under 600 KB (Tauri: What is Tauri?); the trade-off is that on Linux your app runs on whatever WebKitGTK the distribution ships, and on macOS on whatever WebKit the OS version ships.
Architecture summary¶
| Property | PWA | Native | React Native | Flutter | Capacitor/Cordova | Electron | Tauri |
|---|---|---|---|---|---|---|---|
| UI primitives | DOM/CSS | Platform widgets | Platform widgets | Own widgets, own renderer | DOM/CSS | DOM/CSS | DOM/CSS |
| Code execution | JIT JS, Wasm | AOT (Swift), ART AOT+JIT (Kotlin) | Hermes bytecode | AOT Dart | WebView JIT JS | V8 JIT + Node.js | WebView JIT + Rust |
| Engine updates | Browser vendor, automatic | OS + your releases | Your releases | Your releases | OS (WebView) + your releases | Your releases | OS (WebView) + your releases |
| Web platform version you target | Every browser your users run | n/a | n/a | n/a | Oldest supported OS WebView | The Chromium you bundle | Oldest supported OS WebView |
| Can call arbitrary OS APIs | No | Yes | Yes (native modules) | Yes (platform channels) | Yes (plugins) | Yes (Node.js, native addons) | Yes (Rust, plugins) |
Sandboxing and security model¶
The web sandbox: origins, permissions and secure contexts¶
A PWA's security boundary is its origin. Everything — storage, service worker registration, permissions, push subscriptions — is keyed by scheme + host + port and enforced by the same-origin policy. Powerful APIs are available only in secure contexts, most require a user activation (a recent click or tap) to prompt, and every permission is revocable by the user from browser settings. The renderer process that executes your code has no file system or process access at all; anything outside the origin's sandbox — a user-chosen file, a Bluetooth device, a USB device — is mediated by a browser picker in which the user grants access to one specific object. Browsers additionally isolate sites into separate processes (fully on desktop Chromium; on Android, Chromium isolates a subset of sites to save memory), which limits the blast radius of renderer exploits and speculative-execution attacks. See Permissions and Service Worker Security.
The consequence for architecture: a compromised PWA (for example via XSS) can do anything the origin can do — read its storage, act as the user against its own backend, show notifications if permission was granted — but it cannot escape to the device. That is a smaller worst case than any stack that exposes native APIs to JavaScript.
Native sandboxes: containers, entitlements and review¶
iOS apps run in a per-app container with a sandbox profile; access to protected resources requires both an Info.plist usage-description string and a runtime permission prompt, and some capabilities (push, iCloud, HealthKit, NFC tag reading, App Groups) additionally require entitlements baked into the code signature. Android gives each app its own Linux UID and SELinux domain; permissions are declared in AndroidManifest.xml, and "dangerous" permissions are granted at runtime. Both platforms enforce code signing, and store review adds a policy layer the web does not have. The native worst case after a compromise is larger: the attacker gets every permission the user granted to the app, plus anything reachable in the app's container.
Hybrid and desktop wrappers: the bridge is the attack surface¶
WebView wrappers and desktop shells deliberately punch holes from web content into native capabilities, and those holes are where most serious vulnerabilities in hybrid apps live:
- In Capacitor or Cordova, any script that runs in the WebView can call every registered plugin. An XSS bug in a hybrid app is therefore potentially a native compromise (file system, contacts, camera — whatever plugins you installed).
- In Electron, a renderer with
nodeIntegration: trueor without context isolation lets page scripts reach Node.js — that is, arbitrary code execution on the user's machine. The Electron defaults (context isolation, sandboxed renderers) exist to prevent this, and the Electron security checklist documents the remaining hardening steps. - Tauri inverts the default: commands are denied unless a capability file in
src-tauri/capabilities/grants specific permissions to specific windows, and by default only bundled code — not remote URLs — can reach the API (Tauri capabilities).
Never load remote content into a privileged WebView
If a WebView or BrowserWindow can call native code, only let it render content you ship in the package. Block navigation to other origins, open external links in the system browser, apply a strict Content Security Policy, and never expose a generic "call any native function" bridge. A PWA in a normal browser does not have this class of problem because the browser never grants web content native privileges.
A hardened Electron window looks like this:
import { app, BrowserWindow, net, protocol, shell, session } from "electron";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ORIGIN = "app://bundle"; // served by the protocol handler below, not file://
const WEB_ROOT = path.join(__dirname, "..", "dist");
// Must run before "ready": a standard, secure scheme gets a real origin,
// so storage, fetch() and CSP behave as they would on https.
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
function serveBundle() {
protocol.handle("app", (request) => {
const { host, pathname } = new URL(request.url);
const filePath = path.normalize(path.join(WEB_ROOT, decodeURIComponent(pathname)));
// Reject other hosts and path traversal ("app://bundle/../../etc/passwd").
if (host !== "bundle" || !filePath.startsWith(WEB_ROOT + path.sep)) {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(filePath).toString());
});
}
function isBundled(url) {
try {
const u = new URL(url);
return u.protocol === "app:" && u.host === "bundle";
} catch {
return false; // unparsable URLs are never trusted
}
}
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true, // default since Electron 12; keep it explicit
sandbox: true, // default for renderers since Electron 20
nodeIntegration: false, // page scripts must never see Node.js
webSecurity: true,
},
});
// Deny every permission request that the app does not explicitly need.
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(permission === "notifications");
});
// Keep the privileged window on bundled content only. Compare parsed
// components, not string prefixes: "app://bundle.evil" starts with "app://bundle".
win.webContents.on("will-navigate", (event, url) => {
if (!isBundled(url)) event.preventDefault();
});
// window.open() and target=_blank go to the user's browser, never a new privileged window.
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https://")) shell.openExternal(url);
return { action: "deny" };
});
win.loadURL(`${APP_ORIGIN}/index.html`);
}
app.whenReady().then(() => {
serveBundle();
createWindow();
});
The equivalent Tauri grant is declarative:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Only what the main window needs",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-set-title"
]
}
Security model comparison¶
| Aspect | PWA | Native | React Native / Flutter | Capacitor / Cordova | Electron | Tauri |
|---|---|---|---|---|---|---|
| Isolation unit | Origin | App container / UID | App container / UID | App container; web content shares app privileges via plugins | Process per window; main process has full user privileges | App process; commands gated by capabilities |
| Worst case after XSS / injection | Origin data and backend session | n/a (no web content) unless a WebView is embedded | Code injection into the JS bundle is rare; OTA bundle integrity matters | Every installed plugin | Arbitrary code if hardening is off | Only commands granted to that window |
| Permission prompts | Per origin, per API, revocable in browser settings | Per app, OS settings | Per app | Per app (native) | OS-level for some APIs; Chromium prompts otherwise | OS-level |
| Code signing | TLS certificate for the origin | Mandatory | Mandatory | Mandatory | Required for smooth install on macOS/Windows | Required for smooth install on macOS/Windows |
| Engine security patches | Browser auto-updates | OS updates | Your releases (Hermes) | OS/WebView updates | Your releases | OS/WebView updates |
Distribution and install friction¶
PWA: the URL is the install¶
The first use of a PWA requires nothing but a link: no store page, no download size warning, no account. Installation is an optional upgrade the user can perform later:
- Chromium fires
beforeinstallpromptwhen its installability criteria are met, so you can offer a custom install button, and always offers install from the browser menu (see Install Prompts & Custom UI and Installability Criteria). - Safari on iOS/iPadOS has no install event; users choose Share → Add to Home Screen. Since iOS 16.4, Chrome, Edge, Firefox and other browsers on iOS can do the same through the share sheet (WebKit: Web Push for Web Apps on iOS and iPadOS). With iOS 26, WebKit states there are "zero requirements for installability" in Safari: every site added to the Home Screen opens as a web app unless the user turns that off.
- Desktop Safari and Firefox: macOS Safari offers File → Add to Dock; Firefox on Windows offers pinning a site as a web app to the taskbar.
Experimental
Programmatic installation — including installing a different origin's app, for example from an app catalog — is coming through the Web Install API (navigator.install()). Chromium ran an origin trial on desktop from Chrome 143 to 148, extended through Chrome 150, and an Intent to Ship posted in September 2026 targets desktop Chrome 156 (no Android); it is not enabled by default in stable Chrome as of September 2026 (chromestatus: Web Install API). No other engine has shipped it.
The friction you do face is discoverability of install: many users do not know that "Add to Home Screen" exists, especially on iOS. Plan install education UI, and measure install rates as described in Analytics for PWAs.
Native: stores, review and signing¶
A native app's first use requires finding a store listing, downloading a package, and often signing in again inside the app. Distribution channels have their own rules:
- App Store: every build passes App Review. In the EU, Apple additionally allows notarized apps through alternative marketplaces and Web Distribution from a developer-owned website registered with Apple. Starting October 1, 2026, Apple widens Web Distribution eligibility to developers who meet any one of several criteria (for example, a stand-by letter of credit, venture funding, a financial audit, or one million first annual installs worldwide) and moves EU distribution to a single set of business terms (Apple: The Digital Markets Act and apps in the EU).
- Google Play: review is lighter, but apps must meet the target API level policy and Play's payments rules. Outside Play, sideloaded APKs on certified Android devices increasingly require a verified developer: from September 2026, apps must be registered by verified developers to be installed and updated on certified devices in Brazil, Indonesia, Singapore and Thailand, with worldwide expansion in 2027 and beyond (Android Developers Blog).
Store-wrapped PWAs¶
You can put a PWA into stores without rewriting it:
- Google Play via a Trusted Web Activity: a thin Android app whose main activity opens your origin in the user's browser, full screen, after verifying ownership through Digital Asset Links. The browser renders the content and the TWA shares cookies and storage with that browser; the Android app has no access to web state.
- Microsoft Store accepts PWAs packaged with PWABuilder.
- Apple App Store requires a native wrapper (typically
WKWebView) and must pass guideline 4.2, which rejects apps that are not more than a repackaged website — see Store policy constraints.
Distribution comparison¶
| Step before first use | PWA (browser) | PWA installed | TWA / store-wrapped | Native / cross-platform | Electron / Tauri |
|---|---|---|---|---|---|
| Discovery surface | Search, links, QR codes, social shares | Same, plus browser install UI | Store search + web | Store search | Website download, desktop stores |
| Package download | None (streams on demand) | Tiny (WebAPK) or none | Small shell APK; content streamed | Full binary | Full binary (Electron bundles Chromium) |
| Review gate | None | None | Store review | Store review | Notarization / code signing; store review if using a store |
| Account needed to install | No | No | Store account | Store account | No |
| Deep link from a web page into the app | Native (it is the web page) | Android: in-scope links open the WebAPK | Android App Links via Digital Asset Links | Universal Links / App Links | Custom protocol handlers |
Update mechanics¶
How a PWA updates¶
A PWA updates when you deploy. The next navigation fetches new HTML; the browser checks the registered service worker script for byte changes on every in-scope navigation, and on functional events such as push or sync only if the last check was more than 24 hours ago. Since Chrome 68 the check bypasses the HTTP cache by default (updateViaCache: "imports"). A changed worker installs alongside the old one and waits until no page uses the old worker, unless you call skipWaiting() — see Updating Service Workers for the full algorithm and UX patterns. The window between deploy and "every active user runs the new code" is therefore minutes to hours, bounded by how long users keep tabs open.
The manifest updates on a browser-controlled schedule. Since Chrome 144, desktop Chrome checks the manifest on every page load that links it, with no daily limit (Chrome: improvements to web app updates). Chrome for Android checks only when the WebAPK is launched and regenerates it in the background at most about once a day; the interval can stretch to 30 days (web.dev describes this happening when Chrome cannot get an updated manifest from the server). Safari and Firefox document no manifest update mechanism. On Android a change to name, short_name, icons, background_color, display, orientation, scope, shortcuts, start_url, theme_color or share_target requires minting a new WebAPK (web.dev: manifest updates). Identity changes (name and icons) are treated as security-sensitive and surfaced to the user for approval on desktop. Details are in App Identity & Updates.
How native apps update¶
A native fix requires a new binary, a store review and then user-side adoption. Stores support gradual rollout — App Store phased release spreads automatic updates over seven days; Google Play staged rollouts release to a percentage of users you choose — but you cannot force an install. Users with automatic updates disabled or without Wi-Fi can remain on old versions for months, so your backend must support multiple client API versions simultaneously and you typically build a "minimum supported version" check that blocks outdated clients.
Over-the-air updates for JavaScript-based native stacks¶
React Native and Capacitor apps can download a new JavaScript/web bundle at runtime (for example with Expo's update service or Capacitor live-update services), avoiding store review for non-native changes. This is constrained by policy, not technology: App Review guideline 2.5.2 forbids downloading code that "introduces or changes features or functionality of the app", while Apple's Developer Program License Agreement permits downloaded interpreted code as long as it does not change the app's primary purpose or bypass OS security features (App Review Guidelines, Apple developer agreements). OTA updates cannot change native code, so any update that adds a plugin or a permission still needs a store release — which creates native/JS version skew you have to manage with runtime compatibility checks.
Electron uses its autoUpdater module (Squirrel-based on macOS and Windows) and Tauri provides an updater plugin that verifies update signatures; both require you to operate an update server or use a hosted service.
Update latency comparison¶
| Change type | PWA | Native | React Native / Capacitor with OTA | Electron / Tauri |
|---|---|---|---|---|
| Copy, styles, business logic | Next navigation + SW activation | Store release + user adoption | OTA bundle on next launch | Auto-update on next launch/restart |
| New native capability / permission | Browser vendor ships it (cannot be forced) | Store release | Store release | App release |
| Emergency security fix in your code | Deploy; optionally force skipWaiting | Store expedited review + adoption | OTA if JS-only | App release |
| Security fix in the engine | Browser auto-update | OS update | Your release (Hermes/Flutter engine) | Your release (Electron) / OS update (Tauri) |
| Versions you must support concurrently | Usually 1–2 (old SW tabs) | Many | Native versions × bundle versions | Several |
Discoverability and linkability¶
The web's structural advantage is that every screen of a PWA is a URL. That has consequences well beyond marketing:
- Indexing: search engines crawl and rank PWA content like any other site (see SEO for PWAs). Content inside a native app is invisible to web search unless you maintain a parallel website.
- Sharing: a copied link opens the exact state for the recipient on any device, whether or not they have the app. Native apps need a web fallback for every deep link anyway.
- Zero-install entry points: QR codes, NFC tags, email links and chat messages open the app directly.
Native apps close the gap with verified deep links. iOS Universal Links require an apple-app-site-association file on your domain; Android App Links require /.well-known/assetlinks.json and android:autoVerify="true" intent filters. When the app is installed, verified links open it; otherwise the browser opens your website.
For installed PWAs, link handling depends on the platform:
| Platform | What happens when the user taps an in-scope link elsewhere |
|---|---|
| Android, WebAPK (Chrome, Samsung Internet) | The WebAPK registers intent filters for URLs in its scope, so links can open in the installed app instead of a browser tab. |
| Android, TWA | Links verified through Digital Asset Links open the TWA like any App Link. |
| Desktop Chromium | Link capturing into installed apps is controlled by the browser and the user; launch_handler (Chromium 110+) controls whether a launch reuses an existing window. See Protocol Handlers & Launch Handling. |
| iOS / iPadOS Home Screen web apps | Links do not open the installed web app; they open in the browser. Plan for users arriving in Safari while signed in only in the Home Screen app. |
Offline and storage limits per platform¶
Storage limits are where outdated assumptions ("Safari only gives you 50 MB") do the most damage. Current quotas, per the browsers' own documentation:
| Engine / context | Per-origin quota | Overall cap | Eviction |
|---|---|---|---|
| Chromium (Chrome, Edge, Samsung Internet), browser and installed apps | Up to 60% of total disk, best-effort or persistent | Shared with other origins | LRU across non-persistent origins under storage pressure |
| Firefox, best-effort | Smaller of 10% of disk or 10 GiB per site group (eTLD+1) | — | LRU across non-persistent origins |
Firefox, persistent (after persist() is granted) | 50% of disk, capped at 8 TiB, not subject to the group limit | — | Not evicted automatically |
| Safari / WebKit browser apps (macOS 14+, iOS 17+) and Home Screen / Dock web apps | About 60% of total disk | About 80% of disk across all origins | LRU; origins with open pages or persistent mode are excluded |
WebKit in other apps (WKWebView: Capacitor, Cordova, Tauri on Apple platforms) | About 15% of total disk | About 20% of disk | LRU |
| Cross-origin iframes in WebKit | About 1/10 of the parent's quota | — | — |
Sources: MDN: Storage quotas and eviction criteria, WebKit: Updates to Storage Policy.
Two WebKit-specific rules matter more than the quota itself:
- Seven-day proactive eviction in Safari. When cross-site tracking prevention is on (the default), Safari deletes all script-writable storage — IndexedDB, Cache Storage, service worker registrations, localStorage — for an origin with no user interaction in the last seven days of browser use. Server-set cookies are exempt. Home Screen web apps "have their own counter of days of use" that matches actual use of the app, so an installed PWA that people open is not subject to the same cliff (WebKit, March 2020).
navigator.storage.persist()is heuristic. WebKit grants persistent mode based on signals that include whether the site is opened as a Home Screen web app; Chromium grants it based on engagement, installation, bookmarks and notification permission; Firefox shows a prompt.
Native apps are limited only by free disk space, but they too have evictable and durable areas: iOS may purge the Caches directory under storage pressure while Documents and Application Support are durable and backed up; Android may clear an app's cache directory when space runs low. In both cases the OS never deletes durable app data without the user uninstalling the app or clearing its storage — a stronger guarantee than best-effort web storage.
Check what you actually have at runtime and request durability deliberately:
/**
* Reports quota/usage and tries to make storage persistent.
* Call after a meaningful user action (e.g. saving a document),
* because some browsers weigh engagement when deciding.
*/
export async function ensureDurableStorage() {
if (!navigator.storage?.estimate) {
return { supported: false };
}
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
let persisted = (await navigator.storage.persisted?.()) ?? false;
if (!persisted && navigator.storage.persist) {
try {
persisted = await navigator.storage.persist();
} catch (err) {
// Some engines reject in unusual contexts (e.g. third-party iframes).
console.warn("persist() failed", err);
}
}
return {
supported: true,
persisted,
usageMB: +(usage / 1048576).toFixed(1),
quotaMB: +(quota / 1048576).toFixed(1),
// Leave headroom: writes near the quota throw QuotaExceededError.
nearlyFull: quota > 0 && usage / quota > 0.8,
};
}
Deep dives: Storage Quotas & Persistence, IndexedDB, Origin Private File System and Offline-First Data & Sync.
Background execution¶
Background execution is the largest structural gap between web and native, and it is deliberate: browsers do not let a website run code the user cannot see without a strictly bounded, event-driven reason.
What the web allows¶
A service worker runs only in response to events — fetch, push, notificationclick, message, sync, periodicsync, backgroundfetch* — and is terminated when idle (Chromium stops an idle worker after roughly 30 seconds and limits each event to five minutes in general, three minutes for sync and less for push). event.waitUntil() extends its lifetime only for the duration of a promise, subject to browser time limits. There is no API to run a timer in the background, keep a socket open, or track location while the app is closed.
| Background capability | Chromium (Android and desktop) | Safari / WebKit | Firefox |
|---|---|---|---|
| Push-triggered work (must show a notification) | ✅ | ✅ (iOS/iPadOS: Home Screen apps only) | ✅ |
| Background Sync (retry when online) | ✅ | ❌ | ❌ |
| Periodic Background Sync | ✅ installed apps only, frequency set by engagement | ❌ | ❌ |
| Background Fetch (large downloads with UI) | ✅ | ❌ | ❌ |
| Silent push / background refresh | ❌ | ❌ | ❌ |
| Background location, audio processing, Bluetooth | ❌ (media playback continues while backgrounded) | ❌ (media playback continues) | ❌ |
Chrome only fires periodic sync for installed apps that have been launched as distinct applications, only on networks the device has connected to before, and at a frequency derived from the site engagement score rather than the minInterval you request (Chrome: Periodic Background Sync).
What native platforms allow¶
- iOS:
BGAppRefreshTaskfor short periodic refreshes,BGProcessingTaskfor longer deferrable work (often while charging), and — new in iOS 26 —BGContinuedProcessingTaskfor user-initiated work that continues with progress UI after the app is backgrounded (Apple: BGContinuedProcessingTask). Background modes (audio, location, VoIP, Bluetooth accessories) and silent (content-available) pushes wake the app, all scheduled at the system's discretion. - Android: WorkManager schedules deferrable, constraint-aware work with a minimum periodic interval of 15 minutes (Android: Define work requests); foreground services (with a declared type since Android 14) run long tasks with a persistent notification; FCM high-priority data messages wake the app.
Cross-platform and hybrid stacks reach these APIs only through native modules or plugins, which means writing (or adopting) native code for each platform — the capability belongs to the native shell, not to the web layer.
Push notifications¶
| Aspect | Web Push | APNs (iOS native) | FCM (Android native) |
|---|---|---|---|
| Subscription | PushManager.subscribe() with an application server key (VAPID) | Device token after registerForRemoteNotifications | Registration token |
| Transport | Push service chosen by the browser (FCM for Chrome, Mozilla autopush for Firefox, Apple's push service for Safari, WNS for Edge) | APNs | FCM |
| Encryption | End-to-end payload encryption mandated by RFC 8291 (aes128gcm) | TLS to APNs; payload visible to Apple | TLS to FCM |
| Payload size | Push services must accept at least 4096 bytes (RFC 8030); after the RFC 8291 header, tag and padding delimiter, about 3,993 bytes of plaintext fit in one record | 4 KB for standard notifications | 4096 bytes |
| Silent / background push | Not allowed in Chromium and Safari: both require userVisibleOnly: true, and Safari revokes the subscription after repeated pushes that show no notification. Firefox accepts userVisibleOnly: false with a quota on background messages | Allowed, throttled | Allowed (data messages) |
| Requires install | iOS/iPadOS: yes (Home Screen web app). Elsewhere: no | n/a | n/a |
| Rich features | Actions, images (Chromium), badges; no custom UI | Notification Service/Content extensions, Live Activities, time-sensitive and critical alerts | Custom layouts, channels, full-screen intents |
Safari's rule is explicit: WebKit requires userVisibleOnly: true and states that "violations of the userVisibleOnly promise will result in a push subscription being revoked" (WebKit: Meet Web Push). WebKit does not document a numeric threshold, so treat every push that ends without a visible notification as a risk. The most common cause is a push handler that calls showNotification() without passing the promise to event.waitUntil(): the event can finish before the notification is displayed. Web Push reached iOS and iPadOS in 16.4 for Home Screen web apps, and Declarative Web Push — push messages that display a notification without running a service worker — shipped for iOS/iPadOS 18.4 web apps and for macOS in Safari 18.5 (WebKit: Meet Declarative Web Push).
Full implementation details: Push Notifications, The Web Push Protocol and Web Push on iOS & Safari.
Hardware and OS capability matrix¶
This matrix compares what a PWA can reach on each platform with what a native app can reach on the same device. "Safari iOS web app" means a site added to the Home Screen on iOS/iPadOS; the same WebKit limits apply to Chrome, Edge and Firefox on iOS because they use WebKit.
| Capability | iOS native | Safari iOS web app | Android native | Chrome Android PWA | Desktop Chromium PWA |
|---|---|---|---|---|---|
| Camera and microphone | ✅ | ✅ getUserMedia | ✅ | ✅ | ✅ |
| Foreground geolocation | ✅ | ✅ | ✅ | ✅ | ✅ |
| Background location, geofencing | ✅ | ❌ | ✅ | ❌ | ❌ |
| Bluetooth LE | ✅ Core Bluetooth | ❌ | ✅ | ✅ Web Bluetooth | ⚠️ Web Bluetooth1 |
| NFC | ✅ Core NFC | ❌ | ✅ | ✅ Web NFC (89+) | ❌ |
| USB devices | ⚠️ MFi / DriverKit2 | ❌ | ✅ | ✅ WebUSB | ✅ WebUSB |
| Serial ports | ⚠️ MFi only | ❌ | ✅ | ✅ Web Serial (148+)3 | ✅ Web Serial (89+) |
| HID devices | ⚠️ Game controllers, MFi | ❌ | ✅ | ❌ | ✅ WebHID (89+) |
| MIDI | ✅ Core MIDI | ❌ | ✅ | ✅ Web MIDI | ✅ Web MIDI |
| Vibration / haptics | ✅ Core Haptics | ❌ | ✅ | ✅ navigator.vibrate | n/a |
| Motion and orientation sensors | ✅ | ✅ after DeviceMotionEvent.requestPermission() | ✅ | ✅ incl. Generic Sensor API | ⚠️ Device-dependent |
| Screen wake lock | ✅ | ✅ (18.4+ in Home Screen apps) | ✅ | ✅ | ✅ |
| Orientation lock | ✅ | ❌ | ✅ | ✅ screen.orientation.lock() | ❌ |
| Contacts | ✅ | 🧪 Contact Picker behind a flag | ✅ | ✅ Contact Picker | ❌ |
| Barcode detection | ✅ Vision / VisionKit | 🧪 behind a flag | ✅ ML Kit | ✅ BarcodeDetector | ⚠️ macOS and ChromeOS only |
| Passkeys / biometrics | ✅ | ✅ WebAuthn | ✅ | ✅ WebAuthn | ✅ WebAuthn |
| Identity documents | ✅ | ✅ Digital Credentials API (Safari 26) | ✅ | ✅ Digital Credentials API (141+) | ✅ (141+) |
| In-app payments | ✅ Apple Pay, StoreKit | ✅ Apple Pay via Payment Request | ✅ Google Pay, Play Billing | ✅ Payment Request; Play Billing in a TWA | ✅ Payment Request |
| User file access | ✅ Document picker, File Provider | ⚠️ <input type=file> only | ✅ Storage Access Framework | ✅ File System Access pickers (132+) | ✅ File System Access (86+) |
| Private app files | ✅ | ✅ OPFS | ✅ | ✅ OPFS | ✅ OPFS |
| Share out (share sheet) | ✅ | ✅ Web Share | ✅ | ✅ Web Share | ✅ Web Share |
| Receive shares (share target) | ✅ Share extension | ❌ | ✅ Intent filters | ✅ share_target (installed) | ⚠️ ChromeOS; Edge on Windows4 |
| Open files from the OS ("Open with") | ✅ | ❌ | ✅ | ❌ | ✅ file_handlers (102+) |
| Custom URL schemes | ✅ | ❌ | ✅ | ❌ | ✅ protocol_handlers (96+) |
| Launcher shortcuts | ✅ Quick actions | ❌ (macOS Dock: Safari 17.4+) | ✅ | ✅ shortcuts | ✅ shortcuts |
| App icon badge | ✅ | ✅ Badging API (16.4+) | ✅ launcher-dependent | ❌ (notification dots only) | ✅ Windows, macOS, ChromeOS |
| Home screen widgets | ✅ WidgetKit | ❌ | ✅ App widgets | ❌ | ⚠️ Edge on Windows 11 only5 |
| Live Activities / ongoing notifications | ✅ | ❌ | ✅ | ❌ | ❌ |
| GPU compute | ✅ Metal | ✅ WebGPU (Safari 26) | ✅ Vulkan | ✅ WebGPU (121+) | ✅ WebGPU |
| AR | ✅ ARKit | ❌ WebXR | ✅ ARCore | ✅ WebXR with ARCore | ⚠️ WebXR with a headset |
| Health data, calendar, telephony, assistant integration | ✅ | ❌ | ✅ | ❌ | ❌ |
Support data as of September 2026. Version numbers are the first stable Chromium or Safari release with default-on support, from MDN's browser compatibility data; check MDN and caniuse.com for live data. Most device APIs in the Chromium columns are Chromium-only — Firefox and Safari have declined to implement WebUSB, WebHID, Web Bluetooth and Web NFC on privacy and security grounds — so treat them as progressive enhancements even on Android.
Each capability has a dedicated page under Device & OS Integration, notably Hardware & Device APIs, File System Access and Payments. A robust app detects capabilities at runtime instead of assuming them from the platform:
// Feature detection only: never infer capabilities from the user agent string.
export const capabilities = Object.freeze({
serviceWorker: "serviceWorker" in navigator,
push: "PushManager" in globalThis && "serviceWorker" in navigator,
badging: "setAppBadge" in navigator,
share: typeof navigator.share === "function",
shareFiles: typeof navigator.canShare === "function",
backgroundSync: "SyncManager" in globalThis,
periodicSync: "PeriodicSyncManager" in globalThis,
backgroundFetch: "BackgroundFetchManager" in globalThis,
fileSystemAccess: "showOpenFilePicker" in globalThis,
opfs: typeof navigator.storage?.getDirectory === "function",
bluetooth: "bluetooth" in navigator,
usb: "usb" in navigator,
serial: "serial" in navigator,
hid: "hid" in navigator,
nfc: "NDEFReader" in globalThis,
wakeLock: "wakeLock" in navigator,
contacts: "contacts" in navigator && "ContactsManager" in globalThis,
webgpu: "gpu" in navigator,
webauthn: "PublicKeyCredential" in globalThis,
});
// iOS/iPadOS only exposes Push and Notification to Home Screen web apps,
// so "push supported" also depends on how the app was launched.
export function isStandalone() {
return (
matchMedia("(display-mode: standalone)").matches ||
matchMedia("(display-mode: fullscreen)").matches ||
matchMedia("(display-mode: minimal-ui)").matches ||
navigator.standalone === true // non-standard: iOS/iPadOS, and macOS Safari 17+ Dock apps
);
}
Performance¶
Performance comparisons usually collapse into "native is faster", which hides the parts that matter. Break it down by phase.
Startup¶
PWA cold start is a chain: the browser process must be running (on Android, launching a WebAPK starts Chrome if it is not already alive), the service worker is started if it is not running, the navigation is answered from the service worker or network, then HTML is parsed and JavaScript is compiled and executed. Each link has a mitigation:
- Navigation Preload starts the network request in parallel with service worker startup.
- The Static Routing API (Chromium 123+, Safari 27) lets the browser route requests without starting the worker at all.
- Scripts that a service worker adds to Cache Storage during its
installevent get a full V8 code cache immediately, so later loads skip parsing and compilation (V8: Code caching for JavaScript developers). - An app shell or streamed HTML (Streaming Responses) renders something meaningful before the JavaScript bundle finishes.
Native cold start skips the network entirely (code is on disk) and skips parsing (code is compiled), but still pays for process creation, dynamic linking, and framework initialization. Android forks from the Zygote process and benefits from Baseline Profiles; iOS pays for dyld and any work done before the first frame.
React Native removes JavaScript parsing via precompiled Hermes bytecode, but still initializes the JS runtime and native modules before rendering. Flutter loads an AOT snapshot and initializes its engine; there is no parse step. Capacitor starts the native shell, initializes a WebView (a comparatively heavy object) and loads bundled assets from disk — it never waits on the network, but pays WebView initialization on every cold start.
Rendering and frame budget¶
All stacks render through GPU-composited layers; the difference is which thread does what. On the web, JavaScript, style, layout and most event handling share the main thread, so a long task blocks input — measured by INP (see Core Web Vitals and Runtime Performance). Compositor-driven animations (transform, opacity, CSS scroll-driven animations) keep running during main-thread work. Native toolkits also have a single UI thread, but layout of native views is cheaper to invalidate than CSS layout on a large DOM, and native lists recycle cells by default whereas on the web you implement virtualization yourself. Flutter separates a UI thread (building the frame) from a raster thread (GPU commands), which isolates rasterization cost from Dart work.
JavaScript vs compiled code¶
For CPU-bound work, AOT-compiled Swift, Kotlin or Dart generally beats JIT-compiled JavaScript on predictability — no warm-up, no deoptimization cliffs. WebAssembly closes much of that gap for compute kernels (image processing, codecs, CRDT merges). One edge case: Apple's Lockdown Mode disables JIT compilation and WebAssembly in WebKit unless the user exempts the site, which can make JavaScript-heavy PWAs dramatically slower for that small population; native apps are unaffected.
Memory¶
Browsers are multi-process, so a PWA pays for a renderer process plus shared browser and GPU processes; on a phone where the browser is already running, the incremental cost is the renderer. iOS aggressively terminates background apps and WebKit content processes under memory pressure; a WKWebView whose content process dies shows a blank view unless the host app implements webViewWebContentProcessDidTerminate(_:) and reloads (Android's equivalent is WebViewClient.onRenderProcessGone). Electron apps pay for their own Chromium per app and a renderer process per window, which is why several Electron apps open at once consume more memory than the same number of tabs in one browser.
Performance summary¶
| Phase | PWA | Native | React Native | Flutter | Capacitor | Electron | Tauri |
|---|---|---|---|---|---|---|---|
| First-ever launch | Network-bound (no install) | Download + install first | Download + install first | Download + install first | Download + install first | Download + install first | Download + install first |
| Warm/cold start after install | SW start + cache; improved by navigation preload, static routing, code cache | Fastest | JS runtime init; no parse | Engine init; AOT | WebView init + disk load | Chromium + Node init | WebView init |
| Scrolling long lists | Needs virtualization | Recycling built in | Recycling via list components | Lazy builders | Needs virtualization | Needs virtualization | Needs virtualization |
| CPU-heavy compute | JIT JS or Wasm | AOT native | Hermes (no JIT) or native module | AOT Dart | WebView JIT or native plugin | V8 JIT, Node native addons | WebView JIT or Rust |
| Memory baseline | Browser renderer | App process | App + JS runtime | App + engine | App + WebView processes | Full Chromium per app | App + OS WebView |
Measure instead of assuming: Measuring Performance covers lab and field tooling for PWAs.
OS integration¶
| Integration point | PWA (best case, Chromium) | PWA on iOS | Native |
|---|---|---|---|
| Launcher icon, app switcher entry | ✅ | ✅ | ✅ |
| Registered as an OS-level app (app list, app settings, uninstall) | ✅ Android (WebAPK), Windows, macOS, ChromeOS | ⚠️ Home Screen icon; remove by deleting it | ✅ |
| Share sheet (send) | ✅ Web Share | ✅ | ✅ |
| Share sheet (receive) | ✅ Web Share Target | ❌ | ✅ |
| File associations | ✅ desktop File Handling | ❌ | ✅ |
| Launcher shortcuts / jump lists | ✅ App Shortcuts | ❌ | ✅ |
| Icon badge | ✅ desktop Badging API | ✅ | ✅ |
| Title bar customization | ✅ Window Controls Overlay | n/a | ✅ |
| Widgets, Live Activities, Control Center, watch complications | ❌ | ❌ | ✅ |
| Spotlight / system search indexing of in-app content | ❌ | ❌ | ✅ |
| Siri, App Intents, Google Assistant actions | ❌ | ❌ | ✅ |
| Default-app roles (browser, mail, dialer, keyboard) | ❌ | ❌ | ✅ |
Store policy technical constraints¶
Store policies are not business trivia; they decide which code paths you must build.
Apple App Store¶
- Guideline 4.2, Minimum Functionality: "Your app should include features, content, and UI that elevate it beyond a repackaged website." A
WKWebViewwrapper around a PWA with no native value is a common rejection. Practical implication: an App Store build of a PWA needs genuine native integration (push through APNs, widgets, share extension, offline data, biometric unlock) to be reliably approved. - Guideline 2.5.6: apps that browse the web must use WebKit, except under the alternative browser engine entitlements for the EU and Japan. Your wrapper cannot bundle Chromium.
- Guideline 3.1.1: unlocking digital features or content requires In-App Purchase. The prohibition on buttons and external links to other purchasing mechanisms does not apply to the United States storefront, per the current guidelines. A wrapped PWA that sells digital goods must therefore detect that it is running inside the App Store build and switch to StoreKit (through a native plugin) where required.
- Guideline 2.5.2 and 4.7: downloaded code may not change the app's features beyond what was reviewed; HTML5 mini apps and games are allowed under 4.7 with the developer responsible for their compliance.
Read the current text in the App Review Guidelines; they change several times a year.
Google Play¶
- A TWA is a Play app, so Play policies apply to the web content it displays, including the Payments policy. Selling digital goods inside a Play-distributed app requires Google Play Billing by default. For a TWA, Chrome 101+ on Android and ChromeOS exposes Play Billing to the web content through the Digital Goods API plus the Payment Request API with the
https://play.google.com/billingpayment method (Chrome: Play Billing in TWAs). - Region-specific rules change the options. In the United States, after the Ninth Circuit upheld the Epic v. Google injunction on September 12, 2025, Google stopped requiring Play Billing for apps serving US users (effective October 29, 2025) and launched alternative-billing and external-link programs on December 9, 2025 (Play Console Help). Check the current policy for each market you ship to.
- The same origin served in a normal browser tab is not a Play app, so web checkout remains available there. You need per-channel logic:
const PLAY_BILLING = "https://play.google.com/billing";
/**
* Decide which payment path the current launch context must use.
* A TWA launch can be recognized because Chrome sets the referrer of the
* first navigation to android-app://<package-name>. Persist the result,
* because later navigations lose that referrer.
*/
export function detectChannel() {
const stored = sessionStorage.getItem("launch-channel");
if (stored) return stored;
let channel = "web";
if (document.referrer.startsWith("android-app://")) channel = "play-twa";
else if (globalThis.Capacitor?.isNativePlatform?.()) channel = "app-store-wrapper";
sessionStorage.setItem("launch-channel", channel);
return channel;
}
export async function getPlayBillingService() {
if (!("getDigitalGoodsService" in window)) return null;
try {
return await window.getDigitalGoodsService(PLAY_BILLING);
} catch {
return null; // Not a Play-installed TWA, or Play Billing unavailable.
}
}
export async function buyWithPlayBilling(sku) {
const service = await getPlayBillingService();
if (!service) throw new Error("Play Billing is not available in this context");
const request = new PaymentRequest(
[{ supportedMethods: PLAY_BILLING, data: { sku } }],
// Play shows the real price; the total here is required but ignored.
{ total: { label: "Total", amount: { currency: "USD", value: "0" } } },
);
const response = await request.show();
const { purchaseToken } = response.details;
// Verify and acknowledge on your server before granting the entitlement:
// Play refunds and revokes purchases not acknowledged within three days.
const ok = await fetch("/api/play/acknowledge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sku, purchaseToken }),
}).then((r) => r.ok, () => false);
await response.complete(ok ? "success" : "fail");
return ok;
}
Bubblewrap-generated TWAs enable this with "playBilling": { "enabled": true } plus alphaDependencies enabled in twa-manifest.json (Bubblewrap 1.8.2 or later). Two server-side details matter: verify the purchaseToken with the Google Play Developer API before granting anything, and acknowledge (or consume, for consumables) every purchase — Chrome's documentation warns that unacknowledged purchases are refunded and revoked after three days. Store-specific packaging is covered in Publishing to App Stores and Trusted Web Activity; payment APIs in Payments.
Microsoft Store and others¶
The Microsoft Store lists PWAs packaged as MSIX by PWABuilder; the installed app still runs in Edge's engine. MDN also lists Meta Quest's store as accepting PWAs. These channels add review but not an engine change.
Development and maintenance cost drivers¶
Cost is dominated by the number of codebases × release pipelines × platform-specific capability work, plus the long tail of keeping each current. The table lists the drivers rather than invented dollar figures.
| Cost driver | PWA | Native (iOS + Android) | React Native / Flutter | Capacitor | Electron / Tauri |
|---|---|---|---|---|---|
| UI codebases | 1 | 2 | 1 (+ platform-specific branches) | 1 | 1 |
| Languages to staff | Web stack | Swift + Kotlin | JS/TS or Dart + some Swift/Kotlin | Web stack + some Swift/Kotlin | Web stack + Node.js or Rust |
| Release pipelines | CI → CDN | 2 store pipelines, signing certificates, provisioning | 2 store pipelines | 2 store pipelines | Installers, code signing and notarization per OS, update server |
| Mandatory yearly work | Browser deprecations (rare, announced) | New OS SDK requirements each year; Google Play target API level within one year of the latest Android release | Framework upgrades tied to OS SDKs | WebView and plugin upgrades | Electron major every 8 weeks for security support |
| Capability gaps | Can only wait for browsers | None | Native modules | Native plugins | Node/Rust modules |
| Backend version skew | Minimal | Must support old app versions | Native × OTA versions | Native × OTA versions | Old installer versions |
Two effects are easy to underestimate. First, design system duplication: a native pair needs the same component library twice, with drift. Second, the web codebase never goes away: almost every native product also runs a website for SEO, sharing and desktop users, so "native" usually means three front ends, not two.
Testing surface¶
| Dimension | PWA | Native | Hybrid / wrappers |
|---|---|---|---|
| Engines | Blink, WebKit, Gecko × versions users run | One runtime per platform × OS versions | OS WebView versions × OS versions |
| Launch modes | Browser tab, installed standalone, iOS Home Screen (separate storage), TWA | Single | Single, plus the web build if also shipped as a PWA |
| Lifecycle states | Service worker installing / waiting / active, cache versions, storage eviction | App updates, migrations, background states | Both native lifecycle and web caching (if SW used) |
| Offline and flaky networks | Must test explicitly | Must test explicitly | Must test explicitly |
| Automation | Playwright/WebDriver across Chromium, WebKit, Firefox; real iOS devices for Home Screen push | XCTest/XCUITest, Espresso/Compose testing, device farms | Both toolchains |
| Store review regressions | None | Every release | Every native release |
The PWA-specific traps — service worker update states, cache poisoning, standalone-only behavior — are covered in Automated Testing and Browser DevTools.
When native clearly wins¶
Choose native (or a cross-platform native framework) when any of these is a core requirement rather than a nice-to-have:
- Background execution: location tracking with the app closed, continuous Bluetooth sessions with wearables, background audio processing, scheduled sync on iOS.
- Deep OS surfaces: widgets, Live Activities, watch apps, CarPlay/Android Auto, assistant intents, system-wide share extensions on iOS, custom keyboards, call handling.
- Hardware on iOS: Bluetooth LE, NFC, USB accessories and serial devices are unavailable to web apps on iOS; if your iOS users need them, the web cannot deliver.
- Silent push and data-driven wake-ups: messaging apps that must sync before displaying a notification, or apps that update content in the background.
- Sustained heavy compute or graphics with predictable latency: pro camera pipelines, low-latency audio, AAA-class 3D — although WebGPU and WebAssembly have narrowed this considerably.
- Store-first discovery in a category where users only search the App Store or Play Store — and where store policies do not make a wrapped PWA impractical.
When a PWA wins¶
- Reach and first use matter more than depth: e-commerce, media, news, travel booking, internal tools, SaaS dashboards — anything where a link must work instantly on any device.
- You already have a web product: adding a manifest and service worker is incremental (see Migrating an Existing Site), while a native app is a new product.
- Release velocity: you want every user on the fix within hours, without review.
- Desktop is a first-class target: installed PWAs on Windows, macOS, ChromeOS and Linux cost nothing extra, whereas native desktop apps are a separate project.
- Content must be indexable and shareable.
- Your capability needs are within the web platform on the browsers your users actually use — verify with the matrix above and your analytics, not with assumptions.
The companion guide When to Build a PWA turns these into a decision checklist.
Hybrid strategies¶
Strategy 1: PWA everywhere, TWA for Google Play¶
Build one PWA, then publish the same origin to Google Play as a Trusted Web Activity. The TWA inherits Chrome's engine and storage, so updates to the site reach Play users instantly, and Play-specific requirements (billing) are handled with the Digital Goods API as shown above. Ownership is proven by serving a Digital Asset Links file:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.shop",
"sha256_cert_fingerprints": [
"AB:CD:EF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC"
]
}
}
]
If you use Play App Signing, the fingerprint must be the one of Google's app signing key from the Play Console, not your upload key — the most common reason a TWA falls back to showing a browser URL bar.
Strategy 2: PWA plus a Capacitor shell for the App Store¶
Ship the PWA to browsers and the same web build inside Capacitor for iOS (and optionally Android), adding native plugins where the web cannot reach — APNs push, widgets through a native extension, StoreKit. Bundle the web assets into the app rather than loading your live site, both for guideline 4.2 and so the app works offline without service workers:
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.shop",
appName: "Example Shop",
webDir: "dist", // the same production build you deploy as the PWA
server: {
// Defaults shown explicitly: bundled assets are served from these origins,
// which are different origins from https://shop.example.com.
androidScheme: "https",
iosScheme: "capacitor",
hostname: "localhost",
},
ios: {
// Needed only if you want service workers in WKWebView; requires
// WKAppBoundDomains in Info.plist (max 10 domains, including localhost).
limitsNavigationsToAppBoundDomains: false,
},
};
export default config;
Two consequences of the different origin: storage in the Capacitor app is not shared with the PWA in Safari, and your API must allow CORS from capacitor://localhost and https://localhost. Guard web-only features (install prompts, service worker registration) behind a platform check so the same bundle behaves correctly in both contexts.
Strategy 3: progressive migration¶
Migration can run in either direction, one screen at a time:
- Native app adopting web screens: host rarely used or fast-changing flows (settings, help, checkout, campaigns) in a WebView within the native app, sharing the web codebase. Treat the WebView as untrusted relative to native code and pass only narrow, validated messages.
- Native app to PWA: launch the PWA for new users and desktop, keep the native app for existing users, route shared links to the PWA, and retire native screens as the web reaches parity. Use
related_applicationsandgetInstalledRelatedApps()(Chromium, Android) to avoid prompting installed-native users to install the PWA too; see Detecting Installed Apps. - PWA to native: when a single capability gap (for example background location) becomes critical, wrap the existing PWA in Capacitor and implement only that feature natively instead of rewriting everything.
Choosing a strategy¶
flowchart TD
A["Core feature needs background execution, widgets or iOS hardware APIs?"] -->|Yes| B["Native or cross-platform native, plus a website"]
A -->|No| C["Must users find you in app stores?"]
C -->|No| D["PWA only"]
C -->|"Google Play only"| E["PWA + Trusted Web Activity"]
C -->|"App Store too"| F["PWA + Capacitor shell with real native features"]
B --> G["Web team already strong and UI is standard?"]
G -->|Yes| H["React Native or Capacitor with native modules"]
G -->|No| I["Swift and Kotlin, or Flutter"]
D --> J["Desktop app needs OS access beyond the browser?"]
J -->|Yes| K["Add Tauri or Electron shell"]
J -->|No| L["Installed PWA on desktop"] Common pitfalls¶
- Comparing a well-built native app to a poorly built website. Many "PWA vs native" benchmarks compare a native app to a site without a service worker, without code splitting, and with third-party scripts on the critical path. Compare equivalent engineering effort.
- Assuming Android Chrome behavior on iOS. Background Sync, Web Bluetooth,
beforeinstallprompt, share targets and orientation lock do not exist on iOS. Push exists only for Home Screen web apps. Test on real iOS devices. - Forgetting the iOS storage split. A user who installs your PWA to the Home Screen gets its own cookie jar and storage. Since iOS 17.2, Safari's cookies are copied into the web app when it is created, so users generally stay signed in, but
localStorage, IndexedDB and caches written in Safari are not visible to the installed app. - Loading the live site inside a hybrid shell. It combines the WebView's reduced quota and missing service workers (iOS) with the store's review risk, and gives remote content access to native plugins.
- Treating TWA as a WebView. A TWA renders in the user's browser, so browser-specific bugs and features apply; you cannot inject JavaScript or intercept requests from the Android side.
- Ignoring engine patching in Electron. Every Electron app that is not updated regularly ships an increasingly vulnerable Chromium.
- Using user-agent sniffing to gate features. Detect APIs at runtime as shown in the capability module; iOS browsers, Android WebViews and desktop Chromium variants report misleading strings.
- Over-promising offline in hybrid apps. Without service workers (iOS WebViews), offline means bundled assets plus your own data layer; network requests still fail.
Further reading¶
On this site
- What Is a PWA?
- Core Building Blocks
- Installability Criteria
- iOS & iPadOS platform support
- Android platform support
- Desktop Platforms
- Trusted Web Activity
- The future: native, PWA or both?
External references
- MDN: Storage quotas and eviction criteria
- WebKit: Updates to Storage Policy
- WebKit: Features in Safari 26.0
- WebKit: Features for Safari 27.0
- Apple App Review Guidelines
- Chrome for Developers: Trusted Web Activity
- Electron: Security checklist
- Tauri: Capabilities
- React Native 0.82 release notes
- RFC 8030: Generic Event Delivery Using HTTP Push and RFC 8291: Message Encryption for Web Push
-
Web Bluetooth is on by default in Chromium on Windows, macOS, ChromeOS and Android; on Linux it is not enabled by default. ↩
-
iOS exposes accessories through the External Accessory framework (MFi program); iPadOS supports DriverKit drivers on iPads with M-series chips. There is no general-purpose USB API. ↩
-
Chrome on Android exposed Web Serial for Bluetooth RFCOMM serial ports from Chrome 138 and full support from Chrome 148. Firefox shipped Web Serial on desktop in Firefox 151. ↩
-
Microsoft Edge lets PWAs distributed through the Microsoft Store declare Adaptive Card widgets for the Windows 11 Widgets Board via a
widgetsmanifest member (Microsoft Learn). No other browser supports it. ↩