Fixing a Fatal iOS Crash in @capacitor/google-maps on Fresh App Installs
If you're building a Capacitor 8 app with @capacitor/google-maps and your app crashes on iOS with Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value in Map.swift, you're not alone. This is a race condition in the plugin that surfaces most reliably on fresh installs — when the iOS location permission dialog delays the WebView layout. The crash is non-deterministic, which makes it particularly frustrating to debug.
I recently rebuilt the Cork Hounds mobile app using React 18, TypeScript, Vite, and Capacitor 8. The app uses @capacitor/google-maps to render a native GMSMapView behind a transparent WebView, with a draggable bottom sheet overlay. Everything worked great on subsequent launches, but on fresh installs — where the user hadn't yet granted location permission — the app would crash before the map ever appeared.
In this post, I'll walk through the root cause, what I tried that didn't work, and the three-part native Swift patch that ultimately fixed it.
The Crash
The Xcode console showed a clean sequence every time:
⚡️ To Native -> CapacitorGoogleMaps create 33593168
⚡️ TO JS undefined
⚡️ To Native -> CapacitorGoogleMaps setPadding 33593169
CapacitorGoogleMapsPlugin/Map.swift:540: Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value
The create call resolves to JavaScript, the app immediately calls setPadding, and the native side crashes. Sometimes it was setCamera instead. Sometimes it didn't crash at all. The inconsistency is a hallmark of a race condition.
Environment
@capacitor/google-maps: 8.0.1@capacitor/core: 8.3.1- Google Maps iOS SDK: 9.4.0
- iOS Simulator: iPhone 16 Pro, iOS 18.x
- React 18, TypeScript, Vite, Capacitor 8
Understanding the Architecture
To understand the bug, you need to know how the plugin creates the native map. The @capacitor/google-maps plugin renders a native GMSMapView behind the WKWebView. The WebView is transparent, and the native map shows through. Here's the creation flow:
GoogleMap.create()in JavaScript calls the nativeCapacitorGoogleMaps.createplugin method- The native side constructs a
Mapobject, which creates aGMViewControllerand callsrender() render()runs onDispatchQueue.main.asyncand searches the WKWebView's subview hierarchy for aWKChildScrollViewwhose dimensions match the<capacitor-google-map>HTML element- If it finds the matching scroll view, it adds the
GMViewController's view as a subview, which triggersviewDidLoad() viewDidLoad()creates theGMSMapViewand assigns it to theGMapViewproperty
The critical detail: GMapView is declared as var GMapView: GMSMapView! — an implicitly unwrapped optional. It starts as nil and only gets a value when viewDidLoad() fires.
The Race Condition
GoogleMap.create() resolves its JavaScript Promise as soon as the Map object is constructed in step 2. But render() runs asynchronously in step 3. This means JavaScript receives the map object and can immediately call methods on it — but the native GMSMapView doesn't exist yet. Any call to setPadding(), setCamera(), or other methods that access self.mapViewController.GMapView before viewDidLoad() fires will crash.
Why Fresh Installs Are the Worst Case
On fresh installs, Geolocation.requestPermissions() triggers an iOS system alert. While this dialog is presented, the WebView layout may not be finalized. The plugin's getTargetContainer() function searches for a WKChildScrollView whose contentSize matches the HTML element's dimensions. If the element hasn't been laid out yet (because the permission dialog is blocking), no matching scroll view exists.
When getTargetContainer() returns nil, render() silently does nothing. The GMViewController's view is never added to the hierarchy, viewDidLoad() never fires, and GMapView stays nil permanently. The map never appears, and every API call either crashes or gets silently dropped.
What I Tried (and Why It Didn't Work)
Attempt 1: JavaScript-Side Delay
I added a setTimeout after GoogleMap.create() before calling any map methods:
await new Promise((r) => setTimeout(r, 300));
await map.setPadding({ ... });
This reduced the crash frequency but didn't eliminate it. The native view initialization time varies, so no fixed delay is reliable.
Attempt 2: The onMapReady Callback
GoogleMap.create() accepts a second argument — a callback for when the map is ready:
const map = await GoogleMap.create(options, () => {
console.log('Map ready!');
});I tried gating all map operations behind this callback using a Promise. The problem: on fresh installs where getTargetContainer() fails, viewDidLoad() never fires, so finishMapConfiguration() never runs, and onMapReady is never emitted. The callback never fires, and the app hangs.
Attempt 3: JavaScript Retry Loop
I set up a setInterval that called setPadding and setCamera every 500ms for 5 seconds, hoping to catch the moment the native view became ready. This actually worked — the map eventually appeared — but it generated 20+ native bridge calls that saturated the message queue and delayed the iOS permission dialog from ~5 seconds to ~30 seconds. Not acceptable.
Attempt 4: try/catch Around setPadding
Since the crash is a Swift Fatal error (force-unwrapping a nil optional), it kills the entire process. A JavaScript try/catch can't intercept a native fatal error. This approach had no effect.
The Fix: A Three-Part Native Swift Patch
The only reliable solution was to patch the native Swift code in the plugin. I used patch-package to apply three changes to Map.swift.
Fix 1: Nil Guard on setPadding()
// Before — crashes if GMapView is nil
func setPadding(padding: GoogleMapPadding) throws {
DispatchQueue.main.sync {
let mapInsets = UIEdgeInsets(...)
self.mapViewController.GMapView.padding = mapInsets
}
}
// After — safely skips if not ready
func setPadding(padding: GoogleMapPadding) throws {
guard self.mapViewController.GMapView != nil else {
CAPLog.print("CapacitorGoogleMaps Warning: GMapView not yet initialized, skipping setPadding")
return
}
DispatchQueue.main.sync {
let mapInsets = UIEdgeInsets(...)
self.mapViewController.GMapView.padding = mapInsets
}
}Fix 2: Nil Guard on setCamera()
// Before — crashes if GMapView is nil
func setCamera(config: GoogleMapCameraConfig) throws {
let currentCamera = self.mapViewController.GMapView.camera
// ...
}
// After — safely skips if not ready
func setCamera(config: GoogleMapCameraConfig) throws {
guard let gMapView = self.mapViewController.GMapView else {
CAPLog.print("CapacitorGoogleMaps Warning: GMapView not yet initialized, skipping setCamera")
return
}
let currentCamera = gMapView.camera
// ...
}Fix 3: render() Retry When Target Container Not Found
This is the most important fix. Without it, the nil guards prevent the crash, but the map never appears because GMapView is never initialized.
// Before — if getTargetContainer returns nil, nothing happens. Ever.
func render() {
DispatchQueue.main.async {
self.targetViewController = self.getTargetContainer(...)
if let target = self.targetViewController {
target.addSubview(self.mapViewController.view)
}
}
}
// After — retries every 500ms until the WebView layout is ready
private var renderRetryCount = 0
func render() {
DispatchQueue.main.async {
self.targetViewController = self.getTargetContainer(...)
if let target = self.targetViewController {
target.addSubview(self.mapViewController.view)
} else if self.renderRetryCount < 20 {
self.renderRetryCount += 1
CAPLog.print("CapacitorGoogleMaps: target container not found, retrying render (\(self.renderRetryCount)/20)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.render()
}
}
}
}All three fixes work together:
- The nil guards prevent the crash during the window between
create()resolving andviewDidLoad()firing - The render retry ensures
viewDidLoad()eventually fires, even when the permission dialog delays the WebView layout - Once
GMapViewis initialized, subsequent calls from the JavaScript side succeed normally
Applying the Patch with patch-package
Install patch-package as a dev dependency:
npm install --save-dev patch-package
Add a postinstall script to package.json:
{
"scripts": {
"postinstall": "patch-package"
}
}After modifying Map.swift in node_modules, generate the patch:
npx patch-package @capacitor/google-maps
This creates patches/@capacitor+google-maps+8.0.1.patch, which is automatically applied on every npm install. Commit the patch file to your repository.
JavaScript-Side Considerations
With the native patch in place, the JavaScript side needs minimal changes. A single delayed re-sync after map creation is sufficient:
const map = await GoogleMap.create({ ... });
googleMapRef.current = map;
// Single delayed re-sync for fresh installs where
// initial setPadding/setCamera calls were no-ops
setTimeout(() => {
if (googleMapRef.current) {
googleMapRef.current.setPadding({ ... }).catch(() => {});
}
}, 2000);
Don't flood the native bridge with retries. I learned this the hard way — a retry loop saturated the native message queue and delayed the iOS permission dialog by 25 seconds.
Other Methods at Risk
The setPadding and setCamera nil guards cover the two methods I hit in practice, but other methods in Map.swift also access GMapView without nil guards: getMapType(), setMapType(), enableIndoorMaps(), enableTrafficLayer(), enableAccessibilityElements(), and enableCurrentLocation(). If your app calls these early in the lifecycle, you may want to add similar guards.
Testing the Fix
- Delete the app from the iOS Simulator
- Build and run from Xcode
- Watch for
"target container not found, retrying render"in the console — this confirms the retry is working - The permission dialog should appear within ~5 seconds
- After granting permission, the map should render with all markers
- On subsequent launches (Cmd+R), the map should load immediately
Related GitHub Issues
This class of bug has been reported multiple times:
- ionic-team/capacitor-plugins#2116
- ionic-team/capacitor-plugins#1832
- ionic-team/capacitor-plugins#2114
- ionic-team/capacitor-google-maps#132
- ionic-team/capacitor-google-maps#129
A previous PR (#136) addressed a related symptom by moving delegate assignment into finishMapConfiguration(), but it didn't add nil guards or address the getTargetContainer() failure on fresh installs.
Conclusion
This was a frustrating bug to track down because of its non-deterministic nature and the fact that a JavaScript-side fix isn't possible — Swift fatal errors kill the process before any JS error handling can intercept them. The combination of nil guards and a render retry loop in the native code is a robust fix that handles both the crash and the fresh-install scenario where the map would otherwise never appear. Hopefully this saves you some time if you've encountered the same issue. Leave a comment if you found this useful or need any assistance!