Swift iOS Networking: Zero to Advanced

A Comprehensive Deep-Dive for Senior iOS Engineers


Table of Contents

  1. Foundation: How HTTP & TCP/IP Actually Work
  2. URLSession: The Core Engine
  3. URLSession Configuration Deep Dive
  4. Request Construction & HTTP Semantics
  5. Response Handling & Decoding
  6. Authentication & Security
  7. Caching Strategies
  8. Background Networking
  9. WebSockets
  10. Combine & Async/Await Networking
  11. Network Monitoring with Network Framework
  12. Multipart Uploads & Streaming
  13. Certificate Pinning & TLS Customization
  14. Retry Logic, Backoff & Circuit Breakers
  15. Building a Production-Ready Network Layer
  16. Performance Optimization & Profiling
  17. Testing Networking Code
  18. GraphQL, gRPC & Protocol Buffers
  19. Security Hardening & Common Vulnerabilities
  20. Advanced Patterns: Interceptors, Middleware & Request Pipelines

1. Foundation: How HTTP & TCP/IP Actually Work

Before writing a single line of URLSession code, you must deeply understand what happens under the hood. Every performance problem, every weird retry behavior, every TLS error — all of it traces back to this layer.

The Network Stack

When your iOS app makes a network request, data travels through a layered model. At the top sits your Swift code. Below that, URLSession speaks HTTP/HTTPS. Below that, TCP handles reliable delivery. Below that, IP handles routing. At the bottom, the physical/wireless layer moves bits.

The TCP Handshake is a three-step process: SYN → SYN-ACK → ACK. This round-trip costs one RTT (Round-Trip Time) before a single byte of application data can be sent. On a mobile network with 50ms RTT, this means 50ms of pure overhead before your request even starts. This is why connection reuse (HTTP keep-alive) is so important — it amortizes this cost.

TLS adds more overhead. TLS 1.2 requires two additional round trips (2 RTTs) after the TCP handshake before encrypted data can flow. TLS 1.3, which iOS 13+ supports, reduces this to one RTT, and even supports 0-RTT resumption for sessions where a prior connection exists. This is why upgrading to TLS 1.3 is a measurable performance win for mobile apps.

HTTP/1.1, HTTP/2, and HTTP/3

HTTP/1.1 is request-response with keep-alive. Each connection can only serve one in-flight request at a time (head-of-line blocking). Browsers and URLSession work around this by opening multiple parallel connections (typically 6 per host), but each connection still has TCP + TLS overhead.

HTTP/2 solves head-of-line blocking at the HTTP layer with multiplexing. Multiple requests and responses are interleaved over a single TCP connection as independent "streams." A single connection serves all traffic to a host. It also uses HPACK header compression (since headers like Authorization and Content-Type are repeated constantly, this is a major bandwidth saving) and supports server push.

However, HTTP/2 still suffers from TCP-level head-of-line blocking. If a TCP packet is lost, all HTTP/2 streams on that connection stall waiting for retransmission — this can actually make HTTP/2 worse than HTTP/1.1 on lossy networks.

HTTP/3 solves this by running over QUIC instead of TCP. QUIC is a UDP-based protocol that builds reliability, ordering, and stream multiplexing directly into itself, so a single lost packet only blocks the stream that owns that data, not all streams. QUIC also integrates TLS 1.3, reducing connection establishment overhead further.

iOS 15+ with URLSession supports HTTP/3 natively. You can check which protocol was used via URLSessionTaskMetrics.

// Inspect which protocol was used
func urlSession(_ session: URLSession, task: URLSessionTask, 
                didFinishCollecting metrics: URLSessionTaskMetrics) {
    for transaction in metrics.transactionMetrics {
        print("Protocol: \(transaction.networkProtocolName ?? "unknown")")
        // Could be "h3", "h2", "http/1.1"
        print("DNS start: \(transaction.domainLookupStartDate ?? Date())")
        print("Connect start: \(transaction.connectStartDate ?? Date())")
        print("TLS start: \(transaction.secureConnectionStartDate ?? Date())")
        print("Request start: \(transaction.requestStartDate ?? Date())")
        print("Response start: \(transaction.responseStartDate ?? Date())")
        print("Response end: \(transaction.responseEndDate ?? Date())")
    }
}

The URLSessionTaskMetrics timestamps let you break down exactly where your latency is coming from — DNS, TCP connect, TLS, TTFB (time to first byte), or download. This is invaluable for diagnosing real-world performance issues.

DNS & Connection Reuse

DNS resolution itself can add 20–200ms on a cold request. URLSession caches DNS results, and your URLSessionConfiguration controls how long. The system also does DNS prefetching for known hosts. On a warm connection, all of the TCP + TLS overhead disappears — you get straight to sending your request. This is why the difference between a "cold" first request and subsequent warm requests can be dramatic.


2. URLSession: The Core Engine

URLSession is Apple's networking API, and understanding its internals makes you dramatically more effective with it.

URLSession Architecture

A URLSession is a factory for URLSessionTask objects. The session holds the configuration, delegate, and delegate queue. Tasks are the actual units of work. There are four task types:

// The three fundamental ways to create a session
let shared = URLSession.shared  // Singleton, no delegate, no customization, no background

let session = URLSession(configuration: .default)  // Custom config, no delegate

let delegateSession = URLSession(
    configuration: .default,
    delegate: self,          // Receives callbacks for auth, redirects, metrics, etc.
    delegateQueue: .main     // Queue on which delegate methods are called
)

Task Lifecycle

Every URLSessionTask goes through a state machine: suspended → running → (suspended | canceling) → completed. Tasks are created in .suspended state and must be explicitly resumed:

let task = session.dataTask(with: url) { data, response, error in
    // completion handler
}
task.resume()  // Crucial! Forgetting this is a very common bug.

You can also suspend() a running task (pauses it, though the network request may have already started) and cancel() it. Cancellation is asynchronous — the completion handler still fires, with an error of URLError.cancelled.

Modern Async/Await Tasks

iOS 15 added first-class async/await support directly on URLSession:

// Data task
let (data, response) = try await URLSession.shared.data(from: url)

// Upload task
let (data, response) = try await URLSession.shared.upload(for: request, from: bodyData)

// Download task — returns local URL of downloaded file
let (localURL, response) = try await URLSession.shared.download(from: url)

// Bytes — for streaming response bodies
let (asyncBytes, response) = try await URLSession.shared.bytes(from: url)
for try await byte in asyncBytes {
    // Process byte by byte — useful for SSE (Server-Sent Events)
}

The async variants integrate cleanly with Swift's structured concurrency, including cooperative task cancellation. When the Swift Task is cancelled, the underlying URLSessionTask is cancelled automatically.


3. URLSession Configuration Deep Dive

URLSessionConfiguration is where you control the behavior of the entire session. Getting this right is essential for correctness and performance.

The Three Factory Configurations

// .default — disk-based cache, credential storage, cookie storage
// Uses the system's shared cache and credential store
let defaultConfig = URLSessionConfiguration.default

// .ephemeral — no cache, no cookies, no credentials persisted to disk
// Data only lives in RAM for the session's lifetime
// Perfect for: private browsing, sensitive data, testing
let ephemeralConfig = URLSessionConfiguration.ephemeral

// .background(withIdentifier:) — transfers continue when app is suspended or killed
// Critical for large uploads/downloads
// The identifier must be unique and consistent across app launches
let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.myapp.transfers")

Key Configuration Properties

var config = URLSessionConfiguration.default

// Timeout for initial connection establishment (default: 60s)
config.timeoutIntervalForRequest = 30.0

// Timeout for the entire resource — from first request byte to last response byte (default: 7 days!)
// The default is enormous — always set this to something reasonable
config.timeoutIntervalForResource = 300.0  // 5 minutes

// Cellular and WWAN control
config.allowsCellularAccess = true          // Allow cellular (default: true)
config.allowsConstrainedNetworkAccess = true  // Allow Low Data Mode (default: true)
config.allowsExpensiveNetworkAccess = true    // Allow cellular/hotspot (default: true)

// HTTP/2 and HTTP/3
// HTTP/2 is enabled by default. HTTP/3 must be opted into:
config.requiresDNSSECValidation = false  // iOS 16+

// Discretionary — system decides WHEN to transfer (great for background, not interactive)
config.isDiscretionary = false  // For foreground work, this should be false

// HTTP Additional Headers — applied to every request in this session
config.httpAdditionalHeaders = [
    "Accept": "application/json",
    "X-App-Version": "2.1.0",
    "X-Platform": "iOS"
]
// Note: Authorization headers set here persist across redirects to different domains — security risk!

// Cookie policy
config.httpCookieAcceptPolicy = .onlyFromMainDocumentDomain
config.httpShouldSetCookies = true

// Maximum simultaneous connections per host (default: 6 for HTTP/1.1, 1 for HTTP/2)
config.httpMaximumConnectionsPerHost = 4

// Protocol classes — custom URL protocol handlers (advanced use)
// config.protocolClasses = [MockURLProtocol.self] + (URLSessionConfiguration.default.protocolClasses ?? [])

// Waits for connectivity rather than immediately failing
// Essential for apps that should queue requests when offline
config.waitsForConnectivity = true
// Combined with the delegate method:
// urlSession(_:taskIsWaitingForConnectivity:) -> called while waiting

waitsForConnectivity In Depth

