Summary
On Android, clearWatch removes the location listener and releases the PluginCall, but it never cancels the coroutine started in startWatch. The underlying flow keeps running. If the watch never delivered a location before it was cleared, emitOrTimeoutBeforeFirstEmission keeps counting and eventually emits a timeout — which is still delivered to the app's JavaScript callback, long after the watch was cleared.
Versions
@capacitor/geolocation 8.2.0
io.ionic.libs:iongeolocation-android 2.2.0 (pinned in android/build.gradle)
@capacitor/core / @capacitor/android 8.4.x
Mechanism
GeolocationPlugin.startWatch:
private fun startWatch(call: PluginCall) {
coroutineScope.launch {
controller.addWatch(activity, locationOptions, watchId).collect { … }
}
watchingCalls[call.callbackId] = call
}
clearWatch does:
watchingCalls.remove(id)?.release(bridge)
val watchCleared = controller.clearWatch(id)
controller.clearWatch removes the LocationHandler and stops location updates, but nothing cancels the launch { … collect } above. The callbackFlow in watchLocationUpdatesFlow is never closed, so the collector stays suspended forever.
Meanwhile IONGLOCController.addWatch wraps the flow in:
.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout)
and emitOrTimeoutBeforeFirstEmission runs withTimeoutOrNull(timeoutMillis) { while (firstValue == null) delay(10) }. That polling loop is unaffected by clearWatch, so for a watch that never produced a first emission it keeps polling on Dispatchers.Main for the full timeout, then sends IONGLOCLocationRetrievalTimeoutException.
That exception reaches onLocationError(exception, call) → call.sendError(...) → reject(...). PluginCall.reject does not check isReleased, and MessageHandler.sendResponseMessage posts the message to the WebView regardless. On the JS side the callback for that watch id is still registered, because clearWatch never produces a save: false response for it.
Consequences
- A timeout error arrives for a watch the app cleared long ago. An app that restarts its watch on error will treat this as a fresh failure and restart a healthy watch — which, if it also has no fix yet, leaks another pending timeout. It is self-sustaining.
- Main-thread cost. Every cleared-but-unfinished watch keeps a
delay(10) loop (100 wakeups/second) on Dispatchers.Main until its timeout expires. Apps that use a long timeout to avoid the watch being torn down accumulate several of these at once.
- Leaked collectors and
LocationCallback references accumulate for the process lifetime.
This is easiest to hit with a long timeout on watchPosition, which is a reasonable configuration since the timeout tears the watch down when it fires.
Reproduction
watchPosition({ enableHighAccuracy: true, timeout: 600000, interval: 1000, minimumUpdateInterval: 1000 }, cb) on Android.
- Ensure no location is produced (indoors / no fix), so the watch makes no first emission.
clearWatch({ id }) after a few seconds. Do not start another watch, so the observation is unambiguous.
- Wait out the timeout.
Observed: cb is invoked with OS-PLUG-GLOC-0010 about 10 minutes after the watch was cleared.
Expected: a cleared watch produces no further callbacks, and its pending work is cancelled.
Field data
Measured over 14 days in a production app: 2 601 such timeout callbacks across 261 Android users, against 20 on iOS over the same period with the same JavaScript. Every one of them is for a watch that had already been cleared.
Suggested fix
Keep the Job returned by startWatch's launch alongside the PluginCall, and cancel it in clearWatch:
private val watchingJobs: MutableMap<String, Job> = mutableMapOf()
private fun startWatch(call: PluginCall) {
watchingJobs[call.callbackId] = coroutineScope.launch { … }
watchingCalls[call.callbackId] = call
}
fun clearWatch(call: PluginCall) {
…
watchingJobs.remove(id)?.cancel()
watchingCalls.remove(id)?.release(bridge)
…
}
Cancelling the collector also cancels the channelFlow in emitOrTimeoutBeforeFirstEmission, which stops both the polling loop and the late timeout emission.
Separately, it may be worth having emitOrTimeoutBeforeFirstEmission await the first emission instead of polling every 10 ms — the polling cost is paid on the main thread for the whole pre-fix period even when the watch is working normally.
Summary
On Android,
clearWatchremoves the location listener and releases thePluginCall, but it never cancels the coroutine started instartWatch. The underlying flow keeps running. If the watch never delivered a location before it was cleared,emitOrTimeoutBeforeFirstEmissionkeeps counting and eventually emits a timeout — which is still delivered to the app's JavaScript callback, long after the watch was cleared.Versions
@capacitor/geolocation8.2.0io.ionic.libs:iongeolocation-android2.2.0 (pinned inandroid/build.gradle)@capacitor/core/@capacitor/android8.4.xMechanism
GeolocationPlugin.startWatch:clearWatchdoes:controller.clearWatchremoves theLocationHandlerand stops location updates, but nothing cancels thelaunch { … collect }above. ThecallbackFlowinwatchLocationUpdatesFlowis never closed, so the collector stays suspended forever.Meanwhile
IONGLOCController.addWatchwraps the flow in:.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout)and
emitOrTimeoutBeforeFirstEmissionrunswithTimeoutOrNull(timeoutMillis) { while (firstValue == null) delay(10) }. That polling loop is unaffected byclearWatch, so for a watch that never produced a first emission it keeps polling onDispatchers.Mainfor the full timeout, then sendsIONGLOCLocationRetrievalTimeoutException.That exception reaches
onLocationError(exception, call)→call.sendError(...)→reject(...).PluginCall.rejectdoes not checkisReleased, andMessageHandler.sendResponseMessageposts the message to the WebView regardless. On the JS side the callback for that watch id is still registered, becauseclearWatchnever produces asave: falseresponse for it.Consequences
delay(10)loop (100 wakeups/second) onDispatchers.Mainuntil its timeout expires. Apps that use a long timeout to avoid the watch being torn down accumulate several of these at once.LocationCallbackreferences accumulate for the process lifetime.This is easiest to hit with a long
timeoutonwatchPosition, which is a reasonable configuration since the timeout tears the watch down when it fires.Reproduction
watchPosition({ enableHighAccuracy: true, timeout: 600000, interval: 1000, minimumUpdateInterval: 1000 }, cb)on Android.clearWatch({ id })after a few seconds. Do not start another watch, so the observation is unambiguous.Observed:
cbis invoked withOS-PLUG-GLOC-0010about 10 minutes after the watch was cleared.Expected: a cleared watch produces no further callbacks, and its pending work is cancelled.
Field data
Measured over 14 days in a production app: 2 601 such timeout callbacks across 261 Android users, against 20 on iOS over the same period with the same JavaScript. Every one of them is for a watch that had already been cleared.
Suggested fix
Keep the
Jobreturned bystartWatch'slaunchalongside thePluginCall, and cancel it inclearWatch:Cancelling the collector also cancels the
channelFlowinemitOrTimeoutBeforeFirstEmission, which stops both the polling loop and the late timeout emission.Separately, it may be worth having
emitOrTimeoutBeforeFirstEmissionawait the first emission instead of polling every 10 ms — the polling cost is paid on the main thread for the whole pre-fix period even when the watch is working normally.