Shipping a Web App to iOS and Android with Capacitor: Plugins, Push and Common Blockers
Key takeaways
Capacitor loads your built web assets into a native shell and connects JavaScript to OS APIs through a thin, predictable bridge. This article covers architecture, iOS/Android project structure, official plugins (Camera, Filesystem, etc.), custom native plugins, push and background limits, performance tuning, and how to choose versus Cordova.
Introduction
Capacitor is a cross-platform runtime from the Ionic team. It lets you ship any web frontend build (Vue, React, Angular, Svelte, etc.) to iOS, Android, and the web (PWA). The UI is drawn with web technology, but the app still lives inside native lifecycle rules, permission prompts, and store policies, and most of the work in a Capacitor project is about that boundary.
This article covers the architecture, the iOS and Android project layout, official and custom plugins, the real limits on push and background work, performance, the comparison with Cordova, and the problems that most often block a first release.
Core Capacitor concepts
Native shell and web assets
A Capacitor app is not merely “a browser opening a URL.” Static web assets (webDir) are copied into the native project produced by Xcode/Android Studio, and WKWebView (iOS) or Android System WebView loads them. During development you can point at a dev server with server.url for an experience close to hot reload.
Capacitor runtime and bridge
On the JavaScript side, @capacitor/core handles the plugin registry and message passing. On the native side, the Capacitor runtime invokes implementations that match the same plugin and method names. Because that contract is explicit, TypeScript definitions line up well, and new plugins follow a repeatable pattern.
What npx cap sync does
sync (1) copies your web build to the right place (e.g. android/app/src/main/assets/public) and (2) aligns native dependencies and plugin hooks with Gradle / CocoaPods. This is why rebuilding “only the web” does not show up in a store build until you sync. For release builds, always follow web build → cap sync → native build.
Central config: capacitor.config
One place holds app ID (appId), display name (appName), web asset path (webDir), dev server URL, and iOS/Android-specific options. Larger teams often split config by environment (staging API, logging) in capacitor.config.ts, or swap files in the build pipeline.
// capacitor.config.ts (example structure)
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'MyApp',
webDir: 'dist',
server: {
// Dev only: live reload from a dev server. Remove both lines for production builds.
// url: 'http://192.168.0.10:5173',
// cleartext: true, // needed on Android only when url is plain http
},
ios: {
contentInset: 'automatic',
},
android: {
allowMixedContent: false,
},
};
export default config;
webDir must match your production build output from Vite, Webpack, Angular CLI, etc. If you ship a store archive with server.url still set, the app loads its UI from that remote address instead of the bundled assets: it breaks when the dev machine is off and is likely to be rejected in review. Use a separate production config in CI.
Understand the security model
By default Capacitor serves the bundled assets from capacitor://localhost on iOS and https://localhost on Android, so the WebView origin is not your API’s origin. Hybrid apps also deal with mixed content and plain HTTP left over from debug builds. For production, prefer HTTPS only, disable unnecessary cleartext, and add only trusted origins to allowNavigation. Many teams avoid leaving sensitive tokens in WebView storage alone and use Keychain / Keystore wrapper plugins instead.
iOS and Android project layout
Typical setup flow
- Get your web project to a state where a production build works.
npm install @capacitor/core @capacitor/cli, thennpx cap initwith app ID andwebDir.npm install @capacitor/ios @capacitor/android, thennpx cap add iosandnpx cap add androidto create the native projects.npx cap syncto copy assets and align native dependencies.npx cap open iosornpx cap open androidto open the IDE.
If Xcode, CocoaPods, Android SDK, and JDK versions differ per developer, you get “works on my machine” issues. Write down the versions the team builds with and update them deliberately.
iOS: what to inspect in Xcode
Under Signing & Capabilities you set the bundle ID and enable push notifications, background modes, and app groups. Info.plist must contain a usage description for every protected resource the app touches (NSCameraUsageDescription, NSMicrophoneUsageDescription, NSPhotoLibraryUsageDescription, and so on); a missing string crashes the app the moment the permission is requested and gets the build rejected. AppDelegate is where deep links and push token callbacks arrive, so plugins that need them ask you to forward those calls there. WKWebView behavior also changes between iOS versions, so decide on a minimum iOS version and test on it.
Android: Gradle and manifest
Keep applicationId in android/app/build.gradle consistent with appId in capacitor.config. Google Play raises the required targetSdkVersion every year, so plan to bump it (and the Capacitor major version that supports it) on a schedule rather than when an upload is refused. AndroidManifest.xml holds permissions, android:usesCleartextTraffic, and any services. If you enable R8 minification for release, test the release build itself: official plugins ship their own keep rules, but custom plugins or reflection-based SDKs may need extra ones.
Mental model of the folder tree
The repo root often has src/ (frontend), dist/ (build), ios/, and android/. If you edit native code by hand, Capacitor upgrades can conflict—isolate custom native work in plugins or keep documented patch files.
flowchart LR
subgraph web [Web build]
A[Source TS/Vue/React] --> B[Bundler build]
B --> C[webDir output]
end
subgraph cap [Capacitor]
C --> D[npx cap sync]
D --> E[Copy to iOS/Android]
end
subgraph native [Native]
E --> F[Xcode / Android Studio build]
F --> G[IPA / AAB·APK]
end
Native plugin APIs
Official @capacitor/* packages wrap native features in Promise-based APIs. The usual pattern is import { PluginName } from '@capacitor/...' then await PluginName.method(). A rejected promise is a normal outcome here, not an edge case: the user can deny a permission or cancel a picker, so the UI needs a path for each.
Camera
@capacitor/camera provides photo capture and the gallery picker. Full-resolution photos from modern phones are large, so decide early whether to downscale (the width/height and quality options do this natively) and whether to strip EXIF metadata such as location before upload.
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
export async function pickPhoto() {
const image = await Camera.getPhoto({
quality: 85,
allowEditing: false,
resultType: CameraResultType.Uri,
source: CameraSource.Prompt, // Camera vs gallery chooser UI
});
// image.webPath — path usable inside the WebView
return image;
}
In production, prefer file URI streams over Base64 for uploads when memory matters. On iOS, account for Photos permission scope (limited library) when designing UX.
Filesystem
@capacitor/filesystem abstracts DATA, CACHE, DOCUMENTS, and similar storage areas. Use it for offline caches, logs, and user-generated content. Prefer API-provided URIs and directory constants over hand-built path strings to absorb OS differences.
For large files, design chunked read/write and progress UI, and verify compatibility with Android scoped storage.
Other common official plugins
| Area | Representative plugins | Notes |
|---|---|---|
| Device info | App, Device | Model and OS version for analytics and compatibility branches |
| Network | Network | Offline banners, retry triggers for sync queues |
| Storage | Preferences | Small KV; review extra encryption for sensitive data |
| UI | Toast, Dialog, Status Bar, Splash Screen | Native look and feel |
| Hardware | Haptics, Geolocation | Mind permissions and battery |
Each plugin has its own minimum OS and permission declarations—validate on a real-device matrix before submission.
Custom plugin development
When to build custom
Use custom plugins when in-house SDKs, special peripherals, or security policy require code that must live only in native code. Capacitor lets you implement methods in Swift/Objective-C (iOS) and Kotlin/Java (Android) and call them from TypeScript with the same identifiers.
Design rules
Every call crosses the bridge as serialized JSON, so plugin inputs and outputs should be plain JSON values; large binary data is better written to a file and passed as a path. Native methods are not called on the UI thread by default, so anything touching UI must hop to the main thread (DispatchQueue.main on iOS, getActivity().runOnUiThread on Android), while heavy work stays off it. Reject calls with stable error codes rather than free-form messages, so the JavaScript side can branch on them.
TypeScript stub example
import { registerPlugin } from '@capacitor/core';
export interface EchoPlugin {
echo(options: { value: string }): Promise<{ value: string }>;
}
export const Echo = registerPlugin<EchoPlugin>('Echo', {
web: () => import('./echo-web').then((m) => new m.EchoWeb()),
});
If you target web (PWA) too, provide a web implementation so you can develop against one interface. For mobile-only apps, return a clear no-op or error from web.
Native side
On iOS, the plugin is a CAPPlugin subclass whose methods are @objc and take a CAPPluginCall; since Capacitor 6 the class also adopts CAPBridgedPlugin and lists its methods in pluginMethods (older versions used an Objective-C .m registration file). On Android, it is a Plugin subclass annotated with @CapacitorPlugin, with @PluginMethod on each exposed method. Whatever concurrency you use inside, every call must end in exactly one call.resolve(...) or call.reject(...); a code path that does neither leaves the JavaScript promise pending forever.
If native experience is thin, prefer one cohesive plugin over many tiny ones to control maintenance cost.
Push notifications and background work
Push: FCM / APNs
@capacitor/push-notifications handles permission requests, remote push token registration, and receiving pushes; scheduling local notifications is a separate plugin, @capacitor/local-notifications. You still build the server side that actually sends pushes (FCM HTTP v1, APNs keys or certificates) and an API for storing device tokens. On Android the plugin uses Firebase Cloud Messaging, so the project needs a google-services.json.
Tokens can change after reinstall or restore, so send the token to the server on every registration event rather than once. By default a push that arrives while the app is in the foreground is delivered to your listener instead of being shown; decide whether to display it in-app or set presentationOptions to show it as a system notification. Notification payloads usually carry a route or deep link, which you handle in the pushNotificationActionPerformed listener. On Android 8 and later, notifications also need a channel.
Background reality
Mobile OSes strictly limit background execution for battery and privacy. Do not expect Capacitor alone to run always-on workers like on desktop.
On iOS, background work only runs through specific mechanisms such as the BackgroundTasks framework (BGAppRefreshTask, BGProcessingTask), location updates, or a silent push, and the system decides when and for how long. On Android, deferrable work goes through WorkManager, and long-running work needs a foreground service with a visible notification; Google keeps tightening which foreground service types are allowed, so check the current policy for your target SDK.
@capacitor/background-runner runs a small JavaScript file on these OS schedulers, but it runs in a separate, limited JavaScript environment, not your app’s WebView, and the OS still decides when it fires. If periodic sync is core to the product, design it natively early. In practice the most reliable pattern is to keep an offline queue and flush it when the app resumes, using a push to prompt short work when something is urgent.
Performance optimization
Suspect the WebView first
If profiling shows JS execution, layout, and paint, prioritize frontend tuning over native tweaks. Long lists: virtual scrolling; images: lazy load and sensible resolution; main-thread work: chunking and Web Workers.
Remote debugging works the same as on the web: Safari Web Inspector attaches to the iOS WebView, and chrome://inspect attaches to the Android WebView in debug builds, so you can profile with the familiar Performance panel.
Startup
The assets are local, so download size is not the bottleneck, but parsing and executing a large initial bundle on a mid-range Android phone is. Route-level code splitting and lazy import() of rarely used screens and plugins help here just as they do on the web.
WebView process termination
Under memory pressure, iOS can kill the WKWebView content process while the app stays alive, which shows up as a blank screen when the user returns. Capacitor reloads the WebView in that case, so the app should be able to restore its state (current route, unsent form data) from storage rather than assume memory survived.
Memory
Camera, files, and maps can cause large memory spikes. Prefer passing file paths over Base64 strings, revoking object URLs when done, and downsampling images before displaying them.
Cordova vs Capacitor
Philosophy and structure
Cordova has a long history and a huge plugin ecosystem. Capacitor treats native projects as first-class source and aligns with npm-centric tooling. Capacitor can use some Cordova plugins via a compatibility layer, but not every plugin works.
Choosing
In Cordova the platforms/ folders are build artifacts regenerated from config.xml; in Capacitor the ios/ and android/ projects are committed source that you open and edit in Xcode and Android Studio. That makes native customization straightforward, at the cost of owning those projects. For a new project, Capacitor is the usual choice. For an existing Cordova app, the deciding factor is the plugin list: check each plugin against Capacitor’s compatibility notes or an official replacement, and migrate gradually if some have no equivalent.
CLI commands and daily workflow
Verify flags with npx cap --help for your installed @capacitor/cli version.
| Command | Purpose |
|---|---|
npx cap init | Initialize Capacitor metadata (app ID, name, webDir) |
npx cap add ios / android | Create native folders (avoid overwriting if they already exist) |
npx cap sync | Copy web build + sync native dependencies |
npx cap copy | Copy assets only (subset of sync; use sync if Gradle/Pods must update) |
npx cap open ios / android | Open Xcode / Android Studio |
npx cap run ios / android | Build and run from CLI (requires local tooling) |
The daily loop is: develop in the browser, npm run build, npx cap sync, then run from the IDE. On release branches, have CI verify that server.url is unset and the correct environment variables are injected.
Troubleshooting: common blockers
White screen / missing index.html
Often webDir does not match the build output, or a wrong base path 404s assets. Use the browser devtools Network tab to confirm first HTML/JS loads; with Vite, check base matches Capacitor’s root loading path.
"Plugin ... is not implemented on android"
This usually means the npm package was installed but the native project was not updated. Run npx cap sync after every npm install @capacitor/... and rebuild. Since Capacitor 3, plugins from npm are registered automatically; only plugins written inside the app’s own Android project need registerPlugin(MyPlugin.class) in MainActivity.onCreate before super.onCreate.
API fails only on iOS
Often missing usage strings, Capabilities, or simulator vs device differences. Camera, mic, and location especially need real devices and Info.plist review.
CORS and mixed content
The WebView applies the same CORS rules as a browser, and its origin is capacitor://localhost (iOS) or https://localhost (Android), which your API must allow. Either add those origins to the API’s CORS configuration, or route requests through native HTTP: the bundled CapacitorHttp plugin can patch fetch and XMLHttpRequest to go through native networking, which bypasses CORS (enable it in config and test cookies and uploads carefully). Do not leave plain HTTP open in production builds.
Deep links and universal links (concept)
To open app screens instead of the web for specific URLs, configure iOS Universal Links and Android App Links. Capacitor does not replace all of this—you still need domain verification (AASA, Digital Asset Links) and native manifest / Associated Domains setup. After implementation, test on real devices: tap link → app opens, back navigation, etc.
Capacitor or a native-UI framework
Capacitor fits when the app’s interface already exists, or could exist, as a good web app: forms, content, dashboards, account screens. You keep one codebase, ship web fixes through your normal build, and reach native APIs through plugins. It fits poorly when the product depends on long, fast-scrolling lists, complex gestures, heavy animation, or controls that must look and behave exactly like the platform’s own. The WebView renders all of that, and on lower-end Android devices the gap shows. React Native or Flutter, which render with native widgets or their own engine, are better there.
A practical test is to build the most demanding screen of the app as a web page and run it inside Capacitor on the slowest device you intend to support, before committing the rest of the project.