One codebase, web and Android: what Capacitor actually costs
Shipping the same React app to the browser and to Google Play. The parts that are genuinely free, the parts that are not, and the config bug that ships…
Most of my products ship as a web app and an Android app from the same codebase. Capacitor is what makes that a build target rather than a second project, and it delivers on that promise more completely than I expected. It is also not free, and the costs land in places the marketing does not mention.
What you actually get
Your built web app runs inside a native WebView, with a bridge to native APIs. That is the whole model, and its consequences are the important part:
- Your React app is the app. No parallel implementation, no shared-logic layer that drifts.
- Native capability — camera, filesystem, push, biometrics, share — comes through plugins with a JavaScript API.
- The native projects are real Android and iOS directories you can open and edit.
That last point is the one people miss. Capacitor does not hide the native project from you; it generates it and then gets out of the way. You commit the native source and ignore the native build output, and when you need a manifest change you make it in the manifest.
The one bug that has cost me the most
This is the one I would put on a poster. capacitor.config.ts looks like application code, so it is natural to read environment variables in it:
// Silently ships an EMPTY value.
const config: CapacitorConfig = {
appId: 'com.example.app',
plugins: { GoogleAuth: { serverClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID } },
};
The config is evaluated by the Capacitor CLI during cap sync, which is Node — not Vite. There is no import.meta.env, nothing loads your .env, and the value is undefined. It does not throw. It writes an empty string into the generated native config, the build succeeds, every gate stays green, and sign-in fails on a device with an error that points nowhere near the cause.
The fix is to load the file yourself, explicitly:
process.loadEnvFile('.env');
const clientId = process.env.VITE_GOOGLE_CLIENT_ID ?? '';
if (clientId === '') throw new Error('VITE_GOOGLE_CLIENT_ID is empty at cap sync time');
The throw is the important half. Failing the sync is recoverable in ten seconds; shipping an empty credential is a store submission and a rejection cycle.
Edge-to-edge, and the Android version that changed the default
Recent Android versions draw applications edge to edge by default, so a WebView that assumed it owned the space now paints under the status bar and the gesture navigation area. Content ends up behind system chrome, and the fix is not more CSS padding — it is telling the system bars plugin to stop handling insets so your own env(safe-area-inset-*) handling is the only one in play. Two systems both trying to be helpful is what produces the doubled padding everyone tries to fix by halving a number.
What Capacitor does not fix
Being honest about this is what makes the trade worth taking.
- WebView performance is WebView performance. Long lists, heavy animation and large DOM trees behave like a mobile browser, because they are one. Virtualise long lists, keep animations on transform and opacity, and stop pretending a table with two thousand rows is fine.
- Keyboard and IME behaviour are genuinely worse. A
contenteditable rich-text editor and an Android keyboard with autocorrect will find each other's edges in ways a native text field never does.
- The store review is a real product surface. Every permission in the merged manifest needs a justification in your privacy policy and your data safety form — including permissions a plugin injected that you never asked for. Read the merged manifest, not yours.
- A green build proves nothing about the device. The build and the running app fail in different places, and only one of them has a keyboard.
Updates without a store round trip
The part that made this stack sustainable for me: because the app is a bundle of web assets, you can ship a new bundle over the air without a store review. I maintain a Capacitor plugin for exactly this, and the one rule that matters is the rollback contract — the app must confirm it booted healthily, or the native layer reverts to the previous bundle after two cold starts.
That single call is the difference between over-the-air updates being a superpower and being a way to brick your install base remotely.
// Call this once the app has actually rendered and its data layer is alive.
await NativeUpdate.notifyAppReady();
Put it somewhere that genuinely proves health. Calling it at the top of your entry file technically satisfies the API and defeats the entire mechanism.
What actually differs between the browser build and the device build
The mental model that saved me the most time: treat the native app as the same application with a different set of capabilities available, not as a different application. That means one runtime check, at the point of decision, rather than parallel implementations.
Sharing is the clearest example. On the web I open my own share dialog with a grid of destinations and an explicit copy-link option. On a device I hand off to the native share sheet. The split happens at the moment the button is pressed, in one function, and everything above it is identical.
The anti-pattern — and it is common enough that the fleet rule I work to bans it explicitly — is using the browser's own share API as the primary web path with a silent clipboard fallback. On a desktop browser without that API, the person presses Share, something is copied without any visible confirmation, and no sheet appears. They have no idea whether it worked or where it went.
Storage is the other place where one interface and several implementations pays off. The web build uses browser storage, the native build uses the platform's preferences, and neither the calling code nor the tests know which is underneath. That sounds like over-abstraction until the first time you need the same setting to survive an app update on Android and a hard refresh on the web, and there is exactly one place to make that true.
Everything else — routing, state, data fetching, the design system, validation — is genuinely identical, and that is the whole reason to accept the WebView trade in the first place.
Would I choose it again
For a product whose value is its data and its workflows, yes, without hesitation — one codebase, one design system, one set of tests, and Android as a build target. For something whose value is the interaction itself, a camera app or a game, no. The WebView is exactly where you would feel it, and Capacitor's honesty about being a WebView is the reason it is easy to know which side of that line you are on.
https://aoneahsan.com/blog/capacitor-8-web-to-native