6.6 — Networking Advanced

Opening scenario

Your app shipped. The crash rate is fine. The one-star reviews aren’t about crashes — they’re about flakiness. “Login fails on my commute.” “Image never loads on hotel WiFi.” “App says I’m offline when I’m clearly not.” Welcome to the second life of every iOS network layer: when the happy path works but the real world doesn’t.

Real networking is intercepting requests for auth, retrying with backoff, refreshing expired tokens before the user sees a 401, uploading multipart with progress, downloading 100MB videos that can pause and resume, and surviving a Wi-Fi-to-cellular transition mid-request. URLSession does all of this. You just have to know how.

ContextWhat it usually means
Reads “interceptor”Has worked on a network layer with auth refresh
Reads “exponential backoff”Has been bitten by hammering a failing API
Reads “401 → refresh → retry”Has built or maintained an auth-aware client
Reads “multipart/form-data”Has uploaded an image to a backend
Reads “background URLSession”Has shipped large downloads/uploads that survive backgrounding

Concept → Why → How → Code

Concept

The right mental model: URLSession is a transport. Above it lives an APIClient you own — a thin layer that owns base URL, headers, encoding, decoding, auth, retry policy, and error mapping. Below URLSession lives the system: TLS, DNS, NSURLConnection internals, the network reachability you don’t touch directly.

Why

URLSession calls scattered through view models is the source of half the bugs in any iOS codebase past 10k LOC. Centralizing in an APIClient gives you:

  • One place to add auth, one place to revoke
  • One place to test, one place to mock
  • One place to enforce timeouts, retry policy, logging
  • One place to introduce certificate pinning (Chapter 9.3) without touching call sites

How — the production APIClient skeleton

import Foundation

public protocol Endpoint {
    var path: String { get }
    var method: HTTPMethod { get }
    var headers: [String: String] { get }
    var query: [URLQueryItem] { get }
    var body: Data? { get }
}

public enum HTTPMethod: String {
    case get, post, put, patch, delete
}

public actor APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    private var tokenProvider: TokenProvider

    public init(baseURL: URL, session: URLSession = .shared, tokenProvider: TokenProvider) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = JSONDecoder()
        self.decoder.dateDecodingStrategy = .iso8601
        self.tokenProvider = tokenProvider
    }

    public func send<T: Decodable>(_ endpoint: Endpoint, as type: T.Type) async throws -> T {
        try await sendWithRetry(endpoint, attempt: 0, as: type)
    }
}

Note the actor: requests are serialized through the actor so the token refresh logic doesn’t race when ten concurrent calls all see a 401 at the same time. Real-world auth bugs are almost always concurrency bugs.

Interceptors via request building

Build the URLRequest inside the client so you control the full pipeline:

extension APIClient {
    private func buildRequest(_ endpoint: Endpoint, token: String?) -> URLRequest {
        var components = URLComponents(url: baseURL.appendingPathComponent(endpoint.path),
                                       resolvingAgainstBaseURL: false)!
        if !endpoint.query.isEmpty { components.queryItems = endpoint.query }

        var request = URLRequest(url: components.url!)
        request.httpMethod = endpoint.method.rawValue.uppercased()
        request.httpBody = endpoint.body
        request.timeoutInterval = 30

        // Default headers
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(userAgent(), forHTTPHeaderField: "User-Agent")

        // Auth interceptor
        if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }

        // Endpoint-specific
        endpoint.headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }

        return request
    }
}

Auth refresh + retry on 401

public protocol TokenProvider: Sendable {
    func currentAccessToken() async -> String?
    func refreshTokens() async throws -> String  // returns the new access token
}

extension APIClient {
    private func sendWithRetry<T: Decodable>(_ endpoint: Endpoint, attempt: Int, as type: T.Type) async throws -> T {
        let token = await tokenProvider.currentAccessToken()
        let request = buildRequest(endpoint, token: token)
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }

        switch http.statusCode {
        case 200...299:
            return try decoder.decode(T.self, from: data)
        case 401 where attempt == 0:
            _ = try await tokenProvider.refreshTokens()
            return try await sendWithRetry(endpoint, attempt: 1, as: type)
        case 429, 500...599 where attempt < 3:
            try await Task.sleep(nanoseconds: backoff(attempt: attempt))
            return try await sendWithRetry(endpoint, attempt: attempt + 1, as: type)
        default:
            throw APIError.server(status: http.statusCode, body: data)
        }
    }

    private func backoff(attempt: Int) -> UInt64 {
        // 0.5s, 1s, 2s with ±20% jitter
        let base = pow(2.0, Double(attempt)) * 0.5
        let jitter = base * Double.random(in: -0.2...0.2)
        return UInt64((base + jitter) * 1_000_000_000)
    }
}