This is one of the most underused and impactful configuration options. When set to true, instead of immediately failing with URLError.notConnectedToInternet when there's no network, the session will hold the request and start it automatically when connectivity is restored.

extension NetworkManager: URLSessionTaskDelegate {
    func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) {
        // Update UI to show "Waiting for network..." 
        // This is called on the delegate queue
        DispatchQueue.main.async {
            self.connectionState = .waitingForConnectivity
        }
    }
}

This eliminates the need for manual retry logic in many cases — the system handles the wait for you, and the request fires automatically when the network returns.


4. Request Construction & HTTP Semantics

URLRequest Fundamentals

var request = URLRequest(url: URL(string: "https://api.example.com/v1/users")!)

// HTTP Method
request.httpMethod = "POST"

// Headers
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")

// URLSession automatically adds:
// - Host header
// - Content-Length (if body is set)
// - Accept-Encoding (gzip, deflate, br)
// - Connection: keep-alive

// Body
let payload = ["name": "Simran", "role": "Senior iOS Developer"]
request.httpBody = try? JSONEncoder().encode(payload)

// Cache policy (overrides session config for this request)
request.cachePolicy = .returnCacheDataElseLoad

// Timeout (overrides session config for this request)
request.timeoutInterval = 15.0

URL Construction with URLComponents

Never concatenate URL strings manually. Use URLComponents to safely handle encoding:

var components = URLComponents()
components.scheme = "https"
components.host = "api.example.com"
components.path = "/v1/search"
components.queryItems = [
    URLQueryItem(name: "q", value: "swift networking"),
    URLQueryItem(name: "limit", value: "20"),
    URLQueryItem(name: "offset", value: "0"),
    URLQueryItem(name: "filter", value: "active & verified")  // Will be properly percent-encoded
]

guard let url = components.url else { 
    throw NetworkError.invalidURL
}
// Result: https://api.example.com/v1/search?q=swift%20networking&limit=20&offset=0&filter=active%20%26%20verified

URLComponents handles percent-encoding correctly, including characters that are valid in some URL positions but not others. Manual string building almost always gets this wrong for edge-case inputs.

HTTP Method Semantics & Idempotency

Understanding HTTP verb semantics is critical for correct retry logic:

For POST operations that must be exactly-once, use idempotency keys:

// Generate a stable key for this operation
let idempotencyKey = UUID().uuidString
request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")

// Persist the key so if the app restarts mid-retry, same key is used
UserDefaults.standard.set(idempotencyKey, forKey: "pendingPaymentIdempotencyKey")

HTTP Status Code Handling

A production network layer must handle status codes precisely:

enum HTTPStatusCategory {
    case informational  // 1xx
    case success        // 2xx
    case redirection    // 3xx
    case clientError    // 4xx — do NOT retry, fix the request
    case serverError    // 5xx — may retry with backoff
    case unknown
}

extension HTTPURLResponse {
    var statusCategory: HTTPStatusCategory {
        switch statusCode {
        case 100..<200: return .informational
        case 200..<300: return .success
        case 300..<400: return .redirection
        case 400..<500: return .clientError
        case 500..<600: return .serverError
        default: return .unknown
        }
    }
    
    // Key status codes to handle explicitly:
    // 200 OK, 201 Created, 204 No Content
    // 301 Moved Permanently (update saved URLs), 302 Found (temporary redirect)
    // 304 Not Modified (use cached response)
    // 400 Bad Request (client bug — log and fix)
    // 401 Unauthorized (refresh token and retry)
    // 403 Forbidden (don't retry — user lacks permission)
    // 404 Not Found (resource gone)
    // 409 Conflict (e.g., duplicate resource)
    // 429 Too Many Requests (respect Retry-After header)
    // 503 Service Unavailable (retry with backoff)
}

5. Response Handling & Decoding

Type-Safe JSON Decoding

Codable is the standard, but using it at production quality requires understanding its edge cases:

// Basic usage
struct User: Codable {
    let id: Int
    let name: String
    let email: String
    let createdAt: Date
    let avatarURL: URL?
}

let decoder = JSONDecoder()
// Dates as ISO 8601 strings (common in modern APIs)
decoder.dateDecodingStrategy = .iso8601
// Dates as Unix timestamps
// decoder.dateDecodingStrategy = .secondsSince1970
// snake_case JSON → camelCase Swift
decoder.keyDecodingStrategy = .convertFromSnakeCase

let user = try decoder.decode(User.self, from: data)

Custom Decoding for Complex APIs

Real-world APIs rarely have perfectly clean JSON. Custom Decodable implementations handle the mess:

struct APIResponse<T: Decodable>: Decodable {
    let data: T
    let metadata: Metadata
    
    struct Metadata: Decodable {
        let total: Int
        let page: Int
        let perPage: Int
    }
}

// Handling polymorphic types with a "type" discriminator field
enum EventPayload: Decodable {
    case purchase(PurchaseEvent)
    case pageView(PageViewEvent)
    case unknown
    
    private enum CodingKeys: String, CodingKey {
        case type
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(String.self, forKey: .type)
        
        let singleContainer = try decoder.singleValueContainer()
        switch type {
        case "purchase":
            self = .purchase(try singleContainer.decode(PurchaseEvent.self))
        case "page_view":
            self = .pageView(try singleContainer.decode(PageViewEvent.self))
        default:
            self = .unknown
        }
    }
}

// Handling missing vs. null — different meanings!
struct Profile: Decodable {
    let displayName: String
    let bio: String?  // null in JSON — user has no bio
    // If "bio" key is missing entirely from JSON, decoding fails
    // To handle missing keys:
    
    private enum CodingKeys: String, CodingKey {
        case displayName, bio
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        displayName = try container.decode(String.self, forKey: .displayName)
        bio = try container.decodeIfPresent(String.self, forKey: .bio)
        // decodeIfPresent returns nil for both missing key AND null value
    }
}

Error Decoding

APIs often return error bodies with useful information. Decode them:

struct APIError: Decodable, Error {
    let code: String
    let message: String
    let details: [String: String]?
}

enum NetworkError: Error {
    case httpError(statusCode: Int, apiError: APIError?)
    case decodingError(DecodingError)
    case transportError(URLError)
    case unknown(Error)
}

func decode<T: Decodable>(_ type: T.Type, from data: Data, response: URLResponse) throws -> T {
    guard let httpResponse = response as? HTTPURLResponse else {
        throw NetworkError.unknown(URLError(.badServerResponse))
    }
    
    guard httpResponse.statusCategory == .success else {
        // Try to decode the error body
        let apiError = try? JSONDecoder().decode(APIError.self, from: data)
        throw NetworkError.httpError(statusCode: httpResponse.statusCode, apiError: apiError)
    }
    
    // Handle 204 No Content
    if httpResponse.statusCode == 204 {
        // Return EmptyResponse or handle specially
    }
    
    do {
        return try JSONDecoder().decode(T.self, from: data)
    } catch let error as DecodingError {
        // Log the full DecodingError — it contains the exact path that failed
        // e.g., "Expected String at path: user.address.city"
        throw NetworkError.decodingError(error)
    }
}

Response Validation Chain

Structure your response handling as a pipeline of validations:

func validate(_ data: Data, _ response: URLResponse, _ error: Error?) throws -> Data {
    // 1. Check transport error
    if let error = error as? URLError {
        throw NetworkError.transportError(error)
    } else if let error = error {
        throw NetworkError.unknown(error)
    }
    
    // 2. Validate HTTP response
    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.unknown(URLError(.badServerResponse))
    }
    
    // 3. Check status code
    switch http.statusCode {
    case 200...299: break  // Success — continue
    case 401: throw NetworkError.unauthorized
    case 403: throw NetworkError.forbidden
    case 404: throw NetworkError.notFound
    case 429:
        let retryAfter = http.value(forHTTPHeaderField: "Retry-After")
            .flatMap(Double.init) ?? 60.0
        throw NetworkError.rateLimited(retryAfter: retryAfter)
    case 500...599: throw NetworkError.serverError(statusCode: http.statusCode)
    default: throw NetworkError.httpError(statusCode: http.statusCode, apiError: nil)
    }
    
    // 4. Return data for decoding
    return data
}

6. Authentication & Security

Bearer Token Authentication

JWT (JSON Web Tokens) are the most common API authentication mechanism. A production implementation handles token refresh transparently:

actor TokenStore {
    private var accessToken: String?
    private var refreshToken: String?
    private var accessTokenExpiry: Date?
    private var refreshTask: Task<String, Error>?
    
    func validAccessToken() async throws -> String {
        // If we have a valid, non-expired token, return it
        if let token = accessToken, 
           let expiry = accessTokenExpiry,
           expiry > Date().addingTimeInterval(60) {  // 60s buffer before expiry
            return token
        }
        
        // If a refresh is already in progress, wait for it
        // This is the critical pattern — prevents multiple simultaneous refreshes
        if let existingTask = refreshTask {
            return try await existingTask.value
        }
        
        // Start a new refresh
        let task = Task<String, Error> {
            defer { refreshTask = nil }
            return try await performTokenRefresh()
        }
        refreshTask = task
        return try await task.value
    }
    
    private func performTokenRefresh() async throws -> String {
        guard let refreshToken = refreshToken else {
            throw AuthError.notAuthenticated
        }
        
        var request = URLRequest(url: URL(string: "https://auth.example.com/refresh")!)
        request.httpMethod = "POST"
        request.httpBody = try JSONEncoder().encode(["refresh_token": refreshToken])
        
        let (data, _) = try await URLSession.shared.data(for: request)
        let response = try JSONDecoder().decode(TokenResponse.self, from: data)
        
        self.accessToken = response.accessToken
        self.refreshToken = response.refreshToken
        self.accessTokenExpiry = response.expiresAt
        
        return response.accessToken
    }
}

