Why Capacitor's Plugin Architecture Beats Cordova's exec() for Native Access
Capacitor replaces Cordova's string-based exec() bridge with typed Swift/Kotlin plugins, auto-generated permission handling, and web fallbacks — moving native failures from runtime to compile time.
31 Jan 2026, 04:14 UTC

The problem: stringly-typed bridges break at runtime
If you've ever maintained a Cordova app, you know the drill: cordova.exec(success, error, 'PluginName', 'methodName', [args]). One typo in the service name or method string, and you don't find out until the device logs show Class not found or Method not found. No TypeScript compiler catches it. No IDE autocomplete helps. You ship, users crash, you debug in production.
Capacitor's plugin architecture was designed to eliminate that entire class of errors. Instead of a single string-based bridge, each plugin is a native Swift (iOS) or Kotlin (Android) module that exposes a typed JavaScript interface through decorators. The result: Camera.getPhoto({ quality: 90 }) is checked at compile time, and the native implementation is just a Swift function with the same signature.
How the plugin structure works
A Capacitor plugin is an npm package with three targets: ios (Swift), android (Kotlin), and web (TypeScript fallback). The native side uses @CapacitorPlugin on the class and @PluginMethod on each exposed function. Capacitor's CLI reads those annotations at npx cap sync time and generates the TypeScript definitions you import in your app.
For iOS, the plugin subclasses CAPPlugin and implements methods that receive a CAPPluginCall object — strongly typed input, promise-based return. Android mirrors this with a Plugin base class and @PluginMethod annotation on Kotlin suspend functions. The JavaScript side sees a single class with async methods returning typed promises.
This matters because the bridge call is no longer a string lookup. The generated TypeScript definitions (in node_modules/@capacitor/camera/dist/esm/definitions.d.ts for example) give you getPhoto(options: CameraOptions): Promise<Photo> with full autocomplete and compile-time checking.
Permissions: declared once, requested uniformly
Cordova plugins often forgot to declare required permissions in config.xml, leaving you to manually edit AndroidManifest.xml and Info.plist. Capacitor plugins declare permissions in their native manifests — NSCameraUsageDescription in Info.plist, <uses-permission android:name="android.permission.CAMERA"> in AndroidManifest.xml — and the CLI auto-generates the runtime request code.
From JavaScript you call await Camera.requestPermissions() and get a typed PermissionStatus object. The same call works on iOS 17+ and Android 14+ because the plugin handles the platform-specific privacy manifests (PrivacyInfo.xcprivacy for iOS, foregroundServiceType for Android 14). You don't write platform-specific permission logic.
Web fallbacks and event listeners: same API, different runtime
The web target lets a plugin ship a pure-JavaScript implementation for PWAs or browsers that lack the native capability. @capacitor/camera's web fallback uses navigator.mediaDevices.getUserMedia() and returns a Photo object shaped identically to the native result. Your app code doesn't branch.
Capacitor 5+ replaced Cordova's document.addEventListener('deviceready') with plugin-scoped events: const listener = await Geolocation.addListener('position', callback) and listener.remove(). This maps cleanly to React useEffect cleanup, Vue onUnmounted, or Svelte onDestroy — no global event bus leakage.
Worked example: building a typed echo plugin
Run npx @capacitor/cli plugin:generate in your workspace. Name it echo, choose TypeScript. The CLI scaffolds:
echo/\n├── ios/Plugin/EchoPlugin.swift\n├── android/src/main/kotlin/com/example/echo/EchoPlugin.kt\n├── src/definitions.ts\n├── src/index.ts\n├── src/web.ts\n└── package.json (with "capacitor" metadata)
In EchoPlugin.swift implement:
@PluginMethod\nfunc echo(_ call: CAPPluginCall) {\n let value = call.getString("value") ?? ""\n call.resolve(["value": "Echo: \(value)"])\n}
In EchoPlugin.kt:
@PluginMethod\nsuspend fun echo(call: PluginCall) {\n val value = call.getString("value") ?: ""\n call.resolve(mapOf("value" to "Echo: $value"))\n}
The generated definitions.ts already declares echo(options: { value: string }): Promise<{ value: string }>. Run npx cap sync, then in a React component:
import { Echo } from 'echo';\nconst { value } = await Echo.echo({ value: 'hello' });\n// value is typed as string, IDE knows it
No string bridges, no manual type definitions. The native code is just Swift/Kotlin functions you can debug with Xcode or Android Studio breakpoints.
Trade-offs you'll hit in production
- Version pinning is mandatory. Capacitor 4 → 5 renamed
PluginstoregisterPluginand changed event APIs. Pin@capacitor/coreand every plugin inpackage.json; test on real devices after any major upgrade. - iOS 17+/Android 14 privacy manifests. Each plugin must declare
NSPrivacyAccessedAPITypeandNSPrivacyCollectedDataTypeinPrivacyInfo.xcprivacy. Community plugins like@capacitor/cameraupdate promptly, but custom or abandoned plugins will cause App Store rejection. Verify each plugin's manifest before release. - Web fallback permissions are mocks.
Camera.requestPermissions()on the web returns{ camera: 'granted' }immediately — no system prompt. Gate native-only UI withCapacitor.isNativePlatform()before calling permission-dependent features. - No unified background API. Geofencing, Bluetooth LE, background fetch require separate native implementations per platform. Capacitor doesn't abstract background execution; expect platform-specific code paths in your plugin.
- Local plugins need rebuilds. Custom plugins in a local
/pluginsfolder (linked viacapacitor.config.ts) must runnpx cap syncafter every native change. Hot reload only touches the web layer.
What to do next
- Create a minimal Capacitor 6 project:
npm init @capacitor/app, add@capacitor/camera, runnpx cap sync. Inspectios/App/Podsandandroid/capacitor-camerato see the generated Swift/Kotlin source. - Open
node_modules/@capacitor/camera/dist/esm/definitions.d.tsand confirm typed signatures likegetPhoto(options: CameraOptions): Promise<Photo>. - Test the permission flow on a physical iOS 17+ device: call
Camera.requestPermissions()and verify the system prompt appears andInfo.plistcontainsNSCameraUsageDescriptionadded by the plugin. - Before upgrading production apps, read the official migration guide at
capacitorjs.com/docs/updating/5-0for breaking changes between your current version and the latest LTS.
The shift from Cordova's exec() to Capacitor's typed plugin model isn't just syntactic sugar — it moves native failures from runtime crashes to compile-time errors. That's the engineering decision that pays off every time you refactor or onboard a new developer.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.