Description
We shipped 1.4.3 in production and App Store review rejected our latest build with a crash "after the initial launch". Crashlytics caught it from the reviewer's device: EXC_BAD_ACCESS (KERN_INVALID_ADDRESS) inside NitroGeolocation.handleAuthorizationChange(_:).
After digging through NitroGeolocation.swift I'm fairly confident this is a data race between the JS thread and the CoreLocation delegate, and it hits fresh installs almost exclusively — which is why our field data looked clean for weeks while the reviewer (always a fresh install) crashed on first launch.
Root cause on 1.4.3:
- JS-side entry points (
getCurrentPosition, watchPosition, requestPermission, unwatch, ...) run on the Nitro/JS thread and mutate the instance's collections (pendingPositionRequests, watchSubscriptions, pendingPermissionResolvers) with no synchronization.
CLLocationManager delivers its delegate callbacks on the main run loop, and locationManagerDidChangeAuthorization fires immediately when the delegate is first assigned (iOS 14+ behavior).
initializeLocationManagerIfNeeded() sets the delegate via DispatchQueue.main.sync, then returns to the JS thread which continues into e.g. self.pendingPositionRequests[id] = request. Meanwhile the main run loop is already delivering didChangeAuthorization → handleAuthorizationChange, which reads pendingPositionRequests.isEmpty / watchSubscriptions.isEmpty and does pendingPermissionResolvers.removeAll().
- Swift
Dictionary/Array aren't thread-safe, so a JS-thread insert reallocating storage while the main thread reads it is heap corruption → EXC_BAD_ACCESS.
On an existing install the collections are empty when that first callback fires, so nothing collides — the wide window only exists on first launch of a fresh install where the permission flow and the first getCurrentPosition overlap with the delegate's initial callback. Worst possible audience for it: app reviewers.
I checked main before filing this: the 2.0 line already routes these entry points through runLocationOperationOnMain, which as far as I can tell fixes exactly this. But 2.0 is still in RC and is a breaking major, so everyone on the stable release is exposed.
Ask: would you consider backporting the main-thread confinement to a 1.4.4 patch release? The diff against 1.4.3 is small (we're running it in production via patch-package right now — it's the same approach main took, just without the refactor). Happy to open the PR myself if you cut a 1.x branch to target.
To Reproduce
It's a race, so no deterministic repro — but this maximizes the odds:
- Fresh install (must be first launch, location permission not yet determined).
- On startup, request the location permission and call
getCurrentPosition as soon as it resolves (i.e. have JS-side location work in flight while the first CLLocationManager is being created).
- Cold-start the app. Older/slower hardware widens the window — the crash we caught was an iPhone 11 Pro.
Thread sanitizer should flag it deterministically on the pendingPermissionResolvers / pendingPositionRequests accesses.
Expected Behavior
First launch on a fresh install requests permission and resolves the first position without crashing.
Actual Behavior
Intermittent EXC_BAD_ACCESS (KERN_INVALID_ADDRESS) on the main thread during the first authorization callback:
Thread (crashed)
at NitroGeolocation.handleAuthorizationChange(_:) (NitroGeolocation.swift:515)
at @objc LocationManagerDelegate.locationManagerDidChangeAuthorization(_:) (IOSGeolocationDelegate.swift:13)
at CLClientGetAuthorizationStatusAndCorrectiveCompensation
at __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__
at __CFRunLoopDoBlocks
at __CFRunLoopRun
at _CFRunLoopRunSpecificWithOptions
at GSEventRunModal
at -[UIApplication _run]
at UIApplicationMain
(Line 515 is the pendingPositionRequests.isEmpty || watchSubscriptions.isEmpty snapshot; with optimizations the blame line is approximate — the whole function touches the shared collections.)
Environment
- Package version: 1.4.3
- Nitro Modules version: 0.37.1
- React Native version: 0.86.2
- React version: 19.2.3
- Expo SDK (if used): not used
- Platform and OS version: iOS 26.6.1
- Device and CPU architecture: Physical device (iPhone 11 Pro, arm64)
- Build path: CocoaPods
- Build configuration: Release
- New Architecture enabled: Yes
Diagnostic Evidence
Not applicable — the crash is native and happens during the very first authorization callback on a fresh install, before any JS-side diagnostics could run. Crashlytics report available if useful (stack above is from it).
Code Sample
// Nothing exotic — the standard first-launch flow. We wrap the modern API
// in a small callback facade, but the calls boil down to:
requestPermission((status) => {
if (status === 'granted') {
getCurrentPosition({ enableHighAccuracy: true, timeout: 15000 })
.then(onPosition, onError);
}
});
// Crash only ever on first launch of a fresh install.
Additional Context
The patch we're shipping against 1.4.3 — every JS-thread entry point that touches instance state hops to the main queue, where the delegate callbacks and the timeout timers (already DispatchSource on .main) live:
patches/react-native-nitro-geolocation+1.4.3.patch
diff --git a/node_modules/react-native-nitro-geolocation/ios/NitroGeolocation.swift b/node_modules/react-native-nitro-geolocation/ios/NitroGeolocation.swift
index 814da31..1258fc5 100644
--- a/node_modules/react-native-nitro-geolocation/ios/NitroGeolocation.swift
+++ b/node_modules/react-native-nitro-geolocation/ios/NitroGeolocation.swift
@@ -37,7 +37,14 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
// MARK: - Configuration
func setConfiguration(config: GeolocationConfiguration) {
+ // All mutable state on this instance is confined to the main thread:
+ // CLLocationManager delivers its delegate callbacks on the main run
+ // loop (and does so immediately when the delegate is first set), so
+ // entry points invoked from the JS thread must hop over before
+ // touching that state or the two threads race on the collections.
+ DispatchQueue.main.async {
self.configuration = config
+ }
}
// MARK: - Permission API
@@ -53,6 +60,7 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
success: @escaping (PermissionStatus) -> Void,
error: ((LocationError) -> Void)?
) throws -> Void {
+ DispatchQueue.main.async {
self.initializeLocationManagerIfNeeded()
let currentStatus = CLLocationManager.authorizationStatus()
@@ -70,6 +78,7 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
// Request permission
let authLevel = self.determineAuthorizationLevel()
self.requestSystemPermission(for: authLevel)
+ }
}
// MARK: - Provider/Settings API
@@ -187,6 +196,7 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
options: LocationRequestOptions,
error: ((LocationError) -> Void)?
) throws -> Void {
+ DispatchQueue.main.async {
// Check permission
let status = CLLocationManager.authorizationStatus()
if status == .denied || status == .restricted {
@@ -245,6 +255,7 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
// Update configuration and start monitoring
self.updateLocationManagerConfiguration()
self.startMonitoring()
+ }
}
func getLastKnownPosition(
@@ -252,6 +263,7 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
options: LocationRequestOptions,
error: ((LocationError) -> Void)?
) throws -> Void {
+ DispatchQueue.main.async {
let status = CLLocationManager.authorizationStatus()
if status == .denied || status == .restricted {
let message = status == .restricted
@@ -275,6 +287,7 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
self.lastLocation = cached
success(self.locationToPosition(cached))
+ }
}
// MARK: - Geocoding
@@ -382,7 +395,8 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
) throws -> Void {
guard validateHeadingAvailability(error: error) else { return }
- initializeLocationManagerIfNeeded()
+ DispatchQueue.main.async {
+ self.initializeLocationManagerIfNeeded()
let id = UUID()
var request = HeadingRequest(
@@ -401,9 +415,10 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
timer.resume()
request.timer = timer
- pendingHeadingRequests[id] = request
- updateHeadingConfiguration()
- startHeadingMonitoring()
+ self.pendingHeadingRequests[id] = request
+ self.updateHeadingConfiguration()
+ self.startHeadingMonitoring()
+ }
}
func watchHeading(
@@ -433,11 +448,13 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
lastDeliveredHeading: nil
)
- headingSubscriptions[token] = subscription
+ DispatchQueue.main.async {
+ self.headingSubscriptions[token] = subscription
- initializeLocationManagerIfNeeded()
- updateHeadingConfiguration()
- startHeadingMonitoring()
+ self.initializeLocationManagerIfNeeded()
+ self.updateHeadingConfiguration()
+ self.startHeadingMonitoring()
+ }
return token
}
@@ -458,48 +475,54 @@ class NitroGeolocation: HybridNitroGeolocationSpec {
options: parsedOptions
)
- watchSubscriptions[token] = subscription
+ DispatchQueue.main.async {
+ self.watchSubscriptions[token] = subscription
- initializeLocationManagerIfNeeded()
- updateLocationManagerConfiguration()
- startMonitoring()
+ self.initializeLocationManagerIfNeeded()
+ self.updateLocationManagerConfiguration()
+ self.startMonitoring()
+ }
return token
}
func unwatch(token: String) {
- watchSubscriptions.removeValue(forKey: token)
- headingSubscriptions.removeValue(forKey: token)
+ DispatchQueue.main.async {
+ self.watchSubscriptions.removeValue(forKey: token)
+ self.headingSubscriptions.removeValue(forKey: token)
// Stop monitoring if no more subscriptions or pending requests
- if watchSubscriptions.isEmpty && pendingPositionRequests.isEmpty {
- stopMonitoring()
+ if self.watchSubscriptions.isEmpty && self.pendingPositionRequests.isEmpty {
+ self.stopMonitoring()
} else {
- updateLocationManagerConfiguration()
+ self.updateLocationManagerConfiguration()
}
- if headingSubscriptions.isEmpty && pendingHeadingRequests.isEmpty {
- stopHeadingMonitoring()
+ if self.headingSubscriptions.isEmpty && self.pendingHeadingRequests.isEmpty {
+ self.stopHeadingMonitoring()
} else {
- updateHeadingConfiguration()
+ self.updateHeadingConfiguration()
+ }
}
}
func stopObserving() {
- watchSubscriptions.removeAll()
- headingSubscriptions.removeAll()
+ DispatchQueue.main.async {
+ self.watchSubscriptions.removeAll()
+ self.headingSubscriptions.removeAll()
// Stop monitoring if no pending requests
- if pendingPositionRequests.isEmpty {
- stopMonitoring()
+ if self.pendingPositionRequests.isEmpty {
+ self.stopMonitoring()
} else {
- updateLocationManagerConfiguration()
+ self.updateLocationManagerConfiguration()
}
- if pendingHeadingRequests.isEmpty {
- stopHeadingMonitoring()
+ if self.pendingHeadingRequests.isEmpty {
+ self.stopHeadingMonitoring()
} else {
- updateHeadingConfiguration()
+ self.updateHeadingConfiguration()
+ }
}
}
For watchPosition / watchHeading the token is still generated and returned synchronously; only the registration hops queues, so unwatch ordering is preserved (both funnel through the same serial main queue).
Description
We shipped 1.4.3 in production and App Store review rejected our latest build with a crash "after the initial launch". Crashlytics caught it from the reviewer's device:
EXC_BAD_ACCESS (KERN_INVALID_ADDRESS)insideNitroGeolocation.handleAuthorizationChange(_:).After digging through
NitroGeolocation.swiftI'm fairly confident this is a data race between the JS thread and the CoreLocation delegate, and it hits fresh installs almost exclusively — which is why our field data looked clean for weeks while the reviewer (always a fresh install) crashed on first launch.Root cause on 1.4.3:
getCurrentPosition,watchPosition,requestPermission,unwatch, ...) run on the Nitro/JS thread and mutate the instance's collections (pendingPositionRequests,watchSubscriptions,pendingPermissionResolvers) with no synchronization.CLLocationManagerdelivers its delegate callbacks on the main run loop, andlocationManagerDidChangeAuthorizationfires immediately when the delegate is first assigned (iOS 14+ behavior).initializeLocationManagerIfNeeded()sets the delegate viaDispatchQueue.main.sync, then returns to the JS thread which continues into e.g.self.pendingPositionRequests[id] = request. Meanwhile the main run loop is already deliveringdidChangeAuthorization→handleAuthorizationChange, which readspendingPositionRequests.isEmpty/watchSubscriptions.isEmptyand doespendingPermissionResolvers.removeAll().Dictionary/Arrayaren't thread-safe, so a JS-thread insert reallocating storage while the main thread reads it is heap corruption →EXC_BAD_ACCESS.On an existing install the collections are empty when that first callback fires, so nothing collides — the wide window only exists on first launch of a fresh install where the permission flow and the first
getCurrentPositionoverlap with the delegate's initial callback. Worst possible audience for it: app reviewers.I checked
mainbefore filing this: the 2.0 line already routes these entry points throughrunLocationOperationOnMain, which as far as I can tell fixes exactly this. But 2.0 is still in RC and is a breaking major, so everyone on the stable release is exposed.Ask: would you consider backporting the main-thread confinement to a 1.4.4 patch release? The diff against 1.4.3 is small (we're running it in production via patch-package right now — it's the same approach
maintook, just without the refactor). Happy to open the PR myself if you cut a 1.x branch to target.To Reproduce
It's a race, so no deterministic repro — but this maximizes the odds:
getCurrentPositionas soon as it resolves (i.e. have JS-side location work in flight while the firstCLLocationManageris being created).Thread sanitizer should flag it deterministically on the
pendingPermissionResolvers/pendingPositionRequestsaccesses.Expected Behavior
First launch on a fresh install requests permission and resolves the first position without crashing.
Actual Behavior
Intermittent
EXC_BAD_ACCESS (KERN_INVALID_ADDRESS)on the main thread during the first authorization callback:(Line 515 is the
pendingPositionRequests.isEmpty || watchSubscriptions.isEmptysnapshot; with optimizations the blame line is approximate — the whole function touches the shared collections.)Environment
Diagnostic Evidence
Not applicable — the crash is native and happens during the very first authorization callback on a fresh install, before any JS-side diagnostics could run. Crashlytics report available if useful (stack above is from it).
Code Sample
Additional Context
The patch we're shipping against 1.4.3 — every JS-thread entry point that touches instance state hops to the main queue, where the delegate callbacks and the timeout timers (already
DispatchSourceon.main) live:patches/react-native-nitro-geolocation+1.4.3.patch
For
watchPosition/watchHeadingthe token is still generated and returned synchronously; only the registration hops queues, sounwatchordering is preserved (both funnel through the same serial main queue).