URLSession Authentication Delegate

For server-challenge based auth (Basic Auth, client certificates, etc.):

extension NetworkManager: URLSessionTaskDelegate {
    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        
        let space = challenge.protectionSpace
        
        switch space.authenticationMethod {
        case NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodHTTPDigest:
            // Server requires username/password
            let credential = URLCredential(
                user: "username",
                password: "password",
                persistence: .forSession
            )
            completionHandler(.useCredential, credential)
            
        case NSURLAuthenticationMethodServerTrust:
            // TLS server certificate validation
            // Default behavior: let system validate
            // For pinning, implement here (see Section 13)
            completionHandler(.performDefaultHandling, nil)
            
        case NSURLAuthenticationMethodClientCertificate:
            // Client certificate authentication (mutual TLS)
            guard let identity = loadClientIdentity() else {
                completionHandler(.cancelAuthenticationChallenge, nil)
                return
            }
            let credential = URLCredential(
                identity: identity,
                certificates: nil,
                persistence: .forSession
            )
            completionHandler(.useCredential, credential)
            
        default:
            // For unknown challenges, cancel (don't use default handling — that may prompt user)
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

Keychain for Credential Storage

Never store tokens in UserDefaults. Always use Keychain:

import Security

enum KeychainError: Error {
    case duplicateItem
    case itemNotFound
    case unexpectedStatus(OSStatus)
}

struct KeychainManager {
    static let service = "com.myapp.credentials"
    
    static func save(token: String, forKey key: String) throws {
        let data = Data(token.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            // Only accessible when device is unlocked (not background-accessible)
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
        ]
        
        // Try to add
        let status = SecItemAdd(query as CFDictionary, nil)
        if status == errSecDuplicateItem {
            // Update existing
            let updateQuery: [String: Any] = [
                kSecClass as String: kSecClassGenericPassword,
                kSecAttrService as String: service,
                kSecAttrAccount as String: key
            ]
            let attributes: [String: Any] = [kSecValueData as String: data]
            let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
            guard updateStatus == errSecSuccess else {
                throw KeychainError.unexpectedStatus(updateStatus)
            }
        } else if status != errSecSuccess {
            throw KeychainError.unexpectedStatus(status)
        }
    }
    
    static func load(key: String) throws -> String {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess, let data = result as? Data else {
            if status == errSecItemNotFound { throw KeychainError.itemNotFound }
            throw KeychainError.unexpectedStatus(status)
        }
        return String(decoding: data, as: UTF8.self)
    }
    
    static func delete(key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unexpectedStatus(status)
        }
    }
}

7. Caching Strategies

URLCache Deep Dive

URLCache provides an on-disk and in-memory cache for HTTP responses. URLSession uses it automatically for cacheable responses (those with appropriate Cache-Control or Expires headers).

// Configure cache for your session
let cache = URLCache(
    memoryCapacity: 20 * 1024 * 1024,   // 20 MB in-memory
    diskCapacity: 100 * 1024 * 1024,     // 100 MB on-disk
    directory: nil  // nil = system default directory
)

let config = URLSessionConfiguration.default
config.urlCache = cache

// Cache policy options per-request:
// .useProtocolCachePolicy — follow server headers (default)
// .reloadIgnoringLocalCacheData — always hit network
// .returnCacheDataElseLoad — use cache if available, otherwise load
// .returnCacheDataDontLoad — use cache only, fail if not cached (offline mode)
// .reloadRevalidatingCacheData — conditional GET (If-None-Match / If-Modified-Since)

var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad

// Manual cache interaction
if let cached = URLCache.shared.cachedResponse(for: request) {
    let response = cached.response as? HTTPURLResponse
    print("Cached at: \(cached.storagePolicy)")
}

// Manually cache a response (useful for pre-caching, or caching non-standard responses)
let cachedResponse = CachedURLResponse(
    response: urlResponse,
    data: data,
    userInfo: ["cachedAt": Date()],
    storagePolicy: .allowed  // or .allowedInMemoryOnly, .notAllowed
)
URLCache.shared.storeCachedResponse(cachedResponse, for: request)

// Invalidate specific URL
URLCache.shared.removeCachedResponse(for: request)
// Clear all cache
URLCache.shared.removeAllCachedResponses()

Conditional GET / ETag Validation

The ETag and Last-Modified headers enable conditional requests, which are extremely efficient — if the resource hasn't changed, the server returns 304 Not Modified with no body:

// First request: server returns ETag and/or Last-Modified headers
// Cache them:
let etag = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "ETag")
let lastModified = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Last-Modified")

// Subsequent request: include validators
var request = URLRequest(url: url)
if let etag = etag {
    request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
if let lastModified = lastModified {
    request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since")
}

// If server returns 304: use your cached data
// If server returns 200: update your cache with new ETag + data

URLSession with .useProtocolCachePolicy handles this automatically when the server sends proper caching headers. But many APIs don't set these headers correctly, so implementing it manually gives you control.

Custom Response Caching Strategy

For APIs that don't use HTTP caching headers but where you want caching, implement your own layer:

// A simple time-to-live cache
actor ResponseCache {
    private struct CacheEntry {
        let data: Data
        let timestamp: Date
        let ttl: TimeInterval
        
        var isExpired: Bool {
            Date().timeIntervalSince(timestamp) > ttl
        }
    }
    
    private var store: [String: CacheEntry] = [:]
    
    func get(key: String) -> Data? {
        guard let entry = store[key], !entry.isExpired else {
            store.removeValue(forKey: key)
            return nil
        }
        return entry.data
    }
    
    func set(key: String, data: Data, ttl: TimeInterval) {
        store[key] = CacheEntry(data: data, timestamp: Date(), ttl: ttl)
    }
    
    func invalidate(prefix: String) {
        store = store.filter { !$0.key.hasPrefix(prefix) }
    }
}

8. Background Networking

Background transfers are one of the most misunderstood areas of iOS networking. They're essential for reliable large file transfers.

Background Session Setup

// Background sessions MUST be created with the same identifier after app relaunch
// The identifier must be unique per app
class BackgroundTransferManager: NSObject {
    static let backgroundIdentifier = "com.myapp.background.transfers"
    
    private(set) lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: Self.backgroundIdentifier)
        config.isDiscretionary = false      // false = transfer ASAP, true = system decides (better battery)
        config.sessionSendsLaunchEvents = true  // Wakes app when transfer completes
        config.allowsCellularAccess = true
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()
    
    // CRITICAL: Call this in AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:)
    var backgroundCompletionHandler: (() -> Void)?
}

// AppDelegate.swift
func application(_ application: UIApplication,
                 handleEventsForBackgroundURLSession identifier: String,
                 completionHandler: @escaping () -> Void) {
    // Reconnect to the background session (creates it if needed) and save the handler
    BackgroundTransferManager.shared.backgroundCompletionHandler = completionHandler
    // Accessing BackgroundTransferManager.shared.session reconnects to the background session
    _ = BackgroundTransferManager.shared.session
}

Background Download Task

// Start a background download
func startDownload(url: URL) {
    // For background downloads, use a URLRequest not just a URL
    let request = URLRequest(url: url)
    let task = session.downloadTask(with: request)
    task.taskDescription = "file-\(url.lastPathComponent)"  // For identification after relaunch
    task.resume()
}

extension BackgroundTransferManager: URLSessionDownloadDelegate {
    // Called when download completes (may be in background)
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, 
                    didFinishDownloadingTo location: URL) {
        // IMPORTANT: location is a temporary file — move it IMMEDIATELY
        // This callback happens on the delegate queue; location will be deleted when this returns
        let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent(downloadTask.taskDescription ?? "download")
        
        do {
            if FileManager.default.fileExists(atPath: dest.path) {
                try FileManager.default.removeItem(at: dest)
            }
            try FileManager.default.moveItem(at: location, to: dest)
            DispatchQueue.main.async { self.notifyDownloadComplete(url: dest) }
        } catch {
            print("Failed to move downloaded file: \(error)")
        }
    }
    
    // Progress updates
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64,
                    totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        let progress = totalBytesExpectedToWrite > 0 
            ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) 
            : 0
        DispatchQueue.main.async { self.updateProgress(progress, for: downloadTask) }
    }
    
    // Resumable downloads — save resume data on failure
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error as NSError?,
           let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
            // Save resume data to disk for later
            let resumeURL = resumeDataURL(for: task)
            try? resumeData.write(to: resumeURL)
        }
    }
    
    // CRITICAL: Called when all background events have been delivered
    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            // Call the completion handler to tell the system we're done
            // Failure to call this may cause app termination
            self.backgroundCompletionHandler?()
            self.backgroundCompletionHandler = nil
        }
    }
}

Resumable Downloads

func resumeDownload(taskDescription: String) {
    let resumeURL = resumeDataURL(for: taskDescription)
    if let resumeData = try? Data(contentsOf: resumeURL) {
        // Resume from where we left off (no re-download of already-received bytes)
        let task = session.downloadTask(withResumeData: resumeData)
        task.taskDescription = taskDescription
        task.resume()
        try? FileManager.default.removeItem(at: resumeURL)
    } else {
        // No resume data available — start fresh
        startDownload(url: originalURL)
    }
}

9. WebSockets

WebSockets provide a persistent, bidirectional channel over a single TCP connection. Essential for real-time features: chat, live prices, collaborative editing, live sports scores.

URLSessionWebSocketTask

class WebSocketManager: NSObject {
    private var webSocketTask: URLSessionWebSocketTask?
    private var pingTimer: Timer?
    private var isConnected = false
    
    private lazy var session: URLSession = {
        URLSession(configuration: .default, delegate: self, delegateQueue: nil)
    }()
    
    func connect(to url: URL) {
        var request = URLRequest(url: url)
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        
        webSocketTask = session.webSocketTask(with: request)
        webSocketTask?.resume()
        
        isConnected = true
        startReceiving()
        schedulePing()
    }
    
    // IMPORTANT: You must continuously call receive() to keep getting messages
    // It's a one-shot call — each receive() delivers exactly one message
    private func startReceiving() {
        webSocketTask?.receive { [weak self] result in
            guard let self = self, self.isConnected else { return }
            
            switch result {
            case .success(let message):
                self.handleMessage(message)
                self.startReceiving()  // Schedule next receive immediately
                
            case .failure(let error):
                self.handleDisconnection(error: error)
            }
        }
    }
    
    private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
        switch message {
        case .string(let text):
            // Parse JSON text message
            guard let data = text.data(using: .utf8),
                  let event = try? JSONDecoder().decode(WebSocketEvent.self, from: data) else {
                return
            }
            handleEvent(event)
            
        case .data(let binaryData):
            // Handle binary message (e.g., Protocol Buffers)
            handleBinaryData(binaryData)
            
        @unknown default:
            break
        }
    }
    
    func send(_ event: Encodable) {
        guard let data = try? JSONEncoder().encode(event),
              let text = String(data: data, encoding: .utf8) else { return }
        
        webSocketTask?.send(.string(text)) { [weak self] error in
            if let error = error {
                self?.handleSendError(error)
            }
        }
    }
    
    // Ping/Pong keeps the connection alive through NAT/load balancers
    // Most WebSocket servers disconnect idle connections after 30–60 seconds
    private func schedulePing() {
        pingTimer = Timer.scheduledTimer(withTimeInterval: 25.0, repeats: true) { [weak self] _ in
            self?.webSocketTask?.sendPing { error in
                if let error = error {
                    print("Ping failed: \(error)")
                    // Connection is dead — trigger reconnect
                }
            }
        }
    }
    
    func disconnect(code: URLSessionWebSocketTask.CloseCode = .normalClosure) {
        isConnected = false
        pingTimer?.invalidate()
        webSocketTask?.cancel(with: code, reason: nil)
        webSocketTask = nil
    }
}

extension WebSocketManager: URLSessionWebSocketDelegate {
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
                    didOpenWithProtocol protocol: String?) {
        print("WebSocket connected, protocol: \(`protocol` ?? "none")")
    }
    
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
                    didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "none"
        print("WebSocket closed: \(closeCode), reason: \(reasonString)")
        handleDisconnection(error: nil)
    }
}

WebSocket Reconnection Strategy

Production WebSocket clients need exponential backoff reconnection:

class ReconnectingWebSocketManager: WebSocketManager {
    private var reconnectAttempts = 0
    private let maxReconnectAttempts = 10
    private var reconnectTask: Task<Void, Never>?
    
    override func handleDisconnection(error: Error?) {
        guard reconnectAttempts < maxReconnectAttempts else {
            notifyPermanentDisconnection()
            return
        }
        
        reconnectTask = Task {
            let delay = min(pow(2.0, Double(reconnectAttempts)), 60.0)  // Cap at 60s
            let jitter = Double.random(in: 0...1.0)  // Jitter prevents thundering herd
            try? await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000))
            
            guard !Task.isCancelled else { return }
            
            reconnectAttempts += 1
            connect(to: serverURL)
        }
    }
    
    override func connect(to url: URL) {
        reconnectAttempts = 0
        super.connect(to: url)
    }
}

10. Combine & Async/Await Networking

Async/Await: The Modern Standard

Async/await is now the preferred approach for all new networking code on iOS 15+. It composes cleanly with Swift's structured concurrency:

struct NetworkClient {
    private let session: URLSession
    private let tokenStore: TokenStore
    private let baseURL: URL
    
    func request<T: Decodable>(
        _ endpoint: Endpoint,
        as type: T.Type = T.self
    ) async throws -> T {
        let token = try await tokenStore.validAccessToken()
        
        var request = try endpoint.urlRequest(baseURL: baseURL)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        
        let (data, response) = try await session.data(for: request)
        
        guard let http = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        
        switch http.statusCode {
        case 200...299:
            return try JSONDecoder().decode(T.self, from: data)
        case 401:
            // Token was invalid — clear token and throw
            await tokenStore.clearTokens()
            throw NetworkError.unauthorized
        default:
            let apiError = try? JSONDecoder().decode(APIError.self, from: data)
            throw NetworkError.httpError(statusCode: http.statusCode, apiError: apiError)
        }
    }
}

// Usage — beautifully linear, no callbacks
func loadUserProfile() async throws -> User {
    let user = try await networkClient.request(.getUser(id: currentUserID), as: User.self)
    return user
}

// Parallel requests
func loadDashboard() async throws -> Dashboard {
    async let user = networkClient.request(.getUser(id: currentUserID), as: User.self)
    async let feed = networkClient.request(.getFeed, as: [FeedItem].self)
    async let notifications = networkClient.request(.getNotifications, as: [Notification].self)
    
    return try await Dashboard(
        user: user,
        feed: feed,
        notifications: notifications
    )
}

// Graceful cancellation
func loadWithCancellation() async {
    let task = Task {
        do {
            let result = try await networkClient.request(.heavyQuery, as: LargeDataset.self)
            await MainActor.run { self.data = result }
        } catch is CancellationError {
            print("Request was cancelled")
        } catch {
            await MainActor.run { self.error = error }
        }
    }
    
    // Store task reference to cancel later
    self.currentTask = task
}

Combine-Based Networking

While async/await is preferred for new code, Combine is still valuable for declarative data pipelines and when integrating with existing Combine-based code:

extension URLSession {
    func publisher<T: Decodable>(
        for request: URLRequest,
        as type: T.Type,
        decoder: JSONDecoder = .init()
    ) -> AnyPublisher<T, NetworkError> {
        dataTaskPublisher(for: request)
            .tryMap { output in
                guard let http = output.response as? HTTPURLResponse else {
                    throw NetworkError.invalidResponse
                }
                guard 200...299 ~= http.statusCode else {
                    throw NetworkError.httpError(statusCode: http.statusCode, apiError: nil)
                }
                return output.data
            }
            .decode(type: T.self, decoder: decoder)
            .mapError { error in
                switch error {
                case let netError as NetworkError: return netError
                case let decodingError as DecodingError: return .decodingError(decodingError)
                case let urlError as URLError: return .transportError(urlError)
                default: return .unknown(error)
                }
            }
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

// Usage
class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var error: NetworkError?
    private var cancellables = Set<AnyCancellable>()
    
    func loadUser(id: Int) {
        let request = URLRequest(url: URL(string: "https://api.example.com/users/\(id)")!)
        
        URLSession.shared.publisher(for: request, as: User.self)
            .sink(receiveCompletion: { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.error = error
                }
            }, receiveValue: { [weak self] user in
                self?.user = user
            })
            .store(in: &cancellables)
    }
}

Streaming Responses (Server-Sent Events)

SSE (Server-Sent Events) is a simple one-directional streaming protocol — the server pushes events to the client over a persistent HTTP connection. Common for live notifications, progress updates, and AI streaming responses:

func streamEvents(from url: URL) async throws {
    var request = URLRequest(url: url)
    request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
    request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
    
    let (asyncBytes, response) = try await URLSession.shared.bytes(for: request)
    
    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
        throw NetworkError.invalidResponse
    }
    
    var currentEvent: SSEEvent = SSEEvent()
    
    for try await line in asyncBytes.lines {
        if line.isEmpty {
            // Empty line signals end of event — dispatch it
            if !currentEvent.data.isEmpty {
                await handleSSEEvent(currentEvent)
                currentEvent = SSEEvent()
            }
            continue
        }
        
        if line.hasPrefix("data:") {
            let data = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
            currentEvent.data += data
        } else if line.hasPrefix("event:") {
            currentEvent.event = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
        } else if line.hasPrefix("id:") {
            currentEvent.id = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
        } else if line.hasPrefix("retry:") {
            let retryMs = Int(line.dropFirst(6).trimmingCharacters(in: .whitespaces))
            currentEvent.retry = retryMs
        }
    }
}

struct SSEEvent {
    var event: String = "message"
    var data: String = ""
    var id: String?
    var retry: Int?
}

11. Network Monitoring with Network Framework

NWPathMonitor from the Network framework provides fine-grained, real-time network status — far superior to the old Reachability pattern:

import Network

final class NetworkMonitor {
    static let shared = NetworkMonitor()
    
