Bridging Web and Native in Capacitor: When to Build a Custom Plugin
Capacitor's plugin bridge lets you call native Swift/Kotlin code from TypeScript, but serialization overhead, manual permissions, and maintenance costs mean you should only build custom plugins when Web APIs truly can't solve the problem.
27 Oct 2025, 14:18 UTC

The Problem: Web APIs Hit a Wall
You're building a cross-platform app with Capacitor. Everything works fine until you need to talk to a proprietary Bluetooth LE sensor, access the iOS Keychain with specific access control flags, or integrate a vendor SDK that only ships native binaries. The standard Web APIs and Capacitor's core plugins don't cover these cases. You have two choices: find a community plugin that might be unmaintained, or write your own bridge to native code.
How the Capacitor Bridge Actually Works
Capacitor's plugin system uses a message-passing bridge. When your TypeScript code calls await Plugins.YourPlugin.doSomething(), the runtime serializes the call into a JSON message, sends it over a WebView channel (WKWebView on iOS, WebView on Android), and the native plugin receives it on a background thread. The native side executes the work, then posts a success or error callback back across the bridge, which resolves or rejects the original Promise.
This asynchronous design keeps the JavaScript main thread free. However, every call crosses a serialization boundary. Passing a 5 MB image buffer as a base64 string means JSON encoding on the native side, decoding in JavaScript, and significant memory pressure on both ends.
Building a Minimal Custom Plugin
Let's create a plugin that reads a device-specific identifier from native code. You'll need a Capacitor project initialized (npx @capacitor/create@latest) and the native IDEs installed (Xcode for iOS, Android Studio for Android).
1. Define the TypeScript Interface
Create plugins/device-id/src/definitions.ts:
import { Plugin } from '@capacitor/core';
export interface DeviceIdPlugin extends Plugin {
getId(): Promise<{ value: string }>;
}
2. Implement the iOS Side (Swift)
In plugins/device-id/ios/Source/DeviceIdPlugin.swift:
import Capacitor
import Foundation
@objc(DeviceIdPlugin)
public class DeviceIdPlugin: CAPPlugin {
@objc func getId(_ call: CAPPluginCall) {
let id = UIDevice.current.identifierForVendor?.uuidString ?? ""
call.resolve(["value": id])
}
}
Register it in DeviceIdPlugin.m (Objective-C bridging file):
#import
CAP_PLUGIN(DeviceIdPlugin, "DeviceId",
CAP_PLUGIN_METHOD(getId, CAPPluginReturnPromise);
)
3. Implement the Android Side (Kotlin)
In plugins/device-id/android/src/main/java/com/example/deviceid/DeviceIdPlugin.kt:
package com.example.deviceid
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import android.provider.Settings.Secure
@CapacitorPlugin(name = "DeviceId")
class DeviceIdPlugin : Plugin() {
@PluginMethod
fun getId(call: PluginCall) {
val context = bridge?.activity?.applicationContext
val id = context?.let { Secure.getString(it.contentResolver, Secure.ANDROID_ID) } ?? ""
call.resolve(mapOf("value" to id))
}
}
Add the plugin to android/src/main/AndroidManifest.xml inside <application>:
<meta-data android:name="com.example.deviceid.DeviceIdPlugin" android:value="true" />
4. Wire It Up in Your App
Run npx cap sync to copy the native code into your iOS and Android projects. Then in your TypeScript:
import { Plugins } from '@capacitor/core';
const { DeviceId } = Plugins;
async function showDeviceId() {
const { value } = await DeviceId.getId();
console.log('Device ID:', value);
}
Where the Bridge Gets Expensive
The worked example passes a tiny string. Real-world plugins often move larger payloads: image data, sensor streams, or database records. Each crossing involves:
- JSON.stringify on the sender side
- Base64 encoding for binary data (adds ~33% size overhead)
- JSON.parse on the receiver side
- Garbage collection pressure from temporary strings
If you're streaming accelerometer data at 100 Hz, the bridge becomes a bottleneck. In those cases, consider:
- Using Capacitor's
WebViewevaluateJavascriptfor one-way native-to-web pushes (lower overhead than round-trip Promises) - Writing performance-critical logic entirely in native code and only surfacing aggregated results
- Investigating community plugins that use platform-specific optimizations (e.g., Android's
JavascriptInterfacewith direct byte buffer access)
Permissions Don't Cross the Bridge Automatically
A common surprise: declaring a permission in your TypeScript plugin definition does nothing on the native side. You must manually edit:
- iOS:
Info.plistkeys likeNSBluetoothAlwaysUsageDescription - Android:
AndroidManifest.xml<uses-permission>entries
Capacitor does not inject these. If your plugin crashes with a security exception, check the native manifest first.
Maintenance Overhead Is Real
Every custom plugin you own becomes a dependency on:
- Xcode and Android Studio version compatibility
- OS release cycles (iOS 17/18, Android 14/15 deprecations)
- Capacitor major upgrades (v4 → v5 changed plugin registration)
Before writing a plugin, ask: does a maintained community plugin exist? Can the feature live in a Web Worker with a polyfill? Is the native SDK available as a WebAssembly build? Each "yes" saves you ongoing native maintenance.
Verify Your Plugin Works
After npx cap sync and building both platforms:
- Run on a physical device (simulators may return placeholder IDs)
- Open the browser dev tools console (Safari Web Inspector for iOS, Chrome DevTools for Android)
- Call your plugin method and confirm the Promise resolves with expected data
- Test error paths: revoke permissions, disable Bluetooth, put the app in background
If the Promise never settles, check the native logs (logcat on Android, Xcode console on iOS) for unhandled exceptions in the plugin code.
Closing Decision Framework
Use a custom Capacitor plugin when:
- You need a native API with no Web equivalent (Keychain, Bluetooth LE, NFC, proprietary SDK)
- The data payload per call is small (< 100 KB) and latency tolerance is high (> 50 ms)
- You're prepared to maintain Swift/Kotlin code alongside your web codebase
Skip the plugin and stay in web land when:
- The feature has a Web API or polyfill (Geolocation, Camera via
getUserMedia, File System Access API) - You're moving large binary blobs frequently (consider a native module with a streaming interface instead)
- Your team lacks native debugging experience and timeline is tight
The bridge is a powerful tool, but it's not free. Treat each custom plugin as a long-term commitment, not a quick workaround.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.