public enum APIError: Error {
    case invalidResponse
    case server(status: Int, body: Data)
}

The actor isolation gives you one critical guarantee: when ten concurrent requests get 401, only the first one performs the refresh; the others wait for the actor to be free, then re-read currentAccessToken() and get the fresh one.

Multipart upload

public struct MultipartFormData {
    public struct Part {
        public let name: String
        public let filename: String?
        public let mimeType: String?
        public let data: Data
    }
    public let parts: [Part]
    public let boundary = "Boundary-\(UUID().uuidString)"

    public func encode() -> Data {
        var body = Data()
        for part in parts {
            body.append("--\(boundary)\r\n")
            var disposition = "Content-Disposition: form-data; name=\"\(part.name)\""
            if let filename = part.filename { disposition += "; filename=\"\(filename)\"" }
            body.append("\(disposition)\r\n")
            if let mime = part.mimeType { body.append("Content-Type: \(mime)\r\n") }
            body.append("\r\n")
            body.append(part.data)
            body.append("\r\n")
        }
        body.append("--\(boundary)--\r\n")
        return body
    }
}

private extension Data {
    mutating func append(_ string: String) { append(string.data(using: .utf8)!) }
}

Set Content-Type: multipart/form-data; boundary=\(boundary) on the request and use URLSession.upload(for:from:) for progress tracking via the session delegate.

Download progress + resumable

final class DownloadController: NSObject, URLSessionDownloadDelegate {
    private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
    private var continuations: [Int: CheckedContinuation<URL, Error>] = [:]
    private var progressHandlers: [Int: (Double) -> Void] = [:]

    func download(from url: URL, onProgress: @escaping (Double) -> Void) async throws -> URL {
        let task = session.downloadTask(with: url)
        return try await withCheckedThrowingContinuation { continuation in
            continuations[task.taskIdentifier] = continuation
            progressHandlers[task.taskIdentifier] = onProgress
            task.resume()
        }
    }

    func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask,
                    didWriteData _: Int64, totalBytesWritten written: Int64, totalBytesExpectedToWrite total: Int64) {
        guard total > 0 else { return }
        progressHandlers[downloadTask.taskIdentifier]?(Double(written) / Double(total))
    }

    func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        // Move file out of the temp location before delegate returns
        let target = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        try? FileManager.default.moveItem(at: location, to: target)
        continuations.removeValue(forKey: downloadTask.taskIdentifier)?.resume(returning: target)
    }

    func urlSession(_ s: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error { continuations.removeValue(forKey: task.taskIdentifier)?.resume(throwing: error) }
    }
}

For background downloads that survive app suspension, use URLSessionConfiguration.background(withIdentifier:) and implement urlSessionDidFinishEvents(forBackgroundURLSession:) in your AppDelegate. Background sessions are how Apple Music downloads a 500MB album while your app is closed.

Timeouts & cancellation

The default 60s timeout is too long for interactive UI. Set per-request timeoutInterval = 15 for foreground API calls, longer for uploads. Cancel in-flight requests when the user navigates away:

struct ProfileView: View {
    @State private var profile: Profile?

    var body: some View {
        VStack { /* … */ }
            .task { @MainActor in
                profile = try? await api.send(ProfileEndpoint(), as: Profile.self)
            }
        // .task auto-cancels when the view disappears
    }
}

In the wild

  • Alamofire and Moya are the venerable Swift networking libraries. Both are still useful, but for new projects the ergonomics of async/await over URLSession are good enough that adding a dependency is rarely worth it.
  • Apollo iOS wraps URLSession for GraphQL — same patterns, codegen-driven typed responses.
  • Uber, Lyft, Instagram publish iOS engineering posts confirming they all run custom URLSession wrappers similar to the skeleton above, plus pinning and metrics. Nobody pulls in Alamofire at that scale.
  • The native iOS networking layer used by Safari, Mail, Messages is URLSession (or its lower-level cousin NSURLConnection historically). Apple eats their own dog food.

