Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

macOS Network.framework: Path Monitoring, Connection State, and Recovery

Track macOS path changes and endpoint connections with Network.framework, interpret readiness correctly, and build bounded recovery instead of reachability guesses.

Network state is not a single Boolean. A Mac can have an active Wi-Fi route while a DNS lookup fails, a VPN can change the route for one process, a server can be down while the local network is healthy, and a connection that was ready a moment ago can become unusable. Robust macOS applications treat interface-path observations and endpoint connection state as separate signals.

Apple’s Network framework provides both. NWPathMonitor reports changes to paths available to an app. NWConnection manages a connection to a specific endpoint and reports whether that connection is setting up, ready, waiting, failed, or cancelled. Use the former for general network state and policy-aware UI; use the latter to determine whether a particular service connection actually works. Neither substitutes for application-level request and response validation.

This is distinct from Network Extension. Network Extension lets entitled apps provide or manage system networking capabilities such as VPNs and content filters. Network.framework is the app-facing API for creating connections, listeners, and service discovery clients. A normal app should not infer that it needs a Network Extension provider just because it needs to communicate over the network.

A path is a route candidate, not an internet probe

NWPathMonitor starts observing the paths available to the process and invokes pathUpdateHandler on the dispatch queue passed to start(queue:). A path can report .satisfied, .unsatisfied, or .requiresConnection. .satisfied means a path is available to establish connections and send data. It does not prove that a chosen hostname resolves, that a remote server accepts a connection, that TLS succeeds, or that the application request will return useful data.

Do not build the pattern “ping a public host, cache isOnline, and block all other requests when that ping fails.” A public endpoint can fail independently, a captive portal can allow only some traffic, and a VPN or proxy can make the app’s actual path different from a generic probe. Start the real operation and handle the result from the API responsible for that operation.

Use a monitor when the app needs to react to broad path changes, such as updating a status indicator or choosing whether to start optional synchronization. Keep the callback lightweight and move UI updates onto the main queue. The sample deliberately reports properties rather than treating one interface type as a requirement:

import Network

final class PathObserver {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "com.example.app.path-observer")

    func start() {
        monitor.pathUpdateHandler = { path in
            let state: String
            switch path.status {
            case .satisfied:
                state = "path-available"
            case .unsatisfied:
                state = "no-usable-path"
            case .requiresConnection:
                state = "connection-may-activate-path"
            @unknown default:
                state = "unknown-path-state"
            }

            print("\(state), wifi=\(path.usesInterfaceType(.wifi)), expensive=\(path.isExpensive)")
        }

        monitor.start(queue: queue)
    }

    func stop() {
        monitor.cancel()
    }
}

Keep one monitor per meaningful observation lifecycle instead of constructing monitors in a view’s frequently called update method. Install the handler before starting the monitor, retain the monitor while its observations are needed, and cancel it when the owner is finished. The handler runs on the configured queue, not automatically on the main thread. If it feeds UI, dispatch only the resulting state change to the main queue and avoid sharing mutable state without synchronization.

NWPathMonitor(requiredInterfaceType:) and prohibitedInterfaceTypes are useful for a specific policy question, but they change what is being observed. A Wi-Fi-only monitor that is unsatisfied does not mean Ethernet or a VPN-backed path is unusable. Do not use a filtered monitor to answer whether the process has any usable network path.

Track the endpoint connection separately

When a feature depends on a specific service, observe NWConnection. Its state handler reports setup, preparing, ready, waiting(error), failed(error), or cancelled. ready means the connection is established and ready for protocol I/O. waiting means the connection is waiting for a path change. failed means the connection disconnected or encountered an error; it is not the same as a temporary path observation.

The following illustrates lifecycle handling for a TLS connection. It does not implement an application protocol or send credentials; production code must add its own request framing, response validation, timeout policy, and cancellation ownership.

import Network

let queue = DispatchQueue(label: "com.example.app.api-connection")
let connection = NWConnection(
    host: "api.example.com",
    port: 443,
    using: .tls
)

