NWConnection on macOS: Stream Framing, State, and Cancellation
Operate Network.framework NWConnection streams with explicit state handling, bounded receives, framing, retries, and orderly cancellation.
NWConnection is a bidirectional connection abstraction that runs a protocol stack over a selected endpoint and parameters. Its lifecycle is asynchronous: constructing the object does not establish a connection, a ready state does not mean an application request succeeded, and cancel() does not imply that remote work was rolled back. Reliable code keeps transport state, protocol framing, request semantics, and ownership separate.
This distinction is especially important for stream transports. A stream is a sequence of bytes, not a sequence of your application’s messages. A receive call returns data available within the requested range; it does not promise to align with a JSON object, line, or custom record. Your protocol must define framing and handle partial reads, multiple messages in one read, and end-of-stream conditions.
Start by observing connection state
Create a connection with the endpoint and parameters that describe the intended protocol. Set the state handler before starting it, then start on a queue dedicated to connection events. The documented states include setup, waiting, preparing, ready, failed, and cancelled. Treat waiting as a signal that the framework is waiting for a path change, not as success and not necessarily as a permanent error. A failed state carries an error; cancelled is terminal for that connection object.
import Network
import Foundation
func receiveNext(_ connection: NWConnection) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
data, _, isComplete, error in
if let data, !data.isEmpty {
consumeBytes(data)
}
guard error == nil, !isComplete else { return }
receiveNext(connection)
}
}
func consumeBytes(_ data: Data) {
// Feed a bounded protocol decoder, not a UI or an unbounded buffer.
}
let connection = NWConnection(
host: "example.com",
port: 443,
using: .tls
)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
receiveNext(connection)
case .waiting(let error), .failed(let error):
reportTransportProblem(error)
case .cancelled:
break
default:
break
}
}
connection.start(queue: .global(qos: .utility))
func reportTransportProblem(_ error: NWError) {
// Map the error into an application-level diagnostic.
}
The example starts a TLS connection and issues a receive loop only after the connection is ready. It is intentionally not an HTTP client: selecting TLS does not implement HTTP request semantics, certificate pinning policy, or application authentication. The receive function must also be paired with the framing expected by the actual protocol. In production, make lifecycle callbacks update a synchronized connection owner rather than mutating shared UI state directly.
State is a transport signal, not a transaction result
ready means the connection is established and can send and receive data. It does not guarantee the server accepts an application message, that a credential is valid, or that a write has been committed remotely. Define a protocol handshake or request-response boundary above Network.framework and validate it independently. A transport callback should not mark a purchase, upload, or remote configuration update complete without the protocol’s acknowledgement.
waiting can occur while the system waits for a network path. Decide whether the app should keep the connection pending, display an offline indicator, or allow the owning operation to be cancelled. If the connection is in a waiting state, restart() can reattempt connection establishment according to the API; do not create a new connection every time a transient path changes without first cancelling and accounting for the old one.
failed and cancelled should be distinct in logs and state models. A failure may be recoverable by creating a new connection after backoff or by restarting a waiting connection. Cancellation is usually an app decision. Keep an explicit generation or request ID so a late callback from an old connection cannot overwrite the state of a newer attempt.
Frame byte streams before decoding messages
For a TCP-like stream, receive(minimumIncompleteLength:maximumLength:completion:) returns a bounded portion of available bytes. The callback can contain fewer bytes than a complete application message, several messages, or a complete message plus the prefix of the next. Define an unambiguous framing scheme such as a fixed-size header with a length, a delimiter with escaping, or a format whose decoder can safely find boundaries. Enforce a maximum frame size before allocating the full message.
Maintain decoder state across receives. If a declared frame length is larger than policy permits, close the connection and record a protocol violation. If a delimiter-based protocol has an unterminated buffer growing beyond a limit, fail closed rather than allowing memory usage to grow with an untrusted peer. Test UTF-8 multibyte characters split across callbacks; converting each chunk independently to a string can corrupt boundaries.
receiveMessage is intended for a complete message boundary provided by message-oriented protocols. Do not switch from stream receive to message receive unless the configured protocol stack actually supplies those message semantics. For datagrams, use protocol-appropriate message APIs and respect maximum datagram size and loss/reordering characteristics.
Send completion and application acknowledgements
send accepts content, a content context, a completion policy, and a completion handler. A send completion indicates the framework has finished processing sent content according to that completion mode; it is not equivalent to a business acknowledgement from the server. Protocols that need delivery confirmation must wait for a response correlated with the request ID and handle server-side idempotency.
Make writes bounded. A user can produce data faster than the network drains it; queuing unbounded Data values can exhaust memory. Apply backpressure at the producer boundary, cap in-flight work, and decide whether a slow peer should pause, reject, or cancel the operation. Use batch only to group operations when its performance behavior is beneficial; it does not replace framing, acknowledgement, or queue limits.
If a connection fails after the client sends a mutation but before a response arrives, the outcome is ambiguous. Do not blindly resend non-idempotent operations. Use an idempotency key or query the server for the operation’s status. Treat transport retries as a product and protocol decision, not a generic response to every NWError.
Ownership, cancellation, and path changes
Assign one owner to each connection: a transfer coordinator, document session, or service object. The owner stores the connection, request context, receive decoder, pending request table, and cancellation policy. A screen may observe this state without owning the transport. When ownership ends, cancel once, stop issuing receives, release pending buffers, and ignore callbacks tagged with the cancelled generation.
cancel() gracefully disconnects established network protocols according to the API; forceCancel() requests immediate disconnection. Select based on protocol shutdown needs. Neither API can guarantee that the remote peer did not process the bytes already sent. Path updates, viability changes, and better-path notifications provide context about current network conditions; they do not prove the server is healthy or that the application protocol remains synchronized.
When a path changes, decide if an established connection can remain usable or if the owning operation needs a reconnect. Preserve requests only when their protocol semantics permit it. A retryable read can usually be repeated under a bounded policy; a one-shot mutation requires idempotency or server reconciliation. Avoid using path monitoring as a gate that blocks all network attempts, because the path can change between observation and use.
Security and observability boundaries
Choose NWParameters deliberately. TLS parameters configure transport security, but the app still needs a host identity policy, certificate validation behavior, and authentication protocol appropriate to its service. Do not disable trust evaluation to make a test connection work. Keep secrets out of endpoint strings, logs, and error descriptions. If the connection exposes a currentPath, log only the path details needed to diagnose behavior and avoid collecting identifying network information without a product need.
Use connection establishment reports and data transfer reports when measuring DNS, handshake, and transfer costs. Record connection generation, endpoint class, state transitions, elapsed time, byte counts, and normalized errors. Avoid treating a single duration as evidence of whether delay came from DNS, TLS, server processing, or client backpressure.
Acceptance matrix
Test a valid connection, a host that does not resolve, refused port, blocked path, peer close, TLS trust failure, slow peer, fragmented frame, coalesced frames, oversized frame, incomplete frame at EOF, send without server acknowledgement, cancellation during a receive, and reconnection after a temporary outage. Assert bounded buffer growth, one terminal outcome for each request, no stale generation updates, and no duplicate mutation after ambiguous failure.
Network.framework manages connection establishment and protocol plumbing. The application still owns framing, request correlation, retries, flow control, persistence, and user-visible success. Keeping those boundaries explicit is what makes a connection lifecycle observable and safe to recover.
Related:
- macOS Network.framework: Path Monitoring, Connection State, and Recovery
- URLSession on macOS: Transfer Lifecycles, Delegate Ownership, and Caching
Sources: