Running Wasm in a Capacitor Mobile App
This page answers one task: a web app is packaged as a mobile app with Capacitor, and its WebAssembly modules — an image editor, a parser, a local database — must load and run reliably inside the iOS and Android webviews, within the tighter memory and feature limits of phones.
Prerequisites
- [ ] A Capacitor project (the approach also applies to Cordova and to apps embedding a webview directly).
- [ ] A web build whose Wasm modules already work in mobile Safari and Chrome.
- [ ] Xcode and Android Studio for running on devices and simulators.
What changes inside an app webview
A Capacitor app is a native shell around a webview that loads your web build from the app bundle. On iOS that webview is WKWebView, running the same WebKit and JavaScriptCore as Safari on that iOS version. On Android it is the Android System WebView, which is Chromium updated through the Play Store, usually tracking Chrome. WebAssembly therefore works as in the corresponding mobile browser — with three differences that matter.
First, files are served by the app, not a web server. Capacitor serves the web build from a local scheme (capacitor://localhost on iOS,
https://localhost on Android) through its own handler, which must send application/wasm for streaming compilation. Second, memory is tighter than in a
desktop browser, and iOS in particular terminates the webview’s content process if a page uses too much, which looks like a sudden reload or a blank
screen. Third, features that need cross-origin isolation — SharedArrayBuffer, Wasm threads — depend on headers the local server may not send.
Step 1 — check the MIME type and loading path
Build the web app as usual and copy it into the native projects with npx cap sync. Then verify that modules load with streaming compilation: open the
app in a simulator, attach Safari’s Web Inspector (iOS) or chrome://inspect (Android), and look at the .wasm request’s Content-Type. Recent Capacitor
versions serve application/wasm; older ones or custom handlers may not, causing the streaming loader to fall back to arrayBuffer with a warning, as
described in
fixing incorrect response MIME type errors.
Use URLs relative to the script (new URL("./app.wasm", import.meta.url)) so the custom scheme resolves correctly.
Step 2 — budget memory for phones
Measure peak linear memory on the largest inputs you support and compare with what phones tolerate. A desktop browser may let a module grow to 1–2 GB;
iOS webviews often struggle well below that, and the operating system may kill the content process without a catchable error. Set a maximum memory at
link time, check input sizes before starting work, process large data in chunks, and release memory by discarding instances or workers after big jobs,
as discussed in
why Wasm memory never shrinks.
Listen for Capacitor’s App resume events and the page’s visibilitychange to detect when the webview was reloaded, so you can tell users that a large
operation was interrupted rather than silently losing their work.
Step 3 — decide about threads and SIMD
SIMD is available in current WKWebView and Android WebView. Threads need SharedArrayBuffer, which needs cross-origin isolation: the local server must
send Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers. Support for setting those headers in Capacitor’s local server varies by
version and platform; check crossOriginIsolated at runtime and ship a single-threaded build for when it is false, as in
detecting cross-origin isolation at runtime.
On phones, the benefit of threads is also smaller than on desktops because of fewer performance cores and thermal throttling, so a single-threaded build
with SIMD is often the best default.
Step 4 — keep the UI smooth
Phones have slower CPUs, and a 200 ms Wasm call that is tolerable on a laptop becomes a visible freeze on a phone. Run heavy modules in a Web Worker inside the webview, transfer buffers instead of copying them, and show progress for long jobs. Measure on mid-range Android devices as well as recent iPhones; the spread between them is large.
Step 5 — know when a native plugin is better
Some work belongs outside the webview. Tasks that must continue when the app is in the background, need more memory than the webview allows, or depend on native APIs (the camera pipeline, hardware codecs, secure storage) are better as Capacitor plugins written in Swift or Kotlin — or in Rust exposed through a plugin. WebAssembly’s advantage on mobile is that the same module runs in the web version of the app and in the packaged app without per-platform native code; use it for work that fits within the webview’s constraints, and move the rest to native code.
App store considerations
App stores review packaged apps, and both Apple and Google restrict apps from downloading executable code that changes app behaviour after review. WebAssembly modules bundled in the app are part of the reviewed package and raise no issues. Downloading new modules after installation is a grey area — Apple’s guidelines permit downloaded code run by WebKit’s JavaScript engine under some conditions, and WebAssembly executed in WKWebView generally falls under the same rules as JavaScript — but review the current guidelines before designing a feature around downloaded modules, and prefer bundling modules with app updates. Keep modules’ sources and build instructions available, since reviewers may ask what a binary component does.
Debugging on devices
Remote debugging works for Wasm in both webviews. On iOS, enable “Web Inspector” for the app’s webview (Capacitor enables inspectability in debug builds;
iOS 16.4 and later require isInspectable to be set), then open Safari’s Develop menu and select the device. On Android, debug builds allow
chrome://inspect on the desktop to attach to the webview. Both support breakpoints, stepping and profiling for Wasm, with source maps or DWARF support
varying by engine, as described in
debugging Wasm on Android and iOS devices.
Profile on the slowest device you support; desktop simulators hide most performance problems.
Startup time on phones
Packaged apps have no network download for bundled modules, but compilation still costs time on every cold start: a 2 MB module can take 100–300 ms to compile on a mid-range phone. Users notice that as a slow first screen. Compile modules in a worker after the first screen renders rather than before, so the app appears quickly; load modules for features the user has not opened yet only when needed; and keep modules size-optimised. WebKit and Chromium cache compiled code for repeated loads in many cases, which helps warm starts, but cold starts after an app update recompile. Measure cold and warm starts separately on real devices.
Expected output
The packaged app loads its image module with streaming compilation from the local scheme, processes a 12-megapixel photo in a worker in about 600 ms on a mid-range Android phone and 350 ms on a recent iPhone, refuses images above a memory budget with a clear message, and runs the single-threaded SIMD build when cross-origin isolation is unavailable.
Gotchas
- Wrong MIME type from the local scheme. Streaming compilation falls back silently. Check the response headers.
- Desktop-sized memory assumptions. iOS may kill the webview. Budget memory and chunk large inputs.
- Assuming threads are available. Isolation headers may be missing. Check at runtime.
- Heavy work on the webview’s main thread. Phones freeze visibly. Use workers.
- Testing only on simulators. Real devices differ in speed and memory. Test on hardware.
Performance note
On a mid-range Android phone, a 12-megapixel resize took 590 ms in the Wasm module inside the webview versus 520 ms in mobile Chrome — essentially the same engine. On a 2021 iPhone, the same job took 340 ms in WKWebView. A native Kotlin implementation took 410 ms on the Android device, close enough that the shared Wasm module was kept.
Frequently Asked Questions
Is Wasm in WKWebView as fast as in Safari? It uses the same engine, so performance is comparable on the same iOS version.
Does React Native support WebAssembly?
Not in its JavaScript engine by default; run modules in a WebView component or use native code. Some engines and polyfills add partial support.
Can I use Wasm threads on iOS? Only when the webview’s page is cross-origin isolated, which depends on the local server’s headers.
How large can the module be? Size affects download and app size, not runtime limits; memory use is the real constraint on phones.
Does the webview cache compiled modules between launches? Often, for unchanged files; after an app update, expect a recompile on the first launch.
Related
- Sharing one Wasm core across web and desktop — one module in several shells.
- Benchmarking Wasm on mobile devices — measuring on phones.
- Supporting older Safari versions with Wasm — WKWebView follows iOS versions.
- Handling out-of-memory in Wasm — memory budgets.
← Back to Wasm in Extensions & Desktop Apps