    private let monitor: NWPathMonitor
    private let queue = DispatchQueue(label: "NetworkMonitor", qos: .utility)
    
    @Published private(set) var isConnected: Bool = true
    @Published private(set) var connectionType: ConnectionType = .unknown
    @Published private(set) var isExpensive: Bool = false     // Cellular or hotspot
    @Published private(set) var isConstrained: Bool = false   // Low Data Mode
    
    enum ConnectionType: String {
        case wifi = "WiFi"
        case cellular = "Cellular"
        case ethernet = "Ethernet"
        case loopback = "Loopback"
        case other = "Other"
        case unknown = "Unknown"
    }
    
    private init() {
        monitor = NWPathMonitor()
    }
    
    func start() {
        monitor.pathUpdateHandler = { [weak self] path in
            guard let self = self else { return }
            
            let connected = path.status == .satisfied
            let expensive = path.isExpensive
            let constrained = path.isConstrained
            
            let type: ConnectionType
            if path.usesInterfaceType(.wifi) {
                type = .wifi
            } else if path.usesInterfaceType(.cellular) {
                type = .cellular
            } else if path.usesInterfaceType(.wiredEthernet) {
                type = .ethernet
            } else if path.usesInterfaceType(.loopback) {
                type = .loopback
            } else if path.status == .satisfied {
                type = .other
            } else {
                type = .unknown
            }
            
            DispatchQueue.main.async {
                self.isConnected = connected
                self.connectionType = type
                self.isExpensive = expensive
                self.isConstrained = constrained
            }
        }
        
        monitor.start(queue: queue)
    }
    
    func stop() {
        monitor.cancel()
    }
    
    // Use this to adapt behavior based on network quality
    var shouldUseLowQualityMedia: Bool {
        isConstrained || isExpensive
    }
}

Integrating Network Monitoring with Requests

// Adapt request behavior based on network state
func buildImageURL(for asset: Asset) -> URL {
    let monitor = NetworkMonitor.shared
    
    if monitor.isConstrained || monitor.isExpensive {
        // Low Data Mode or cellular — request compressed thumbnail
        return asset.thumbnailURL(quality: .low)
    } else if monitor.connectionType == .wifi {
        return asset.fullResolutionURL
    } else {
        return asset.thumbnailURL(quality: .medium)
    }
}

12. Multipart Uploads & Streaming

Multipart/Form-Data

Multipart is the standard format for file uploads that include metadata:

struct MultipartFormData {
    private let boundary: String
    private var parts: [Part] = []
    
    struct Part {
        let name: String
        let filename: String?
        let contentType: String
        let data: Data
    }
    
    init() {
        boundary = "Boundary-\(UUID().uuidString)"
    }
    
    var contentType: String {
        "multipart/form-data; boundary=\(boundary)"
    }
    
    mutating func add(field name: String, value: String) {
        let data = Data(value.utf8)
        parts.append(Part(name: name, filename: nil, contentType: "text/plain", data: data))
    }
    
    mutating func add(file data: Data, name: String, filename: String, mimeType: String) {
        parts.append(Part(name: name, filename: filename, contentType: mimeType, data: data))
    }
    
    func encode() -> Data {
        var body = Data()
        let boundaryPrefix = "--\(boundary)\r\n"
        
        for part in parts {
            body.append(Data(boundaryPrefix.utf8))
            
            var headers = "Content-Disposition: form-data; name=\"\(part.name)\""
            if let filename = part.filename {
                headers += "; filename=\"\(filename)\""
            }
            headers += "\r\n"
            headers += "Content-Type: \(part.contentType)\r\n\r\n"
            body.append(Data(headers.utf8))
            body.append(part.data)
            body.append(Data("\r\n".utf8))
        }
        
        body.append(Data("--\(boundary)--\r\n".utf8))
        return body
    }
}

// Usage
func uploadProfilePhoto(_ image: UIImage) async throws {
    guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
    
    var form = MultipartFormData()
    form.add(field: "userId", value: currentUser.id)
    form.add(file: imageData, name: "photo", filename: "profile.jpg", mimeType: "image/jpeg")
    
    let encoded = form.encode()
    
    var request = URLRequest(url: URL(string: "https://api.example.com/profile/photo")!)
    request.httpMethod = "POST"
    request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
    request.setValue(String(encoded.count), forHTTPHeaderField: "Content-Length")
    
    // Use uploadTask for progress reporting
    let task = session.uploadTask(with: request, from: encoded)
    task.resume()
}

Streaming Large Uploads (InputStream)

For very large files, loading everything into memory is impractical. Stream from disk instead:

func streamUpload(fileURL: URL) async throws {
    let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as! Int
    
    var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
    request.httpMethod = "PUT"
    request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
    request.setValue(String(fileSize), forHTTPHeaderField: "Content-Length")
    
    // httpBodyStream lets URLSession read from an InputStream
    // Never loaded fully into memory
    request.httpBodyStream = InputStream(url: fileURL)
    
    let task = session.uploadTask(withStreamedRequest: request)
    task.resume()
    
    // Track progress via URLSessionTaskDelegate
    // func urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:)
}

Chunked Transfer for Very Large Files

For huge files (videos, backups), use chunked/resumable upload — upload in fixed-size pieces and recover from failures without restarting:

class ResumableUploader {
    private let chunkSize: Int = 5 * 1024 * 1024  // 5 MB chunks
    
    func upload(fileURL: URL) async throws {
        let fileSize = try fileSize(at: fileURL)
        let uploadID = try await initiateUpload(fileURL: fileURL, totalSize: fileSize)
        
        var offset = 0
        let handle = try FileHandle(forReadingFrom: fileURL)
        defer { try? handle.close() }
        
        while offset < fileSize {
            let remaining = fileSize - offset
            let currentChunkSize = min(chunkSize, remaining)
            
            try handle.seek(toOffset: UInt64(offset))
            let chunk = handle.readData(ofLength: currentChunkSize)
            
            let contentRange = "bytes \(offset)-\(offset + currentChunkSize - 1)/\(fileSize)"
            try await uploadChunk(chunk, uploadID: uploadID, contentRange: contentRange)
            
            offset += currentChunkSize
        }
        
        try await finalizeUpload(uploadID: uploadID)
    }
    
    private func uploadChunk(_ data: Data, uploadID: String, contentRange: String) async throws {
        var request = URLRequest(url: URL(string: "https://api.example.com/uploads/\(uploadID)")!)
        request.httpMethod = "PUT"
        request.setValue(contentRange, forHTTPHeaderField: "Content-Range")
        
        let (_, response) = try await session.upload(for: request, from: data)
        guard let http = response as? HTTPURLResponse,
              http.statusCode == 200 || http.statusCode == 308 else {
            throw UploadError.chunkFailed
        }
    }
}

13. Certificate Pinning & TLS Customization

Certificate pinning prevents man-in-the-middle attacks by refusing connections to servers that don't present a specific certificate or public key. It's an extra layer of defense against attacks that compromise a Certificate Authority.

Pinning the public key is better than pinning the whole certificate — certificates can be rotated (e.g., annual renewal) without changing the public key, which means you don't need an app update to rotate certs:

extension NetworkManager: URLSessionDelegate {
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        // Let the system validate the certificate chain first (expiry, CA trust, etc.)
        var error: CFError?
        let isValid = SecTrustEvaluateWithError(serverTrust, &error)
        guard isValid else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        // Then check public key hash against our pinned values
        guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        let serverPublicKeyHash = publicKeyHash(for: serverCertificate)
        
        let pinnedHashes: Set<String> = [
            // Extract with: openssl s_client -connect api.example.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64
            "abc123...your-pinned-sha256-hash-base64-encoded",
            "def456...backup-pin-for-key-rotation"  // Always include at least one backup!
        ]
        
        if pinnedHashes.contains(serverPublicKeyHash) {
            completionHandler(.useCredential, URLCredential(trust: serverTrust))
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            // Log this — it may indicate an attack or a pin that needs updating
            reportPinningFailure(host: challenge.protectionSpace.host)
        }
    }
    
    private func publicKeyHash(for certificate: SecCertificate) -> String {
        guard let publicKey = SecCertificateCopyKey(certificate),
              let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
            return ""
        }
        
        // SHA-256 hash the raw public key data
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        publicKeyData.withUnsafeBytes { bytes in
            _ = CC_SHA256(bytes.baseAddress, CC_LONG(publicKeyData.count), &hash)
        }
        return Data(hash).base64EncodedString()
    }
}

TLS Minimum Version

Enforce TLS 1.3 minimum in your Info.plist and session config:

<!-- Info.plist — App Transport Security -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
    <!-- For specific domains requiring exceptions: -->
    <key>NSExceptionDomains</key>
    <dict>
        <key>staging.internal.com</key>
        <dict>
            <key>NSExceptionMinimumTLSVersion</key>
            <string>TLSv1.2</string>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <false/>
        </dict>
    </dict>
</dict>

14. Retry Logic, Backoff & Circuit Breakers

Exponential Backoff with Jitter

Naive retry logic (try again immediately) can overwhelm a struggling server and worsen outages. Exponential backoff with jitter spreads retries over time:

struct RetryPolicy {
    let maxRetries: Int
    let baseDelay: TimeInterval
    let maxDelay: TimeInterval
    let multiplier: Double
    let jitterRange: ClosedRange<Double>
    
