Lifecycle¶
Reachability is a long-lived handle that owns a platform observer and a
coroutine scope. The library offers two lifecycle paths — singleton and
per-instance — with different construction and close contracts.
Singleton path — Reachability.shared¶
Reachability.shared is a process-lifetime singleton. Use it for the
vast majority of app code: it requires no construction, no Context
plumbing, and no teardown.
When to use it¶
Whenever you need reachability in a @Composable, a ViewModel, a
background service, or any code that doesn't have a natural "this scope
owns reachability" owner.
Construction (Android)¶
On Android, the library's bundled ReachabilityInitializer (an
androidx.startup.Initializer) attaches the singleton to the application
Context during the InitializationProvider ContentProvider pass —
before Application.onCreate. This means:
- The singleton is fully functional by the time any
Activity,ContentProvider, orApplication.onCreateruns. - Collectors started before attach receive
ReachabilityStatus.Unknownfirst, then live values as soon as the platform reports them.StateFlowlate-joiner semantics make this race-free.
Construction (Apple)¶
On Apple (iOS, iPadOS, macOS), first access constructs an
nw_path_monitor-backed observer and starts it eagerly. There is no
Context dependency — the monitor is self-contained. Subsequent accesses
return the same instance.
Close semantics¶
Calling close() on Reachability.shared is an intentional no-op.
The singleton's lifetime is the process; neither platform has a usable
"natural deinit" path for a long-lived singleton:
- On Android,
ConnectivityManager.registerNetworkCallbackcauses the OS to hold a strong reference to the callback, rooting it back to theReachabilityinstance. The instance is unreclaimable untilunregisterNetworkCallbackruns — so a "natural deinit" doesn't exist. The kernel reaps the registration at process exit. - On Apple,
nw_path_monitor_set_update_handlerretains the update handler, which captures the instance. ARC won't drop it untilnw_path_monitor_cancel. Same kernel-reaps-at-exit story.
A misplaced use { } block, an AutoCloseable-aware DI container, or a
test fixture that closes everything in @AfterEach must not accidentally
freeze the singleton's StateFlow and break every other consumer in the
process. The no-op close() is the safeguard.
Per-instance path — factories¶
For tests, per-feature observers, or any code that needs explicit teardown:
When to construct¶
| Where you are | Where to construct |
|---|---|
| Android | Application.onCreate() — bind to the application context. Or just use Reachability.shared. |
| iOS | App init (the @main App struct) or your composition root. Or just use Reachability.shared. |
| macOS | Same as iOS — both Apple platforms share appleMain. |
Test (runTest) |
Per-test, in a try-with-resources-style block. Don't share across tests (use the factory, not Reachability.shared). |
The Apple factory creates a per-instance serial dispatch queue and starts
an nw_path_monitor. The Android factory grabs applicationContext's
ConnectivityManager and registers a NetworkCallback. Both are cheap
(microseconds) but each instance is a small allocation plus a platform
observer registration.
When to close¶
close() cancels the platform observer (nw_path_monitor_cancel on Apple,
unregisterNetworkCallback on Android) and cancels the internal coroutine
scope.
| Where you are | Where to close |
|---|---|
| Android | Application.onTerminate(). The OS rarely calls it; in practice the process dies first. |
| iOS / macOS | deinit on the owning view-model or composition root. |
| Test | At the end of the test. runTest and Turbine leak the scope otherwise. |
close() is idempotent and synchronous; multiple calls are no-ops. After
close, status.value continues to expose its last observed value but never
emits again. Collectors of status see the Flow complete on the next
dispatcher tick after the scope cancels.
What threads things fire on¶
Apple¶
The nw_path_monitor update handler fires on the per-instance serial
dispatch queue created at construction time
(dispatch_queue_create("dev.reachable.monitor", null)). The handler body
is a single MutableStateFlow.value write, which is concurrency-safe.
Collectors observe on whatever dispatcher they collect on.
The first emission lands on the queue typically within tens of
milliseconds of construction. Until then, status.value returns
ReachabilityStatus.Unknown.
Android¶
NetworkCallback methods fire on a binder thread. The handler body is a
single MutableStateFlow.value write. Collectors observe on whatever
dispatcher they collect on.
Explicit-lifecycle factory (Reachability(context)): construction
performs a synchronous read of connectivityManager.activeNetwork plus
getNetworkCapabilities(network) and seeds status.value from that — so
status.value is meaningful immediately after construction, before any
callback fires. A LaunchedEffect-style synchronous check on app start
works without racing.
Singleton (Reachability.shared): status.value starts as
ReachabilityStatus.Unknown and transitions to the live value after the
ReachabilityInitializer calls attach() during the ContentProvider
startup pass (before Application.onCreate). Collectors started before
that transition see Unknown first, then the live value. The transition
happens early enough that in practice the Unknown window is
sub-millisecond by the time any UI is drawn.
Multiple collectors¶
Multiple collectors share one underlying platform observer; the library
does not register a new nw_path_monitor or NetworkCallback per
collector. Collecting in 50 places costs the same as collecting in one.
A late-joining collector immediately receives the most recent value and then every subsequent change. There's no replay buffer to tune.
Patterns to avoid¶
- Constructing inside a Compose
@Composablewithoutremember(...). Every recomposition would create a new platform observer; the AndroidNetworkCallbackregistry would fill up. UseReachability.shared(preferred), or wrap inremember(context) { Reachability(context) }, or hoist into a view-model. - Constructing inside a SwiftUI
View'sbody. Same problem. UseReachability.shared(preferred), or hoist into an@StateObjectview-model whoseinitcalls the factory once. - Calling
close()onReachability.shared. It's a no-op by design — but if you see code that explicitly calls it, the intent was likely to use a per-instance factory. The no-op prevents accidents; don't rely on it as deliberate cleanup. - Sharing a
Reachabilityinstance across processes (Android multi- process apps withandroid:process=...per service). Each process needs its ownConnectivityManagerregistration; cross-process sharing doesn't work.Reachability.sharedis per-process — each process that hosts aContentProvidergets its own auto-attached singleton. - Forgetting to
close()in test code when using per-instance factories.runTestleaves dangling scopes alive between tests; the next test sees stale state. Bracket withval r = Reachability(...); try { ... } finally { r.close() }. Tests that useReachability.shareddon't need to close it.
Cost summary¶
A construction allocates:
- Apple: one dispatch queue (~kB), one
nw_path_monitor_t(kernel object, ~kB). - Android: one
NetworkCallback(~kB) plus a binder transaction to register it.
A close() releases both. The SupervisorJob cancels any in-flight
collectors. The MutableStateFlow is GC-eligible after collectors complete.