connection.stateUpdateHandler = { state in
    switch state {
    case .setup:
        print("connection created")
    case .preparing:
        print("resolving and establishing")
    case .ready:
        print("transport is ready for protocol I/O")
    case .waiting(let error):
        print("waiting for a usable path: \(error)")
    case .failed(let error):
        print("connection failed: \(error)")
    case .cancelled:
        print("connection cancelled")
    @unknown default:
        print("unknown connection state")
    }
}

connection.start(queue: queue)

The endpoint and parameters belong to this connection. When it is ready, the app still has to speak the expected protocol and decide what counts as a successful operation. A TCP or TLS handshake does not prove an HTTP request was accepted, a server-side transaction committed, or a response body passed validation. Keep the connection’s owner responsible for cancelling it after completion, user cancellation, or a terminal failure so resources do not outlive the operation.

Connections in waiting automatically restart when the network path changes. Network.framework also provides restart() when the app has a reason to believe a waiting connection may succeed if it tries again. Treat it as a state transition, not a retry loop: do not call it repeatedly from every generic path update, use bounded backoff for application-level retries, and make retried operations safe if the prior result is ambiguous. A failed connection usually calls for a new connection or a user-visible failure path rather than restarting the same object indiscriminately.

Use currentPath or pathUpdateHandler on NWConnection when the question concerns the path used by that connection. viabilityUpdateHandler reports changes in whether the connection can currently send and receive data. These connection-scoped observations are often more relevant than a separate global monitor. A betterPathUpdateHandler can notify that an alternative path is preferred. If the protocol permits migration, establish a new connection, wait until it is ready, move work to it, and then cancel the original connection. That callback does not itself migrate application state, and an ambiguous request should not be replayed without protocol-level idempotency.

Design reconnection around operation semantics

A path change can coincide with an in-flight write. The client may not know whether the server committed the request before the transport failed. Retrying a read is often safe; retrying a payment, job submission, or state mutation may duplicate work unless the application protocol uses an idempotency key or another deduplication strategy. Record operation identifiers and outcomes at the application layer instead of treating “connection ready again” as a completed recovery.

Separate transport recovery from user experience. A path observer can tell a view that a path is now available, while the connection state handler controls the status of a particular endpoint. The request owner should decide whether to queue, cancel, or retry work. Avoid storing only a global isConnected flag: different endpoints, VPN routes, and interfaces can fail independently.

Prefer the least complicated observer that answers the question:

  • Use NWPathMonitor for broad path changes or path properties, such as expensive or constrained interface state.
  • Use NWConnection for a concrete host, service, or protocol endpoint.
  • Use connection viability and current-path callbacks when monitoring an already established connection.
  • Use Network Extension only when implementing a system-managed networking capability that requires its provider model and entitlements.

For diagnostics, record state transitions with a monotonic timestamp, endpoint identifier that contains no secret, NWError domain/code, and the relevant path attributes. Avoid logging tokens, query strings, or private payloads. If a connection takes too long to become ready, collect establishment metrics where supported instead of inferring a DNS or TLS cause from elapsed time alone.

Test the state machine, not only the happy path

Test with a representative Mac on every supported architecture and macOS release, then exercise at least these transitions in a controlled environment:

  • Start with Wi-Fi enabled, begin an endpoint request, and confirm that .ready follows the expected setup states.
  • Disable the active interface during a transfer and verify that the connection becomes waiting or fails as documented by the observed result; verify that the app does not report the request as successful without a response.
  • Restore connectivity and confirm that the chosen retry path is bounded and does not submit a non-idempotent operation twice.
  • Change from Wi-Fi to Ethernet or a VPN and verify that interface policy, path callbacks, and the actual endpoint behave as expected.
  • Make DNS resolution fail or point a test endpoint at a closed port while leaving the local network active. The app should not mistake a satisfied generic path for endpoint success.
  • Cancel the owning task while a connection is preparing or waiting. No later callback should resurrect a cancelled user operation.

Acceptance evidence should distinguish three measurements: path state, connection state, and application response. A path transition without a request is not proof of service availability; a connection marked ready is not proof of a successful application operation. Keeping those layers separate makes macOS network behavior observable instead of guesswork.

Related:

Sources:

Comments