    static let `default` = RetryPolicy(
        maxRetries: 3,
        baseDelay: 1.0,
        maxDelay: 60.0,
        multiplier: 2.0,
        jitterRange: 0...0.5
    )
    
    func delay(for attempt: Int) -> TimeInterval {
        let exponential = baseDelay * pow(multiplier, Double(attempt))
        let capped = min(exponential, maxDelay)
        let jitter = Double.random(in: jitterRange)
        return capped + jitter
    }
    
    // Only certain errors are worth retrying
    func shouldRetry(error: Error, statusCode: Int?) -> Bool {
        // Don't retry client errors — they won't change
        if let code = statusCode, (400..<500).contains(code), code != 429 {
            return false
        }
        
        if let urlError = error as? URLError {
            switch urlError.code {
            case .timedOut, .networkConnectionLost, .notConnectedToInternet:
                return true
            case .cancelled:
                return false  // Intentional cancellation
            default:
                return false
            }
        }
        
        // Retry 429 (rate limited) and 5xx server errors
        if let code = statusCode, code == 429 || (500..<600).contains(code) {
            return true
        }
        
        return false
    }
}

func requestWithRetry<T: Decodable>(
    _ request: URLRequest,
    policy: RetryPolicy = .default,
    as type: T.Type
) async throws -> T {
    var lastError: Error?
    
    for attempt in 0...policy.maxRetries {
        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }
            
            // Check if we should retry based on status code
            if policy.shouldRetry(error: URLError(.unknown), statusCode: http.statusCode) 
               && attempt < policy.maxRetries {
                // Respect Retry-After header if present (429 responses often include it)
                if let retryAfter = http.value(forHTTPHeaderField: "Retry-After"),
                   let delay = Double(retryAfter) {
                    try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                } else {
                    let delay = policy.delay(for: attempt)
                    try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                }
                continue
            }
            
            return try JSONDecoder().decode(T.self, from: data)
            
        } catch {
            lastError = error
            
            if policy.shouldRetry(error: error, statusCode: nil) && attempt < policy.maxRetries {
                let delay = policy.delay(for: attempt)
                try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                continue
            } else {
                throw error
            }
        }
    }
    
    throw lastError ?? NetworkError.maxRetriesExceeded
}

Circuit Breaker Pattern

A circuit breaker prevents your app from repeatedly hammering a failing service. After a threshold of failures, it "trips" and immediately fails requests (fast-fail), preventing timeout cascades:

actor CircuitBreaker {
    enum State {
        case closed      // Normal operation
        case open        // Failing — reject requests immediately
        case halfOpen    // Testing if the service has recovered
    }
    
    private var state: State = .closed
    private var failureCount = 0
    private var lastFailureTime: Date?
    
    let failureThreshold: Int
    let timeout: TimeInterval  // Time in open state before moving to half-open
    let successThreshold: Int  // Successes in half-open to move to closed
    private var halfOpenSuccessCount = 0
    
    init(failureThreshold: Int = 5, timeout: TimeInterval = 30, successThreshold: Int = 2) {
        self.failureThreshold = failureThreshold
        self.timeout = timeout
        self.successThreshold = successThreshold
    }
    
    func execute<T>(_ operation: () async throws -> T) async throws -> T {
        switch state {
        case .open:
            // Check if timeout has passed — if so, move to half-open
            if let lastFailure = lastFailureTime,
               Date().timeIntervalSince(lastFailure) > timeout {
                state = .halfOpen
                halfOpenSuccessCount = 0
            } else {
                throw CircuitBreakerError.circuitOpen
            }
            fallthrough
            
        case .halfOpen:
            do {
                let result = try await operation()
                halfOpenSuccessCount += 1
                if halfOpenSuccessCount >= successThreshold {
                    state = .closed
                    failureCount = 0
                }
                return result
            } catch {
                state = .open
                lastFailureTime = Date()
                throw error
            }
            
        case .closed:
            do {
                let result = try await operation()
                failureCount = 0  // Reset on success
                return result
            } catch {
                failureCount += 1
                lastFailureTime = Date()
                if failureCount >= failureThreshold {
                    state = .open
                }
                throw error
            }
        }
    }
}

15. Building a Production-Ready Network Layer

Putting it all together into a clean, testable, and maintainable architecture:

Endpoint Definition

// Defines what a request is, not how to execute it
enum Endpoint {
    // Auth
    case login(email: String, password: String)
    case refreshToken(String)
    
    // Users
    case getUser(id: String)
    case updateUser(id: String, update: UserUpdate)
    case uploadAvatar(id: String, imageData: Data)
    
    // Feed
    case getFeed(page: Int, limit: Int)
    case likePost(id: String)
    
    var method: HTTPMethod {
        switch self {
        case .login, .refreshToken, .likePost:
            return .post
        case .getUser, .getFeed:
            return .get
        case .updateUser:
            return .patch
        case .uploadAvatar:
            return .put
        }
    }
    
    var path: String {
        switch self {
        case .login: return "/auth/login"
        case .refreshToken: return "/auth/refresh"
        case .getUser(let id): return "/users/\(id)"
        case .updateUser(let id, _): return "/users/\(id)"
        case .uploadAvatar(let id, _): return "/users/\(id)/avatar"
        case .getFeed: return "/feed"
        case .likePost(let id): return "/posts/\(id)/like"
        }
    }
    
    var queryItems: [URLQueryItem] {
        switch self {
        case .getFeed(let page, let limit):
            return [
                URLQueryItem(name: "page", value: String(page)),
                URLQueryItem(name: "limit", value: String(limit))
            ]
        default: return []
        }
    }
    
    var body: Encodable? {
        switch self {
        case .login(let email, let password):
            return ["email": email, "password": password]
        case .refreshToken(let token):
            return ["refresh_token": token]
        case .updateUser(_, let update):
            return update
        default: return nil
        }
    }
    
    var requiresAuth: Bool {
        switch self {
        case .login, .refreshToken: return false
        default: return true
        }
    }
    
    func urlRequest(baseURL: URL, encoder: JSONEncoder = .init()) throws -> URLRequest {
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)!
        if !queryItems.isEmpty { components.queryItems = queryItems }
        
        guard let url = components.url else { throw NetworkError.invalidURL }
        
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        
        if let body = body {
            request.httpBody = try encoder.encode(AnyEncodable(body))
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        
        return request
    }
}

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case patch = "PATCH"
    case delete = "DELETE"
}

The Network Client

final class NetworkClient {
    private let baseURL: URL
    private let session: URLSession
    private let tokenStore: TokenStore
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder
    private let circuitBreaker: CircuitBreaker
    private let retryPolicy: RetryPolicy
    
    init(
        baseURL: URL,
        session: URLSession = .shared,
        tokenStore: TokenStore,
        retryPolicy: RetryPolicy = .default
    ) {
        self.baseURL = baseURL
        self.session = session
        self.tokenStore = tokenStore
        self.retryPolicy = retryPolicy
        self.circuitBreaker = CircuitBreaker()
        
        decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        
        encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.keyEncodingStrategy = .convertToSnakeCase
    }
    
    func send<T: Decodable>(_ endpoint: Endpoint, as type: T.Type = T.self) async throws -> T {
        return try await circuitBreaker.execute {
            try await self.executeWithRetry(endpoint: endpoint, type: type)
        }
    }
    
    // For void responses (204 No Content, etc.)
    func send(_ endpoint: Endpoint) async throws {
        _ = try await circuitBreaker.execute {
            try await self.executeRaw(endpoint: endpoint)
        }
    }
    
    private func executeWithRetry<T: Decodable>(
        endpoint: Endpoint,
        type: T.Type,
        attempt: Int = 0
    ) async throws -> T {
        do {
            let (data, response) = try await executeRaw(endpoint: endpoint)
            let http = response as! HTTPURLResponse
            
            let validData = try validateResponse(data: data, response: http)
            return try decoder.decode(T.self, from: validData)
            
        } catch NetworkError.unauthorized where endpoint.requiresAuth {
            // Token expired mid-request — refresh and retry once
            try await tokenStore.forceRefresh()
            return try await executeWithRetry(endpoint: endpoint, type: type, attempt: attempt + 1)
            
        } catch {
            if retryPolicy.shouldRetry(error: error, statusCode: (error as? NetworkError)?.statusCode),
               attempt < retryPolicy.maxRetries {
                let delay = retryPolicy.delay(for: attempt)
                try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                return try await executeWithRetry(endpoint: endpoint, type: type, attempt: attempt + 1)
            }
            throw error
        }
    }
    