Common misconceptions

  1. “Reachability tells me if the network works.” It tells you the interface state; it doesn’t tell you the server is reachable. Use it for offline UX hints, never as a gate before a request.
  2. URLSession.shared is fine for everything.” It’s a singleton with default configuration — no custom timeouts, no custom delegates, no background mode. Build your own configured sessions for production code.
  3. “Retrying on every error is good.” No. Retry on 5xx, 429, and network transport errors (URLError.notConnectedToInternet, .timedOut). Don’t retry on 4xx (you’ll just hit the same wall) or on .cancelled (the user wanted to stop).
  4. async/await cancellation propagates automatically.” It propagates through Task. If you use a continuation to bridge to a delegate-based API (like download tasks), you must call task.cancel() when the parent Task is cancelled — withTaskCancellationHandler is your friend.
  5. “Multipart is hard; use a library.” Multipart is 30 lines of code (above). Libraries hide bugs in encoding edge cases; writing it yourself once teaches you what’s actually going over the wire.

Seasoned engineer’s take

The network layer is the single highest-leverage piece of infrastructure in a mobile app. Done well, your app feels fast, recovers from spotty connections, and never asks the user to log in again after a token expires silently. Done poorly, your app is the one users delete on the train.

My recommendation: build the APIClient actor I sketched above, use it everywhere, and resist every temptation to call URLSession.shared.data(for:) directly from anywhere outside it. The first time you need to add Bearer token refresh, you’ll thank past-you for the discipline. The second time you need to add metrics, ditto. The third time you need to swap to a different transport (mocks for tests, certificate-pinned in production), you’ll do it in 20 lines.

The single piece of advice that takes most engineers years to internalize: errors are not edge cases; they are 30% of the runtime of your app on flaky cellular networks. Build error UI before you build happy-path UI. Test offline. Test “WiFi connected but no internet behind the captive portal.” Test “request started on WiFi, completed on cellular.” Most one-star reviews are bugs that would have been caught by testing one of those three.

TIP: Add a debug-only URLSession delegate that logs every request and response with curl-equivalent format and timing. You’ll save days of “why does this work on staging but not production” investigations.

WARNING: Do not put Authorization headers, OAuth tokens, or anything secret into the URL query string. URL query strings are logged by intermediaries, captured in crash reports, and stored in browser history. Always send sensitive values in headers or the body.

Interview corner

Junior: “How do you make a network request in Swift?”

URLSession.shared.data(for: request) returns (Data, URLResponse) as an async throws call. Cast the response to HTTPURLResponse, check the status code, decode the body with JSONDecoder. Wrap in an actor or class so the call sites stay clean.

Mid: “Design an auth interceptor that refreshes tokens on 401.”

Centralize requests through an actor-isolated APIClient. On 401 from the first attempt, await the token-provider’s refreshTokens() (the actor isolation serializes concurrent refresh attempts), then retry once. Don’t retry indefinitely — one attempt, then surface the auth failure to the UI which logs the user out. Refresh tokens themselves are stored in Keychain (Chapter 9.2), never UserDefaults.

Senior: “Design the network layer for a video app that supports large downloads, foreground streaming, offline caching, and token-authenticated APIs.”

Two URLSession instances. One default-config foreground session for JSON APIs, wrapped by the APIClient actor with auth + retry. One background session for large video downloads (URLSessionConfiguration.background) with a delegate that survives app relaunch via handleEventsForBackgroundURLSession. Video streaming uses AVAssetResourceLoader for HLS, separate from URLSession. Cache layer: URLCache for small JSON, custom FileManager-backed cache for videos with LRU eviction and a size cap. Auth flow: tokens in Keychain, refresh through the actor pattern, with notify on 401 events surfaced as os_log for metrics. Add request timing via URLSessionTaskMetrics and ship a P50/P95/error-rate per-endpoint dashboard.

Red flag: “We have a class Networking { static func get(_ url: String, completion: ...) } and every view controller calls it.”

Tells the interviewer the codebase has no centralized control over networking. There’s no path to add auth refresh, retry, pinning, or metrics without rewriting every call site. Refactor is a 6-month project.

Lab preview

Lab 6.3 — Production Network Layer implements the APIClient actor with auth refresh, retry/backoff, pagination, and a full unit-test suite using a mock URLProtocol.


Next: Combine