
Axiom Networking
- 1k installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-networking is a Claude Code skill that gives systematic guidance for implementing and debugging iOS network connections, API calls, sockets, URLSession, and Network.framework code.
About
axiom-networking is an iOS networking discipline skill from charleswiltgen/axiom covering URLSession with structured concurrency, Network.framework patterns, NetworkConnection diagnostics, deprecated API migration, and pressure scenarios like reachability changes. The skill mandates use for any HTTP request, WebSocket, TCP connection, or network debugging task in Apple platforms. Developers reach for axiom-networking when building or fixing iOS networking stacks instead of ad hoc URLSession snippets that hide race conditions, deprecated APIs, or connection lifecycle bugs.
- MUST-use rule for ANY networking work including HTTP requests, WebSockets, TCP connections, or network debugging
- Quick-reference table mapping 15+ symptoms and tasks to the correct reference file
- Covers URLSession with structured concurrency, Network.framework anti-patterns, and deprecated API migration
- Includes diagnostics for connection timeouts, TLS failures, reachability, ATS, and App Store rejections
- Provides API references for NWConnection (iOS 12-18), NetworkConnection (iOS 26+), TLV framing, NWListener, and NWBrowse
Axiom Networking by the numbers
- 1,021 all-time installs (skills.sh)
- +38 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #389 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-networkingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1k |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you debug iOS URLSession and Network.framework connections?
Get reliable, systematic guidance when implementing or debugging any network connection, API call, socket, or iOS networking stack.
Who is it for?
iOS and Swift developers implementing or debugging URLSession, Network.framework, WebSockets, or TCP networking in production apps.
Skip if: Android OkHttp work, server-side API design-only tasks, or non-Apple platform networking without iOS-specific APIs.
When should I use this skill?
A developer implements or debugs any iOS HTTP request, WebSocket, TCP socket, URLSession, or Network.framework connection issue.
What you get
Corrected iOS networking code patterns, migration notes, and connection diagnostic guidance
- networking implementation guidance
- connection diagnostic checklists
- deprecated API migration notes
Files
Networking
You MUST use this skill for ANY networking work including HTTP requests, WebSockets, TCP connections, or network debugging.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| URLSession with structured concurrency | See skills/networking-discipline.md |
| Network.framework anti-patterns | See skills/networking-discipline.md |
| Deprecated API migration | See skills/networking-discipline.md |
gRPC Swift — typed RPC, streaming, .proto codegen (WWDC 2026) | See skills/networking-discipline.md |
| Pressure scenarios (reachability, sockets) | See skills/networking-discipline.md |
| NetworkConnection (iOS 26+) API reference | See skills/network-framework-ref.md |
| NWConnection (iOS 12-18) API reference | See skills/network-framework-ref.md |
| TLV framing, Coder protocol | See skills/network-framework-ref.md |
| NetworkListener, NetworkBrowser, Wi-Fi Aware | See skills/network-framework-ref.md |
| Connection timeouts, TLS failures | See skills/networking-diag.md |
| Data not arriving, connection drops | See skills/networking-diag.md |
| ATS / HTTP / App Store rejection | See skills/networking-diag.md |
| Production crisis diagnosis | See skills/networking-diag.md |
| NWConnection patterns (iOS 12-18) | See skills/networking-legacy.md |
| UDP batch, NWListener, NWBrowser | See skills/networking-legacy.md |
| BSD sockets → NWConnection migration | See skills/networking-migration.md |
| NWConnection → NetworkConnection migration | See skills/networking-migration.md |
| URLSession StreamTask → NetworkConnection | See skills/networking-migration.md |
Decision Tree
digraph networking {
start [label="Networking task" shape=ellipse];
what [label="What do you need?" shape=diamond];
start -> what;
what -> "skills/networking-discipline.md" [label="implement patterns,\nanti-patterns,\npressure scenarios"];
what -> "skills/network-framework-ref.md" [label="API reference\n(iOS 26+ or 12-18)"];
what -> "skills/networking-diag.md" [label="debug connection\nfailures"];
what -> "skills/networking-legacy.md" [label="iOS 12-18\nNWConnection patterns"];
what -> "skills/networking-migration.md" [label="migrate from\nsockets/URLSession"];
}1. URLSession with structured concurrency? → skills/networking-discipline.md 2. Network.framework / NetworkConnection (iOS 26+)? → skills/network-framework-ref.md 3. NWConnection (iOS 12-18)? → skills/networking-legacy.md 4. Migrating from sockets/URLSession? → skills/networking-migration.md 5. Connection issues / debugging? → skills/networking-diag.md 6. Typed RPC / streaming against a service you control? → gRPC Swift (skills/networking-discipline.md) 7. ATS / HTTP / App Store rejection for networking? → skills/networking-diag.md + networking-auditor 8. Certificate pinning, signing API requests, encrypting payloads? → /skill axiom-security 9. UIWebView or deprecated API rejection? → networking-auditor (Agent) 10. Want deprecated API / anti-pattern scan? → networking-auditor (Agent)
Platform-specific networking
- watchOS low-level-networking limits (TN3135) → See axiom-watchos (skills/background-and-networking.md)
Pressure Resistance
When user has invested significant time in custom implementation:
Do NOT capitulate to sunk cost pressure. The correct approach is:
1. Diagnose first — Understand what's actually failing before recommending changes 2. Recommend correctly — If standard APIs (URLSession, Network.framework) would solve the problem, say so professionally 3. Respect but don't enable — Acknowledge their work while providing honest technical guidance
Critical Patterns
Networking (skills/networking-discipline.md):
- URLSession with structured concurrency
- 8 red-flag anti-patterns (SCNetworkReachability, blocking sockets, hardcoded IPs)
- Decision tree for choosing TCP/UDP/TLS patterns
- NetworkConnection patterns (iOS 26+): TLS, UDP, TLV framing, Coder protocol
- 3 pressure scenarios with professional push-back templates
- Pre-shipping checklist
Network Framework Reference (skills/network-framework-ref.md):
- NetworkConnection (iOS 26+): all 12 WWDC 2025 examples
- NWConnection (iOS 12-18): complete API with examples
- TLV framing, Coder protocol, NetworkListener, NetworkBrowser
- Mobility: viability, better path, Multipath TCP, NWPathMonitor
- Security: TLS, certificate pinning, cipher suites
- Performance: user-space networking, ECN, service class, TCP Fast Open
Diagnostics (skills/networking-diag.md):
- Systematic decision tree for all connection failure types
- DNS failures, TLS certificate validation, message framing
- TCP congestion, IPv6-only cellular, VPN interference, ATS
- Production crisis scenario with professional communication templates
Legacy (skills/networking-legacy.md):
- NWConnection with TLS (completion handlers)
- UDP batch (30% CPU reduction)
- NWListener, NWBrowser (Bonjour discovery)
Automated Scanning
Networking audit → Launch networking-auditor agent or /axiom:audit networking (deprecated APIs, anti-patterns, and completeness gaps — transition handling, TLS coverage, connection cleanup, framework selection)
Anti-Rationalization
| Thought | Reality |
|---|---|
| "URLSession is simple, I don't need a skill" | URLSession with structured concurrency has async/cancellation patterns. skills/networking-discipline.md covers them. |
| "I'll debug the connection timeout myself" | Connection failures have 8 causes (DNS, TLS, proxy, cellular). skills/networking-diag.md diagnoses systematically. |
| "I just need a basic HTTP request" | Even basic requests need error handling, retry, and cancellation patterns. |
| "My custom networking layer works fine" | Custom layers miss cellular/proxy edge cases. Standard APIs handle them automatically. |
Example Invocations
User: "My API request is failing with a timeout" → Read: skills/networking-diag.md
User: "How do I use URLSession with async/await?" → Read: skills/networking-discipline.md
User: "I need to implement a TCP connection" → Read: skills/network-framework-ref.md
User: "Should I use NWConnection or NetworkConnection?" → Read: skills/network-framework-ref.md
User: "My app was rejected for using HTTP connections" → Read: skills/networking-diag.md (ATS compliance)
User: "App Store says I'm using UIWebView" → Invoke: networking-auditor agent (deprecated API scan)
User: "Check my networking code for deprecated APIs" → Invoke: networking-auditor agent
Network.framework API Reference
Overview
Network.framework is Apple's modern networking API that replaces Berkeley sockets, providing smart connection establishment, user-space networking, built-in TLS support, and seamless mobility. Introduced in iOS 12 (2018) with NWConnection and evolved in iOS 26 (2025) with NetworkConnection for structured concurrency.
Evolution timeline
- 2018 (iOS 12) NWConnection with completion handlers, deprecates CFSocket/NSStream/SCNetworkReachability
- 2019 (iOS 13) User-space networking (30% CPU reduction), TLS 1.3 default
- 2025 (iOS 26) NetworkConnection with async/await, TLV framing built-in, Coder protocol, Wi-Fi Aware discovery
Key capabilities
- Smart connection establishment Happy Eyeballs (IPv4/IPv6 racing), proxy evaluation (PAC), VPN detection, WiFi Assist fallback
- User-space networking ~30% lower CPU usage vs sockets, memory-mapped regions, reduced context switches
- Built-in security TLS 1.3 by default, DTLS for UDP, certificate pinning support
- Mobility Automatic network transition handling (WiFi ↔ cellular), viability notifications, Multipath TCP
- Performance ECN (Explicit Congestion Notification), service class marking, TCP Fast Open, UDP batching
When to use vs URLSession
- URLSession HTTP, HTTPS, WebSocket, simple TCP/TLS streams → Use URLSession (optimized for these)
- Network.framework UDP, custom protocols, low-level control, peer-to-peer, gaming, streaming → Use Network.framework
Related Skills
- See
skills/networking-discipline.mdfor anti-patterns, common patterns, pressure scenarios - See
skills/networking-diag.mdfor systematic troubleshooting of connection failures
---
When to Use This Skill
Use this skill when:
- Planning migration from BSD sockets, CFSocket, NSStream, or SCNetworkReachability
- Understanding API differences between NWConnection (iOS 12+, still available, not deprecated) and NetworkConnection (iOS 26+)
- Implementing all 12 WWDC 2025 examples (TLS connection, TLV framing, Coder protocol, NetworkListener, Wi-Fi Aware)
- Choosing protocols (TCP, UDP, TLS, QUIC) for your use case
- Peer-to-peer discovery setup with NetworkBrowser and Wi-Fi Aware
- Optimizing performance with user-space networking, batching, pacing
- Migrating from completion handlers to async/await (NWConnection → NetworkConnection)
---
API Evolution
Timeline
| Year | iOS Version | Key Features |
|---|---|---|
| 2018 | iOS 12 | NWConnection, NWListener, NWBrowser introduced |
| 2019 | iOS 13 | User-space networking (30% CPU reduction), TLS 1.3 default |
| 2021 | iOS 15 | WebSocket support in URLSession |
| 2025 | iOS 26 | NetworkConnection (async/await), TLV framing, Coder protocol, Wi-Fi Aware |
NWConnection (iOS 12+) vs NetworkConnection (iOS 26+)
NWConnection is @available(macOS 10.14, iOS 12.0, watchOS 5.0, tvOS 12.0, *) — open-ended and not deprecated. It remains fully supported in the iOS 26.5 SDK; the "iOS 12+" label is the availability floor, not an upper bound.
| Feature | NWConnection (iOS 12+) | NetworkConnection (iOS 26+) |
|---|---|---|
| Async model | Completion handlers | async/await structured concurrency |
| State updates | stateUpdateHandler callback | onStateUpdate { conn, state in } closure (returns Self) |
| Send | send(content:completion:) callback | try await send(content) suspending |
| Receive | receive(minimumIncompleteLength:maximumLength:completion:) | try await receive(exactly:) suspending |
| Framing | Manual or custom NWFramer | TLV built-in (TLV { TLS() }) |
| Codable | Manual JSON encode/decode | Coder protocol (Coder(MyType.self, using: .json)) |
| Memory | Requires [weak self] in all closures | No [weak self] needed (Task cancellation automatic) |
| Error handling | Check error in completion | throws with natural propagation |
| State machine | Callbacks on state changes | connection.onStateUpdate { conn, state in } |
| Discovery | NWBrowser (Bonjour only) | NetworkBrowser (Bonjour + Wi-Fi Aware) |
Recommendation
- New apps targeting iOS 26+: Use NetworkConnection (cleaner, safer)
- Apps supporting iOS 12+ (below iOS 26): Use NWConnection (still available, not deprecated)
- Migration: Both APIs coexist, migrate incrementally
---
NetworkConnection (iOS 26+) Complete Reference
4.1 Creating Connections
NetworkConnection uses declarative protocol stack composition.
Example 1: Basic TLS Connection (WWDC 4:04)
import Network
// Basic connection with TLS (TCP and IP inferred)
let connection = NetworkConnection(
to: .hostPort(host: "www.example.com", port: 1029)
) {
TLS()
}
// Send and receive with async/await
public func sendAndReceiveWithTLS() async throws {
let outgoingData = Data("Hello, world!".utf8)
try await connection.send(outgoingData)
let incomingData = try await connection.receive(exactly: 98).content
print("Received data: \(incomingData)")
}Key points
TLS()infersTCP()andIP()automatically- No explicit connection.start() needed (happens on first send/receive)
- Async/await eliminates callback nesting
Example 2: Custom IP Options (WWDC 4:41)
// Customize IP fragmentation
let connection = NetworkConnection(
to: .hostPort(host: "www.example.com", port: 1029)
) {
TLS {
TCP {
IP()
.fragmentationDisabled(true) // Disable IP fragmentation (don't-fragment)
}
}
}When to customize IP
.fragmentationDisabled(true)— For protocols that handle fragmentation themselves (QUIC).version(.v6)— Force IPv6 only (testing);Versioncases are.any,.v4,.v6
Example 3: Custom Parameters (WWDC 5:07)
// Constrained paths (low data mode) + custom IP
let connection = NetworkConnection(
to: .hostPort(host: "www.example.com", port: 1029),
using: .parameters {
TLS {
TCP {
IP()
.fragmentationDisabled(true)
}
}
}
.constrainedPathsProhibited(true) // Don't use cellular in low data mode
)Common parameters
.constrainedPathsProhibited(true)— Respect low data mode.expensivePathsProhibited(true)— Don't use cellular/hotspot.multipathServiceType(.handover)— Enable Multipath TCP
Endpoint Types
// Host + Port
.hostPort(host: "example.com", port: 443)
// Service (Bonjour)
.service(name: "MyPrinter", type: "_ipp._tcp", domain: "local.", interface: nil)
// Unix domain socket
.unix(path: "/tmp/my.sock")Protocol Stack Composition
// TLS over TCP (most common)
TLS()
// QUIC (TLS + UDP, multiplexed streams)
QUIC()
// UDP (datagrams)
UDP()
// TCP (stream, no encryption)
TCP()
// WebSocket over TLS
WebSocket {
TLS()
}
// Custom framing
TLV {
TLS()
}---
4.2 State Machine
NetworkConnection transitions through these states:
setup
↓
preparing (DNS, TCP handshake, TLS handshake)
↓
┌─ waiting (no network, retrying)
│ ↓
└→ ready (can send/receive)
↓
failed (error) or cancelledMonitoring States
// NetworkConnection has NO `states` async sequence. Observe state with the
// onStateUpdate closure modifier (returns Self, @discardableResult). The
// handler passes (connection, state).
connection.onStateUpdate { connection, state in
switch state {
case .preparing:
print("Connecting...")
case .waiting(let error):
print("Waiting for network: \(error)")
case .ready:
print("Connected!")
case .failed(let error):
print("Failed: \(error)")
case .cancelled:
print("Cancelled")
@unknown default:
break
}
}Most code never observes state directly — send/receive suspend until ready and throw on failure. Use onStateUpdate only when you need explicit transitions, or read the synchronous connection.state property.
Key states
- .preparing DNS lookup, TCP SYN, TLS handshake
- .waiting No network available, framework retries automatically
- .ready Connection established, can send/receive
- .failed Unrecoverable error (server refused, TLS failed, timeout)
- .cancelled Task cancelled or connection.cancel() called
Path, viability & better-path monitoring
Beyond onStateUpdate, NetworkConnection offers path-level monitors (all @discardableResult, returning Self):
connection
.onPathUpdate { connection, newPath in /* route changed */ }
.onViabilityUpdate { connection, viable in /* network up/down */ }
.onBetterPathUpdate { connection, better in if better { /* migrate */ } }At iOS 26 these modifiers existed only on one-to-one connections. OS27 extends them to multiplexed connections (ApplicationProtocol: MultiplexProtocol, e.g. QUIC) and to individual QUIC streams (NetworkChannel where ApplicationProtocol == QUICStream), so each stream can react to path changes independently.
A related OS27 framer addition: NWProtocolFramer.Instance.prependApplicationProtocolIgnoringReady(options:) prepends a protocol without waiting for the ready handshake.
---
4.3 Send/Receive Patterns
Send: Basic
let data = Data("Hello".utf8)
try await connection.send(data)Receive: Exact Byte Count (WWDC 7:30)
// Receive exactly 98 bytes
let incomingData = try await connection.receive(exactly: 98).content
print("Received \(incomingData.count) bytes")Receive: Variable Length (WWDC 8:29)
// Read UInt32 length prefix (4 bytes, big-endian / network order), then read that many bytes.
// NetworkConnection has no receive(as:) — read a fixed byte count and decode.
let lengthData = try await connection.receive(exactly: 4).content
let remaining32 = lengthData.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self).bigEndian }
guard var remaining = Int(exactly: remaining32) else { throw MyError.invalidLength }
while remaining > 0 {
let chunk = try await connection.receive(atLeast: 1, atMost: remaining).content
remaining -= chunk.count
// Process chunk...
}receive() variants
receive(exactly: n)— Wait for exactly n bytesreceive(atLeast: min, atMost: max)— Get between min and max bytesreceive()— Read the next available message (for message-framed protocols)
---
4.4 TLV Framing (iOS 26+)
TLV (Type-Length-Value) solves message boundary problem on stream protocols (TCP/TLS).
Format
- Type: UInt32 (message identifier)
- Length: UInt32 (message size, automatic)
- Value: Message bytes
Example: GameMessage with TLV (WWDC 11:06, 11:24, 11:53)
import Network
// Define message types
enum GameMessage: Int {
case selectedCharacter = 0
case move = 1
}
struct GameCharacter: Codable {
let character: String
}
struct GameMove: Codable {
let row: Int
let column: Int
}
// Connection with TLV framing
let connection = NetworkConnection(
to: .hostPort(host: "www.example.com", port: 1029)
) {
TLV {
TLS()
}
}
// Send typed message
public func sendWithTLV() async throws {
let characterData = try JSONEncoder().encode(GameCharacter(character: "🐨"))
try await connection.send(characterData, type: GameMessage.selectedCharacter.rawValue)
}
// Receive typed message
public func receiveWithTLV() async throws {
let (incomingData, metadata) = try await connection.receive()
switch GameMessage(rawValue: metadata.type) {
case .selectedCharacter:
let character = try JSONDecoder().decode(GameCharacter.self, from: incomingData)
print("Character selected: \(character)")
case .move:
let move = try JSONDecoder().decode(GameMove.self, from: incomingData)
print("Move: row=\(move.row), column=\(move.column)")
case .none:
print("Unknown message type: \(metadata.type)")
}
}Benefits
- Message boundaries preserved (send 3 messages → receive exactly 3)
- Type-safe message handling (enum-based routing)
- Minimal overhead (8 bytes per message: type + length)
When to use
- Mixed message types (chat + presence + typing)
- Existing protocols using TLV
- Need message boundaries without heavy framing
---
4.5 Coder Protocol (iOS 26+)
Coder eliminates manual JSON encoding/decoding boilerplate.
Example: GameMessage with Coder (WWDC 12:50, 13:13, 13:53)
import Network
// Define message types as Codable enum
enum GameMessage: Codable {
case selectedCharacter(String)
case move(row: Int, column: Int)
}
// Connection with Coder
let connection = NetworkConnection(
to: .hostPort(host: "www.example.com", port: 1029)
) {
Coder(GameMessage.self, using: .json) {
TLS()
}
}
// Send Codable directly (no encoding needed!)
public func sendWithCoder() async throws {
let selectedCharacter: GameMessage = .selectedCharacter("🐨")
try await connection.send(selectedCharacter)
}
// Receive Codable directly (no decoding needed!)
public func receiveWithCoder() async throws {
let gameMessage = try await connection.receive().content // Returns GameMessage!
switch gameMessage {
case .selectedCharacter(let character):
print("Character selected: \(character)")
case .move(let row, let column):
print("Move: (\(row), \(column))")
}
}Supported formats
.json— JSON encoding (human-readable, widely compatible).propertyList— Property list (faster, smaller)
Benefits
- No JSON boilerplate (~50 lines → ~10 lines)
- Type-safe (compiler catches message structure changes)
- Automatic framing (handles message boundaries)
When to use
- App-to-app communication (you control both ends)
- Prototyping (fastest time to working code)
- Type-safe protocols
When NOT to use
- Interoperating with non-Swift servers
- Need custom wire format
- Performance-critical (prefer manual encoding for control)
---
4.6 NetworkListener (iOS 26+)
Listen for incoming connections with automatic subtask management.
Example: Listening for Connections (WWDC 15:16)
import Network
// Listener with Coder protocol
public func listenForIncomingConnections() async throws {
try await NetworkListener {
Coder(GameMessage.self, using: .json) {
TLS()
}
}.run { connection in
// Each connection gets its own subtask
for try await (gameMessage, _) in connection.messages {
switch gameMessage {
case .selectedCharacter(let character):
print("Player chose: \(character)")
case .move(let row, let column):
print("Player moved: (\(row), \(column))")
}
}
}
}Key features
- Automatic subtask per connection (no manual Task management)
- Structured concurrency (all subtasks cancelled when listener exits)
connection.messagesasync sequence for receiving
Listener configuration
// Specify port
NetworkListener(port: 1029) { TLS() }
// Let system choose port
NetworkListener { TLS() }
// Bonjour advertising
NetworkListener(service: .init(name: "MyApp", type: "_myapp._tcp")) { TLS() }---
4.7 NetworkBrowser & Wi-Fi Aware (iOS 26+)
NetworkBrowser(for:) takes a BrowserProvider — .bonjour(...) for local-network discovery, or .wifiAware(...) (from the WiFiAware framework) for peer-to-peer discovery of nearby paired devices. There is no NWBrowser.Descriptor here; the legacy NWBrowser(for: .bonjour(...)) descriptor API is iOS 12–18 only (see §5.6).
Prerequisites for Wi-Fi Aware
- Declare services in `Info.plist` under
WiFiAwareServices, each with aPublishable/Subscribablerole. Read them by name: theallServices[name]subscript returns an optional (nilif the service isn't declared) —WASubscribableService.allServices[name]/WAPublishableService.allServices[name]. - Devices must be paired first via AccessorySetupKit. Enumerate paired devices with the
WAPairedDevice.allDevicesasync sequence. - Both
WASubscribableServiceandWAPublishableServicecarry theWiFiAwareentitlement requirement; iOS 26+ only (macOS/tvOS/watchOS/visionOS unavailable).
A convenience extension gives the leading-dot syntax used below:
// Force-unwrap is safe only because "_ttt._udp" is declared in Info.plist.
extension WASubscribableService {
static var ticTacToe: WASubscribableService { allServices["_ttt._udp"]! }
}
extension WAPublishableService {
static var ticTacToe: WAPublishableService { allServices["_ttt._udp"]! }
}Subscriber — browse for a service, then connect (WWDC 17:39)
import Network
import WiFiAware
@available(iOS 26.0, *)
func joinGame() async throws {
// Browse paired devices offering the service; finish on the first match.
let endpoint = try await NetworkBrowser(
for: .wifiAware(.connecting(to: .allPairedDevices, from: .ticTacToe))
).run { endpoints in
.finish(endpoints.first!) // assumes at least one match was found
}
// Connect to the discovered WAEndpoint over Network.framework.
let connection = NetworkConnection(to: endpoint) {
Coder(GameMessage.self, using: .json) { TLS() }
}
_ = connection // ... then connection.run { ... }
}Publisher — advertise a service and accept connections
NetworkListener(for:) accepts the .wifiAware(...) ListenerProvider. Argument roles are reversed from the subscriber — the publisher passes connecting(to: myService, from: pairedDevices), the subscriber passes connecting(to: pairedDevices, from: myService).
@available(iOS 26.0, *)
func hostGame() async throws {
let listener = try NetworkListener(
for: .wifiAware(.connecting(to: .ticTacToe, from: .allPairedDevices))
) { Coder(GameMessage.self, using: .json) { TLS() } }
try await listener.run { connection in
// Optional: derive a shared secret. Listening itself is iOS 26.0+;
// only connection.wifiAware / deriveSharedSecret requires iOS 26.4+.
if #available(iOS 26.4, *) {
let secret = await connection.wifiAware?.deriveSharedSecret(
for: .tlsPSK, method: .kdfHash256)
_ = secret
}
}
}Device filters
Both WASubscriberBrowser.Devices and WAPublisherListener.Devices expose the same options. There is no .pairedDevice(identifier:) — scope to one device with .selected([device]) or .matching(_:).
| Filter | Meaning |
|---|---|
.allPairedDevices | Every paired device offering the service |
.userSpecifiedDevices | Devices the user picks in the system sheet |
.selected([device]) | A specific Sequence<WAPairedDevice> you choose |
.matching(#Predicate { … }) | Paired devices matching a Foundation.Predicate |
Wi-Fi Aware features
- Peer-to-peer without infrastructure (no Wi-Fi router needed)
- Discovery limited to already-paired devices
- Low latency, high throughput
- iOS 26+ (connection-level
wifiAwaresecret derivation is iOS 26.4+)
---
NWConnection (iOS 12+) Complete Reference
5.1 Creating Connections
NWConnection uses completion handlers (pre-async/await).
Basic TLS Connection (WWDC 2018 lines 133-166)
import Network
// Create connection
let connection = NWConnection(
host: NWEndpoint.Host("mail.example.com"),
port: NWEndpoint.Port(integerLiteral: 993),
using: .tls // TCP inferred
)
// Handle connection state changes
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
print("Connection established")
self?.sendData()
case .waiting(let error):
print("Waiting for network: \(error)")
// Show "Waiting..." UI, don't fail immediately
case .failed(let error):
print("Connection failed: \(error)")
case .cancelled:
print("Connection cancelled")
default:
break
}
}
// Start connection
connection.start(queue: .main)Critical Always use [weak self] in stateUpdateHandler to prevent retain cycles.
Custom Parameters
// Create custom parameters
let parameters = NWParameters.tls
// Prohibit expensive networks
parameters.prohibitExpensivePaths = true // Don't use cellular/hotspot
// Prohibit constrained networks
parameters.prohibitConstrainedPaths = true // Respect low data mode
// Require IPv6
parameters.requiredInterfaceType = .wifi
parameters.ipOptions.version = .v6
let connection = NWConnection(host: "example.com", port: 443, using: parameters)---
5.2 State Handling
NWConnection state machine (same as NetworkConnection):
setup → preparing → waiting/ready → failed/cancelledState handling best practices
connection.stateUpdateHandler = { [weak self] state in
guard let self = self else { return }
switch state {
case .preparing:
// DNS lookup, TCP SYN, TLS handshake in progress
self.updateUI(.connecting)
case .waiting(let error):
// Network unavailable or blocked
// DON'T fail immediately, framework retries automatically
print("Waiting: \(error.localizedDescription)")
self.updateUI(.waiting)
case .ready:
// Connection established, can send/receive
self.updateUI(.connected)
self.startSending()
case .failed(let error):
// Unrecoverable error after all retry attempts
print("Failed: \(error.localizedDescription)")
self.updateUI(.failed)
case .cancelled:
// connection.cancel() called
self.updateUI(.disconnected)
default:
break
}
}---
5.3 Send/Receive with Callbacks
Send with Pacing (WWDC 2018 lines 320-341)
// Send with contentProcessed callback for pacing
func sendData() {
let data = Data("Hello, world!".utf8)
connection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("Send error: \(error)")
return
}
// contentProcessed = network stack consumed data
// NOW send next chunk (pacing)
self?.sendNextData()
})
}contentProcessed callback Invoked when network stack consumes your data (equivalent to when blocking socket call would return). Use this for pacing to avoid buffering excessive data.
Receive with Exact Byte Count
// Receive exactly 10 bytes
connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in
if let error = error {
print("Receive error: \(error)")
return
}
if let data = data {
print("Received \(data.count) bytes")
// Process data...
// Continue receiving
self?.receiveMore()
}
}Receive parameters
minimumIncompleteLength: Minimum bytes before callback (1 = return any data)maximumLength: Maximum bytes per callback- For "exactly n bytes": Set both to n
---
5.4 UDP Batching (WWDC 2018 lines 343-347)
Batch sending for 30% CPU reduction.
// UDP connection
let connection = NWConnection(
host: NWEndpoint.Host("game-server.example.com"),
port: NWEndpoint.Port(integerLiteral: 9000),
using: .udp
)
connection.start(queue: .main)
// Batch multiple datagrams
func sendVideoFrames(_ frames: [Data]) {
connection.batch {
for frame in frames {
connection.send(content: frame, completion: .contentProcessed { error in
if let error = error {
print("Send error: \(error)")
}
})
}
}
// All sends batched into ~1 syscall
// Result: 30% lower CPU usage vs individual sends
}Without batch 100 datagrams = 100 syscalls = high CPU With batch 100 datagrams = ~1 syscall = 30% lower CPU (measured with Instruments)
---
5.5 NWListener (WWDC 2018 lines 233-293)
Accept incoming connections.
import Network
// Create listener on port 1029
let listener = try NWListener(using: .tcp, on: 1029)
// Advertise Bonjour service
listener.service = NWListener.Service(name: "MyApp", type: "_myapp._tcp")
// Handle service registration
listener.serviceRegistrationUpdateHandler = { update in
switch update {
case .add(let endpoint):
if case .service(let name, let type, let domain, _) = endpoint {
print("Advertising: \(name).\(type)\(domain)")
}
default:
break
}
}
// Handle new connections
listener.newConnectionHandler = { [weak self] newConnection in
print("New connection from: \(newConnection.endpoint)")
newConnection.stateUpdateHandler = { state in
if case .ready = state {
print("Client connected")
self?.handleClient(newConnection)
}
}
newConnection.start(queue: .main)
}
// Handle listener state
listener.stateUpdateHandler = { state in
switch state {
case .ready:
print("Listener ready on port \(listener.port ?? 0)")
case .failed(let error):
print("Listener failed: \(error)")
default:
break
}
}
// Start listening
listener.start(queue: .main)---
5.6 NWBrowser (Bonjour Discovery)
Discover services on local network.
import Network
// Browse for Bonjour services
let browser = NWBrowser(
for: .bonjour(type: "_http._tcp", domain: nil),
using: .tcp
)
// Handle discovered services
browser.browseResultsChangedHandler = { results, changes in
for result in results {
switch result.endpoint {
case .service(let name, let type, let domain, _):
print("Found service: \(name).\(type)\(domain)")
// Connect to this service
let connection = NWConnection(to: result.endpoint, using: .tcp)
connection.start(queue: .main)
default:
break
}
}
}
// Handle browser state
browser.stateUpdateHandler = { state in
switch state {
case .ready:
print("Browser ready")
case .failed(let error):
print("Browser failed: \(error)")
default:
break
}
}
// Start browsing
browser.start(queue: .main)---
Mobility & Network Transitions
Connection Viability (WWDC 2018 lines 453-463)
Viability = connection can send/receive data (has valid route).
connection.viabilityUpdateHandler = { isViable in
if isViable {
print("✅ Connection viable (can send/receive)")
} else {
print("⚠️ Connection not viable (no route)")
// Don't tear down immediately, may recover
// Show UI: "Connection interrupted"
}
}When viability changes
- Walk into elevator (WiFi signal lost) → not viable
- Walk out of elevator (WiFi returns) → viable again
- Switch WiFi → cellular → not viable briefly → viable on cellular
Best practice Don't tear down connection on viability loss. Framework will recover when network returns.
Better Path Available (WWDC 2018 lines 464-477)
Better path = alternative network with better characteristics.
connection.betterPathUpdateHandler = { betterPathAvailable in
if betterPathAvailable {
print("📶 Better path available (e.g., WiFi while on cellular)")
// Consider migrating to new connection
self.migrateToNewConnection()
}
}Scenarios
- Connected on cellular, walk into building with WiFi → better path available
- Connected on WiFi, WiFi quality degrades, cellular available → better path available
Migration pattern
func migrateToNewConnection() {
// Create new connection
let newConnection = NWConnection(host: host, port: port, using: parameters)
newConnection.stateUpdateHandler = { [weak self] state in
if case .ready = state {
// New connection ready, switch over
self?.currentConnection?.cancel()
self?.currentConnection = newConnection
}
}
newConnection.start(queue: .main)
// Keep old connection until new one ready
}Multipath TCP (WWDC 2018 lines 480-487)
Automatically migrate between networks without application intervention.
let parameters = NWParameters.tcp
parameters.multipathServiceType = .handover // Seamless network transition
let connection = NWConnection(host: "example.com", port: 443, using: parameters)Multipath TCP modes
.handover— Seamless handoff between networks (WiFi ↔ cellular).interactive— Use multiple paths simultaneously (lowest latency).aggregate— Use multiple paths simultaneously (highest throughput)
Benefits
- Automatic network transition (no viability handlers needed)
- No connection interruption when switching networks
- Fallback to single-path if MPTCP unavailable
NWPathMonitor (WWDC 2018 lines 489-496)
Monitor network state changes (replaces SCNetworkReachability).
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
print("✅ Network available")
// Check interface types
if path.usesInterfaceType(.wifi) {
print("Using WiFi")
} else if path.usesInterfaceType(.cellular) {
print("Using cellular")
}
// Check if expensive
if path.isExpensive {
print("⚠️ Expensive path (cellular/hotspot)")
}
} else {
print("❌ No network")
}
}
monitor.start(queue: .main)Use cases
- Show "No network" UI when path.status == .unsatisfied
- Disable high-bandwidth features when path.isExpensive
- Adjust quality based on interface type
When to use
- Global network state monitoring
- When "waiting for connectivity" isn't enough
- Need to know available interfaces before connecting
When NOT to use
- Checking before connecting (use waiting state instead)
- Per-connection monitoring (use viability handlers instead)
---
Security Configuration
TLS Version
// iOS 13+ requires TLS 1.2+ by default
let tlsOptions = NWProtocolTLS.Options()
// Allow TLS 1.2 and 1.3
tlsOptions.minimumTLSProtocolVersion = .TLSv12
// Require TLS 1.3 only
tlsOptions.minimumTLSProtocolVersion = .TLSv13
let parameters = NWParameters(tls: tlsOptions)
let connection = NWConnection(host: "example.com", port: 443, using: parameters)Certificate Pinning
// Production-grade certificate pinning
let tlsOptions = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ (metadata, trust, complete) in
// Get server certificate
let serverCert = sec_protocol_metadata_copy_peer_public_key(metadata)
// Compare with pinned certificate
let pinnedCertData = Data(/* your pinned cert */)
let serverCertData = SecCertificateCopyData(serverCert) as Data
if serverCertData == pinnedCertData {
complete(true) // Accept
} else {
complete(false) // Reject (prevents MITM attacks)
}
},
.main
)
let parameters = NWParameters(tls: tlsOptions)Certificate Pinning + Corporate Proxies
Corporate networks often use TLS inspection proxies that present their own certificates. Strict pinning breaks these environments.
Strategy: Pin against the public key (SPKI) rather than the full certificate, and provide a configuration escape hatch:
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ (metadata, trust, complete) in
// 1. Check if system trusts the certificate chain (handles corporate CAs)
let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
SecTrustEvaluateAsyncWithError(secTrust, .main) { _, result, _ in
guard result else { complete(false); return }
// 2. If pinning enabled, also verify public key
if PinningConfig.isEnabled {
let serverKey = SecTrustCopyKey(secTrust)
let matches = pinnedKeys.contains { $0 == serverKey }
complete(matches)
} else {
complete(true) // System trust only (enterprise mode)
}
}
},
.main
)Rules:
- Always validate system trust first (
SecTrustEvaluateAsyncWithError) — this respects enterprise-installed root CAs - Use public key pinning over certificate pinning (survives cert rotation)
- Provide a managed configuration (MDM profile or app config) to disable pinning in enterprise environments
- Pin at least 2 keys (current + backup) to survive rotation
Cipher Suites
let tlsOptions = NWProtocolTLS.Options()
// Specify allowed cipher suites
tlsOptions.tlsCipherSuites = [
tls_ciphersuite_t(rawValue: 0x1301), // TLS_AES_128_GCM_SHA256
tls_ciphersuite_t(rawValue: 0x1302), // TLS_AES_256_GCM_SHA384
]
// iOS defaults to secure modern ciphers, only customize if required---
Performance Optimization
User-Space Networking (WWDC 2018 lines 409-441)
Automatic on iOS/tvOS. Network.framework moves TCP/UDP stack into your app process.
Benefits
- ~30% lower CPU usage (measured with Instruments)
- No kernel→userspace copy (memory-mapped regions)
- Reduced context switches
Legacy vs User-Space
| Traditional Sockets | User-Space Networking |
|---|---|
| Packet → driver → kernel → copy → userspace | Packet → driver → memory-mapped region → userspace (no copy) |
| 100 datagrams = 100 syscalls | 100 datagrams = ~1 syscall (with batching) |
| ~30% higher CPU | Baseline CPU |
WWDC demo Live UDP video streaming showed 30% CPU difference (sockets vs Network.framework).
ECN for UDP (WWDC 2018 lines 365-378)
Explicit Congestion Notification for smooth UDP transmission.
// Create IP metadata with ECN
let ipMetadata = NWProtocolIP.Metadata()
ipMetadata.ecnFlag = .congestionEncountered // Or .ect0, .ect1
// Attach to send context
let context = NWConnection.ContentContext(
identifier: "video_frame",
metadata: [ipMetadata]
)
connection.send(content: data, contentContext: context, completion: .contentProcessed { _ in })ECN flags
.ect0/.ect1— ECN-capable transport.congestionEncountered— Congestion notification received
Benefits Network can signal congestion without dropping packets.
Service Class (WWDC 2018 lines 379-388)
Mark traffic priority.
// Connection-wide service class
let parameters = NWParameters.tcp
parameters.serviceClass = .background // Low priority
let connection = NWConnection(host: "example.com", port: 443, using: parameters)
// Per-packet service class (UDP)
let ipMetadata = NWProtocolIP.Metadata()
ipMetadata.serviceClass = .realTimeInteractive // High priority (voice)
let context = NWConnection.ContentContext(identifier: "voip", metadata: [ipMetadata])
connection.send(content: audioData, contentContext: context, completion: .contentProcessed { _ in })Service classes
.background— Low priority (large downloads, sync).default— Normal priority.responsiveData— Interactive data (API calls).realTimeInteractive— Time-sensitive (voice, gaming)
TCP Fast Open (WWDC 2018 lines 389-406)
Send initial data in TCP SYN packet (saves round trip).
let parameters = NWParameters.tcp
parameters.allowFastOpen = true
let connection = NWConnection(host: "example.com", port: 443, using: parameters)
// Send initial data BEFORE calling start()
let initialData = Data("GET / HTTP/1.1\r\n".utf8)
connection.send(
content: initialData,
contentContext: .defaultMessage,
isComplete: false,
completion: .idempotent // Data is safe to replay
)
// Now start connection (initial data sent in SYN)
connection.start(queue: .main)Benefits Reduces connection establishment time by 1 RTT. Requirements Data must be idempotent (safe to replay if SYN retransmitted).
---
Migration Strategies
From BSD Sockets to NWConnection
| BSD Sockets | NWConnection | Notes |
|---|---|---|
socket() + connect() | NWConnection(host:port:using:) + start() | Non-blocking by default |
send() / sendto() | connection.send(content:completion:) | Async callback |
recv() / recvfrom() | connection.receive(min:max:completion:) | Async callback |
bind() + listen() | NWListener(using:on:) | Automatic port binding |
accept() | listener.newConnectionHandler | Callback per connection |
getaddrinfo() | Use NWEndpoint.Host(hostname) | DNS automatic |
SCNetworkReachability | connection.stateUpdateHandler waiting state | No race conditions |
setsockopt() | NWParameters | Type-safe options |
Migration example
Before (blocking sockets)
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, &addr, addrlen); // BLOCKS
send(sock, data, len, 0);After (NWConnection)
let connection = NWConnection(host: "example.com", port: 443, using: .tls)
connection.stateUpdateHandler = { state in
if case .ready = state {
connection.send(content: data, completion: .contentProcessed { _ in })
}
}
connection.start(queue: .main)From URLSession StreamTask to NetworkConnection
When to migrate
- Need UDP (StreamTask only supports TCP)
- Need custom protocols
- Need low-level control
When to STAY with URLSession
- HTTP/HTTPS (URLSession optimized for this)
- WebSocket support
- Built-in caching, cookies
Migration example
Before (URLSession StreamTask)
let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443)
task.resume()
task.write(Data("Hello".utf8), timeout: 10) { _ in }After (NetworkConnection iOS 26+)
let connection = NetworkConnection(to: .hostPort(host: "example.com", port: 443)) { TLS() }
try await connection.send(Data("Hello".utf8))From NWConnection to NetworkConnection
Benefits of migration
- Async/await (no callback nesting)
- No
[weak self]needed - TLV framing built-in
- Coder protocol for Codable types
Migration mapping
| NWConnection | NetworkConnection |
|---|---|
connection.stateUpdateHandler = { } | connection.onStateUpdate { conn, state in } |
connection.send(content:completion:) | try await connection.send(content) |
connection.receive(min:max:completion:) | try await connection.receive(exactly:) |
| Manual JSON | Coder(MyType.self, using: .json) |
| Custom framer | TLV { TLS() } |
[weak self] everywhere | No [weak self] needed |
Migration example
Before (NWConnection)
connection.stateUpdateHandler = { [weak self] state in
if case .ready = state {
self?.sendData()
}
}
func sendData() {
connection.send(content: data, completion: .contentProcessed { [weak self] error in
self?.receiveData()
})
}After (NetworkConnection)
// send/receive suspend until ready and throw on failure — no state loop needed
try await connection.send(data)
let received = try await connection.receive(exactly: 10).content
// Observe transitions explicitly only when needed (returns Self):
connection.onStateUpdate { connection, state in
if case .ready = state { print("Ready") }
}---
Testing Checklist
Before shipping networking code:
Device Testing
- [ ] Tested on real device (not just simulator)
- [ ] Tested on multiple iOS versions (12, 15, 26)
- [ ] Tested on iPhone and iPad (different network characteristics)
Network Conditions
- [ ] WiFi (home network)
- [ ] Cellular (disable WiFi)
- [ ] Airplane Mode → WiFi (test waiting state)
- [ ] WiFi → cellular transition (walk out of building)
- [ ] Cellular → WiFi transition (walk into building)
- [ ] Weak signal (basement, elevator)
- [ ] Network Link Conditioner (100ms latency, 3% packet loss)
Network Types
- [ ] IPv4-only network
- [ ] IPv6-only network (some cellular carriers)
- [ ] Dual-stack (IPv4 + IPv6)
- [ ] Corporate VPN active
- [ ] Personal hotspot (expensive path)
Performance
- [ ] Connection establishment < 500ms (check logs)
- [ ] Using batch for UDP (verify with Instruments)
- [ ] Using contentProcessed for pacing (check send timing)
- [ ] Profiled with Instruments Network template
- [ ] CPU usage acceptable (< 10% for networking)
- [ ] Memory stable (no leaks, check [weak self])
Error Handling
- [ ] Handling .waiting state (show "Waiting..." UI)
- [ ] Handling .failed state (specific error messages)
- [ ] TLS handshake errors logged
- [ ] Timeout handling (don't wait forever)
- [ ] User-facing errors actionable ("Check network" not "POSIX 61")
iOS 26+ Features (if using NetworkConnection)
- [ ] Using TLV framing if need message boundaries
- [ ] Using Coder protocol if sending Codable types
- [ ] Using NetworkListener instead of NWListener
- [ ] Using NetworkBrowser for Wi-Fi Aware if peer-to-peer
---
API Quick Reference
NetworkConnection (iOS 26+)
// Create connection
NetworkConnection(to: .hostPort(host: "example.com", port: 443)) { TLS() }
// Send
try await connection.send(data)
// Receive
try await connection.receive(exactly: n).content
// States (closure modifier, returns Self — NO `states` async sequence)
connection.onStateUpdate { conn, state in }
// TLV framing
NetworkConnection(to: endpoint) { TLV { TLS() } }
// Coder protocol
NetworkConnection(to: endpoint) { Coder(MyType.self, using: .json) { TLS() } }
// Listener
NetworkListener { TLS() }.run { connection in }
// Browser
NetworkBrowser(for: .wifiAware(...)).run { endpoints in }NWConnection (iOS 12+)
// Create connection
let connection = NWConnection(host: "example.com", port: 443, using: .tls)
// State handler
connection.stateUpdateHandler = { [weak self] state in }
// Start
connection.start(queue: .main)
// Send
connection.send(content: data, completion: .contentProcessed { [weak self] error in })
// Receive
connection.receive(minimumIncompleteLength: min, maximumLength: max) { [weak self] data, context, isComplete, error in }
// Viability
connection.viabilityUpdateHandler = { isViable in }
// Better path
connection.betterPathUpdateHandler = { betterPathAvailable in }
// Cancel
connection.cancel()NWListener (iOS 12+)
let listener = try NWListener(using: .tcp, on: 1029)
listener.newConnectionHandler = { newConnection in }
listener.start(queue: .main)NWBrowser (iOS 12+)
let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { results, changes in }
browser.start(queue: .main)NWPathMonitor
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in }
monitor.start(queue: .main)---
Resources
WWDC: 2018-715, 2025-250
Docs: /network, /network/nwconnection, /network/networkconnection
Skills: See skills/networking-discipline.md, skills/networking-diag.md
---
Last Updated 2025-12-02 Status Production-ready reference from WWDC 2018 and WWDC 2025 Coverage NWConnection (iOS 12+), NetworkConnection (iOS 26+), all 12 WWDC 2025 code examples
Network.framework Diagnostics
Overview
Core principle 85% of networking problems stem from misunderstanding connection states, not handling network transitions, or improper error handling—not Network.framework defects.
Network.framework is battle-tested in every iOS app (powers URLSession internally), handles trillions of requests daily, and provides smart connection establishment with Happy Eyeballs, proxy evaluation, and WiFi Assist. If your connection is failing, timing out, or behaving unexpectedly, the issue is almost always in how you're using the framework, not the framework itself.
This skill provides systematic diagnostics to identify root causes in minutes, not hours.
Red Flags — Suspect Networking Issue
If you see ANY of these, suspect a networking misconfiguration, not framework breakage:
- Connection times out after 60 seconds with no clear error
- TLS handshake fails with "certificate invalid" on some networks
- Data sent but never arrives at receiver
- Connection drops when switching WiFi to cellular
- Works perfectly on WiFi but fails 100% of time on cellular
- Works in simulator but fails on real device
- Connection succeeds on your network but fails for users
- ❌ FORBIDDEN "Network.framework is broken, we should rewrite with sockets"
- Network.framework powers URLSession, used in every iOS app
- Handles edge cases you'll spend months discovering with sockets
- Apple engineers have 10+ years of production debugging baked into framework
- Switching to sockets will expose you to 100+ edge cases
Critical distinction Simulator uses macOS networking stack (not iOS), hides cellular-specific issues (IPv6-only networks), and doesn't simulate network transitions. MANDATORY: Test on real device with real network conditions.
To condition a slow/lossy network for tests without sudo (in-process URLProtocol harness, plus the optional toxiproxy escalation), see axiom-testing (skills/ui-testing.md) → "No-sudo, automatable conditioning".
Mandatory First Steps
ALWAYS run these commands FIRST (before changing code):
// 1. Enable Network.framework logging
// Add to Xcode scheme: Product → Scheme → Edit Scheme → Arguments
// -NWLoggingEnabled 1
// -NWConnectionLoggingEnabled 1
// 2. Check connection state history
connection.stateUpdateHandler = { state in
print("\(Date()): Connection state: \(state)")
// Log every state transition with timestamp
}
// 3. Check TLS configuration
// If using custom TLS parameters:
print("TLS version: \(tlsParameters.minimumTLSProtocolVersion)")
print("Cipher suites: \(tlsParameters.tlsCipherSuites ?? [])")
// 4. Test with packet capture (Charles Proxy or Wireshark)
// On device: Settings → WiFi → (i) → Configure Proxy → Manual
// Charles: Help → SSL Proxying → Install Charles Root Certificate on iOS
// 5. Test on different networks
// - WiFi
// - Cellular (disable WiFi)
// - Airplane Mode → WiFi (test waiting state)
// - VPN active
// - IPv6-only (some cellular carriers)What this tells you
| Observation | Diagnosis | Next Step |
|---|---|---|
| Stuck in .preparing > 5 seconds | DNS failure or network down | Pattern 1a |
| Moves to .waiting immediately | No connectivity (Airplane Mode, no signal) | Pattern 1b |
| .failed with POSIX error 61 | Connection refused (server not listening) | Pattern 1c |
| .failed with POSIX error 50 | Network down (interface disabled) | Pattern 1d |
| .ready then immediate .failed | TLS handshake failure | Pattern 2b |
| .ready, send succeeds, no data arrives | Framing problem or receiver not processing | Pattern 3a |
| Works WiFi, fails cellular | IPv6-only network (hardcoded IPv4) | Pattern 5a |
| Works without VPN, fails with VPN | Proxy interference or DNS override | Pattern 5b |
MANDATORY INTERPRETATION
Before changing ANY code, identify ONE of these:
1. If stuck in .preparing AND network is available → DNS failure (check nslookup) 2. If .waiting immediately AND Airplane Mode is off → Interface-specific issue (cellular blocked) 3. If .failed POSIX 61 → Server issue (check server logs) 4. If .failed with TLS error -9806 → Certificate validation (check with openssl) 5. If .ready but data not arriving → Framing or receiver issue (enable packet capture)
If diagnostics are contradictory or unclear
- STOP. Do NOT proceed to patterns yet
- Add timestamp logging to every send/receive call
- Enable packet capture (Charles/Wireshark)
- Test on different device to isolate hardware vs software issue
Decision Tree
Use this to reach the correct diagnostic pattern in 2 minutes:
Network problem?
├─ Using URLSession (not NWConnection)?
│ ├─ URLError(-1005) "network connection lost" after backgrounding? → Pattern 7a (URLSession Stale Pool)
│ ├─ Works after cold restart but fails on resume? → Pattern 7a (URLSession Stale Pool)
│ └─ Otherwise: most NWConnection patterns apply — URLSession runs on Network.framework internally
│
├─ Connection never reaches .ready?
│ ├─ Stuck in .preparing for >5 seconds?
│ │ ├─ DNS lookup timing out? → Pattern 1a (DNS Failure)
│ │ ├─ Network available but can't reach host? → Pattern 1c (Connection Refused)
│ │ └─ First connection slow, subsequent fast? → Pattern 1e (DNS Caching)
│ │
│ ├─ Moves to .waiting immediately?
│ │ ├─ Airplane Mode or no signal? → Pattern 1b (No Connectivity)
│ │ ├─ Cellular blocked by parameters? → Pattern 1b (Interface Restrictions)
│ │ └─ VPN connecting? → Wait and retry
│ │
│ ├─ .failed with POSIX error 61?
│ │ └─ → Pattern 1c (Connection Refused)
│ │
│ └─ .failed with POSIX error 50?
│ └─ → Pattern 1d (Network Down)
│
├─ Connection reaches .ready, then fails?
│ ├─ Fails immediately after .ready?
│ │ ├─ TLS error -9806? → Pattern 2b (Certificate Validation)
│ │ ├─ TLS error -9801? → Pattern 2b (Protocol Version)
│ │ └─ POSIX error 54? → Pattern 2d (Connection Reset)
│ │
│ ├─ Fails after network change (WiFi → cellular)?
│ │ ├─ No viabilityUpdateHandler? → Pattern 2a (Viability Not Handled)
│ │ ├─ Didn't detect better path? → Pattern 2a (Better Path)
│ │ └─ IPv6 → IPv4 transition? → Pattern 5a (Dual Stack)
│ │
│ ├─ Fails after timeout?
│ │ └─ → Pattern 2c (Receiver Not Responding)
│ │
│ └─ Random disconnects?
│ └─ → Pattern 2d (Network Instability)
│
├─ Data not arriving?
│ ├─ Send succeeds, receive never returns?
│ │ ├─ No message framing? → Pattern 3a (Framing Problem)
│ │ ├─ Wrong byte count? → Pattern 3b (Min/Max Bytes)
│ │ └─ Receiver not calling receive()? → Check receiver code
│ │
│ ├─ Partial data arrives?
│ │ ├─ receive(exactly:) too large? → Pattern 3b (Chunking)
│ │ ├─ Sender closing too early? → Check sender lifecycle
│ │ └─ Buffer overflow? → Pattern 3b (Buffer Management)
│ │
│ ├─ Data corrupted?
│ │ ├─ TLS disabled? → Pattern 3c (No Encryption)
│ │ ├─ Binary vs text encoding? → Check ContentType
│ │ └─ Byte order (endianness)? → Use network byte order
│ │
│ └─ Works sometimes, fails intermittently?
│ └─ → Pattern 3d (Race Condition)
│
├─ Performance degrading?
│ ├─ Latency increasing over time?
│ │ ├─ TCP congestion? → Pattern 4a (Congestion Control)
│ │ ├─ No contentProcessed pacing? → Pattern 4a (Buffering)
│ │ └─ Server overloaded? → Check server metrics
│ │
│ ├─ Throughput decreasing?
│ │ ├─ Network transition WiFi → cellular? → Pattern 4b (Bandwidth Change)
│ │ ├─ Packet loss increasing? → Pattern 4b (Network Quality)
│ │ └─ Multiple streams competing? → Pattern 4b (Prioritization)
│ │
│ ├─ High CPU usage?
│ │ ├─ Not using batch for UDP? → Pattern 4c (Batching)
│ │ ├─ Too many small sends? → Pattern 4c (Coalescing)
│ │ └─ Using sockets instead of Network.framework? → Migrate (30% CPU savings)
│ │
│ └─ Memory growing?
│ ├─ Not releasing connections? → Pattern 4d (Connection Leaks)
│ ├─ Not cancelling on deinit? → Pattern 4d (Lifecycle)
│ └─ Missing [weak self]? → Pattern 4d (Retain Cycles)
│
└─ Works on WiFi, fails on cellular/VPN?
├─ IPv6-only cellular network?
│ ├─ Hardcoded IPv4 address? → Pattern 5a (IPv4 Literal)
│ ├─ getaddrinfo with AF_INET only? → Pattern 5a (Address Family)
│ └─ Works on some carriers, not others? → Pattern 5a (Regional IPv6)
│
├─ Corporate VPN active?
│ ├─ Proxy configuration failing? → Pattern 5b (PAC)
│ ├─ DNS override blocking hostname? → Pattern 5b (DNS)
│ └─ Certificate pinning failing? → Pattern 5b (TLS in VPN)
│
├─ Port blocked by firewall?
│ ├─ Non-standard port? → Pattern 5c (Firewall)
│ ├─ Outbound only? → Pattern 5c (NATing)
│ └─ Works on port 443, not 8080? → Pattern 5c (Port Scanning)
│
├─ Peer-to-peer connection failing?
│ ├─ NAT traversal issue? → Pattern 5d (STUN/TURN)
│ ├─ Symmetric NAT? → Pattern 5d (NAT Type)
│ └─ Local network only? → Pattern 5d (Bonjour/mDNS)
│
└─ URLSession fails but NWConnection works?
├─ HTTP URL blocked? → Pattern 6a (ATS HTTP Block)
├─ "SSL error" on HTTPS? → Pattern 6b (ATS TLS Version)
└─ Works on older iOS? → Pattern 6a/6b (ATS enforcement)Pattern Selection Rules (MANDATORY)
Before proceeding to a pattern:
1. Connection never reaching .ready → Start with Pattern 1 (DNS, connectivity, refused) 2. TLS error codes → Jump directly to Pattern 2b (Certificate validation) 3. Data not arriving → Enable packet capture FIRST, then Pattern 3 4. Network-specific (works WiFi, fails cellular) → Test on that exact network, Pattern 5 5. Performance degradation → Profile with Instruments Network template, Pattern 4
Apply ONE pattern at a time
- Implement the fix from one pattern
- Test thoroughly
- Only if issue persists, try next pattern
- DO NOT apply multiple patterns simultaneously (can't isolate cause)
FORBIDDEN
- Guessing at solutions without diagnostics
- Changing multiple things at once
- Assuming "just needs more timeout"
- Disabling TLS "temporarily"
- Switching to sockets to "avoid framework issues"
Diagnostic Patterns
Pattern 1a: DNS Resolution Failure
Time cost 10-15 minutes
Symptom
- Connection stuck in .preparing for >5 seconds
- Eventually fails or times out
- Works with IP address but not hostname
- Works on one network, fails on another
Diagnosis
// Enable DNS logging
// -NWLoggingEnabled 1
// Check DNS resolution manually
// Terminal: nslookup example.com
// Terminal: dig example.com
// Logs show:
// "DNS lookup timed out"
// "getaddrinfo failed: 8 (nodename nor servname provided)"Common causes
1. DNS server unreachable (corporate network blocks external DNS) 2. Hostname typo or doesn't exist 3. DNS caching stale entry (rare, but happens) 4. VPN blocking DNS resolution
Fix
// ❌ WRONG — Adding timeout doesn't fix DNS
/*
let parameters = NWParameters.tls
parameters.expiredDNSBehavior = .allow // Doesn't help if DNS never resolves
*/
// ✅ CORRECT — Verify hostname, test DNS manually
// 1. Test DNS manually:
// $ nslookup your-hostname.com
// If this fails, DNS is the problem (not your code)
// 2. If DNS works manually but not in app:
// Check if VPN or enterprise config blocking app DNS
// 3. If hostname doesn't exist:
let connection = NWConnection(
host: NWEndpoint.Host("correct-hostname.com"), // Fix typo
port: 443,
using: .tls
)
// 4. If DNS caching issue (rare):
// Restart device to clear DNS cache
// Or use IP address temporarily while investigating DNS server issueVerification
- Run
nslookup your-hostname.com— should return IP in <1 second - Test on cellular (different DNS servers) — should work
- Check corporate network DNS configuration
Prevention
- Use well-known hostnames (don't rely on internal DNS)
- Test on multiple networks during development
- Don't hardcode IPs (if DNS fails, you need to fix DNS, not bypass it)
---
Pattern 2b: TLS Certificate Validation Failure
Time cost 15-20 minutes
Symptom
- Connection reaches .ready briefly, then .failed immediately
- Error:
-9806(kSSLPeerCertInvalid) - Error:
-9807(kSSLPeerCertExpired) - Error:
-9801(kSSLProtocol) - Works on some servers, fails on others
Diagnosis
# Test TLS manually with openssl
openssl s_client -connect example.com:443 -showcerts
# Check certificate details
openssl s_client -connect example.com:443 | openssl x509 -noout -dates
# notBefore: Jan 1 00:00:00 2024 GMT
# notAfter: Dec 31 23:59:59 2024 GMT ← Check if expired
# Check certificate chain
openssl s_client -connect example.com:443 -showcerts | grep "CN="
# Should show: Subject CN=example.com, Issuer CN=Trusted CACommon causes
1. Self-signed certificate (dev/staging servers) 2. Expired certificate 3. Certificate hostname mismatch (cert for "example.com" but connecting to "www.example.com") 4. Missing intermediate CA certificate 5. TLS 1.0/1.1 (iOS 13+ requires TLS 1.2+)
Fix
For production servers with invalid certs
// ❌ WRONG — Never disable certificate validation in production
/*
let tlsOptions = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(tlsOptions.securityProtocolOptions, { ... }, .main)
// This disables validation → security vulnerability
*/
// ✅ CORRECT — Fix the certificate on server
// 1. Renew expired certificate (Let's Encrypt, DigiCert, etc.)
// 2. Ensure hostname matches (CN=example.com or SAN includes example.com)
// 3. Include intermediate CA certificates on server
// 4. Test with: openssl s_client -connect example.com:443For development servers (temporary)
// ⚠️ ONLY for development/staging
#if DEBUG
let tlsOptions = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in
// Trust any certificate (DEV ONLY)
sec_protocol_verify_complete(true)
},
.main
)
let parameters = NWParameters(tls: tlsOptions)
let connection = NWConnection(host: "dev-server.example.com", port: 443, using: parameters)
#endifFor pinning — pick ONE API, never mix them
sec_protocol_metadata_copy_peer_public_key(_:) returns a dispatch_data_t of the raw public key, NOT a SecCertificate. Passing it to SecCertificateCopyData(_:) (which requires a SecCertificateRef) is a type error. Choose public-key pinning OR certificate pinning, not a hybrid of the two.
Option A — Public-key pinning (compare raw SPKI bytes)
let tlsOptions = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ (metadata, trust, complete) in
// peer public key is a dispatch_data_t of raw key bytes
guard let peerKey = sec_protocol_metadata_copy_peer_public_key(metadata) else {
complete(false)
return
}
let peerKeyData = peerKey as AnyObject as! Data // dispatch_data_t bridges to Data
let pinnedKeyData = Data(/* your pinned SPKI bytes */)
complete(peerKeyData == pinnedKeyData) // never call SecCertificateCopyData on a key
},
.main
)Option B — Certificate pinning (evaluate SecTrust, then compare leaf cert)
let tlsOptions = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ (metadata, trust, complete) in
// sec_trust_copy_ref(_:) returns the underlying SecTrustRef
let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
SecTrustEvaluateAsyncWithError(secTrust, .main) { _, result, _ in
guard result,
let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate],
let leaf = chain.first else {
complete(false)
return
}
let serverCertData = SecCertificateCopyData(leaf) as Data // SecCertificateRef, not a key
let pinnedCertData = Data(/* your pinned cert DER */)
complete(serverCertData == pinnedCertData) // Reject non-pinned certificates
}
},
.main
)iOS 26+ declarative path
For NetworkConnection, validate inside TLS().certificateValidator { metadata, trust in ... } (an async -> Bool closure), applying the same Option A or Option B logic.
Verification
openssl s_client -connect example.com:443showsVerify return code: 0 (ok)- Certificate expiration > 30 days in future
- Certificate CN matches hostname
- Test on real iOS device (not just simulator)
---
Pattern 3a: Message Framing Problem
Time cost 20-30 minutes
Symptom
- connection.send() succeeds with no error
- connection.receive() never returns data
- Or receive() returns partial data
- Packet capture shows bytes on wire, but app doesn't process them
Diagnosis
// Enable detailed logging
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
print("Send error: \(error)")
} else {
print("✅ Sent \(data.count) bytes at \(Date())")
}
})
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, context, isComplete, error in
if let error = error {
print("Receive error: \(error)")
} else if let data = data {
print("✅ Received \(data.count) bytes at \(Date())")
}
}
// Use Charles Proxy or Wireshark to verify bytes on wireCommon cause Stream protocols (TCP/TLS) don't preserve message boundaries.
Example
// Sender sends 3 messages:
send("Hello") // 5 bytes
send("World") // 5 bytes
send("!") // 1 byte
// Receiver might get:
receive() → "HelloWorld!" // All 11 bytes at once
// Or:
receive() → "Hel" // 3 bytes
receive() → "loWorld!" // 8 bytes
// Message boundaries lost!Fix
Solution 1: Use TLV Framing (iOS 26+)
// NetworkConnection with TLV
let connection = NetworkConnection(
to: .hostPort(host: "example.com", port: 1029)
) {
TLV {
TLS()
}
}
// Send typed messages
enum MessageType: Int {
case chat = 1
case ping = 2
}
let chatData = Data("Hello".utf8)
try await connection.send(chatData, type: MessageType.chat.rawValue)
// Receive typed messages
let (data, metadata) = try await connection.receive()
if metadata.type == MessageType.chat.rawValue {
print("Chat message: \(String(data: data, encoding: .utf8)!)")
}Solution 2: Manual Length Prefix (iOS 12-18)
// Sender: Prefix message with UInt32 length
func sendMessage(_ message: Data) {
var length = UInt32(message.count).bigEndian
let lengthData = Data(bytes: &length, count: 4)
connection.send(content: lengthData, completion: .contentProcessed { _ in
connection.send(content: message, completion: .contentProcessed { _ in
print("Sent message with length prefix")
})
})
}
// Receiver: Read length, then read message
func receiveMessage() {
// 1. Read 4-byte length
connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { lengthData, _, _, error in
guard let lengthData = lengthData else { return }
let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
// 2. Read message of exact length
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { messageData, _, _, error in
guard let messageData = messageData else { return }
print("Received complete message: \(messageData.count) bytes")
}
}
}Verification
- Send 10 messages, verify receiver gets exactly 10 messages
- Send messages of varying sizes (1 byte, 1000 bytes, 64KB)
- Test with packet loss simulation (Network Link Conditioner)
---
Pattern 4a: TCP Congestion and Buffering
Time cost 15-25 minutes
Symptom
- First few sends fast, then increasingly slow
- Latency grows from 50ms → 500ms → 2000ms over time
- Memory usage growing (buffering unsent data)
- User reports app "feels sluggish" after 5 minutes
Diagnosis
// Monitor send completion time
let sendStart = Date()
connection.send(content: data, completion: .contentProcessed { error in
let elapsed = Date().timeIntervalSince(sendStart)
print("Send completed in \(elapsed)s") // Should be < 0.1s normally
// If > 1s, TCP congestion or receiver not draining fast enough
})
// Profile with Instruments
// Xcode → Product → Profile → Network template
// Check "Bytes Sent" vs "Time" graph
// Should be smooth line, not stepped/stalledCommon causes
1. Sender sending faster than receiver can process (back pressure) 2. Network congestion (packet loss, retransmits) 3. No pacing with contentProcessed callback 4. Sending on connection that lost viability
Fix
// ❌ WRONG — Sending without pacing
/*
for frame in videoFrames {
connection.send(content: frame, completion: .contentProcessed { _ in })
// Buffers all frames immediately → memory spike → congestion
}
*/
// ✅ CORRECT — Pace with contentProcessed callback
func sendFrameWithPacing() {
guard let nextFrame = getNextFrame() else { return }
connection.send(content: nextFrame, completion: .contentProcessed { [weak self] error in
if let error = error {
print("Send error: \(error)")
return
}
// contentProcessed = network stack consumed frame
// NOW send next frame (pacing)
self?.sendFrameWithPacing()
})
}
// Start pacing
sendFrameWithPacing()Alternative: Async/await (iOS 26+)
// NetworkConnection with natural back pressure
func sendFrames() async throws {
for frame in videoFrames {
try await connection.send(frame)
// Suspends automatically if network can't keep up
// Built-in back pressure, no manual pacing needed
}
}Verification
- Send 1000 messages, monitor memory usage (should stay flat)
- Monitor send completion time (should stay < 100ms)
- Test with Network Link Conditioner (100ms latency, 3% packet loss)
---
Pattern 5a: IPv6-Only Cellular Network (Hardcoded IPv4)
Time cost 10-15 minutes
Symptom
- Works perfectly on WiFi (dual-stack IPv4/IPv6)
- Fails 100% of time on cellular (IPv6-only)
- Works on some carriers (T-Mobile), fails on others (Verizon)
- Logs show "Host unreachable" or POSIX error 65 (EHOSTUNREACH)
Diagnosis
# Check if hostname has IPv6
dig AAAA example.com
# Check if device is on IPv6-only network
# Settings → WiFi/Cellular → (i) → IP Address
# If starts with "2001:" or "fe80:" → IPv6
# If "192.168" or "10." → IPv4
# Test with IPv6-only simulator
# Xcode → Devices → (device) → Use as Development Target
# Settings → Developer → Networking → DNS64/NAT64Common causes
1. Hardcoded IPv4 address ("192.168.1.1") 2. getaddrinfo with AF_INET only (filters out IPv6) 3. Server has no IPv6 address (AAAA record) 4. Not using Connect by Name (manual DNS)
Fix
// ❌ WRONG — Hardcoded IPv4
/*
let host = "192.168.1.100" // Fails on IPv6-only cellular
*/
// ❌ WRONG — Forcing IPv4
/*
let parameters = NWParameters.tcp
parameters.requiredInterfaceType = .wifi
parameters.ipOptions.version = .v4 // Fails on IPv6-only
*/
// ✅ CORRECT — Use hostname, let framework handle IPv4/IPv6
let connection = NWConnection(
host: NWEndpoint.Host("example.com"), // Hostname, not IP
port: 443,
using: .tls
)
// Framework automatically:
// 1. Resolves both A (IPv4) and AAAA (IPv6) records
// 2. Tries IPv6 first (if available)
// 3. Falls back to IPv4 (Happy Eyeballs)
// 4. Works on any network (IPv4, IPv6, dual-stack)Verification
- Test on real device with cellular (disable WiFi)
- Test with multiple carriers (Verizon, AT&T, T-Mobile)
- Enable DNS64/NAT64 in developer settings
- Run
dig AAAA your-hostname.comto verify IPv6 record exists
---
Production Crisis Scenario
Context: iOS Update Causes 15% Connection Failures
Situation
- Your company releases iOS app update (v4.2) on Monday morning
- By noon, Customer Support reports surge in "app doesn't work" tickets
- Analytics show 15% of users experiencing connection failures (10,000+ users)
- CEO sends Slack message: "What's going on? How fast can we fix this?"
- Engineering manager asks for ETA
- You're the networking engineer
Pressure signals
- 🚨 Production outage 10K+ users affected, revenue impact, negative App Store reviews incoming
- ⏰ Time pressure "Need fix ASAP, trending on Twitter"
- 👔 Executive visibility CEO personally asking for updates
- 📊 Public image App Store rating dropping from 4.8 → 4.1 in 3 hours
- 💸 Financial impact E-commerce app, each minute costs $5K in lost sales
Rationalization traps (DO NOT fall into these)
1. "Just roll back to v4.1"
- Tempting but takes 1-2 hours for app review, another 24 hours for users to update
- Doesn't find root cause (might happen again)
- Loses v4.2 features you worked on for weeks
2. "Disable TLS temporarily to narrow it down"
- Security vulnerability, will cause App Store rejection
- Doesn't solve actual problem (masks symptoms)
- When would you re-enable? (spoiler: never, because fixing it "later" never happens)
3. "It works on my device, must be user error"
- Arrogance, not diagnosis
- 10K users having same "error"? That's not user error.
4. "Let's add retry logic and more timeouts"
- Doesn't address root cause
- Makes problem worse (more retries = more load on failing path)
MANDATORY Diagnostic Protocol
You have 1 hour to provide CEO with: 1. Root cause 2. Fix timeline 3. Mitigation plan
Step 1: Establish Baseline (5 minutes)
// Check what changed in v4.2
git diff v4.1 v4.2 -- NetworkClient.swift
// Most likely culprits:
// - TLS configuration changed
// - Added certificate pinning
// - Changed connection parameters
// - Updated hostnameStep 2: Reproduce in Production Environment (10 minutes)
// Check failure pattern:
// - Random 15%? Or specific user segment?
// - Specific iOS version? (check analytics)
// - Specific network? (WiFi vs cellular)
// Enable logging on production builds (emergency flag):
#if PRODUCTION
if UserDefaults.standard.bool(forKey: "EnableNetworkLogging") {
// -NWLoggingEnabled 1
}
#endif
// Ask Customer Support to enable for affected users
// Check logs for specific error codeStep 3: Check Recent Code Changes (5 minutes)
// Found in git diff:
// v4.1:
let parameters = NWParameters.tls
// v4.2:
let tlsOptions = NWProtocolTLS.Options()
tlsOptions.minimumTLSProtocolVersion = .TLSv13 // ← SMOKING GUN
let parameters = NWParameters(tls: tlsOptions)Root Cause Identified Some users' backend infrastructure (load balancers, proxy servers) don't support TLS 1.3. v4.1 negotiated TLS 1.2, v4.2 requires TLS 1.3 → connection fails.
Step 4: Apply Targeted Fix (15 minutes)
// Fix: Support both TLS 1.2 and TLS 1.3
let tlsOptions = NWProtocolTLS.Options()
tlsOptions.minimumTLSProtocolVersion = .TLSv12 // ✅ Support older infrastructure
// TLS 1.3 will still be used where supported (automatic negotiation)
let parameters = NWParameters(tls: tlsOptions)Step 5: Deploy Hotfix (20 minutes)
# Build hotfix v4.2.1
# Test on affected user's network (critical!)
# Submit to App Store with expedited review request
# Explain: "Production outage affecting 15% of users"Professional Communication Templates
To CEO (15 minutes after crisis starts)
Found root cause: v4.2 requires TLS 1.3, but 15% of users on older infrastructure
(enterprise proxies, older load balancers) that only support TLS 1.2.
Fix: Change minimum TLS version to 1.2 (backward compatible, 1.3 still used when available).
ETA: Hotfix v4.2.1 in App Store in 1 hour (expedited review).
Full rollout to users: 24 hours.
Mitigation now: Telling affected users to update immediately when available.To Engineering Manager
Root cause: TLS version requirement changed in v4.2 (TLS 1.3 only).
15% of users behind infrastructure that doesn't support TLS 1.3.
Technical fix: Set tlsOptions.minimumTLSProtocolVersion = .TLSv12
This allows backward compatibility while still using TLS 1.3 where supported.
Testing: Verified fix on user's network (enterprise VPN with old proxy).
Deployment: Hotfix build in progress, ETA 30 minutes to submit.
Prevention: Add TLS compatibility testing to pre-release checklist.To Customer Support
Update: We've identified the issue and have a fix deploying within 1 hour.
Affected users: Those on enterprise networks or older ISP infrastructure.
Workaround: None (network level issue).
Expected resolution: v4.2.1 will be available in App Store in 1 hour.
Ask users to update immediately.
Updates: I'll notify you every 30 minutes.Time Saved
| Approach | Time to Resolution | User Impact |
|---|---|---|
| ❌ Panic rollback | 1-2 hours app review + 24 hours user updates = 26 hours | 10K users down for 26 hours |
| ❌ "Add more retries" | Unknown (doesn't fix root cause) | Permanent 15% failure rate |
| ❌ "Works for me" | Days of debugging wrong thing | Frustrated users, bad reviews |
| ✅ Systematic diagnosis | 30 min diagnosis + 20 min fix + 1 hour review = 2 hours | 10K users down for 2 hours |
Lessons Learned
1. Test on diverse networks Don't just test on your WiFi. Test on cellular, VPN, enterprise networks. 2. Monitor TLS compatibility If you change TLS config, verify backend supports it. 3. Gradual rollout Use phased rollout (10% → 50% → 100%) to catch issues early. 4. Emergency logging Have a way to enable detailed logging in production for diagnosis. 5. Communication cadence Update stakeholders every 30 minutes, even if just "still investigating."
---
Quick Reference Table
| Symptom | Likely Cause | First Check | Pattern | Fix Time |
|---|---|---|---|---|
| Stuck in .preparing | DNS failure | nslookup hostname | 1a | 10-15 min |
| .waiting immediately | No connectivity | Airplane Mode? | 1b | 5 min |
| .failed POSIX 61 | Connection refused | Server listening? | 1c | 5-10 min |
| .failed POSIX 50 | Network down | Check interface | 1d | 5 min |
| TLS error -9806 | Certificate invalid | openssl s_client | 2b | 15-20 min |
| Data not received | Framing problem | Packet capture | 3a | 20-30 min |
| Partial data | Min/max bytes wrong | Check receive() params | 3b | 10 min |
| Latency increasing | TCP congestion | contentProcessed pacing | 4a | 15-25 min |
| High CPU | No batching | Use connection.batch | 4c | 10 min |
| Memory growing | Connection leaks | Check [weak self] | 4d | 10-15 min |
| Works WiFi, fails cellular | IPv6-only network | dig AAAA hostname | 5a | 10-15 min |
| Works without VPN, fails with VPN | Proxy interference | Test PAC file | 5b | 20-30 min |
| Port blocked | Firewall | Try 443 vs 8080 | 5c | 10 min |
| HTTP URL blocked silently | ATS enforcement | Check Info.plist | 6a | 5-10 min |
| "An SSL error has occurred" | ATS TLS requirements | Check server TLS version | 6b | 10-15 min |
---
Pattern 6: App Transport Security (ATS) Failures
Time cost 5-15 minutes
ATS enforces HTTPS for all connections by default (iOS 9+). ATS failures are silent — connections fail with generic errors, no ATS-specific message in console.
Pattern 6a: HTTP Blocked by ATS
Symptom
- URLSession request fails with
NSURLErrorSecureConnectionFailed(-1200) orNSURLErrorAppTransportSecurityRequiresSecureConnection(-1022) - Network.framework connection works but URLSession doesn't
- Works in older iOS versions, fails in newer ones
- No clear error message — just "connection failed"
Diagnosis
# Check if ATS is blocking the connection
nscurl --ats-diagnostics https://yourserver.com
# Shows exactly which ATS policy the server fails// In console, look for:
// "App Transport Security has blocked a cleartext HTTP (http://) resource load"
// This only appears if OS-level logging is enabledFix — Allow Specific HTTP Domain (Preferred)
<!-- Info.plist — exception for specific domain only -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>api.legacy-server.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>Do NOT use `NSAllowsArbitraryLoads` — disables ATS entirely. App Store Review flags this and may reject. Use domain-specific exceptions.
Pattern 6b: ATS TLS Version Requirements
Symptom
- HTTPS connection fails with "SSL error" despite valid certificate
- Server uses TLS 1.0 or 1.1 (ATS requires TLS 1.2+)
nscurl --ats-diagnosticsshows TLS version failure
Diagnosis
# Check server's TLS version
openssl s_client -connect yourserver.com:443 -tls1_2
# If this fails but -tls1 succeeds → server doesn't support TLS 1.2Fix — Upgrade Server (Preferred) or Add Exception
<!-- Info.plist — allow TLS 1.0 for specific domain (temporary) -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.example.com</key>
<dict>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
</dict>
</dict>
</dict>Better fix: Upgrade the server to TLS 1.2+. ATS exceptions for TLS downgrade trigger App Store Review scrutiny.
ATS vs Network.framework Distinction
ATS applies to URLSession and WKWebView connections. Network.framework (NWConnection/NetworkConnection) is NOT subject to ATS — it handles TLS configuration directly via tlsOptions. If URLSession fails but NWConnection succeeds for the same server, ATS is almost certainly the cause.
---
Pattern 7a: URLSession Stale Connection Pool
Time cost 15-30 minutes
Symptom
URLError(-1005)"The network connection was lost" — intermittent, after backgrounding- First request post-
applicationDidBecomeActivefails, subsequent retries succeed - "Fixes itself" on cold restart (which drops the pool)
- Both Wi-Fi and cellular affected (rules out cellular-radio-only causes)
- Pre-iOS-13 didn't see this — Apple tightened idle-pool reaping in newer OS versions
Diagnosis
URLSession maintains a connection pool for HTTP/2 and HTTP/1.1 keep-alive. When the app suspends, the kernel/networkd tear down idle TCP/TLS connections after ~30s, but URLSession.shared still holds the dead sockets. The first post-resume request grabs a stale entry, the kernel returns ECONNRESET/EPIPE, CFNetwork surfaces it as -1005. This is NOT a Network.framework issue — it's URLSession's pool not invalidating on lifecycle transitions.
Confirmation metric (do this before any fix)
The thing that separates a real diagnosis from "probably stale pool" is URLSessionTaskTransactionMetrics.isReusedConnection. If the failing request reused a connection, the pool handed you a dead socket — proven, not guessed:
let session = URLSession(configuration: .default, delegate: metricsDelegate, delegateQueue: nil)
// In delegate's urlSession(_:task:didFinishCollecting:):
print(metrics.transactionMetrics.map { ($0.isReusedConnection, $0.fetchStartDate) })
// isReusedConnection == true on the failing request → stale-pool reuse confirmed.
// isReusedConnection == false → look elsewhere (this pattern does NOT apply).Common causes
1. App uses URLSession.shared (no lifecycle control over pool) 2. No invalidation on UIApplication.didBecomeActiveNotification / ScenePhase.active 3. Background duration crossed the ~30s pool-evict threshold
Fix — Recycle-on-Resume pattern
Own the session (never URLSession.shared) and tear down the pool on the foreground transition, so the first post-resume request is forced to open a fresh socket instead of reusing a dead one. The retry path is gated by idempotency (see below).
actor APIClient {
private var session = APIClient.makeSession()
private static func makeSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}
/// Drop the stale connection pool on resume. Call from
/// `.onChange(of: scenePhase)` when transitioning to `.active`.
func recycleOnResume() {
session.finishTasksAndInvalidate() // lets in-flight tasks finish, then invalidates
session = Self.makeSession() // next request opens a fresh socket
}
func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
do {
return try await session.data(for: request)
} catch let e as URLError where e.code == .networkConnectionLost {
guard isRetrySafe(request) else { throw e } // idempotency gate
recycleOnResume()
return try await session.data(for: request)
}
}
}The idempotency gate (why retries don't duplicate charges)
A -1005 can fire AFTER the request reached the server but BEFORE the response got back. Blind-retrying then replays the side effect — a double charge, a duplicate order. Gate every retry on the HTTP method, never on the error code alone:
| Method | Retry-safe? | Why |
|---|---|---|
| GET, HEAD, OPTIONS | Yes | No side effect |
| PUT, DELETE | Yes | Idempotent by HTTP contract — replaying lands the same final state |
| POST | No, unless idempotency-keyed | Each replay creates a new resource / charge |
private func isRetrySafe(_ request: URLRequest) -> Bool {
guard let method = request.httpMethod?.uppercased() else { return false }
if ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"].contains(method) { return true }
// POST is replay-safe ONLY when the server dedups on a client-sent key.
return request.value(forHTTPHeaderField: "Idempotency-Key") != nil
}For non-idempotent POSTs without an Idempotency-Key the server enforces, do NOT retry — surface the error and let the caller decide. This is the single rule that makes a "tight 10x retry loop" safe instead of a charge-duplication machine.
Replacing a reachability gate (waitsForConnectivity, not a pre-flight check)
If the code (or a tech lead) gates requests behind a reachability check — "no network? fail fast" — delete it. A reachability check races: connectivity can change between the check and the request, and it loses Happy Eyeballs / Wi-Fi-Assist / cellular fallback. The URLSession-native replacement is waitsForConnectivity = true (already set above): the task parks instead of failing -1009, then proceeds the instant a path comes up. Surface the wait to the UI via the delegate, do not block on it:
func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) {
// Fires when there's no path yet. Show "Waiting for network…", DON'T cancel.
// The task resumes automatically once connectivity is established.
}waitsForConnectivity covers the initial-connectivity case the reachability gate was trying to handle; timeoutIntervalForResource bounds how long it's allowed to wait. (For BSD-socket / SCNetworkReachability migrations and the deadline-pressure rebuttal, see skills/networking-discipline.md Scenario 1.)
Verification
1. Network Link Conditioner + Airplane Mode toggle for 60s → return to foreground → first request must succeed. Pre-fix: ~30% -1005. Post-fix: 0%. 2. Charles Proxy + observe new TCP/TLS handshake on the first post-resume request (no reused-connection log line). 3. URLSessionTaskMetrics: transactionMetrics[0].isReusedConnection == false on first request post-resume. 4. Run 20 background/foreground cycles per Mistake 4 — failure count drops to 0.
Prevention
- NEVER use `URLSession.shared` for production traffic in apps that backgrounding affects (which is almost all of them).
- Hook session recycling to scene-phase transitions, not to timers.
- For background-eligible work, use
URLSessionConfiguration.background(withIdentifier:)— its pool is managed by the system and isn't subject to this bug.
---
Common Mistakes
Mistake 1: Not Enabling Logging Before Debugging
Problem Trying to debug networking issues without seeing framework's internal state.
Why it fails You're guessing what's happening. Logs show exact state transitions, error codes, timing.
Fix
// Add to Xcode scheme BEFORE debugging:
// -NWLoggingEnabled 1
// -NWConnectionLoggingEnabled 1
// Or programmatically:
#if DEBUG
ProcessInfo.processInfo.environment["NW_LOGGING_ENABLED"] = "1"
#endifMistake 2: Testing Only on WiFi
Problem WiFi and cellular have different characteristics (IPv6-only, proxy configs, packet loss).
Why it fails 40% of connection failures are network-specific. If you only test WiFi, you miss cellular issues.
Fix
- Test on real device with WiFi OFF
- Test on multiple carriers (Verizon, AT&T, T-Mobile have different configs)
- Test with VPN active (enterprise users)
- Use Network Link Conditioner (Xcode → Devices)
Mistake 3: Ignoring POSIX Error Codes
Problem Seeing .failed(let error) and just showing generic "Connection failed" to user.
Why it fails Different error codes require different fixes. POSIX 61 = server issue, POSIX 50 = client network issue.
Fix
if case .failed(let error) = state {
let posixError = (error as NSError).code
switch posixError {
case 61: // ECONNREFUSED
print("Server not listening, check server logs")
case 50: // ENETDOWN
print("Network interface down, check WiFi/cellular")
case 60: // ETIMEDOUT
print("Connection timeout, check firewall/DNS")
default:
print("Connection failed: \(error)")
}
}Mistake 4: Not Testing State Transitions
Problem Testing only happy path (.preparing → .ready). Not testing .waiting, network changes, failures.
Why it fails Real users experience network transitions (WiFi → cellular), Airplane Mode, weak signal.
Fix
// Test with Network Link Conditioner:
// 1. 100% Loss — verify .waiting state shows "Waiting for network"
// 2. WiFi → None → WiFi — verify automatic reconnection
// 3. 3% packet loss — verify performance graceful degradationMistake 5: Assuming Simulator = Device
Problem Testing only in simulator. Simulator uses macOS networking (different from iOS), no cellular.
Why it fails Simulator hides IPv6-only issues, doesn't simulate network transitions, has different DNS.
Fix
- ALWAYS test on real device before shipping
- Test with Airplane Mode toggle (simulate network transitions)
- Test with cellular only (disable WiFi)
---
Cross-References
For Preventive Patterns
`skills/networking-discipline.md` — Discipline-enforcing anti-patterns:
- Red Flags: SCNetworkReachability, blocking sockets, hardcoded IPs
- Pattern 1a: NetworkConnection with TLS (correct implementation)
- Pattern 2a: NWConnection with proper state handling
- Pressure Scenarios: How to handle deadline pressure without cutting corners
For API Reference
`skills/network-framework-ref.md` — Complete API documentation:
- NetworkConnection (iOS 26+): All 12 WWDC 2025 examples
- NWConnection (iOS 12-18): Complete API with examples
- TLV framing, Coder protocol, NetworkListener, NetworkBrowser
- Migration strategies from sockets, URLSession, NWConnection
For Related Issues
swift-concurrency skill — If using async/await:
- Pattern 3: Weak self in Task closures (similar memory leak prevention)
- @MainActor usage for connection state updates
- Task cancellation when connection fails
---
Last Updated 2025-12-02 Status Production-ready diagnostics from WWDC 2018/2025 Tested Diagnostic patterns validated against real production issues
Legacy iOS 12-18 NWConnection Patterns
These patterns use NWConnection with completion handlers for apps supporting iOS 12-18. If your app targets iOS 26+, use NetworkConnection with async/await instead (see skills/network-framework-ref.md).
Pattern 2a: NWConnection with TLS (iOS 12-18)
Use when Supporting iOS 12-18, need TLS encryption, can't use async/await yet
Time cost 10-15 minutes
GOOD: NWConnection with Completion Handlers
import Network
// Create connection with TLS
let connection = NWConnection(
host: NWEndpoint.Host("mail.example.com"),
port: NWEndpoint.Port(integerLiteral: 993),
using: .tls // TCP inferred
)
// Handle connection state changes
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
print("Connection established")
self?.sendInitialData()
case .waiting(let error):
print("Waiting for network: \(error)")
// Show "Waiting..." UI, don't fail immediately
case .failed(let error):
print("Connection failed: \(error)")
case .cancelled:
print("Connection cancelled")
default:
break
}
}
// Start connection
connection.start(queue: .main)
// Send data with pacing
func sendData() {
let data = Data("Hello, world!".utf8)
connection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("Send error: \(error)")
return
}
// contentProcessed callback = network stack consumed data
// This is when you should send next chunk (pacing)
self?.sendNextChunk()
})
}
// Receive exact byte count
func receiveData() {
connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in
if let error = error {
print("Receive error: \(error)")
return
}
if let data = data {
print("Received \(data.count) bytes")
// Process data...
self?.receiveData() // Continue receiving
}
}
}Key differences from NetworkConnection
- Must use
[weak self]in all completion handlers to prevent retain cycles - stateUpdateHandler receives state, not async sequence
- send/receive use completion callbacks, not async/await
When to use
- Supporting iOS 12-15 (70% of devices as of 2024)
- Codebases not yet using async/await
- Libraries needing backward compatibility
Migration to NetworkConnection (iOS 26+)
- stateUpdateHandler -> connection.states async sequence
- Completion handlers -> try await calls
- [weak self] -> No longer needed (async/await handles cancellation)
Pattern 2b: NWConnection UDP Batch (iOS 12-18)
Use when Supporting iOS 12-18, sending multiple UDP datagrams efficiently, need ~30% CPU reduction
Time cost 10-15 minutes
Background Traditional UDP sockets send one datagram per syscall. If you're sending 100 small packets, that's 100 context switches. Batching reduces this to ~1 syscall.
BAD: Individual UDP Sends (High CPU)
// WRONG — 100 context switches for 100 packets
for frame in videoFrames {
sendto(socket, frame.bytes, frame.count, 0, &addr, addrlen)
// Each send = context switch to kernel
}GOOD: Batched UDP Sends (30% Lower CPU)
import Network
// UDP connection
let connection = NWConnection(
host: NWEndpoint.Host("stream-server.example.com"),
port: NWEndpoint.Port(integerLiteral: 9000),
using: .udp
)
connection.stateUpdateHandler = { state in
if case .ready = state {
print("Ready to send UDP")
}
}
connection.start(queue: .main)
// Batch sending for efficiency
func sendVideoFrames(_ frames: [Data]) {
connection.batch {
for frame in frames {
connection.send(content: frame, completion: .contentProcessed { error in
if let error = error {
print("Send error: \(error)")
}
})
}
}
// All sends batched into ~1 syscall
// 30% lower CPU usage vs individual sends
}
// Receive UDP datagrams
func receiveFrames() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in
if let error = error {
print("Receive error: \(error)")
return
}
if let data = data {
// Process video frame
self?.displayFrame(data)
self?.receiveFrames() // Continue receiving
}
}
}Performance characteristics
- Without batch 100 datagrams = 100 syscalls = 100 context switches
- With batch 100 datagrams = ~1 syscall = 1 context switch
- Result ~30% lower CPU usage (measured with Instruments)
When to use
- Real-time video/audio streaming
- Gaming with frequent updates (player position)
- High-frequency sensor data (IoT)
WWDC 2018 demo Live video streaming showed 30% lower CPU on receiver with user-space networking + batching
Pattern 2c: NWListener (iOS 12-18)
Use when Need to accept incoming connections, building servers or peer-to-peer apps, supporting iOS 12-18
Time cost 20-25 minutes
BAD: Manual Socket Listening
// WRONG — Manual socket management
let sock = socket(AF_INET, SOCK_STREAM, 0)
bind(sock, &addr, addrlen)
listen(sock, 5)
while true {
let client = accept(sock, nil, nil) // Blocks thread
// Handle client...
}GOOD: NWListener with Automatic Connection Handling
import Network
// Create listener with default parameters
let listener = try NWListener(using: .tcp, on: 1029)
// Advertise Bonjour service
listener.service = NWListener.Service(name: "MyApp", type: "_myservice._tcp")
// Handle service registration updates
listener.serviceRegistrationUpdateHandler = { update in
switch update {
case .add(let endpoint):
if case .service(let name, let type, let domain, _) = endpoint {
print("Advertising as: \(name).\(type)\(domain)")
}
default:
break
}
}
// Handle incoming connections
listener.newConnectionHandler = { [weak self] newConnection in
print("New connection from: \(newConnection.endpoint)")
// Configure connection
newConnection.stateUpdateHandler = { state in
switch state {
case .ready:
print("Client connected")
self?.handleClient(newConnection)
case .failed(let error):
print("Client connection failed: \(error)")
default:
break
}
}
// Start handling this connection
newConnection.start(queue: .main)
}
// Handle listener state
listener.stateUpdateHandler = { state in
switch state {
case .ready:
print("Listener ready on port \(listener.port ?? 0)")
case .failed(let error):
print("Listener failed: \(error)")
default:
break
}
}
// Start listening
listener.start(queue: .main)
// Handle client data
func handleClient(_ connection: NWConnection) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in
if let error = error {
print("Receive error: \(error)")
return
}
if let data = data {
print("Received \(data.count) bytes")
// Echo back
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
print("Send error: \(error)")
}
})
self?.handleClient(connection) // Continue receiving
}
}
}When to use
- Peer-to-peer apps (file sharing, messaging)
- Local network services
- Development/testing servers
Bonjour advertising
- Automatic service discovery on local network
- No hardcoded IPs needed
- Works with NWBrowser for discovery
Security considerations
- Use TLS parameters for encryption:
NWListener(using: .tls, on: port) - Validate client connections before processing data
- Set connection limits to prevent DoS
Pattern 2d: Network Discovery (iOS 12-18)
Use when Discovering services on local network (Bonjour), building peer-to-peer apps, supporting iOS 12-18
Time cost 25-30 minutes
BAD: Hardcoded IP Addresses
// WRONG — Brittle, requires manual configuration
let connection = NWConnection(host: "192.168.1.100", port: 9000, using: .tcp)
// What if IP changes? What if multiple devices?GOOD: NWBrowser for Service Discovery
import Network
// Browse for services on local network
let browser = NWBrowser(for: .bonjour(type: "_myservice._tcp", domain: nil), using: .tcp)
// Handle discovered services
browser.browseResultsChangedHandler = { results, changes in
for result in results {
switch result.endpoint {
case .service(let name, let type, let domain, _):
print("Found service: \(name).\(type)\(domain)")
// Connect to this service
self.connectToService(result.endpoint)
default:
break
}
}
}
// Handle browser state
browser.stateUpdateHandler = { state in
switch state {
case .ready:
print("Browser ready")
case .failed(let error):
print("Browser failed: \(error)")
default:
break
}
}
// Start browsing
browser.start(queue: .main)
// Connect to discovered service
func connectToService(_ endpoint: NWEndpoint) {
let connection = NWConnection(to: endpoint, using: .tcp)
connection.stateUpdateHandler = { state in
if case .ready = state {
print("Connected to service")
}
}
connection.start(queue: .main)
}When to use
- Peer-to-peer discovery (AirDrop-like features)
- Local network printers, media servers
- Development/testing (find test servers automatically)
Performance characteristics
- mDNS-based (multicast DNS, no central server)
- Near-instant discovery on same subnet
- Automatic updates when services appear/disappear
iOS 26+ alternative
- Use NetworkBrowser with Wi-Fi Aware for peer-to-peer without infrastructure
- See Pattern 1d in
skills/network-framework-ref.md
Resources
Skills: See skills/networking-discipline.md, skills/network-framework-ref.md, skills/networking-migration.md
Network Framework Migration Guides
Do I Need to Migrate?
What networking API are you using?
├─ URLSession for HTTP/HTTPS REST APIs?
│ └─ Stay with URLSession — it's the RIGHT tool for HTTP
│ URLSession handles caching, cookies, auth challenges,
│ HTTP/2/3, and is heavily optimized for web APIs.
│ Network.framework is for custom protocols, NOT HTTP.
│
├─ BSD Sockets (socket, connect, send, recv)?
│ └─ Migrate to NWConnection (iOS 12+)
│ → See Migration 1 below
│
├─ NWConnection / NWListener?
│ ├─ Need async/await? → Migrate to NetworkConnection (iOS 26+)
│ │ → See Migration 2 below
│ └─ Callback-based code working fine? → Stay (not deprecated)
│
├─ URLSession StreamTask for TCP/TLS?
│ └─ Need UDP or custom protocols? → NetworkConnection
│ Need just TCP/TLS for HTTP? → Stay with URLSession
│ → See Migration 3 below
│
├─ SCNetworkReachability?
│ └─ DEPRECATED — Replace with NWPathMonitor (iOS 12+)
│ let monitor = NWPathMonitor()
│ monitor.pathUpdateHandler = { path in
│ print(path.status == .satisfied ? "Online" : "Offline")
│ }
│
└─ CFSocket / NSStream?
└─ DEPRECATED — Replace with NWConnection (iOS 12+)
→ See Migration 1 belowMigration 1: From BSD Sockets to NWConnection
Migration mapping
| BSD Sockets | NWConnection | Notes |
|---|---|---|
socket() + connect() | NWConnection(host:port:using:) + start() | Non-blocking by default |
send() / sendto() | connection.send(content:completion:) | Async, returns immediately |
recv() / recvfrom() | connection.receive(minimumIncompleteLength:maximumLength:completion:) | Async, returns immediately |
bind() + listen() | NWListener(using:on:) | Automatic port binding |
accept() | listener.newConnectionHandler | Callback for each connection |
getaddrinfo() | Let NWConnection handle DNS | Smart resolution with racing |
SCNetworkReachability | connection.stateUpdateHandler waiting state | No race conditions |
setsockopt() | NWParameters configuration | Type-safe options |
Example migration
Before (BSD Sockets)
// BEFORE — Blocking, manual DNS, error-prone
var hints = addrinfo()
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_STREAM
var results: UnsafeMutablePointer<addrinfo>?
getaddrinfo("example.com", "443", &hints, &results)
let sock = socket(results.pointee.ai_family, results.pointee.ai_socktype, 0)
connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // BLOCKS
let data = "Hello".data(using: .utf8)!
data.withUnsafeBytes { ptr in
send(sock, ptr.baseAddress, data.count, 0)
}After (NWConnection)
// AFTER — Non-blocking, automatic DNS, type-safe
let connection = NWConnection(
host: NWEndpoint.Host("example.com"),
port: NWEndpoint.Port(integerLiteral: 443),
using: .tls
)
connection.stateUpdateHandler = { state in
if case .ready = state {
let data = Data("Hello".utf8)
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
print("Send failed: \(error)")
}
})
}
}
connection.start(queue: .main)Benefits
- 20 lines → 10 lines
- No manual DNS, no blocking, no unsafe pointers
- Automatic Happy Eyeballs, proxy support, WiFi Assist
---
Migration 2: From NWConnection to NetworkConnection (iOS 26+)
Why migrate
- Async/await eliminates callback hell
- TLV framing and Coder protocol built-in
- No [weak self] needed (async/await handles cancellation)
send/receivesuspend until ready and throw on failure — most code never observes state at all (useonStateUpdateonly when you need it)
Migration mapping
| NWConnection (iOS 12-18) | NetworkConnection (iOS 26+) | Notes |
|---|---|---|
connection.stateUpdateHandler = { state in } | connection.onStateUpdate { conn, state in } | Closure handler — NOT an async sequence |
connection.send(content:completion:) | try await connection.send(content) | Suspending function |
connection.receive(minimumIncompleteLength:maximumLength:completion:) | try await connection.receive(exactly:) | Suspending function |
| Manual JSON encode/decode | Coder(MyType.self, using: .json) | Built-in Codable support |
| Custom framer | TLV { TLS() } | Built-in Type-Length-Value |
[weak self] everywhere | No [weak self] needed | Task cancellation automatic |
Example migration
Before (NWConnection)
// BEFORE — Completion handlers, manual memory management
let connection = NWConnection(host: "example.com", port: 443, using: .tls)
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
self?.sendData()
case .waiting(let error):
print("Waiting: \(error)")
case .failed(let error):
print("Failed: \(error)")
default:
break
}
}
connection.start(queue: .main)
func sendData() {
let data = Data("Hello".utf8)
connection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("Send error: \(error)")
return
}
self?.receiveData()
})
}
func receiveData() {
connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in
if let error = error {
print("Receive error: \(error)")
return
}
if let data = data {
print("Received: \(data)")
}
}
}After (NetworkConnection)
// AFTER — Async/await, automatic memory management
let connection = NetworkConnection(
to: .hostPort(host: "example.com", port: 443)
) {
TLS()
}
// Optional: observe state with a closure. NetworkConnection has NO `states`
// async sequence — use `onStateUpdate` (returns Self, @discardableResult). The
// handler passes (connection, state); cases are setup/preparing/ready/
// waiting(NWError)/failed(NWError)/cancelled.
connection.onStateUpdate { connection, state in
switch state {
case .preparing:
print("Connecting...")
case .ready:
print("Ready")
case .waiting(let error):
print("Waiting: \(error)")
case .failed(let error):
print("Failed: \(error)")
default:
break
}
}
// Send and receive with async/await. For a STREAM protocol (TLS), send/receive
// auto-establish the connection and suspend until ready, throwing on failure —
// there is NO `start()` to call (start() exists only for multiplex protocols
// like QUIC). Most code can skip state observation entirely.
func sendAndReceive() async throws {
let data = Data("Hello".utf8)
try await connection.send(data)
let received = try await connection.receive(exactly: 10).content
print("Received: \(received)")
}Benefits
- 30 lines → 15 lines
- No callback nesting, no [weak self]
- Errors propagate naturally with throws
- Automatic cancellation on Task exit
---
Migration 3: From URLSession StreamTask to NetworkConnection
When to migrate
- Need UDP (StreamTask only supports TCP)
- Need custom protocols beyond TCP/TLS
- Need low-level control (packet pacing, ECN, service class)
When to STAY with URLSession
- Doing HTTP/HTTPS (URLSession optimized for this)
- Need WebSocket support
- Need built-in caching, cookie handling
Example migration
Before (URLSession StreamTask)
// BEFORE — URLSession for TCP/TLS stream
let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443)
task.resume()
task.write(Data("Hello".utf8), timeout: 10) { error in
if let error = error {
print("Write error: \(error)")
}
}
task.readData(ofMinLength: 10, maxLength: 10, timeout: 10) { data, atEOF, error in
if let error = error {
print("Read error: \(error)")
return
}
if let data = data {
print("Received: \(data)")
}
}After (NetworkConnection)
// AFTER — NetworkConnection for TCP/TLS
let connection = NetworkConnection(
to: .hostPort(host: "example.com", port: 443)
) {
TLS()
}
// No start() for stream protocols — send/receive auto-establish the connection.
func sendAndReceive() async throws {
try await connection.send(Data("Hello".utf8))
let data = try await connection.receive(exactly: 10).content
print("Received: \(data)")
}Resources
Skills: See skills/networking-discipline.md, skills/networking-legacy.md
Related skills
How it compares
Use axiom-networking for opinionated iOS networking discipline rather than generic REST client tutorials without Apple framework specifics.
FAQ
When must developers use axiom-networking?
axiom-networking is required for any iOS networking work including HTTP requests, WebSockets, TCP connections, and network debugging involving URLSession or Network.framework.
Which Apple APIs does axiom-networking cover?
axiom-networking covers URLSession with structured concurrency, Network.framework and NetworkConnection diagnostics, deprecated API migration, and pressure scenarios such as reachability changes.
Is Axiom Networking safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.