    @discardableResult
    private func executeRaw(endpoint: Endpoint) async throws -> (Data, URLResponse) {
        var request = try endpoint.urlRequest(baseURL: baseURL, encoder: encoder)
        
        if endpoint.requiresAuth {
            let token = try await tokenStore.validAccessToken()
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        
        // Request ID for tracing
        let requestID = UUID().uuidString
        request.setValue(requestID, forHTTPHeaderField: "X-Request-ID")
        
        return try await session.data(for: request)
    }
    
    private func validateResponse(data: Data, response: HTTPURLResponse) throws -> Data {
        switch response.statusCode {
        case 200...299: return data
        case 401: throw NetworkError.unauthorized
        case 403: throw NetworkError.forbidden
        case 404: throw NetworkError.notFound
        case 429:
            let retryAfter = response.value(forHTTPHeaderField: "Retry-After")
                .flatMap(Double.init) ?? 60.0
            throw NetworkError.rateLimited(retryAfter: retryAfter)
        default:
            let apiError = try? decoder.decode(APIError.self, from: data)
            throw NetworkError.httpError(statusCode: response.statusCode, apiError: apiError)
        }
    }
}

16. Performance Optimization & Profiling

Measuring with URLSessionTaskMetrics

extension NetworkClient: URLSessionTaskDelegate {
    func urlSession(_ session: URLSession, task: URLSessionTask,
                    didFinishCollecting metrics: URLSessionTaskMetrics) {
        guard let transaction = metrics.transactionMetrics.last else { return }
        
        let dnsTime = transaction.domainLookupEndDate?.timeIntervalSince(transaction.domainLookupStartDate ?? Date()) ?? 0
        let connectTime = transaction.connectEndDate?.timeIntervalSince(transaction.connectStartDate ?? Date()) ?? 0
        let tlsTime = transaction.secureConnectionEndDate?.timeIntervalSince(transaction.secureConnectionStartDate ?? Date()) ?? 0
        let ttfb = transaction.responseStartDate?.timeIntervalSince(transaction.requestEndDate ?? Date()) ?? 0
        let downloadTime = transaction.responseEndDate?.timeIntervalSince(transaction.responseStartDate ?? Date()) ?? 0
        let totalTime = metrics.taskInterval.duration
        
        print("""
        Request: \(task.originalRequest?.url?.path ?? "")
        DNS: \(String(format: "%.0fms", dnsTime * 1000))
        Connect: \(String(format: "%.0fms", connectTime * 1000))
        TLS: \(String(format: "%.0fms", tlsTime * 1000))
        TTFB: \(String(format: "%.0fms", ttfb * 1000))
        Download: \(String(format: "%.0fms", downloadTime * 1000))
        Total: \(String(format: "%.0fms", totalTime * 1000))
        Protocol: \(transaction.networkProtocolName ?? "unknown")
        Reused: \(transaction.isReusedConnection)
        Proxy: \(transaction.isProxyConnection)
        """)
    }
}

Performance Best Practices

Connection reuse — Use a single URLSession instance per API domain, not a new one per request. Session creation is expensive, but more importantly, reusing sessions lets URLSession maintain persistent connections (HTTP keep-alive, HTTP/2 connection pooling).

Response compression — Ensure your server sends gzip/Brotli compressed responses. URLSession automatically decompresses when it sends Accept-Encoding: gzip, deflate, br. A typical JSON response compresses to 10–20% of its original size.

Reduce payload size — Request only the fields you need with field masks (?fields=id,name,email). Paginate large collections. Consider GraphQL for precise field selection.

Prioritize tasks — URLSession tasks have a priority property (0.0–1.0). Use URLSessionTask.highPriority (0.75) for user-initiated requests, URLSessionTask.defaultPriority (0.5) for normal requests, URLSessionTask.lowPriority (0.25) for background prefetch:

let task = session.dataTask(with: request)
task.priority = URLSessionTask.highPriority
task.resume()

Prefetching — When you can predict what the user will need next, prefetch with low-priority tasks. Store in URLCache or your custom cache. UICollectionView's prefetchDataSource is ideal for this pattern.

Image loading — Never load images on the main thread. Use a dedicated download + caching library or implement your own with NSCache for in-memory and URLCache for disk caching.


17. Testing Networking Code

Testable network code is the mark of a senior engineer. The key is dependency injection — your network code should accept a URLSession-like protocol, not depend directly on URLSession.

URLProtocol for Test Mocking

URLProtocol is a powerful and underused feature that lets you intercept and mock network requests at the lowest level — without any dependency injection:

class MockURLProtocol: URLProtocol {
    // Registry of handlers: URL path → (HTTPURLResponse, Data) or Error
    static var handlers: [String: Result<(HTTPURLResponse, Data), Error>] = [:]
    static var requestLog: [URLRequest] = []
    
    override class func canInit(with request: URLRequest) -> Bool {
        return true  // Handle all requests
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    
    override func startLoading() {
        MockURLProtocol.requestLog.append(request)
        
        let path = request.url?.path ?? ""
        
        if let result = MockURLProtocol.handlers[path] {
            switch result {
            case .success(let (response, data)):
                client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
                client?.urlProtocol(self, didLoad: data)
                client?.urlProtocolDidFinishLoading(self)
            case .failure(let error):
                client?.urlProtocol(self, didFailWithError: error)
            }
        } else {
            client?.urlProtocol(self, didFailWithError: URLError(.fileDoesNotExist))
        }
    }
    
    override func stopLoading() {}
}

// In tests:
class NetworkClientTests: XCTestCase {
    var client: NetworkClient!
    var session: URLSession!
    
    override func setUp() {
        super.setUp()
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol.self]
        session = URLSession(configuration: config)
        client = NetworkClient(baseURL: URL(string: "https://api.example.com")!, session: session, tokenStore: MockTokenStore())
    }
    
    override func tearDown() {
        MockURLProtocol.handlers = [:]
        MockURLProtocol.requestLog = []
    }
    
    func testGetUser_success() async throws {
        let expectedUser = User(id: "1", name: "Simran", email: "simran@example.com")
        let data = try JSONEncoder().encode(expectedUser)
        let response = HTTPURLResponse(url: URL(string: "https://api.example.com/users/1")!,
                                       statusCode: 200, httpVersion: nil, headerFields: nil)!
        MockURLProtocol.handlers["/users/1"] = .success((response, data))
        
        let user = try await client.send(.getUser(id: "1"), as: User.self)
        XCTAssertEqual(user.name, "Simran")
    }
    
    func testGetUser_401_refreshesToken() async throws {
        // First call: 401
        let unauthorizedResponse = HTTPURLResponse(url: URL(string: "https://api.example.com/users/1")!,
                                                   statusCode: 401, httpVersion: nil, headerFields: nil)!
        MockURLProtocol.handlers["/users/1"] = .success((unauthorizedResponse, Data()))
        
        // After refresh, should succeed
        // ... setup mock to return 200 after refresh ...
    }
    
    func testVerifyRequestHeaders() async throws {
        // Verify that requests include required headers
        let _ = try? await client.send(.getUser(id: "1"), as: User.self)
        
        let request = MockURLProtocol.requestLog.first
        XCTAssertNotNil(request?.value(forHTTPHeaderField: "Authorization"))
        XCTAssertNotNil(request?.value(forHTTPHeaderField: "X-Request-ID"))
    }
}

Testing with URLSession Protocol

For a cleaner dependency injection approach:

protocol URLSessionProtocol {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

extension URLSession: URLSessionProtocol {}

// In tests:
class MockURLSession: URLSessionProtocol {
    var stubbedResponse: (Data, URLResponse)?
    var stubbedError: Error?
    var lastRequest: URLRequest?
    
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        lastRequest = request
        if let error = stubbedError { throw error }
        return stubbedResponse!
    }
}

18. GraphQL, gRPC & Protocol Buffers

GraphQL

GraphQL requests are always POST to a single endpoint, with a JSON body containing the query:

struct GraphQLRequest: Encodable {
    let query: String
    let variables: [String: AnyEncodable]?
    let operationName: String?
}

struct GraphQLResponse<T: Decodable>: Decodable {
    let data: T?
    let errors: [GraphQLError]?
    
    struct GraphQLError: Decodable {
        let message: String
        let path: [String]?
        let locations: [Location]?
        
