Whether this device is online, answered by two signals that have to agree — as typed values, for a one-shot check or an app that stays up, on any Apple platform.
import Reachability
let verdict = await Reachability.check()
verdict.isOnline // true
verdict.pathIsSatisfied // true — en0 has a route
verdict.probeSucceeded // true — and Apple's page answered, in 0.25 s
verdict.interfaces // ["en0"]
let monitor = Reachability.Monitor() // @MainActor, ObservableObject
monitor.start()
monitor.isOnline // flips within a second of Wi-Fi going off
monitor.offlineSince // and says since when
try await Reachability.publicIP() // 125.26.175.94, v4Every app answers "am I online" in one of two wrong ways, and the difference only shows the day the network is actually broken:
| The usual answer | What goes wrong |
|---|---|
NWPathMonitor says the path is satisfied |
It is satisfied while the router behind it is dead. A route to the router is a route; Wi-Fi stays "connected" through a power cut. |
A HEAD through URLSession.shared |
Its cache can answer from disk. Last week's 200 comes back with the cable out, and the app reports online. |
SCNetworkReachability |
Reports whether a packet could be routed, not whether one was. It has never proved the internet answered, and Apple has moved on from it. |
| One check at launch | The one moment it cannot go wrong is the moment it was checked. |
Two signals, both required: the path monitor for the instant answer when a route disappears, and a tiny request to a page that is always up, through a session that ignores every cache, for whether the route goes anywhere. Online means both said yes.
.package(url: "https://github.com/arraypress/swift-reachability.git", from: "0.1.0")Foundation and Network. No dependencies, every Apple platform, macOS 14+.
let verdict = await Reachability.check(timeout: 5, probe: .apple)Reads the path first — by starting a monitor on a private queue, taking its first
update and cancelling it, because currentPath before start is a placeholder that
says every device is offline — and probes only when the path is up. A dead path means
no probe was sent, and the verdict says so with probeSucceeded == nil rather than a
false. The verdict carries the status the probe got, its latency, the interface names,
and whether the route is metered or in Low Data Mode.
Two pages are built in, both checked with curl -I before they were chosen: Apple's
captive-portal page (https://www.apple.com/library/test/success.html, answers 200)
and Google's (https://www.google.com/generate_204, answers 204). Any status under
400 counts as reached — a redirect is still the internet answering. .custom(URL)
takes a page of your own.
@StateObject private var network = Reachability.Monitor()
network.start() // once, at launch
network.isOnline // @Published
network.offlineSince // @Published, nil while online
network.lastVerdict // the evidence
network.noteOfflineError() // a fetch just failed with a no-internet error: check nowThe path monitor is the fast signal: a route going away flips isOnline at once. The
probe is the confirmation: a route coming back does not flip it on until a request has
reached the internet. While offline the probe repeats every ten seconds, so recovery
shows within seconds; while online, once a minute, so a router that died between
requests is noticed. The monitor starts optimistic, so nothing is drawn as offline
before anything is known. Options sets the intervals, timeout and probe;
override(online:) pins the state for previews and tests.
URLError(.notConnectedToInternet).isOffline // true
URLError(.timedOut).isOffline // false — a slow server is not a dead network
Reachability.classify(error) // the same test for any ErrorThe distinction a fetch needs before recording a failure. Not connected, connection lost, DNS failed, host not found or not reachable, roaming off, data not allowed and a call in progress are the network's fault. Backing off from a service for those punishes the wrong party.
try await Reachability.publicIP() // whichever family the route uses
try await Reachability.publicIP(version: .v4)
try await Reachability.publicIP(version: .v6) // upstream("no IPv6 route…") on most networksFrom ipify, keyless. The body is validated as an address with inet_pton, so an error
page is refused rather than stored. A network with no IPv6 route is not offline, and the
.v6 failure says so instead.
Transport, PathSource and Clock are protocols with the real implementations as
defaults. The suite drives a scripted network, a scripted path and a clock moved by
hand, so "Wi-Fi went off" and "sixty seconds passed" are lines in a test, and the
monitor's every transition is pinned in milliseconds.
35 tests, none touching a network: the verdict for a dead path (no probe sent), a good
probe, a redirect, 404 and 5xx, a timeout, a no-internet error, a path monitor that
never answers, a hotspot flagged expensive, a custom probe; the monitor starting
optimistic and confirming, dropping the instant the path goes, staying offline until a
probe passes, catching a dead router on the minute, checking at once when a fetch
fails, never doubling a probe, being pinned by override, stopping idempotently; every
no-internet code and the ones that are not; the address lookup for each family, an
error page, an empty body, a server error, a missing IPv6 route; and every model
through JSON.
MIT.