From Web to Native: Building and Testing a Capacitor Plugin for iOS and Android
Learn how to write, test, and ship a Capacitor plugin that bridges JavaScript to native Swift and Kotlin, including threading, build steps, and verification.
21 Jun 2026, 19:40 UTC

The WebView Wall
When a mobile app built with Capacitor needs a device feature that the browser’s Web APIs don’t expose, the only way forward is to write a native plugin. The plugin acts as a small bridge that forwards JavaScript calls to native Swift or Kotlin code and returns the result back to the web layer.
Capacitor’s Bridge in a Nutshell
Capacitor implements a bridge pattern: a JavaScript call is serialized to JSON, sent over the bridge, deserialized in native code, executed, and the response is sent back as a promise. The bridge itself is part of the Capacitor runtime that ships with the native projects.
- JavaScript side: call a method on a registered plugin object.
- Bridge: packs arguments into a
JSObject(Android) orNSDictionary(iOS) and dispatches them. - Native side: receives the call, performs logic, and calls
call.resolveorcall.reject. - Response is wrapped in a Promise that resolves in the web layer.
Scaffolding a New Plugin
Capacitor provides a CLI helper that generates the file structure for you. Run the following from the project root:
npx @capacitor/cli plugin:generate
# Provide a name, e.g., "SystemInfo"
# Choose the platforms you want to target (iOS, Android)
The generator creates:
src/android/SystemInfoPlugin.javasrc/ios/SystemInfoPlugin.swiftsrc/SystemInfoPlugin.ts
After editing the native files, run npx cap sync to copy the plugin into the native projects and update the build scripts.
Implementing the Native Code
Android (Kotlin)
Create a class that extends Plugin and annotate it so the bridge can discover it. The @CapacitorPlugin annotation registers the plugin for the app’s plugin registry. The @PluginMethod annotation marks a method that can be called from JavaScript.
@CapacitorPlugin(name = "SystemInfo")
class SystemInfoPlugin : Plugin() {
@PluginMethod
fun getDeviceModel(call: PluginCall) {
val model = android.os.Build.MODEL
val ret = JSObject()
ret.put("model", model)
call.resolve(ret)
}
}
Because the plugin is annotated, Capacitor automatically registers it; no manual addition to MainActivity is required in Capacitor 5+. If you add native dependencies (e.g., a third‑party SDK), include them in build.gradle and run gradlew assembleDebug after cap sync.
iOS (Swift)
Swift plugins must expose a method via the CAP_PLUGIN_METHOD macro in an Objective‑C file so the Objective‑C runtime can find it. A minimal implementation looks like this:
// SystemInfoPlugin.swift
import Capacitor
@objc(SystemInfoPlugin)
public class SystemInfoPlugin: CAPPlugin {
@objc func getDeviceModel(_ call: CAPPluginCall) {
let model = UIDevice.current.model
call.resolve(["model": model])
}
}
And the bridging header (e.g., SystemInfoPlugin.m):
#import <Capacitor/Capacitor.h>
CAP_PLUGIN(SystemInfoPlugin, "SystemInfo",
CAP_PLUGIN_METHOD(getDeviceModel, CAPPluginMethod)
)
After editing, run npx cap sync ios to copy the files and update the Xcode project. If you need a native dependency, add it to the Podfile and run pod install.
Web Layer Integration
The TypeScript side provides type safety and a convenient API. Register the plugin with Capacitor and declare the method signature.
import { registerPlugin } from '@capacitor/core';
export interface SystemInfoPlugin {
getDeviceModel(): Promise<{ model: string }>;
}
const SystemInfo = registerPlugin<SystemInfoPlugin>('SystemInfo');
export default SystemInfo;
Use it in your app like:
import SystemInfo from './SystemInfoPlugin';
async function showModel() {
const info = await SystemInfo.getDeviceModel();
console.log('Device model:', info.model);
}
Threading and Long‑Running Work
Capacitor calls run on the main UI thread. If a plugin performs a heavy task—database access, file I/O, network calls—block the UI and the app will freeze. Offload such work to a background thread and resolve the promise only when it’s finished.
- Android: use Kotlin coroutines or
Thread.@PluginMethod fun getLargeData(call: PluginCall) { Thread { // background val data = fetchLargeDataset() val ret = JSObject() ret.put("data", data) call.resolve(ret) }.start() } - iOS: use
DispatchQueue.global.CAP_PLUGIN_METHOD(getLargeData, CAPPluginMethod) func getLargeData(_ call: CAPPluginCall) { DispatchQueue.global(qos: .background).async { let data = self.fetchLargeDataset() call.resolve(["data": data]) } }
Trade‑Offs and Limitations
- Build Time: Any change to a native plugin forces a full native project rebuild. Web hot‑reload does not apply.
- Serialization: Only JSON‑serializable data can cross the bridge. Complex objects must be flattened into
JSObjectorNSDictionary. - Maintenance Burden: You maintain two codebases (Swift/Kotlin and TypeScript) plus the Capacitor runtime.
- Version Drift: Native SDK updates may require adjusting the plugin code or build scripts.
Verification Checklist
- Bridge Connection: Call the method from the web layer and open the native console (Logcat for Android, Xcode console for iOS). Look for a log line or verify that the promise resolves.
- Async Handling: In the native method, call
call.reject("error")on a simulated failure and confirm the JavaScript promise rejects. - UI Responsiveness: Trigger a long task and interact with the UI. If scrolling or button taps lag, the plugin is still blocking the main thread.
- Cross‑Platform Parity: Ensure the same method signature works on both iOS and Android. Test on the latest stable OS versions.
Closing Thoughts
Custom Capacitor plugins unlock native capabilities while keeping your core logic in a single web codebase. The key to a smooth experience is careful thread management, diligent build synchronization, and thorough cross‑platform testing. Once you master the plugin pattern, you can package it as an npm module and share it across projects, turning a one‑off solution into a reusable building block.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.