        struct Location: Decodable {
            let line: Int
            let column: Int
        }
    }
}

func query<T: Decodable>(_ query: String, variables: [String: Any] = [:]) async throws -> T {
    let body = GraphQLRequest(
        query: query,
        variables: variables.mapValues { AnyEncodable($0) },
        operationName: nil
    )
    
    var request = URLRequest(url: graphqlEndpoint)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(body)
    
    let (data, _) = try await session.data(for: request)
    let response = try JSONDecoder().decode(GraphQLResponse<T>.self, from: data)
    
    if let errors = response.errors, !errors.isEmpty {
        throw GraphQLError.queryErrors(errors)
    }
    
    guard let result = response.data else {
        throw GraphQLError.noData
    }
    
    return result
}

// Usage — query is defined at the call site
let user: User = try await query("""
    query GetUser($id: ID!) {
        user(id: $id) {
            id
            name
            email
            posts(limit: 10) {
                id
                title
            }
        }
    }
""", variables: ["id": "user123"])

Protocol Buffers / gRPC

Protocol Buffers (protobuf) are a binary serialization format — much smaller and faster than JSON. gRPC is a high-performance RPC framework built on HTTP/2 and protobuf.

In iOS, use the swift-protobuf and grpc-swift packages:

// Add to Package.swift:
// .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.25.0")
// .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.21.0")

// Generated from .proto file by protoc:
// message User { string id = 1; string name = 2; string email = 3; }
// service UserService { rpc GetUser(GetUserRequest) returns (User); }

import GRPC
import NIO

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let channel = try GRPCChannelPool.with(
    target: .host("api.example.com", port: 443),
    transportSecurity: .tls(GRPCTLSConfiguration.makeClientDefault()),
    eventLoopGroup: group
)

let client = UserService_UserServiceNIOClient(channel: channel)

// Unary RPC
var request = UserService_GetUserRequest()
request.id = "user123"
let user = try await client.getUser(request).response.get()

// Server streaming RPC
let call = client.streamUserEvents(request)
for try await event in call.responses {
    handleEvent(event)
}

19. Security Hardening & Common Vulnerabilities

App Transport Security (ATS)

ATS enforces HTTPS by default for all network connections. Never disable it globally (NSAllowsArbitraryLoads = true) in production. If you need exceptions (for development servers, third-party SDKs), add targeted exceptions:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
    <key>NSExceptionDomains</key>
    <dict>
        <key>dev.internal.company.com</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSIncludesSubdomains</key>
            <true/>
        </dict>
    </dict>
</dict>

Sensitive Data in URLs

URLs are logged — by URLSession debugging, by your analytics, by CDN access logs, by server logs. Never put sensitive data in URLs:

// BAD — API key in URL
let url = "https://api.example.com/data?api_key=secret123"

// GOOD — API key in header
request.setValue("secret123", forHTTPHeaderField: "X-API-Key")

// BAD — Token in URL (will appear in server logs, browser history, referrer headers)
let url = "https://api.example.com/download?token=jwt.token.here"

// GOOD — Token in Authorization header
request.setValue("Bearer jwt.token.here", forHTTPHeaderField: "Authorization")
// Ensure cookies use Secure and HttpOnly flags
// Check these server-side, but also validate client-side:
if let cookies = HTTPCookieStorage.shared.cookies(for: url) {
    for cookie in cookies {
        guard cookie.isSecure else {
            // Log warning — production API should always set Secure flag
            continue
        }
    }
}

Preventing Sensitive Data Logging

// Create a sanitized debug description for requests
extension URLRequest {
    var sanitizedDescription: String {
        var headers = allHTTPHeaderFields ?? [:]
        // Remove sensitive headers from logs
        let sensitiveHeaders = ["Authorization", "X-API-Key", "Cookie", "Set-Cookie"]
        sensitiveHeaders.forEach { headers[$0] = "[REDACTED]" }
        
        return """
        \(httpMethod ?? "GET") \(url?.absoluteString ?? "")
        Headers: \(headers)
        Body: \(httpBody.map { "\($0.count) bytes" } ?? "none")
        """
    }
}

Input Validation & Injection

Always validate and sanitize user input before including it in network requests:

func buildSearchRequest(query: String) throws -> URLRequest {
    // Validate input
    guard !query.isEmpty, query.count <= 200 else {
        throw ValidationError.invalidInput("Query must be 1-200 characters")
    }
    
    // URLComponents handles encoding — prevents URL injection
    var components = URLComponents()
    components.scheme = "https"
    components.host = "api.example.com"
    components.path = "/search"
    components.queryItems = [URLQueryItem(name: "q", value: query)]
    
    guard let url = components.url else { throw NetworkError.invalidURL }
    return URLRequest(url: url)
}

20. Advanced Patterns: Interceptors, Middleware & Request Pipelines

Request Interceptor Protocol

Interceptors allow cross-cutting concerns (auth, logging, metrics, modification) to be applied to all requests without polluting business logic:

protocol RequestInterceptor {
    func intercept(_ request: URLRequest) async throws -> URLRequest
}

protocol ResponseInterceptor {
    func intercept(_ data: Data, _ response: URLResponse, for request: URLRequest) async throws -> (Data, URLResponse)
}

// Auth Interceptor — adds token to every request
struct AuthInterceptor: RequestInterceptor {
    let tokenStore: TokenStore
    
    func intercept(_ request: URLRequest) async throws -> URLRequest {
        var mutableRequest = request
        let token = try await tokenStore.validAccessToken()
        mutableRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return mutableRequest
    }
}

// Logging Interceptor
struct LoggingInterceptor: RequestInterceptor, ResponseInterceptor {
    func intercept(_ request: URLRequest) async throws -> URLRequest {
        let start = Date()
        print("→ \(request.httpMethod ?? "GET") \(request.url?.path ?? "")")
        return request
    }
    
    func intercept(_ data: Data, _ response: URLResponse, for request: URLRequest) async throws -> (Data, URLResponse) {
        if let http = response as? HTTPURLResponse {
            print("← \(http.statusCode) \(request.url?.path ?? "") [\(data.count) bytes]")
        }
        return (data, response)
    }
}

// User-Agent Interceptor
struct UserAgentInterceptor: RequestInterceptor {
    let userAgent: String
    
    func intercept(_ request: URLRequest) async throws -> URLRequest {
        var r = request
        r.setValue(userAgent, forHTTPHeaderField: "User-Agent")
        return r
    }
}

// Pipeline executor
final class InterceptingNetworkClient {
    private let session: URLSession
    private let requestInterceptors: [RequestInterceptor]
    private let responseInterceptors: [ResponseInterceptor]
    
    init(session: URLSession, requestInterceptors: [RequestInterceptor], responseInterceptors: [ResponseInterceptor]) {
        self.session = session
        self.requestInterceptors = requestInterceptors
        self.responseInterceptors = responseInterceptors
    }
    
    func execute(_ request: URLRequest) async throws -> (Data, URLResponse) {
        // Apply request interceptors in order
        var mutableRequest = request
        for interceptor in requestInterceptors {
            mutableRequest = try await interceptor.intercept(mutableRequest)
        }
        
        // Execute the request
        var (data, response) = try await session.data(for: mutableRequest)
        
        // Apply response interceptors in order
        for interceptor in responseInterceptors {
            (data, response) = try await interceptor.intercept(data, response, for: mutableRequest)
        }
        
        return (data, response)
    }
}

// Setup
let client = InterceptingNetworkClient(
    session: URLSession(configuration: .default),
    requestInterceptors: [
        UserAgentInterceptor(userAgent: "MyApp/2.1 iOS/17.0"),
        AuthInterceptor(tokenStore: tokenStore),
        LoggingInterceptor()
    ],
    responseInterceptors: [
        LoggingInterceptor()
    ]
)

Request Deduplication

Prevent the same request from being made simultaneously from different parts of the app (e.g., two views both requesting the current user at startup):

actor RequestDeduplicator {
    private var inFlight: [String: Task<(Data, URLResponse), Error>] = [:]
    
    func execute(key: String, operation: @escaping () async throws -> (Data, URLResponse)) async throws -> (Data, URLResponse) {
        if let existing = inFlight[key] {
            // Another caller is already fetching this — wait for their result
            return try await existing.value
        }
        
        let task = Task<(Data, URLResponse), Error> {
            defer { Task { await self.removeTask(key: key) } }
            return try await operation()
        }
        
        inFlight[key] = task
        return try await task.value
    }
    
    private func removeTask(key: String) {
        inFlight.removeValue(forKey: key)
    }
}

Offline Queue

Queue requests made while offline and flush them when connectivity is restored:

actor OfflineQueue {
    private var queue: [QueuedRequest] = []
    private let persistence: RequestPersistence
    
    struct QueuedRequest: Codable {
        let id: UUID
        let urlString: String
        let method: String
        let headers: [String: String]
        let bodyBase64: String?
        let enqueuedAt: Date
    }
    
    func enqueue(_ request: URLRequest) async throws {
        let queued = QueuedRequest(
            id: UUID(),
            urlString: request.url!.absoluteString,
            method: request.httpMethod ?? "GET",
            headers: request.allHTTPHeaderFields ?? [:],
            bodyBase64: request.httpBody?.base64EncodedString(),
            enqueuedAt: Date()
        )
        queue.append(queued)
        try await persistence.save(queue)
    }
    
    func flush(using session: URLSession) async {
        let toFlush = queue
        queue = []
        
        await withTaskGroup(of: Void.self) { group in
            for queued in toFlush {
                group.addTask {
                    guard let url = URL(string: queued.urlString) else { return }
                    var request = URLRequest(url: url)
                    request.httpMethod = queued.method
                    request.allHTTPHeaderFields = queued.headers
                    if let b64 = queued.bodyBase64 {
                        request.httpBody = Data(base64Encoded: b64)
                    }
                    _ = try? await session.data(for: request)
                }
            }
        }
    }
}

Quick Reference: Common URLError Codes

Code Meaning Retry?
notConnectedToInternet No network Yes, wait for connectivity
networkConnectionLost Connection dropped mid-request Yes
timedOut Request exceeded timeout Yes, with backoff
cancelled Task was cancelled No
badServerResponse Invalid HTTP response No
cannotFindHost DNS failed Maybe (transient DNS issues)
secureConnectionFailed TLS failure No (check cert)
serverCertificateUntrusted Certificate rejected No (check pinning)
dataNotAllowed Cellular disabled in settings No (user decision)

Quick Reference: HTTP Headers You Must Know

Header Direction Purpose
Authorization Request Auth credentials
Content-Type Request/Response Media type of body
Accept Request Desired response media types
Accept-Encoding Request Compression algorithms client supports
Content-Encoding Response Compression used on response body
Cache-Control Both Caching directives
ETag Response Resource version identifier
If-None-Match Request Conditional GET using ETag
Last-Modified Response Resource last modified time
If-Modified-Since Request Conditional GET using Last-Modified
Retry-After Response Delay before retrying (429, 503)
X-Request-ID Request Distributed tracing correlation ID
Location Response Redirect target or created resource URL

This guide covers the full spectrum of iOS networking from foundational protocol knowledge to advanced production patterns. Master each section, and you'll be equipped to architect, implement, debug, and optimize any networking requirement at the senior level.