An iOS Engineering Reference

Learning iOS Networking with URLSession

32 Sections Swift / iOS Long-Form

A deep, hands-on guide to networking on iOS using Apple’s own URLSession stack — no Alamofire, no Moya, no third-party HTTP library. Just the Foundation networking API that ships in every iOS app, driven from modern Swift. We’ll go from building a single request to writing a complete, testable networking layer, through JSON decoding, authentication and token refresh, uploads and downloads, background transfers, certificate pinning, WebSockets, server-sent events, and SwiftUI integration.

URLSession is the foundation of all networking on Apple platforms. Every higher-level library — Alamofire, Moya, Apollo’s transport, even most of what URLSession-based SDKs do — sits on top of the same API you’ll learn here. Understanding it directly gives you three things: the ability to read and debug what those libraries do, the ability to drop to the raw API when a library’s abstractions get in your way, and a genuine understanding of how HTTP, TLS, caching, and connection management actually work on the platform rather than a memorized set of library calls.

This guide assumes you’re comfortable with Swift — closures, optionals, generics, enums with associated values, async/await, and basic concurrency. It does not assume you know HTTP semantics, the details of Codable, or the URLSession API surface. We’ll build all of that up with iOS-specific Swift examples throughout. The guide leans on modern async/await networking as the default — that’s how you should write new code in 2026 — while still covering the completion-handler and delegate APIs you’ll meet in existing code and need for background sessions, streaming, and authentication challenges.

A note on scope: networking is a vast subject touching HTTP, TLS, JSON, concurrency, caching, and security. This guide covers what an iOS engineer actually needs, in depth, and points you to Apple’s documentation and the relevant RFCs for the long tail. By the end you’ll have written a real, testable networking layer and will understand the platform well enough to decide when raw URLSession is the right tool and when a library earns its place.

Table of Contents

  1. What URLSession Is and the Networking Landscape on iOS
  2. URLs, URLComponents, and Building Request URLs
  3. URLRequest: Methods, Headers, and Bodies
  4. Your First Request: dataTask and Completion Handlers
  5. async/await: The Modern URLSession API
  6. Decoding JSON with Codable
  7. Encoding Request Bodies and Sending Data
  8. HTTP Status Codes and Response Validation
  9. Error Handling: URLError, Decoding Errors, and Typed Errors
  10. URLSessionConfiguration: default, ephemeral, and background
  11. Building a Networking Layer, Part 1: Endpoints and Requests
  12. Building a Networking Layer, Part 2: Client, Decoding, and Errors
  13. Authentication: Bearer Tokens, API Keys, and Headers
  14. Token Refresh and Request Retrying
  15. Uploading Data: uploadTask and Multipart Form Data
  16. Downloading Files: downloadTask and Resumable Downloads
  17. Background Sessions and Out-of-Process Transfers
  18. URLSessionDelegate and the Delegate Pattern
  19. Authentication Challenges and Certificate Pinning
  20. Caching with URLCache and Cache Policies
  21. Cookies, Sessions, and HTTPCookieStorage
  22. Streaming Responses with URLSession.bytes
  23. Concurrency: Parallel Requests, Task Groups, and Cancellation
  24. Rate Limiting, Throttling, and Request Coalescing
  25. Reachability and Waiting for Connectivity
  26. Pagination and Loading Large Result Sets
  27. Testing Networking Code with URLProtocol
  28. WebSockets with URLSessionWebSocketTask
  29. Server-Sent Events and Long-Lived Connections
  30. Integrating Networking with SwiftUI
  31. Common Gotchas and Anti-Patterns
  32. Where to Go Deeper

1. What URLSession Is and the Networking Landscape on iOS

Before you write a request, you should understand what URLSession is, what sits around it, and why you’d use it directly rather than reaching for a library. This framing shapes every decision in the rest of the guide.

What URLSession is

URLSession is Foundation’s API for making HTTP (and HTTPS, FTP, file, and data) requests. It’s the official, first-party networking stack on every Apple platform — the thing the system itself uses, the thing every networking library wraps. When your app talks to a server, the bytes ultimately flow through URLSession and the lower-level networking frameworks beneath it (Network.framework, the kernel’s networking stack).

The mental model that makes everything else click: a session is a context that manages a set of related tasks, configured by a configuration. You create a session (or use the shared one), ask it to create tasks for specific requests, and the session runs those tasks — handling the connection, TLS handshake, HTTP protocol, redirects, caching, and cookies for you. You get back data, a response, and possibly an error.

The core types

Four types form the backbone of the API, and knowing their roles up front orients everything:

  • URLSession — the session object that creates and manages tasks. You can use URLSession.shared for simple cases or create your own configured session.
  • URLSessionConfiguration — the settings for a session: timeouts, caching policy, cookie handling, whether it’s a background session, headers applied to every request, cellular access rules. You configure a session once at creation; the configuration is copied and can’t be meaningfully changed afterward.
  • URLSessionTask — a single transfer. It’s an abstract base with concrete subclasses for different kinds of work: URLSessionDataTask (fetch data into memory), URLSessionUploadTask (send a body, often a file), URLSessionDownloadTask (fetch to a file on disk), URLSessionWebSocketTask (a WebSocket connection), and URLSessionStreamTask (a raw TCP/TLS stream, rarely used directly).
  • URLRequest — the description of what to fetch: the URL, HTTP method, headers, body, and per-request policies. You build a request and hand it to the session to create a task.

There’s also URLResponse (and its HTTP-specific subclass HTTPURLResponse), which describes what came back — status code, headers, MIME type, expected length.

Three ways to drive a task

URLSession offers three programming models, layered historically, and you’ll use different ones for different jobs:

  • Completion handlers — the original closure-based API. You create a task with a completion closure that receives (Data?, URLResponse?, Error?), then call resume() to start it. Still ubiquitous in existing code.
  • async/await — the modern API (iOS 15+). let (data, response) = try await session.data(for: request) reads like synchronous code, integrates with structured concurrency and cancellation, and is how you should write new networking code. This guide treats it as the default.
  • Delegates — a URLSessionDelegate (and its sub-protocols) receives callbacks as a transfer progresses: authentication challenges, redirect decisions, progress updates, per-chunk data, completion. Delegates are required for background sessions, fine-grained progress, certificate pinning, and streaming. The completion-handler and async APIs are conveniences built over the delegate model.

Most app code uses async/await. You drop to delegates for the specific advanced features that need them. We’ll cover all three, in that priority.

Why use URLSession directly

Given Alamofire and friends exist and are good, why use the raw API?

  • Understanding. Every networking library wraps URLSession. When something breaks inside Alamofire, or you need a feature it doesn’t expose cleanly, you need to understand the layer underneath. This guide gives you that.
  • Zero dependencies. URLSession is part of the OS — no package, no binary size cost, no version to maintain, security-patched by Apple. Some teams have hard no-dependency constraints; URLSession satisfies them completely.
  • Control. Writing the layer yourself means no abstraction deciding things for you — you control exactly how requests are built, how responses are validated, how errors map, how retries and auth work. For specialized needs this matters.
  • It’s genuinely capable. Modern URLSession with async/await, Codable, and a thin layer of your own is ergonomic and powerful. The gap that made Alamofire essential a decade ago has largely closed.

And the honest counterpoint: a good library still saves real work (request adapters, retriers, multipart helpers, reachability, response validation chains) and has solved edge cases you’ll otherwise rediscover. This guide teaches the foundation so you can make that choice from knowledge — and so that when you do use a library, you understand what it’s doing.

What URLSession is not

To set expectations: URLSession is an HTTP client. It is not a server, not a real-time messaging framework (though it has WebSockets), not a GraphQL or REST framework (it’s the transport those build on), and not a data layer (it fetches bytes; decoding, caching strategy, and persistence are layers you add). It handles the transport — connection, protocol, TLS, redirects, system-level caching and cookies — and hands you data. Everything above that (modeling, decoding, app caching, offline behavior) is yours to build or to layer a library on top of.

A consistent example API

Throughout this guide we’ll talk to a fictional content API at https://api.example.com/v1, exposing resources like articles, users, and comments. Our recurring Swift models:

struct Article: Codable, Identifiable {
    let id: Int
    let title: String
    let body: String
    let authorID: Int
    let publishedAt: Date
    let tags: [String]
}

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
    let avatarURL: URL?
}

struct Comment: Codable, Identifiable {
    let id: Int
    let articleID: Int
    let authorName: String
    let text: String
}

These give us material for every operation: fetching lists and single resources, creating and updating with request bodies, paginating, authenticating, uploading an avatar image, downloading an attachment, and streaming. Reusing one domain keeps the examples coherent the way a single schema does for a database guide.

What to internalize

URLSession is Foundation’s first-party HTTP client and the foundation every networking library wraps. The model is: a session (configured by a configuration) creates tasks (data, upload, download, WebSocket) from requests (URLRequest) and returns responses (URLResponse/HTTPURLResponse). You drive tasks three ways — completion handlers (legacy, common), async/await (modern default), and delegates (for background, progress, pinning, streaming) — and most app code uses async, dropping to delegates for specific advanced needs. Using URLSession directly buys understanding, zero dependencies, and control; a library buys solved edge cases. It’s an HTTP client, not a server or data layer — transport in, data out, with everything above it yours to build.


2. URLs, URLComponents, and Building Request URLs

Every request starts with a URL, and getting the URL right — correctly encoded, with properly escaped query parameters — is the unglamorous foundation that, done wrong, produces some of the most baffling networking bugs. This section covers URL, URLComponents, and the safe way to assemble request URLs from parts.

URL is more fragile than it looks

Swift’s URL type represents a resource locator. The tempting way to make one is the failable string initializer:

let url = URL(string: "https://api.example.com/v1/articles")

This works for simple, already-valid URLs. But it returns nil for any string that isn’t a valid URL, and — critically — it does not percent-encode for you. If you interpolate a value with a space, an ampersand, a non-ASCII character, or any reserved character, you either get nil or a subtly wrong URL:

let query = "swift networking"
let url = URL(string: "https://api.example.com/v1/search?q=\(query)")
// On older OS versions this is nil (space is illegal); even when it parses,
// hand-encoding is error-prone. Never build query strings by interpolation.

The space breaks it. So would an article title with an apostrophe, a search term in Japanese, or a parameter value containing & or =. Hand-building URL strings with interpolated runtime values is the URL equivalent of SQL injection by string concatenation — wrong for correctness and occasionally for security. The fix is URLComponents.

URLComponents: the safe builder

URLComponents decomposes a URL into its parts — scheme, host, path, query items, fragment — and handles percent-encoding correctly when it assembles them back into a URL:

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: "tag", value: "iOS & Swift")   // reserved chars handled for you
]

guard let url = components.url else {
    throw NetworkError.invalidURL
}
// Resulting URL: https://api.example.com/v1/search?q=swift%20networking&limit=20&tag=iOS%20%26%20Swift

URLComponents percent-encodes each query value as it builds the final URL — the space becomes %20, the ampersand in the value becomes %26 so it isn’t mistaken for a parameter separator. You provide values as plain Swift strings; the encoding is handled. This is the correct, safe way to build any URL with query parameters, and you should treat raw string interpolation of query parameters as a bug.

Query items from a dictionary

In real code you often have parameters as a dictionary and want to turn them into query items. A small helper:

extension URLComponents {
    mutating func setQueryItems(_ parameters: [String: String]) {
        // Sorting keys gives deterministic URLs — helpful for caching and tests.
        queryItems = parameters
            .sorted { $0.key < $1.key }
            .map { URLQueryItem(name: $0.key, value: $0.value) }
    }
}

Sorting the keys produces a stable, deterministic URL for the same parameters, which matters for cache keys (Section 20) and for snapshot-style tests. An unsorted dictionary iteration order would vary run to run.

A plus-sign caveat in query encoding

One genuinely surprising gotcha: URLComponents percent-encodes most reserved characters in query values, but by default it does not encode the + character — and many servers interpret + in a query string as a space (a legacy from form encoding). So a value like "a+b" may arrive at the server as "a b". If your values can contain + and the server treats it as a space, you must encode it yourself:

extension URLComponents {
    /// Works around URLComponents not percent-encoding '+' in query values.
    var safeQueryURL: URL? {
        // Replace the literal + in the already-built percent-encoded query.
        let encodedPlus = percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
        var copy = self
        copy.percentEncodedQuery = encodedPlus
        return copy.url
    }
}

This is a small but real source of “the value I sent isn’t the value the server received” bugs. Most APIs are fine; if you hit one that mangles +, this is why. Knowing the gotcha exists saves an afternoon when you meet it.

Building paths safely

For the path portion, prefer setting components.path to a clean string, or use appendingPathComponent on a base URL (which handles slashes for you):

let base = URL(string: "https://api.example.com/v1")!
let articleURL = base.appendingPathComponent("articles").appendingPathComponent("\(articleID)")
// → https://api.example.com/v1/articles/42

appendingPathComponent inserts the / separators correctly, so you don’t end up with // or a missing slash from manual string joining. For path segments that contain user-provided or special characters, be aware they should be percent-encoded too — addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) handles a path segment. In practice most path components are IDs and fixed names where this doesn’t arise, but it’s the same principle: let the API encode, don’t hand-build.

A reusable URL builder for your API

Tying it together, a small builder centralizes your base URL and safe assembly — the seed of the endpoint abstraction we’ll formalize in Section 11:

struct APIURLBuilder {
    static let baseHost = "api.example.com"
    static let basePath = "/v1"

    static func url(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
        var components = URLComponents()
        components.scheme = "https"
        components.host = baseHost
        components.path = basePath + path          // e.g. "/v1" + "/articles"
        components.queryItems = queryItems.isEmpty ? nil : queryItems
        guard let url = components.url else {
            throw NetworkError.invalidURL
        }
        return url
    }
}

enum NetworkError: Error {
    case invalidURL
    // we'll grow this enum throughout the guide
}

// Usage:
let listURL = try APIURLBuilder.url(path: "/articles",
                                    queryItems: [URLQueryItem(name: "page", value: "1")])
let detailURL = try APIURLBuilder.url(path: "/articles/\(articleID)")

Setting queryItems to nil when empty avoids a trailing ? on the URL. Centralizing the host and base path here means a change to the API’s domain or version is a one-line edit, and every URL in your app is built the same safe way.

URL building pitfalls

Interpolating runtime values into URL strings. Spaces, reserved characters, and non-ASCII break the URL or change its meaning. Build with URLComponents and URLQueryItem.

Force-unwrapping URL(string:) on a string with interpolated values. A nil from an unexpected character crashes. Build safely and handle the optional, throwing invalidURL rather than !.

Relying on dictionary order for query items. Iteration order varies, producing non-deterministic URLs that defeat caching and flaky tests. Sort keys.

Assuming + is encoded in query values. URLComponents leaves + literal, and some servers read it as a space. Encode + to %2B if your server mangles it.

Manual slash-joining for paths. Produces // or missing separators. Use appendingPathComponent or set a clean path.

Not percent-encoding user-provided path segments. A path component with special characters needs urlPathAllowed encoding. Let a builder handle it.

What to internalize

A request begins with a URL, and URL(string:) neither validates nor percent-encodes interpolated values, so building URL strings by interpolation is a correctness (and sometimes security) bug. Use URLComponents — set scheme, host, path, and queryItems with URLQueryItems holding plain Swift values, and let it percent-encode correctly when it produces the URL. Sort query keys for deterministic URLs, watch the +-as-space gotcha if your server mangles it, and build paths with appendingPathComponent rather than manual joins. Centralize all of this in a small URL builder so your base host/version live in one place and every URL is assembled the same safe way — the foundation of the endpoint layer to come.


3. URLRequest: Methods, Headers, and Bodies

A URL says where; a URLRequest says what to do there — which HTTP method, what headers, what body, and what per-request policies apply. Constructing requests correctly is the next foundation, and it’s where HTTP semantics start to matter. This section covers building requests with the right method, headers, and body, plus the per-request settings worth knowing.

Creating a request

A URLRequest wraps a URL and adds the HTTP details:

var request = URLRequest(url: url)
request.httpMethod = "GET"

By default a new request is a GET with no custom headers and no body. You mutate it to set the method, add headers, and attach a body. URLRequest is a value type (a struct), so you configure a local var and hand it to the session.

HTTP methods

The httpMethod string is the HTTP verb. The ones you’ll use:

request.httpMethod = "GET"     // fetch a resource; no body, safe & idempotent
request.httpMethod = "POST"    // create a resource or submit data; has a body
request.httpMethod = "PUT"     // replace a resource entirely; has a body, idempotent
request.httpMethod = "PATCH"   // partially update a resource; has a body
request.httpMethod = "DELETE"  // remove a resource; usually no body
request.httpMethod = "HEAD"    // like GET but headers only, no body in response

The semantics matter for correctness and for how the network behaves. GET and HEAD are safe (they don’t change server state) and idempotent (repeating them has the same effect). PUT and DELETE are idempotent but not safe. POST is neither — repeating a POST may create duplicate resources, which is exactly why retrying a failed POST is dangerous (Section 14). Use the verb the API expects; using GET with a body, for instance, is non-standard and some servers ignore the body entirely.

A modeling note: representing the method as a raw string invites typos ("GTE" fails silently as an unrecognized method). In the networking layer (Section 11) we’ll use an enum HTTPMethod: String so the compiler catches mistakes. For now, the raw string shows the mechanism.

Headers

Headers carry metadata about the request: content type, accepted formats, authentication, caching directives, custom API keys. Two methods set them, and the difference matters:

// Sets the header, REPLACING any existing value for that field
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")

// ADDS a value, appending to any existing value for that field (comma-separated)
request.addValue("gzip", forHTTPHeaderField: "Accept-Encoding")

setValue(_:forHTTPHeaderField:) replaces; addValue(_:forHTTPHeaderField:) appends. For almost everything you want setValue — you want exactly one Content-Type, not two. addValue is for the genuine multi-value cases. The common headers:

  • Content-Type — the format of your request body (e.g. application/json, multipart/form-data). Set it whenever you send a body.
  • Accept — the format you want the response in (e.g. application/json). Tells the server what you can handle.
  • Authorization — credentials, typically Bearer <token> (Section 13).
  • Accept-Encoding — compression you accept (gzip); URLSession sets and handles this automatically, so you rarely set it yourself.
  • User-Agent — identifies your client; URLSession sets a default.

Header field names are case-insensitive per HTTP, and URLSession normalizes them. A subtlety: certain headers are “reserved” — URLSession manages Content-Length, Connection, Host, and a few others itself, and setting them manually is ignored or overridden. Don’t fight the session over those; set the application-level headers (Content-Type, Accept, Authorization) and let it manage the transport-level ones.

The request body

For methods that send data (POST, PUT, PATCH), you attach a body. The body is Data:

let payload = ["title": "Hello", "body": "World"]
let bodyData = try JSONSerialization.data(withJSONObject: payload)
request.httpBody = bodyData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

You set httpBody to the encoded Data and set Content-Type to match. We’ll do this properly with Codable and JSONEncoder in Section 7 — JSONSerialization here just shows the mechanism. The key pairing to remember: a body and its Content-Type go together. A JSON body with no Content-Type (or the wrong one) is a common reason a server rejects an otherwise-correct request with a 400 or 415.

There’s also httpBodyStream for streaming a large body from a source rather than holding it all in memory as Data — relevant for large uploads (Section 15). For most requests, httpBody with in-memory Data is what you use.

Per-request policies

URLRequest carries several per-request settings that override the session’s defaults:

request.timeoutInterval = 30                          // seconds before the request times out
request.cachePolicy = .reloadIgnoringLocalCacheData   // bypass the cache for this request
request.allowsCellularAccess = true                    // permit cellular for this request
request.httpShouldHandleCookies = true                 // attach/store cookies for this request
request.networkServiceType = .responsiveData           // hint about the traffic's nature

The most commonly used:

  • timeoutInterval — how long to wait for the request to make progress before failing with a timeout. The default is 60 seconds. Note this is a “no data for N seconds” timeout on the request, distinct from the session configuration’s resource timeout (the total time allowed); we’ll untangle these in Section 10.
  • cachePolicy — whether to use, ignore, or revalidate cached responses for this request (Section 20). The default uses the protocol’s caching rules.
  • allowsCellularAccess — whether this request may use cellular data. Defaults to true. The modern, more expressive controls are allowsExpensiveNetworkAccess and allowsConstrainedNetworkAccess (Section 25).

These per-request overrides are useful for one-off needs (a request that must bypass the cache, or a large download you want to restrict to Wi-Fi), but session-wide policy belongs in the configuration (Section 10), not repeated on every request.

A complete request

Putting method, headers, body, and a policy together for a POST that creates an article:

func makeCreateArticleRequest(title: String, body: String, token: String) throws -> URLRequest {
    let url = try APIURLBuilder.url(path: "/articles")
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    request.timeoutInterval = 30

    let payload = ["title": title, "body": body]
    request.httpBody = try JSONSerialization.data(withJSONObject: payload)
    return request
}

This request has everything a server needs: the right verb, a JSON body with a matching Content-Type, an Accept declaring the desired response format, an Authorization header for auth, and a sensible timeout. This is the anatomy of a real request, and the shape we’ll formalize and make type-safe in the networking layer.

URLRequest pitfalls

Sending a body without a matching Content-Type. The server may reject it (400/415) or misparse it. Always pair httpBody with the correct Content-Type.

Using addValue where you meant setValue. Appends a second value, producing a malformed multi-value header (two Content-Types). Use setValue to replace; addValue only for genuine multi-value headers.

Setting reserved transport headers like Content-Length or Host. URLSession manages these; your value is ignored or overridden. Set application headers only.

Representing the HTTP method as a hand-typed string everywhere. Typos fail silently. Use an enum HTTPMethod: String in your layer.

Confusing request.timeoutInterval with the session’s resource timeout. They measure different things (per-request progress vs. total resource time). Know which you’re setting (Section 10).

Attaching a body to a GET. Non-standard; many servers ignore it. Put parameters in the query string for GET, in the body for POST/PUT/PATCH.

What to internalize

A URLRequest adds HTTP semantics to a URL: the httpMethod verb (mind safe/idempotent semantics — POST is neither, which makes retrying it risky), headers set with setValue (replace) versus addValue (append, rarely wanted), and a Data body for POST/PUT/PATCH. Always pair a body with the correct Content-Type, and declare Accept for the response format. Per-request policies (timeoutInterval, cachePolicy, cellular access) override session defaults for one-off needs, but session-wide policy belongs in the configuration. Let URLSession manage transport headers like Content-Length and Host. Model the method as an enum in your real layer to make typos impossible — the type-safe request construction we’ll build next.


4. Your First Request: dataTask and Completion Handlers

The completion-handler API is the original way to run a URLSession task, and although async/await (next section) is what you’ll write for new code, the closure-based API is everywhere in existing codebases, in older tutorials, and underneath many libraries. Understanding it teaches the underlying model — tasks, resume(), the three-part result, and the threading rules — that the async API quietly builds on. This section walks through it carefully.

The shape of a data task

URLSession.shared.dataTask(with:completionHandler:) creates a task that fetches data into memory and calls your closure when it finishes:

let url = try APIURLBuilder.url(path: "/articles")
let task = URLSession.shared.dataTask(with: url) { data, response, error in
    // This closure runs when the request completes (success or failure).
    if let error = error {
        print("Request failed: \(error)")
        return
    }
    guard let data = data else {
        print("No data")
        return
    }
    print("Received \(data.count) bytes")
}
task.resume()   // tasks start suspended — you MUST call resume() to begin

Two things define this API. First, the completion closure receives three optionals: (Data?, URLResponse?, Error?). Second — and this trips up everyone once — a task is created suspended and does nothing until you call resume(). Forgetting resume() is the classic “my request never fires and there’s no error” bug. The task object also lets you cancel() or suspend() it.

The three-part result and its ambiguity

The (Data?, URLResponse?, Error?) signature is a holdover from Objective-C and is more permissive than Swift’s type system would like. In principle any combination could arrive, but in practice it’s one of two outcomes: either error is non-nil (the request failed at the transport level — no connection, timeout, cancelled, TLS failure) and data/response are nil, or error is nil and you have a response and (usually) data. The defensive unwrapping pattern handles both:

URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error as? URLError {
        // Transport-level failure: offline, timeout, cancelled, DNS, TLS, etc.
        // Note: a 404 or 500 is NOT an error here — see below.
        return
    }
    guard let httpResponse = response as? HTTPURLResponse else {
        // Not an HTTP response — shouldn't happen for http(s) URLs
        return
    }
    guard (200...299).contains(httpResponse.statusCode) else {
        // The request succeeded at the transport level but the server
        // returned an error status. This must be checked explicitly.
        return
    }
    if let data = data {
        // success path
    }
}.resume()

The single most important gotcha: HTTP errors are not Swift errors

Here is the thing that surprises every newcomer and bites every experienced developer at least once: a server returning 404, 401, or 500 does not populate the error parameter. From URLSession’s perspective, the request succeeded — it connected, sent the request, and received a complete response. That the response says “not found” is application-level information carried in the statusCode, not a transport error. The error parameter is non-nil only for failures to complete the HTTP transaction at all: no network, timeout, cancellation, a TLS problem, a DNS failure.

The practical consequence: you must check httpResponse.statusCode yourself. Code that only checks error and assumes a nil error means success will happily treat a 500 Internal Server Error (with an HTML error page as its data) as a successful response and try to decode it as your expected JSON — producing a confusing decoding error far from the real cause. Validating the status code is not optional; it’s a required step in every response handler. We’ll formalize this in Section 8.

The threading rule

The completion closure does not run on the main thread. It’s called on a queue managed by the session (a background queue by default). This matters because UI updates must happen on the main thread. So if your completion handler updates UI, you must hop to the main actor:

URLSession.shared.dataTask(with: url) { data, response, error in
    // ...validate and decode (off the main thread — good, decoding is work)...
    let articles = decodeArticles(from: data)
    DispatchQueue.main.async {
        self.articles = articles   // UI-affecting state: must be on main
    }
}.resume()

Doing the decoding work off the main thread is actually desirable — you don’t want to block the UI parsing a large JSON payload. The rule is just that the results, when they touch UI or main-actor state, get dispatched back to main. Forgetting this leads to UI updates from a background thread, which in modern UIKit/SwiftUI is a hard error (a purple runtime warning at best, a crash or corruption at worst). This main-thread dance is one of the things async/await makes cleaner.

A complete completion-handler fetch

Assembling the pieces into a function that fetches and decodes articles, with proper validation and threading:

func fetchArticles(completion: @escaping (Result<[Article], Error>) -> Void) {
    let url: URL
    do { url = try APIURLBuilder.url(path: "/articles") }
    catch { completion(.failure(error)); return }

    var request = URLRequest(url: url)
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    URLSession.shared.dataTask(with: request) { data, response, error in
        // 1. Transport error?
        if let error = error {
            completion(.failure(error)); return
        }
        // 2. Valid HTTP response with a success status?
        guard let http = response as? HTTPURLResponse else {
            completion(.failure(NetworkError.invalidResponse)); return
        }
        guard (200...299).contains(http.statusCode) else {
            completion(.failure(NetworkError.httpError(status: http.statusCode))); return
        }
        // 3. Data present?
        guard let data = data else {
            completion(.failure(NetworkError.noData)); return
        }
        // 4. Decode
        do {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            let articles = try decoder.decode([Article].self, from: data)
            completion(.success(articles))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

enum NetworkError: Error {
    case invalidURL, invalidResponse, noData
    case httpError(status: Int)
}

Wrapping the outcome in a Result<[Article], Error> is the idiomatic way to model “success with value or failure with error” in completion handlers — it collapses the three-optional ambiguity into one clean either/or that the caller switches on. Note the four sequential checks: transport error, HTTP response validity, status code, data presence, then decode. Each is a real failure mode the completion-handler API forces you to handle explicitly. The caller:

fetchArticles { result in
    DispatchQueue.main.async {
        switch result {
        case .success(let articles): self.articles = articles
        case .failure(let error): self.showError(error)
        }
    }
}

Why the async API supersedes this

Look at fetchArticles above and notice the friction: the @escaping completion closure, the manual Result wrapping, the early-return-on-each-failure dance, the caller’s own dispatch to main, and the ever-present risk of forgetting to call completion on some path (a leaked callback that hangs the caller forever). Every one of those is a place to introduce a bug. The async/await API, next section, removes all of them: the request reads top to bottom, errors throw instead of branching into a Result, cancellation is built in, and there’s no escaping closure to mismanage. You’ll still meet completion handlers in existing code and need them for some delegate-based work, but for new code, async is strictly better. Learning the completion API here was about understanding the model — tasks, resume(), the three-part result, status-code validation, threading — all of which still applies underneath async.

Completion handler pitfalls

Forgetting resume(). The task never starts and no callback fires — a silent hang. Always resume().

Treating a nil error as success. A 404 or 500 has a nil error. Check httpResponse.statusCode explicitly; transport success isn’t application success.

Updating UI from the completion closure directly. It runs off the main thread. Dispatch UI/main-actor updates to DispatchQueue.main.

Decoding without checking the status code first. A 500’s error-page body fails to decode as your model, masking the real cause. Validate status before decoding.

Forgetting to call completion on an early-return path. The caller’s closure never runs and the operation hangs forever. Ensure every path calls completion exactly once.

Retaining self strongly in the closure. Can create retain cycles or keep objects alive past their time. Capture [weak self] where appropriate and handle the nil case.

What to internalize

The completion-handler dataTask is the original URLSession model and still pervasive: it returns (Data?, URLResponse?, Error?), the task starts suspended so you must call resume(), and the closure runs off the main thread. The defining gotcha is that HTTP error statuses (404, 500) are not Swift errorserror is non-nil only for transport failures, so you must validate httpResponse.statusCode yourself before trusting the data, or you’ll try to decode an error page as your model. Wrap outcomes in a Result, run all paths to a single completion call, and dispatch UI updates back to the main thread. All of this — tasks, resume(), status validation, threading — remains true underneath the async/await API, which removes the closure friction and is what you should write next.


5. async/await: The Modern URLSession API

Since iOS 15, URLSession has first-class async/await methods that make networking read like straight-line code while integrating with structured concurrency, cancellation, and Swift’s error handling. This is the API you should reach for in all new code. This section covers the async methods, how they transform the completion-handler patterns from Section 4, and the concurrency benefits that come for free.

The async data methods

The headline method is data(for:), which takes a URLRequest and returns the data and response, throwing on transport failure:

let (data, response) = try await URLSession.shared.data(for: request)

That single line replaces the entire create-task / resume / completion-closure dance. There’s a sibling that takes a bare URL for the simplest GET:

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

Both await until the response arrives (or the request fails, in which case they throw), then hand back a tuple of non-optional Data and non-optional URLResponse. Notice what’s gone: the optionals collapse (you either get the values or an error is thrown), there’s no resume() (awaiting starts the work), and there’s no escaping closure to mismanage. The code is linear.

Rewriting the fetch with async

Here’s fetchArticles from Section 4, rewritten with async. Compare it to the completion-handler version:

func fetchArticles() async throws -> [Article] {
    let url = try APIURLBuilder.url(path: "/articles")
    var request = URLRequest(url: url)
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }
    guard (200...299).contains(http.statusCode) else {
        throw NetworkError.httpError(status: http.statusCode)
    }

    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    return try decoder.decode([Article].self, from: data)
}

Every improvement from Section 4’s friction is visible. The transport error is handled by try (a failure throws, propagating to the caller). There’s no Result wrapping — the function simply returns [Article] or throws. The four sequential guard-and-return checks become clean guard ... throw statements with no callback to remember. There’s no @escaping, no manual completion call, no risk of forgetting a path. The function reads top to bottom like synchronous code, but it’s fully asynchronous. The caller is equally clean:

do {
    let articles = try await fetchArticles()
    // use articles
} catch {
    // handle error
}

The status-code gotcha persists

One thing async does not change: a 404 or 500 still doesn’t throw. data(for:) throws only for transport-level failures, exactly like the completion API’s error parameter. A server error status comes back as a successful (data, response) tuple with a 4xx/5xx statusCode. So the status-code validation is still mandatory — you can see it’s still there in the rewritten function. Async makes the request ergonomic; it does not relieve you of validating the HTTP status. This is worth stressing because the clean try await can lull you into thinking “if it didn’t throw, it worked,” which is false for HTTP statuses.

Cancellation comes for free

This is where async earns its keep beyond syntax. Swift’s structured concurrency has built-in cooperative cancellation, and URLSession’s async methods participate. When the Task running your network call is cancelled, the in-flight request is cancelled too, and the await throws a CancellationError (or a URLError with .cancelled). You don’t wire anything up:

let loadTask = Task {
    let articles = try await fetchArticles()
    await MainActor.run { self.articles = articles }
}

// Later — e.g., the user navigates away, or a new search supersedes this one:
loadTask.cancel()   // the in-flight URLSession request is cancelled automatically

With completion handlers, cancellation meant holding the URLSessionTask reference and calling cancel() on it manually, then handling the cancelled callback. With async, cancelling the Task cancels the network work. This is enormously valuable for the common iOS pattern of “the view disappeared, stop loading” or “the user typed a new query, abandon the old search.” We’ll go deep on cancellation and parallelism in Section 23; the point here is that it’s a property of the async API you get without effort.

A related habit: in a loop or before expensive work, you can check try Task.checkCancellation() to bail early, and you should design long networking sequences to be cancellation-aware. But for a single data(for:) call, cancellation is automatic.

Hopping to the main actor for UI

Async also cleans up the threading story. Your async function runs on whatever executor the surrounding context provides — often a background context for the network and decoding work, which is what you want. When you need to update UI or main-actor-isolated state, you hop explicitly:

@MainActor
final class ArticlesViewModel {
    private(set) var articles: [Article] = []

    func load() async {
        do {
            let fetched = try await fetchArticles()   // network + decode, off the main actor
            articles = fetched                          // back on the main actor (the class is @MainActor)
        } catch {
            // handle
        }
    }
}

Because the view model is @MainActor, assignments to articles are guaranteed to be on the main thread, while the await fetchArticles() suspends and does its work without blocking the main thread. No DispatchQueue.main.async, no manual thread hopping — the actor isolation handles it. This is the modern, recommended structure, and we’ll build it out fully in the SwiftUI section (Section 30).

Bridging old completion APIs to async

You’ll sometimes have a completion-handler API (a third-party SDK, legacy code) you want to call from async code. withCheckedThrowingContinuation bridges them — it suspends until you resume the continuation with a result or error:

func legacyFetch() async throws -> [Article] {
    try await withCheckedThrowingContinuation { continuation in
        fetchArticles { result in          // the old completion-handler version
            continuation.resume(with: result)
        }
    }
}

The continuation must be resumed exactly once — zero times hangs forever, twice crashes. The checked variant detects misuse in debug builds. This bridge lets you wrap any callback-based API in a clean async surface, useful when migrating a codebase incrementally or wrapping an SDK that hasn’t adopted async. It’s also how you’d expose a delegate-driven transfer (Sections 17-18) as an async call.

Availability and the deployment-target note

The async URLSession methods require iOS 15+. If you must support iOS 13-14, you either use the completion API or backport with a continuation wrapper around dataTask. Given iOS 15 is the floor for most apps in 2026, you can use the async API directly. (There were early versions where the async methods existed only on iOS 15.0+ with some methods arriving slightly later; for current deployment targets this is a non-issue.) Throughout the rest of this guide, async is the default, and we drop to completion handlers or delegates only where a feature requires it.

async pitfalls

Assuming a non-throwing await means HTTP success. It only means transport success. Validate statusCode; a 500 returns normally.

Blocking the main thread by mis-isolating work. If you accidentally run the network call on the main actor without suspension benefits, you can still jank. Let async functions run off the main actor and hop back only for UI state.

Forgetting that cancellation throws. A cancelled task’s await throws CancellationError/URLError.cancelled; treat it as a normal, expected outcome (not a user-facing error), often by simply doing nothing.

Resuming a continuation zero or two times. Hangs forever or crashes. Resume a bridged continuation exactly once on every path.

Spawning detached, unstructured Tasks everywhere. Loses structured cancellation and can leak work. Prefer structured concurrency (Section 23) and tie tasks to a lifecycle.

Ignoring Task lifecycle in views. A Task started in a view that isn’t tied to the view’s lifetime keeps running after the view is gone. Use .task { } in SwiftUI (Section 30), which cancels automatically.

What to internalize

Since iOS 15, try await URLSession.shared.data(for: request) returns a non-optional (Data, URLResponse) and throws on transport failure — replacing the entire task/resume/completion dance with linear, top-to-bottom code that returns a value or throws, with no escaping closure to mismanage. The HTTP-status gotcha persists: a non-throwing await means transport success, so you still must validate statusCode. The real wins beyond syntax are built-in cooperative cancellation (cancelling the surrounding Task cancels the in-flight request and throws) and clean actor-based threading (run the call off the main actor, hop back for UI state, no manual DispatchQueue). Bridge legacy completion APIs with withCheckedThrowingContinuation (resumed exactly once). Use async for all new networking; drop to completion handlers or delegates only where a feature demands it.


6. Decoding JSON with Codable

Almost every API speaks JSON, and turning that JSON into Swift types is where you spend a surprising amount of networking effort. Swift’s Codable makes the common case nearly automatic, but real APIs have snake_case keys, custom date formats, nested structures, optional and missing fields, and occasional type surprises that require knowing JSONDecoder’s strategies and the customization hooks. This section covers decoding from the simple case through the messy realities.

The simple case: matching keys

When your Swift property names exactly match the JSON keys and the types line up, decoding is one line. Given JSON like {"id": 1, "name": "Ada", "email": "ada@example.com"} and a matching Codable struct:

struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

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

Codable (the combination of Decodable and Encodable) synthesizes the decoding logic at compile time from the property names and types. As long as the JSON keys match the property names and the JSON value types are compatible, this just works. The synthesized conformance is why you rarely write decoding code by hand — the compiler does it.

Snake_case keys: the keyDecodingStrategy

Most JSON APIs use snake_case keys (author_id, published_at), while Swift convention is camelCase (authorID, publishedAt). Rather than annotate every property, set a decoding strategy that converts automatically:

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// Now JSON "author_id" maps to Swift "authorId", "published_at" to "publishedAt", etc.
let article = try decoder.decode(Article.self, from: data)

.convertFromSnakeCase transforms each JSON key from snake_case to camelCase before matching it to a property. This handles the overwhelmingly common case with no per-property work. There’s a subtlety: the conversion is purely mechanical, so author_id becomes authorId (lowercase d), not authorID. If your property is authorID, the automatic conversion won’t match it — you’d name the property authorId, or use explicit CodingKeys (below). Pick a convention and be consistent; mixing .convertFromSnakeCase with hand-rolled all-caps acronyms causes mismatches.

Custom key mapping with CodingKeys

When property names can’t or shouldn’t mirror the JSON — a key with an awkward name, an acronym you want capitalized, a property you want named differently — declare a CodingKeys enum that maps each property to its JSON key:

struct Article: Codable {
    let id: Int
    let title: String
    let authorID: Int        // we want capital ID
    let publishedAt: Date

    enum CodingKeys: String, CodingKey {
        case id
        case title
        case authorID = "author_id"     // explicit mapping
        case publishedAt = "published_at"
    }
}

The CodingKeys enum (a String-backed CodingKey) lets you specify the exact JSON key for each property. Cases without a raw value use the property name as-is. This is the precise, explicit alternative to .convertFromSnakeCase — more verbose, but unambiguous and necessary when the automatic conversion doesn’t fit. You can also use it to omit a property from coding (leave it out of the enum) or to decode only a subset of the JSON. When in doubt about a tricky API, explicit CodingKeys is the reliable choice.

Dates: the dateDecodingStrategy

Dates are the single most common decoding headache because JSON has no date type — dates arrive as strings or numbers in some format you must tell the decoder about. JSONDecoder has a dateDecodingStrategy:

let decoder = JSONDecoder()

// ISO 8601, e.g. "2026-01-15T10:30:00Z" — the most common modern API format
decoder.dateDecodingStrategy = .iso8601

// Unix timestamp in seconds, e.g. 1736937000
decoder.dateDecodingStrategy = .secondsSince1970

// Unix timestamp in milliseconds
decoder.dateDecodingStrategy = .millisecondsSince1970

// A custom format the built-ins don't cover
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")   // critical — see below
formatter.timeZone = TimeZone(identifier: "UTC")
decoder.dateDecodingStrategy = .formatted(formatter)

// Fully custom logic (e.g. an API that sometimes sends ISO, sometimes epoch)
decoder.dateDecodingStrategy = .custom { decoder in
    let container = try decoder.singleValueContainer()
    let string = try container.decode(String.self)
    // parse `string` however needed, return a Date or throw
    return ISO8601DateFormatter().date(from: string) ?? Date.distantPast
}

The .iso8601 strategy handles the most common modern format. For epoch timestamps use the secondsSince1970/millisecondsSince1970 variants (matching how you’d store dates in SQLite, incidentally). For anything else, .formatted with a DateFormatter or .custom with a closure handles it. The non-negotiable detail with a custom DateFormatter: set locale to en_US_POSIX. Without it, a user whose device is in a non-Gregorian calendar or unusual locale can have date parsing silently fail or misbehave, because DateFormatter respects the device locale by default. en_US_POSIX forces a fixed, predictable interpretation of the format string — this is a real, intermittent, hard-to-reproduce bug if you skip it.

Optional and missing keys

Swift optionals model “this key might be absent or null.” If a property is Optional, the decoder tolerates a missing key or a JSON null, setting the property to nil:

struct User: Codable {
    let id: Int
    let name: String
    let avatarURL: URL?    // absent key or JSON null → nil, no error
}

A non-optional property, by contrast, requires its key — a missing key throws keyNotFound. This is the lever you use to model “required vs. optional” fields: make a property optional precisely when the API may omit it. A common mistake is making everything non-optional and then getting decoding failures whenever the API omits a field you assumed was always present. When unsure whether a field is guaranteed, optional is the safer default. (For providing a default instead of nil when a key is missing, you write a custom init(from:) and use decodeIfPresent with a fallback — shown next.)

Custom init(from:) for full control

When the synthesized decoding isn’t enough — defaults for missing keys, transforming values, flattening nested structures, handling fields that change type — you implement init(from decoder:) yourself:

struct Article: Codable {
    let id: Int
    let title: String
    let tags: [String]
    let viewCount: Int

    enum CodingKeys: String, CodingKey {
        case id, title, tags, viewCount
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        title = try container.decode(String.self, forKey: .title)
        // Missing tags → empty array instead of a decoding failure
        tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
        // Missing viewCount → 0
        viewCount = try container.decodeIfPresent(Int.self, forKey: .viewCount) ?? 0
    }
}

container.decode requires the key (throws if missing); container.decodeIfPresent returns an optional you can default with ??. A hand-written init(from:) is the escape hatch for any decoding the compiler can’t synthesize — providing defaults, coercing a number that’s sometimes a string, computing a derived property, or reshaping awkward JSON into a clean Swift type. It’s more code, so reserve it for the properties or types that genuinely need it; let synthesis handle the rest.

Nested and wrapped responses

APIs frequently wrap the payload you care about in an envelope: {"data": {...}, "meta": {...}}, or {"results": [...], "next_page": 2}. Model the envelope as its own Codable type and decode that, reaching in for the part you want:

struct ArticleListResponse: Codable {
    let results: [Article]
    let nextPage: Int?
    let total: Int

    enum CodingKeys: String, CodingKey {
        case results, total
        case nextPage = "next_page"
    }
}

let response = try decoder.decode(ArticleListResponse.self, from: data)
let articles = response.results
let nextPage = response.nextPage   // drives pagination (Section 26)

Modeling the envelope explicitly is cleaner than trying to dig into raw JSON, and it captures useful metadata (pagination cursors, totals) as typed properties. For deeply nested JSON where you only want a buried value, you can flatten in a custom init(from:) using nested containers (container.nestedContainer(keyedBy:forKey:)), but a well-modeled envelope type is usually clearer.

Configure the decoder once

A practical point: the decoder’s strategies (keyDecodingStrategy, dateDecodingStrategy) are configuration you want consistent across your whole app. Create and configure one decoder and reuse it, rather than configuring a fresh decoder at every call site (where you’ll inevitably forget the date strategy somewhere and get a mystifying failure):

extension JSONDecoder {
    static let api: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }()
}

// Everywhere: try JSONDecoder.api.decode(Article.self, from: data)

A single configured decoder is the right home for these settings, and the networking layer (Section 12) will own one so decoding is consistent and configured in exactly one place.

Decoding pitfalls

Forgetting the dateDecodingStrategy. The default expects a Double (seconds since reference date), so an ISO string or epoch fails to decode. Set the strategy to match the API.

Omitting en_US_POSIX on a custom DateFormatter. Date parsing breaks intermittently for users in unusual locales/calendars. Always set locale = Locale(identifier: "en_US_POSIX") for fixed-format parsing.

Making fields non-optional that the API may omit. A missing key throws keyNotFound for non-optionals. Use optionals (or decodeIfPresent with defaults) for fields that aren’t guaranteed.

Mixing .convertFromSnakeCase with all-caps acronym properties. author_id converts to authorId, not authorID — a mismatch. Match the convention or use explicit CodingKeys.

Configuring a fresh decoder per call site. You’ll forget a strategy somewhere, producing inconsistent decoding. Configure one shared decoder.

Decoding before validating the HTTP status. A 500’s error-page body fails to decode as your model, hiding the real cause. Validate status (Section 8), then decode.

What to internalize

Codable synthesizes JSON decoding from your property names and types, so the matching-key case is one line. For real APIs, set JSONDecoder strategies: .convertFromSnakeCase for snake_case keys (minding that it produces authorId, not authorID) and a dateDecodingStrategy (.iso8601, the epoch variants, or .formatted/.custom) — and always set en_US_POSIX on a custom DateFormatter to avoid intermittent locale bugs. Use explicit CodingKeys for precise key mapping, optionals (or decodeIfPresent with ?? defaults in a custom init(from:)) for fields the API may omit, and dedicated envelope types for wrapped responses (capturing pagination metadata as you go). Configure one shared decoder so strategies are consistent app-wide — the decoder your networking layer will own.


7. Encoding Request Bodies and Sending Data

Reading data is half of networking; the other half is sending it. When you create or update a resource, you encode a Swift value into a request body — usually JSON — and attach it with the right Content-Type. This section covers JSONEncoder, its strategies (the mirror image of decoding), building bodies for POST/PUT/PATCH, and the common shapes of request payloads.

Encoding a Codable value to JSON

JSONEncoder is the counterpart to JSONDecoder. Give it a Encodable value and it produces Data:

struct CreateArticleBody: Encodable {
    let title: String
    let body: String
    let tags: [String]
}

let payload = CreateArticleBody(title: "Hello", body: "World", tags: ["swift", "iOS"])
let encoder = JSONEncoder()
let bodyData = try encoder.encode(payload)
// bodyData is JSON: {"title":"Hello","body":"World","tags":["swift","iOS"]}

Just as Decodable is synthesized for reading, Encodable is synthesized for writing — the encoder turns your struct’s properties into JSON keys and values automatically. You define a type describing exactly the body the API expects, populate it, and encode. Defining a dedicated request-body type (rather than reusing your full model) is good practice: the body for creating an article often differs from the article you read back (no id yet, no publishedAt, maybe no authorID since the server infers it from auth). A focused CreateArticleBody expresses precisely what you send.

Encoding strategies mirror decoding

The encoder has the same strategy knobs as the decoder, applied in reverse — converting camelCase to snake_case keys and formatting dates:

let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase   // authorID → author_id
encoder.dateEncodingStrategy = .iso8601             // Date → "2026-01-15T10:30:00Z"
encoder.outputFormatting = [.sortedKeys]            // deterministic key order (nice for tests/caching)

.convertToSnakeCase is the inverse of .convertFromSnakeCase — your camelCase properties become snake_case JSON keys to match the API. .dateEncodingStrategy mirrors the decoder’s, so use the same format you decode with (.iso8601 if that’s what the API speaks). .outputFormatting controls the JSON’s appearance: .sortedKeys for deterministic output (helpful in tests and for stable cache keys), .prettyPrinted for human-readable JSON (useful when logging, wasteful on the wire — don’t ship pretty-printed bodies). As with decoding, configure one shared encoder for app-wide consistency:

extension JSONEncoder {
    static let api: JSONEncoder = {
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }()
}

Attaching the body to a request

With the encoded Data, you set the request’s httpBody and the matching Content-Type header:

func makeCreateRequest(_ payload: CreateArticleBody, token: String) throws -> URLRequest {
    let url = try APIURLBuilder.url(path: "/articles")
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    request.httpBody = try JSONEncoder.api.encode(payload)
    return request
}

The pairing of httpBody and Content-Type: application/json is what tells the server “here is a JSON body, parse it as such.” Omitting or mismatching the Content-Type is a leading cause of a server rejecting a well-formed body. The encode can throw (if the value isn’t actually encodable — rare for well-formed Codable types), so it’s in a throws function.

Sending the request and reading the response

For an upload of in-memory Data, the async upload(for:from:) method is the idiomatic choice — it’s like data(for:) but sends a body:

func createArticle(_ payload: CreateArticleBody, token: String) async throws -> Article {
    let request = try makeCreateRequest(payload, token: token)
    let (data, response) = try await URLSession.shared.upload(for: request, from: request.httpBody ?? Data())

    guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse }
    guard (200...299).contains(http.statusCode) else {
        throw NetworkError.httpError(status: http.statusCode)
    }
    return try JSONDecoder.api.decode(Article.self, from: data)
}

A subtlety worth understanding: you can send a body two ways. You can set request.httpBody and call data(for:) — URLSession sends the body. Or you can call upload(for:from:) passing the body data separately. They overlap for in-memory data, but upload is the API designed for sending bodies, supports progress reporting via delegates, and is required for background uploads (Section 17). For simple JSON bodies, either works; many developers just set httpBody and use data(for:). We’ll use upload explicitly when uploading files in Section 15. The example above shows upload; setting httpBody and calling data(for:request) would be equivalent here. Typically a create returns the created resource (with its server-assigned id and timestamps), which you decode and return.

Common body shapes

Beyond a single object, request bodies come in a few common shapes. A simple key-value object, as above, is the most frequent. An array of objects for batch operations:

struct TagUpdate: Encodable { let articleID: Int; let tags: [String] }
let batch = [TagUpdate(articleID: 1, tags: ["a"]), TagUpdate(articleID: 2, tags: ["b"])]
request.httpBody = try JSONEncoder.api.encode(batch)   // JSON array

A PATCH with only the changed fields, where you want to send only what changed (not the whole object). This is where optionals and encoding get subtle: by default, JSONEncoder omits a nil optional from the output (it doesn’t emit "field": null). That’s usually what you want for PATCH — present fields are updates, absent fields are untouched:

struct ArticlePatch: Encodable {
    let title: String?    // nil → omitted from JSON → server leaves title unchanged
    let body: String?     // present → server updates body
}
let patch = ArticlePatch(title: nil, body: "Revised text")
// Encodes to just {"body":"Revised text"} — title is omitted, not null

This default (omit nil) is convenient for PATCH semantics, but be aware of it: if your API distinguishes “field absent (don’t change)” from “field explicitly null (clear it)”, the default omit-nil behavior can’t express “set this to null.” For that you need a custom encode(to:) that encodes an explicit null, or a wrapper type. Most APIs use omit-nil PATCH, so the default fits, but know the limitation.

Form-URL-encoded bodies

Not every API takes JSON. Some (especially OAuth token endpoints and older APIs) expect application/x-www-form-urlencoded bodies — the same key=value&key=value format as a query string, in the body. You build these with URLComponents and a different Content-Type:

func makeFormRequest(_ url: URL, parameters: [String: String]) -> URLRequest {
    var components = URLComponents()
    components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
    let bodyString = components.percentEncodedQuery ?? ""

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
    request.httpBody = bodyString.data(using: .utf8)
    return request
}
// Used for, e.g., an OAuth token request:
// makeFormRequest(tokenURL, parameters: ["grant_type": "refresh_token", "refresh_token": token])

Here URLComponents does the percent-encoding for the form body just as it does for a query string, and the Content-Type is application/x-www-form-urlencoded. You’ll meet this format most often at OAuth/auth endpoints (Sections 13-14). Multipart form data (for file uploads) is a third body format we’ll build carefully in Section 15.

Encoding pitfalls

Forgetting the Content-Type for the body. The server can’t tell how to parse it; expect 400/415. Always pair httpBody with the right Content-Type.

Reusing the full read model as the request body. Sends fields the create/update doesn’t want (a client-set id, server-managed timestamps). Define focused body types.

Mismatched date strategy between encoder and decoder. You send a format the server (and your own decoder) doesn’t expect. Use the same date format both directions.

Assuming nil optionals encode as null. JSONEncoder omits nil by default. Fine for omit-nil PATCH, but it can’t express explicit null without custom encode(to:).

Shipping .prettyPrinted bodies. Wastes bandwidth with whitespace. Pretty-print only for logging; send compact JSON.

Using JSON where the endpoint wants form-encoding. OAuth/token endpoints often require x-www-form-urlencoded. Match the endpoint’s expected format.

What to internalize

Sending data mirrors reading it: define a focused Encodable body type (not your full read model), encode it with a shared JSONEncoder configured with .convertToSnakeCase and a matching dateEncodingStrategy, set it as httpBody with Content-Type: application/json, and send via upload(for:from:) (designed for bodies, supports progress and background) or by setting httpBody and calling data(for:). A create typically returns the created resource to decode. Know that JSONEncoder omits nil optionals by default — convenient for omit-nil PATCH semantics but unable to express explicit null without custom encoding. Some endpoints (OAuth especially) want application/x-www-form-urlencoded, which you build with URLComponents percent-encoding and the matching content type. Always pair a body with its correct Content-Type.


8. HTTP Status Codes and Response Validation

The status code is the server’s verdict on your request, and as established in Sections 4-5, URLSession does not treat error statuses as Swift errors — you must inspect and validate them yourself. Doing this well means understanding what the status ranges mean, extracting the structured error bodies APIs return, and centralizing validation so it happens consistently on every response. This section builds a robust validation step.

The status code ranges

HTTP status codes group into ranges, each with a meaning:

  • 1xx (Informational) — rarely seen by app code; handled by the protocol layer.
  • 2xx (Success) — the request succeeded. 200 OK (general success), 201 Created (a POST created a resource), 204 No Content (success with no body, common for DELETE).
  • 3xx (Redirection) — the resource is elsewhere. URLSession follows redirects automatically by default (you can intercept via delegate, Section 18), so app code usually doesn’t see these.
  • 4xx (Client Error)your request was wrong. 400 Bad Request (malformed), 401 Unauthorized (no/invalid auth), 403 Forbidden (authenticated but not allowed), 404 Not Found, 409 Conflict, 422 Unprocessable Entity (validation failed), 429 Too Many Requests (rate limited).
  • 5xx (Server Error) — the server failed. 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout.

The practical distinction that drives your handling: a 4xx generally means don’t retry the same request (it’ll fail the same way — fix the request, refresh the token, or surface the error to the user), while a 5xx or 429 often means retry later (the server is temporarily unavailable or you’re being throttled). This 4xx-vs-5xx split shapes retry logic (Section 14).

Reading the status code

The status code lives on HTTPURLResponse, which you get by casting the URLResponse:

guard let http = response as? HTTPURLResponse else {
    throw NetworkError.invalidResponse   // not an HTTP response — unexpected for http(s)
}
let status = http.statusCode

The cast to HTTPURLResponse is necessary because the async/completion APIs hand you the base URLResponse type; the status code (and response headers) live on the HTTP-specific subclass. For http/https URLs the cast always succeeds in practice, but guarding it is correct — and it’s where you’d catch the theoretical case of a non-HTTP response.

Validating with meaningful errors

A validation function turns the status code into success or a descriptive thrown error. The naive (200...299).contains(status) check is a start, but a good validator distinguishes the cases so callers (and users) get actionable information:

enum NetworkError: Error {
    case invalidURL
    case invalidResponse
    case unauthorized                          // 401 — token missing/expired
    case forbidden                             // 403 — not allowed
    case notFound                              // 404
    case rateLimited(retryAfter: TimeInterval?) // 429
    case clientError(status: Int, data: Data)   // other 4xx
    case serverError(status: Int)               // 5xx
    case decodingFailed(Error)
    case transport(URLError)
    case unknown(status: Int)
}

func validate(_ data: Data, _ response: URLResponse) throws {
    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }
    switch http.statusCode {
    case 200...299:
        return   // success
    case 401:
        throw NetworkError.unauthorized
    case 403:
        throw NetworkError.forbidden
    case 404:
        throw NetworkError.notFound
    case 429:
        // Respect the Retry-After header if present
        let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
        throw NetworkError.rateLimited(retryAfter: retryAfter)
    case 400...499:
        throw NetworkError.clientError(status: http.statusCode, data: data)
    case 500...599:
        throw NetworkError.serverError(status: http.statusCode)
    default:
        throw NetworkError.unknown(status: http.statusCode)
    }
}

This validator does more than the binary success check: it maps the statuses you handle specially (401 to trigger token refresh, 429 to back off respecting Retry-After, 404 to show “not found”) to distinct cases, while bucketing the rest into client/server error categories. Callers can catch NetworkError.unauthorized to re-authenticate, catch NetworkError.rateLimited(let retryAfter) to wait, and so on — much richer than “it failed.” The 429 case reads the Retry-After header, which servers use to tell you how long to wait before retrying; respecting it is good citizenship and often required by APIs.

Extracting structured error bodies

Well-designed APIs don’t just return a 4xx status — they include a JSON body explaining what went wrong: {"error": "validation_failed", "message": "Title is required", "fields": {"title": "required"}}. Surfacing that message to the user (or logging it) is far more helpful than a bare status code. Model the API’s error envelope and decode it on failure:

struct APIErrorBody: Decodable {
    let error: String
    let message: String
    let fields: [String: String]?
}

func validate(_ data: Data, _ response: URLResponse, decoder: JSONDecoder) throws {
    guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse }
    guard (200...299).contains(http.statusCode) else {
        // Try to extract the server's structured error message
        if let apiError = try? decoder.decode(APIErrorBody.self, from: data) {
            throw NetworkError.apiError(status: http.statusCode, body: apiError)
        }
        // Fall back to a generic error if the body isn't the expected shape
        throw NetworkError.httpError(status: http.statusCode)
    }
}

Attempting to decode the error body with try? (so a non-conforming body doesn’t mask the original failure with a decoding error) gives you the server’s own explanation when available. This is the difference between showing the user “Something went wrong” and “Title is required” — the latter is enormously more useful, and the information is right there in the response body that a naive validator throws away. Capture it.

Empty success responses

A subtlety: some successful responses have no body. A 204 No Content (typical for DELETE) and some 200s return zero bytes. If your code unconditionally tries to decode the body, an empty body fails to decode and you misreport a successful operation as a failure. Handle the no-content case:

func performDelete(_ request: URLRequest) async throws {
    let (data, response) = try await URLSession.shared.data(for: request)
    try validate(data, response)
    // 204 or empty body is success — there's nothing to decode, just return.
}

For endpoints that may return an empty body on success, don’t attempt to decode — just validate the status and return. For generic decode helpers, special-case 204/empty data to avoid a spurious decoding error. This is a common source of “the delete worked but my code threw an error” confusion.

Centralizing validation

The crucial structural point: validation should happen in one place, not be re-implemented at every call site. Every response in your app goes through the same validator, so the status-code handling is consistent and you can’t forget it on some endpoint. In the networking layer (Section 12), the client calls validate on every response before decoding, so individual endpoints never touch status codes. This is exactly why a thin networking layer pays off — the easily-forgotten, must-be-consistent validation lives in the client, applied uniformly.

Validation pitfalls

Skipping status validation entirely. A 404/500 sails through and you decode an error page as your model. Validate every response’s status.

Only checking (200...299) without distinguishing cases. Loses the ability to handle 401 (refresh), 429 (back off), 404 (not found) specifically. Map important statuses to distinct errors.

Discarding the error body. APIs put helpful messages in 4xx bodies. Decode and surface them instead of showing a generic error.

Ignoring Retry-After on 429. You’ll hammer a throttling server. Read and respect the header.

Decoding an empty 204 body. Throws a spurious decoding error on a successful operation. Special-case no-content responses.

Re-implementing validation per endpoint. Inconsistent, and you’ll miss one. Validate centrally in the client.

What to internalize

URLSession doesn’t treat HTTP error statuses as errors, so you must read HTTPURLResponse.statusCode and validate it yourself — and do so meaningfully: 2xx is success, 4xx means your request was wrong (generally don’t retry — fix it, re-auth on 401, surface on 404), 5xx/429 mean retry later (respecting Retry-After on 429). Map the statuses you handle specially to distinct error cases rather than a binary pass/fail, and decode the API’s structured error body (with try?) to surface the server’s actual explanation instead of a generic message. Special-case empty 204/no-content responses so a successful operation isn’t misread as a decoding failure. Above all, centralize validation in one place — your client — so it runs consistently on every response and can never be forgotten on an endpoint.


9. Error Handling: URLError, Decoding Errors, and Typed Errors

Networking fails in many distinct ways — no connection, timeout, cancellation, a bad status, malformed JSON — and a robust app distinguishes them to respond appropriately: retry the timeout, prompt re-login on 401, show “you’re offline” when there’s no connection, log the decoding failure. This section covers the error types URLSession and decoding produce, how to interpret them, and how to design a unified error model your app can reason about.

The two layers of failure

Errors in a networking call come from two distinct layers, and conflating them causes confusion:

  • Transport errors — the request couldn’t complete: no network, the host is unreachable, the request timed out, the user cancelled, TLS validation failed, DNS lookup failed. These surface as the thrown error from data(for:) (or the error parameter in completion handlers), and they’re almost always a URLError.
  • Application errors — the request completed and the server responded, but the response indicates a problem: a 4xx/5xx status, or a 2xx with a body you can’t decode into your model. These are not thrown by URLSession; you generate them yourself during validation (Section 8) and decoding (Section 6).

Keeping these layers distinct in your error model lets you respond correctly: a transport timeout is worth retrying, a 400 is not; “offline” deserves a specific UI, a decoding failure is a bug to log. Lumping everything into one opaque Error throws that information away.

URLError: the transport error type

When a request fails to complete, URLSession throws a URLError — a struct with a code describing what happened. The codes you’ll handle most:

do {
    let (data, response) = try await URLSession.shared.data(for: request)
    // ...
} catch let error as URLError {
    switch error.code {
    case .notConnectedToInternet:
        // The device has no network connection — show an offline state.
        break
    case .timedOut:
        // The request exceeded its timeout — often worth retrying.
        break
    case .cancelled:
        // The task was cancelled (e.g., the Task was cancelled). Usually NOT a
        // user-facing error — often you just do nothing.
        break
    case .networkConnectionLost:
        // The connection dropped mid-request — often worth retrying.
        break
    case .cannotFindHost, .cannotConnectToHost:
        // DNS or connection failure — possibly a bad URL or server down.
        break
    case .secureConnectionFailed, .serverCertificateUntrusted:
        // TLS problem — a security issue, do not silently ignore.
        break
    default:
        break
    }
}

URLError.code is an enum of specific failure reasons, and matching on it lets you respond precisely. The standouts: .notConnectedToInternet drives your offline UI; .timedOut and .networkConnectionLost are the canonical “transient, retry me” cases; .cancelled should usually be treated as a non-error (the operation was deliberately abandoned, so showing an error message would be wrong); TLS errors signal something you must not paper over. URLError also carries useful context like error.failingURL for diagnostics.

Decoding errors: DecodingError

When the response is fine but the JSON doesn’t match your model, JSONDecoder throws a DecodingError, which is richly detailed about what failed — invaluable for debugging the all-too-common “the API changed and now decoding breaks”:

do {
    let articles = try JSONDecoder.api.decode([Article].self, from: data)
} catch let error as DecodingError {
    switch error {
    case .keyNotFound(let key, let context):
        print("Missing key '\(key.stringValue)' at \(context.codingPath)")
    case .typeMismatch(let type, let context):
        print("Expected \(type) at \(context.codingPath): \(context.debugDescription)")
    case .valueNotFound(let type, let context):
        print("Expected non-nil \(type) at \(context.codingPath)")
    case .dataCorrupted(let context):
        print("Corrupted data at \(context.codingPath): \(context.debugDescription)")
    @unknown default:
        print("Unknown decoding error")
    }
}

The four DecodingError cases pinpoint the problem: .keyNotFound (a required key is missing — maybe the API renamed or dropped it), .typeMismatch (a field is the wrong type — e.g., the server sent a string where you expected an int), .valueNotFound (a non-optional was null), .dataCorrupted (the JSON is malformed, or a date string didn’t match your strategy). Each carries a codingPath showing exactly where in the JSON tree the failure occurred. In development, logging this detail turns a baffling “decoding failed” into “the publishedAt field at index 3 isn’t a valid ISO date” — pointing straight at the fix. Capture and log the full DecodingError rather than collapsing it to a generic message.

A unified error model

For your app’s upper layers, you want a single error type that captures all these failure modes in clean, switchable cases — the messy details of URLError, DecodingError, and HTTP statuses confined below it. We’ve been growing NetworkError; here’s a consolidated version:

enum NetworkError: Error {
    case invalidURL
    case invalidResponse
    case transport(URLError)              // no connection, timeout, cancelled, TLS, etc.
    case unauthorized                     // 401
    case forbidden                        // 403
    case notFound                         // 404
    case rateLimited(retryAfter: TimeInterval?)  // 429
    case server(status: Int)              // 5xx
    case client(status: Int, body: Data)  // other 4xx, with body for the message
    case decoding(DecodingError)          // response didn't match the model
    case unknown(Error)

    var isRetryable: Bool {
        switch self {
        case .transport(let urlError):
            // Retry transient transport failures, but not cancellation.
            switch urlError.code {
            case .timedOut, .networkConnectionLost, .cannotConnectToHost: return true
            default: return false
            }
        case .server, .rateLimited:
            return true        // 5xx and 429 are worth retrying (with backoff)
        default:
            return false       // 4xx (except 429), decoding, etc. won't fix on retry
        }
    }
}

This unified model gives the rest of the app one type to handle, with semantic cases instead of raw codes. The isRetryable computed property is especially valuable: it encodes, in one place, the policy of what’s worth retrying (transient transport failures, 5xx, 429) versus what isn’t (4xx, decoding errors, cancellation) — which the retry logic in Section 14 consumes directly. Centralizing this judgment prevents inconsistent retry behavior scattered across the app.

Mapping raw errors into the model

A helper converts whatever was thrown into your NetworkError, so the mapping logic lives in one place (the client, Section 12):

func mapError(_ error: Error) -> NetworkError {
    switch error {
    case let netError as NetworkError:  return netError       // already mapped
    case let urlError as URLError:      return .transport(urlError)
    case let decodingError as DecodingError: return .decoding(decodingError)
    default:                            return .unknown(error)
    }
}

This funnels every error — transport, decoding, your own validation errors, anything unexpected — into the unified type. Combined with the central validator from Section 8, it means every failure anywhere in networking emerges as a clean NetworkError the app can switch on, with the raw URLError/DecodingError preserved inside for logging when you need the detail.

Presenting errors to users

A final consideration: the error model is for your code to reason about; the user needs a human message, and not every error should be shown. A mapping from NetworkError to user-facing text — and a decision about whether to show anything at all — belongs in the presentation layer:

extension NetworkError {
    var userMessage: String? {
        switch self {
        case .transport(let e) where e.code == .notConnectedToInternet:
            return "You appear to be offline. Check your connection and try again."
        case .transport(let e) where e.code == .cancelled:
            return nil   // cancelled deliberately — show nothing
        case .transport, .server:
            return "Something went wrong. Please try again."
        case .unauthorized:
            return "Your session has expired. Please sign in again."
        case .rateLimited:
            return "You're doing that too often. Please wait a moment."
        case .client(_, let body):
            // Prefer the server's specific message if we can extract it
            return Self.extractMessage(from: body) ?? "That request couldn't be completed."
        case .decoding, .invalidURL, .invalidResponse, .notFound, .forbidden, .unknown:
            return "Something went wrong. Please try again."
        }
    }
}

Note that .cancelled maps to nil — a deliberately cancelled request is not a user-facing error, and showing “something went wrong” when the user simply navigated away is a bug. The unified model makes these presentation decisions clean: you switch on semantic cases and decide per case what (if anything) to tell the user, surfacing the server’s specific message for client errors and generic reassurance for the rest.

Error handling pitfalls

Treating all errors as one opaque blob. Loses the ability to retry transient failures, prompt re-login on 401, or show an offline state. Distinguish transport vs. application errors and the cases within.

Showing an error for .cancelled. A deliberately cancelled request isn’t a failure to report. Map cancellation to “do nothing / show nothing.”

Collapsing DecodingError to a generic message. Throws away the codingPath and reason that pinpoint the bug. Log the full DecodingError in development.

Retrying 4xx errors. They’ll fail identically (the request is wrong). Encode retry policy (isRetryable) so only transient/5xx/429 retry.

Scattering error mapping across call sites. Inconsistent handling. Map all raw errors into your unified type in one place.

Silently ignoring TLS errors. A certificate failure is a security signal, not a transient glitch. Surface and investigate it.

What to internalize

Networking fails at two layers: transport errors (no connection, timeout, cancellation, TLS) surface as a thrown URLError whose .code you match to respond precisely (offline UI, retry transient failures, treat .cancelled as a non-error); application errors (bad status, undecodable body) you generate yourself during validation and decoding, where DecodingError’s four cases and codingPath pinpoint exactly what broke. Funnel all of these into one unified NetworkError enum with semantic cases and an isRetryable property that encodes retry policy in a single place. Map every raw error into this type centrally (in the client), preserving the underlying URLError/DecodingError for logging. Keep code-facing error reasoning separate from user-facing presentation, where you decide per case what message to show — and crucially show nothing for deliberate cancellation.


10. URLSessionConfiguration: default, ephemeral, and background

A URLSession is shaped by its URLSessionConfiguration — the object that sets timeouts, caching, cookie handling, default headers, cellular policy, and whether the session runs in the background. Choosing and configuring the right session type is a foundational decision, and using the right one (rather than always reaching for URLSession.shared) gives you control over behavior that matters in real apps. This section covers the configuration types and the settings worth knowing.

Why not just use URLSession.shared

URLSession.shared is a convenient singleton, fine for simple, one-off requests. But it has limits: you can’t configure it (no custom timeouts, headers, or cache), you can’t set a delegate (so no progress, no certificate pinning, no background support), and it uses the system’s default caching and cookie storage. For anything beyond casual fetching — which is most real apps — you create your own session with a configuration you control. The shared session is for quick scripts and trivial cases; a configured session is for production.

The three configuration types

URLSessionConfiguration comes in three flavors, created by factory methods:

// 1. Default — persists caches, cookies, and credentials to disk
let defaultConfig = URLSessionConfiguration.default

// 2. Ephemeral — keeps nothing on disk; all state in memory, gone when released
let ephemeralConfig = URLSessionConfiguration.ephemeral

// 3. Background — transfers continue when your app is suspended or terminated
let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.app.bg")

Each suits a different need:

  • .default — the standard configuration. It uses a persistent on-disk cache (URLCache), persistent cookie storage (HTTPCookieStorage), and the system credential store. This is what you want for normal app networking where caching responses and persisting cookies is desirable.
  • .ephemeral — a “private browsing” configuration. Nothing is written to disk: no cache, no cookies, no credentials persisted. Everything lives in memory and vanishes when the session is deallocated. Use it for sensitive flows (a login session you don’t want cached), or when you explicitly want no persistence.
  • .background — a special configuration whose transfers are handed off to a system daemon and continue even when your app is suspended or terminated by the OS. It requires a unique identifier and a delegate, has significant restrictions (no completion-handler convenience methods, file-based uploads/downloads only), and is the subject of Section 17. Use it for large downloads/uploads that should survive the app being backgrounded.

Creating a configured session

You create a session from a configuration once and reuse it for the app’s lifetime — sessions are meant to be long-lived, not created per request:

final class APIClient {
    private let session: URLSession

    init() {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30      // per-request: no data for 30s → fail
        config.timeoutIntervalForResource = 300    // whole resource must finish within 5 min
        config.waitsForConnectivity = true          // wait for a connection instead of failing immediately
        config.httpAdditionalHeaders = [            // headers applied to every request
            "Accept": "application/json",
            "User-Agent": "ExampleApp/2.0 (iOS)"
        ]
        config.requestCachePolicy = .useProtocolCachePolicy
        self.session = URLSession(configuration: config)
    }
}

Creating a session per request is a real anti-pattern — it discards connection reuse, the cache, and cookie continuity, and wastes resources. Create your configured session once (your networking client owns it, Section 12) and use it for every request. The configuration is copied into the session at creation, so mutating the config object afterward has no effect — set everything before creating the session.

The two timeouts, untangled

Configuration has two timeout settings that are easy to confuse, and they measure genuinely different things:

config.timeoutIntervalForRequest = 30     // (default 60) per-request inactivity timeout
config.timeoutIntervalForResource = 300   // (default 7 days) total time for the whole transfer

timeoutIntervalForRequest is an inactivity timer: if no data flows for this many seconds, the request fails with .timedOut. It resets each time data arrives, so a slow-but-steady transfer won’t trip it. timeoutIntervalForResource is a total deadline for the entire resource — even if data keeps flowing, the transfer fails if it exceeds this. The request timeout guards against a stalled connection; the resource timeout caps the total duration (important for large downloads, and very long for background sessions where it defaults to a week). The per-request URLRequest.timeoutInterval (Section 3) overrides timeoutIntervalForRequest for a single request. Set the request timeout to something reasonable (30s is common) and the resource timeout based on the largest legitimate transfer.

waitsForConnectivity: the modern offline behavior

waitsForConnectivity (iOS 11+) changes how the session reacts to being offline. By default (false), a request made with no connection fails immediately with .notConnectedToInternet. With waitsForConnectivity = true, the session instead waits for connectivity to become available and then proceeds — up to the resource timeout. This is often the better behavior for non-interactive requests: rather than failing instantly when the user is briefly in a tunnel, the request waits and completes when the network returns.

config.waitsForConnectivity = true

When waiting, the session can notify your delegate via urlSession(_:taskIsWaitingForConnectivity:) so you can show a “waiting for connection” indicator. This pairs with the connectivity controls in Section 25. For interactive requests where you want immediate feedback, you might leave it false; for background-ish syncs, true gives a smoother experience.

Default headers and cellular policy

httpAdditionalHeaders sets headers applied to every request the session makes — ideal for an Accept, a User-Agent, or a constant API-Version header you don’t want to repeat:

config.httpAdditionalHeaders = ["Accept": "application/json", "API-Version": "2"]

Note that per-request headers set on the URLRequest override these for that request, and you should not put dynamic values like Authorization here (the token changes; set it per request, Section 13). Use additional headers only for genuinely constant values.

For network cost control, the configuration carries policy flags:

config.allowsCellularAccess = true               // permit cellular at all (default true)
config.allowsExpensiveNetworkAccess = true       // permit "expensive" networks (cellular, personal hotspot)
config.allowsConstrainedNetworkAccess = true     // permit "constrained" networks (Low Data Mode)

The modern, expressive controls are allowsExpensiveNetworkAccess and allowsConstrainedNetworkAccess, which respect the user’s Low Data Mode and the system’s notion of expensive connections. Setting these (or their per-request equivalents) lets you defer non-essential traffic on metered or constrained networks — covered with reachability in Section 25.

Other useful configuration settings

A few more settings you’ll occasionally reach for:

config.httpMaximumConnectionsPerHost = 6     // parallel connections to one host (default 6 on iOS)
config.httpShouldUsePipelining = false        // HTTP pipelining (rarely beneficial; off)
config.httpCookieStorage = HTTPCookieStorage.shared  // cookie store (or nil to disable cookies)
config.httpCookieAcceptPolicy = .onlyFromMainDocumentDomain
config.urlCache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 50_000_000)  // custom cache
config.networkServiceType = .default          // traffic classification hint

httpMaximumConnectionsPerHost bounds parallelism to a single host. The cookie and cache settings (Sections 20-21) let you customize or disable those subsystems. networkServiceType hints to the system about the traffic’s nature (e.g., .responsiveData for interactive, .background for deferrable), which can influence scheduling. Most apps leave these at defaults and adjust only when a specific need arises.

Multiple sessions for different purposes

It’s perfectly reasonable — and sometimes correct — to have more than one session: a .default session for normal API calls, a .background session for large downloads, perhaps an .ephemeral one for a sensitive auth flow. Each is configured for its job. What you avoid is creating sessions per request; you create a small, fixed set of long-lived sessions, each suited to a category of work. For most apps a single .default session handles everything except background transfers, which need their own background session.

Configuration pitfalls

Always using URLSession.shared. It can’t be configured and can’t have a delegate — no custom timeouts, headers, progress, pinning, or background. Create a configured session for production.

Creating a session per request. Discards connection reuse, caching, and cookie continuity; wasteful. Create one long-lived session per category of work.

Mutating the configuration after creating the session. The config is copied at creation; later changes are ignored. Set everything before URLSession(configuration:).

Confusing the two timeouts. timeoutIntervalForRequest is per-request inactivity; timeoutIntervalForResource is the total transfer deadline. Set each deliberately.

Putting dynamic Authorization in httpAdditionalHeaders. Tokens change; a baked-in header goes stale. Use additional headers only for constant values; set auth per request.

Ignoring waitsForConnectivity and allowsConstrainedNetworkAccess. You fail instantly when offline and ignore Low Data Mode. Set connectivity/cost policy to match the request’s importance.

What to internalize

A session’s behavior comes from its URLSessionConfiguration, which is copied in at creation (so configure before creating, and reuse the session — never per request). Choose the type by need: .default (persistent cache/cookies, normal use), .ephemeral (nothing on disk, for sensitive flows), or .background (survives suspension, for large transfers — Section 17). Set the two distinct timeouts deliberately — timeoutIntervalForRequest (per-request inactivity) and timeoutIntervalForResource (total deadline) — and consider waitsForConnectivity = true so requests wait out brief offline moments instead of failing instantly. Use httpAdditionalHeaders only for constant headers (not the dynamic Authorization), and the allowsExpensive/Constrained flags to respect metered networks and Low Data Mode. Prefer a small set of long-lived, purpose-built sessions over the un-configurable URLSession.shared.


11. Building a Networking Layer, Part 1: Endpoints and Requests

You’ve seen that every request repeats the same construction: build a URL, set the method, add headers, attach a body. Doing that ad hoc at each call site is repetitive and error-prone — a forgotten header here, a wrong method there, string-typed verbs everywhere. In this section and the next, we build a small, principled networking layer that makes requests type-safe and the client thin and testable. Part 1 covers modeling endpoints and turning them into URLRequests; Part 2 builds the client that executes them.

Design goals

A good networking layer should: make each endpoint a typed, self-describing value (path, method, body, query — all in one place); turn an endpoint into a URLRequest consistently (so headers and encoding are uniform); keep the client thin (build request, send, validate, decode, map errors); be testable (injectable session, mockable transport); and stay transparent (you can always see what request goes out). We’re not rebuilding Alamofire — we’re building the minimum that makes raw URLSession safe and pleasant.

Type-safe HTTP methods

First, eliminate stringly-typed methods with an enum:

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

A String-backed enum gives compile-time safety (no "GTE" typos) while still producing the raw verb string when needed via .rawValue. This is the type-safe replacement for the hand-typed httpMethod strings from Section 3.

Modeling an endpoint

The central abstraction is an Endpoint: a value that fully describes a request to a specific API resource — its path, method, query items, headers, and body — independent of how it’s executed. We make it generic over the response type it decodes to, so an endpoint knows what it returns:

struct Endpoint<Response> {
    var path: String
    var method: HTTPMethod = .get
    var queryItems: [URLQueryItem] = []
    var headers: [String: String] = [:]
    var body: Data? = nil

    // Whether this endpoint requires authentication (drives the auth layer, Section 13)
    var requiresAuth: Bool = true
}

The Response generic parameter is a phantom type — it doesn’t appear in the stored properties, but it lets the compiler know that, say, an “articles list” endpoint produces [Article] and a “single article” endpoint produces Article. When the client executes the endpoint, it uses this type to decode, and the call site gets the right type back with no casting. This is the key to a type-safe layer: the endpoint carries its response type.

Endpoint factories for your API

Rather than construct endpoints inline, you define static factories that read like a description of your API — one place that documents every call your app makes:

extension Endpoint {
    // GET /articles?page=N  →  ArticleListResponse
    static func articles(page: Int) -> Endpoint<ArticleListResponse> {
        Endpoint<ArticleListResponse>(
            path: "/articles",
            method: .get,
            queryItems: [URLQueryItem(name: "page", value: String(page))]
        )
    }

    // GET /articles/{id}  →  Article
    static func article(id: Int) -> Endpoint<Article> {
        Endpoint<Article>(path: "/articles/\(id)", method: .get)
    }

    // POST /articles  →  Article  (with a JSON body)
    static func createArticle(_ payload: CreateArticleBody) throws -> Endpoint<Article> {
        Endpoint<Article>(
            path: "/articles",
            method: .post,
            headers: ["Content-Type": "application/json"],
            body: try JSONEncoder.api.encode(payload)
        )
    }

    // DELETE /articles/{id}  →  no content (use a Void-ish response type)
    static func deleteArticle(id: Int) -> Endpoint<EmptyResponse> {
        Endpoint<EmptyResponse>(path: "/articles/\(id)", method: .delete)
    }
}

// A stand-in for endpoints that return no body (204)
struct EmptyResponse: Decodable {}

Each factory returns an Endpoint specialized to its exact response type. Reading this extension is reading your API surface: the paths, methods, what each takes and returns. Adding a new API call means adding one factory. The createArticle factory encodes the body at construction (and is throws because encoding can fail). The EmptyResponse type handles no-content responses cleanly so the generic machinery still has a type to work with. This catalog-of-endpoints pattern keeps your API definition in one readable place rather than smeared across the app.

Turning an Endpoint into a URLRequest

The endpoint is a description; to send it, you build a URLRequest from it. This conversion is where the URL assembly (Section 2) and request construction (Section 3) get centralized:

struct RequestBuilder {
    let baseHost: String
    let basePath: String

    func makeRequest<Response>(from endpoint: Endpoint<Response>) throws -> URLRequest {
        var components = URLComponents()
        components.scheme = "https"
        components.host = baseHost
        components.path = basePath + endpoint.path
        components.queryItems = endpoint.queryItems.isEmpty ? nil : endpoint.queryItems

        guard let url = components.url else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        request.httpBody = endpoint.body

        // Apply endpoint-specific headers
        for (field, value) in endpoint.headers {
            request.setValue(value, forHTTPHeaderField: field)
        }
        // A default Accept if the endpoint didn't set one
        if request.value(forHTTPHeaderField: "Accept") == nil {
            request.setValue("application/json", forHTTPHeaderField: "Accept")
        }
        return request
    }
}

The RequestBuilder is the single place that turns any endpoint into a request: it assembles the URL safely with URLComponents, sets the method from the type-safe enum, attaches the body, applies headers, and provides a default Accept. Because every request flows through this one method, URL construction and header application are uniform across the entire app — there’s no call site building a URL by hand or forgetting Accept. This centralization is precisely the value of the layer.

Where auth and dynamic headers fit

Notice the builder above doesn’t add the Authorization header — that’s deliberate. Authentication is a cross-cutting concern that depends on runtime state (the current token) and may need to refresh, so it belongs in a dedicated layer between the builder and the session, not baked into static endpoint definitions. The requiresAuth flag on Endpoint signals whether a given endpoint needs auth; the auth layer (Sections 13-14) reads that flag and injects the token at send time. Keeping auth out of the endpoint and builder keeps those pieces pure and testable — they describe and construct the request; the auth layer decorates it with credentials. We’ll wire that in shortly.

Making endpoints expressive with method chaining (optional polish)

If you prefer a fluent style, you can add chainable modifiers so endpoints read declaratively — though the factory approach above is perfectly good and arguably clearer. A taste:

extension Endpoint {
    func adding(header field: String, value: String) -> Endpoint {
        var copy = self
        copy.headers[field] = value
        return copy
    }
    func query(_ name: String, _ value: String) -> Endpoint {
        var copy = self
        copy.queryItems.append(URLQueryItem(name: name, value: value))
        return copy
    }
}
// Usage: Endpoint<Article>.article(id: 1).adding(header: "X-Trace", value: traceID)

These return modified copies (value-type semantics), letting you adjust an endpoint without mutating the original. This is optional polish; the core layer doesn’t need it. The point of showing it is that because Endpoint is a plain value type, extending its ergonomics is trivial — you own the abstraction.

Networking Layer Part 1 pitfalls

Stringly-typed methods scattered at call sites. Typos fail silently. Centralize with an HTTPMethod enum and build requests in one place.

Building URLs ad hoc per endpoint. Inconsistent encoding and forgotten query handling. Route all URL assembly through one RequestBuilder using URLComponents.

Baking auth into endpoint definitions. Couples pure request descriptions to runtime token state and refresh logic. Keep auth in a separate layer; flag endpoints with requiresAuth.

Losing the response type. A non-generic endpoint forces casting and runtime type errors. Make Endpoint generic over its Response so the call site gets the right type.

Encoding bodies at the call site instead of in the factory. Spreads encoding logic and content-type setting around. Encode in the endpoint factory with the shared encoder.

No default Accept. Some servers behave differently without it. Apply a sensible default in the builder when the endpoint doesn’t specify one.

What to internalize

A networking layer starts by making requests type-safe and centralized. Model each call as a generic Endpoint<Response> value carrying path, method (a type-safe HTTPMethod enum), query items, headers, and body — with the Response phantom type so the endpoint knows what it decodes to and call sites need no casting. Define your whole API as static endpoint factories, giving you one readable catalog of every call your app makes. Convert any endpoint to a URLRequest through a single RequestBuilder that assembles the URL with URLComponents, applies the method/body/headers uniformly, and sets a default Accept — so URL construction and headers are consistent everywhere. Keep authentication out of the endpoint and builder (flag it with requiresAuth and inject it in a dedicated auth layer), so request description and construction stay pure and testable. Part 2 builds the client that sends these requests, validates, decodes, and maps errors.


12. Building a Networking Layer, Part 2: Client, Decoding, and Errors

Part 1 gave us type-safe endpoints and a builder that turns them into requests. What’s missing is the executor: the client that sends a request through the session, validates the response, decodes the body into the endpoint’s Response type, and maps any failure into our unified NetworkError. This section builds that client, makes it injectable for testing, and shows the whole layer working end to end.

The client’s single responsibility

The client does one thing, the same way, for every endpoint: send → validate → decode → map errors. By concentrating those four steps in one place, the per-endpoint code (the factories from Part 1) stays purely declarative, and the must-be-consistent concerns (status validation from Section 8, error mapping from Section 9, decoding configuration from Section 6) happen uniformly. This is the payoff of the layer — write the tricky parts once, reuse them everywhere.

Abstracting the transport for testability

A key design decision up front: the client should not hardwire URLSession, because that makes it hard to test (you’d hit the real network). Instead, define a tiny protocol for “the thing that performs a request,” which URLSession already satisfies and which a mock can implement:

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

// URLSession conforms with zero work — it already has this method.
extension URLSession: HTTPTransport {}

URLSession already has a data(for:) method with exactly this signature, so conforming it to HTTPTransport is an empty extension. The client depends on the protocol, not the concrete session, so in tests you inject a mock transport that returns canned data without touching the network (Section 27). This single abstraction is what makes the entire layer testable — a small investment with a large payoff.

The client

The client holds the transport, the request builder, and the configured decoder, and exposes one generic method to run any endpoint:

final class APIClient {
    private let transport: HTTPTransport
    private let builder: RequestBuilder
    private let decoder: JSONDecoder

    init(transport: HTTPTransport = URLSession(configuration: .default),
         builder: RequestBuilder = RequestBuilder(baseHost: "api.example.com", basePath: "/v1"),
         decoder: JSONDecoder = .api) {
        self.transport = transport
        self.builder = builder
        self.decoder = decoder
    }

    func send<Response: Decodable>(_ endpoint: Endpoint<Response>) async throws -> Response {
        // 1. Build the request
        let request: URLRequest
        do {
            request = try builder.makeRequest(from: endpoint)
        } catch {
            throw NetworkError.invalidURL
        }

        // 2. Send it through the transport
        let data: Data
        let response: URLResponse
        do {
            (data, response) = try await transport.data(for: request)
        } catch let urlError as URLError {
            throw NetworkError.transport(urlError)   // map transport failures
        }

        // 3. Validate the HTTP status
        try validate(data: data, response: response)

        // 4. Decode the body into the endpoint's Response type
        if Response.self == EmptyResponse.self {
            return EmptyResponse() as! Response       // no-content endpoints
        }
        do {
            return try decoder.decode(Response.self, from: data)
        } catch let decodingError as DecodingError {
            throw NetworkError.decoding(decodingError)
        }
    }

    private func validate(data: Data, response: URLResponse) throws {
        guard let http = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        switch http.statusCode {
        case 200...299: return
        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(TimeInterval.init)
            throw NetworkError.rateLimited(retryAfter: retryAfter)
        case 400...499: throw NetworkError.client(status: http.statusCode, body: data)
        case 500...599: throw NetworkError.server(status: http.statusCode)
        default: throw NetworkError.unknown(URLError(.badServerResponse))
        }
    }
}

Trace the send method: it builds the request via the Part 1 builder, sends it through the injected transport (mapping a thrown URLError to .transport), validates the status (the centralized validator from Section 8, mapping statuses to semantic cases), and decodes into the endpoint’s Response (mapping a DecodingError to .decoding). The EmptyResponse special-case handles 204/no-content endpoints so they don’t try to decode an empty body. Every endpoint runs through this one method, so validation, error mapping, and decoder configuration are applied identically everywhere. The defaulted init parameters make the common case (APIClient()) trivial while keeping everything injectable for tests.

Using the client

With the layer complete, every API call in your app is one line, fully typed, with errors and decoding handled:

let client = APIClient()

// Fetch a page of articles — returns ArticleListResponse, typed automatically
let page = try await client.send(.articles(page: 1))
let articles: [Article] = page.results

// Fetch one article — returns Article
let article = try await client.send(.article(id: 42))

// Create an article — returns the created Article
let payload = CreateArticleBody(title: "New", body: "Body", tags: ["swift"])
let created = try await client.send(try .createArticle(payload))

// Delete — returns EmptyResponse (no body)
_ = try await client.send(.deleteArticle(id: 42))

Compare these one-liners to the multi-step raw functions from Sections 4-9. The endpoint factories make each call self-documenting, the generic send returns exactly the right type with no casting, and every failure emerges as a NetworkError the caller can switch on. There’s no URL building, no status checking, no decoder configuration, no error mapping at the call site — all of it lives in the layer. This is the shape of a real, thin, type-safe networking layer over raw URLSession.

A repository on top of the client

For the app’s feature code, you often wrap the client in a repository that exposes domain operations and hides even the endpoints — the networking analog of the data-layer repository pattern:

final class ArticleRepository {
    private let client: APIClient
    init(client: APIClient = APIClient()) { self.client = client }

    func articles(page: Int) async throws -> [Article] {
        try await client.send(.articles(page: page)).results
    }
    func article(id: Int) async throws -> Article {
        try await client.send(.article(id: id))
    }
    func create(title: String, body: String, tags: [String]) async throws -> Article {
        let payload = CreateArticleBody(title: title, body: body, tags: tags)
        return try await client.send(try .createArticle(payload))
    }
    func delete(id: Int) async throws {
        _ = try await client.send(.deleteArticle(id: id))
    }
}

The repository speaks the app’s language (articles(page:), create(title:body:tags:)) and returns domain types ([Article]), hiding the endpoint and envelope details. Feature code (view models, Section 30) depends on the repository, not the client or URLSession — which means features are easy to test (inject a fake repository) and the networking details are fully encapsulated. This layering — URLSession → transport protocol → client → repository → feature — is clean, testable, and each layer has one job.

Testing the layer

Because the client depends on HTTPTransport, not URLSession, you can test it with a mock that returns canned responses — no network, fast and deterministic. A sketch (full treatment in Section 27):

struct MockTransport: HTTPTransport {
    var result: (Data, URLResponse)
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        result
    }
}

@Test func testDecodesArticle() async throws {
    let json = #"{"id":1,"title":"Hi","body":"...","author_id":2,"published_at":"2026-01-01T00:00:00Z","tags":[]}"#
    let data = Data(json.utf8)
    let response = HTTPURLResponse(url: URL(string: "https://api.example.com/v1/articles/1")!,
                                   statusCode: 200, httpVersion: nil, headerFields: nil)!
    let client = APIClient(transport: MockTransport(result: (data, response)))

    let article = try await client.send(.article(id: 1))
    #expect(article.id == 1)
    #expect(article.title == "Hi")
}

Injecting a MockTransport lets you verify the client builds requests, validates statuses, and decodes correctly — all without a server. You can test the 404 path (return a 404 response, assert .notFound throws), the decoding-failure path (return malformed JSON, assert .decoding), and so on. This testability is a direct consequence of the protocol abstraction, and it’s why the small upfront design pays for itself.

Where to stop

This layer is intentionally minimal. It does not yet do: authentication and token refresh (Sections 13-14 add that, decorating the client), retries (Section 14), upload/download progress (Sections 15-16), caching strategy beyond URLCache’s defaults (Section 20), or request coalescing (Section 24). Those are real features you’ll layer on incrementally. The point of building this much is that you now have a clean foundation — typed endpoints, a thin client, unified errors, full testability — that those features extend cleanly. If your needs outgrow what you want to maintain, you’ll evaluate a library (Alamofire) from understanding, recognizing exactly which of its features map to what you’d otherwise build.

Networking Layer Part 2 pitfalls

Hardwiring URLSession into the client. Makes it untestable without the network. Depend on a small HTTPTransport protocol and inject the session.

Re-implementing validation/decoding/error-mapping per endpoint. Inconsistent and error-prone. Centralize all four steps (build, send, validate, decode) in one send method.

Decoding an empty 204 body. Throws a spurious error. Special-case no-content responses (an EmptyResponse type).

Exposing the client directly to feature code. Couples features to endpoints and the client’s shape. Wrap it in a repository that speaks the domain language.

Letting raw URLError/DecodingError escape the client. Upper layers then handle inconsistent error types. Map everything into NetworkError inside the client.

Creating a new client (and session) per call. Discards connection reuse and shared config. Create one client, reuse it; inject it where needed.

What to internalize

The client is the executor that does the same four steps for every endpoint — build the request (via Part 1’s builder), send it through an injected HTTPTransport (which URLSession satisfies for free, enabling tests), validate the HTTP status into semantic NetworkError cases, and decode into the endpoint’s generic Response (special-casing empty 204s) — mapping all raw URLError/DecodingError into the unified error type. This concentrates the must-be-consistent concerns in one place, so endpoint factories stay declarative and every call site is a typed one-liner. Wrap the client in a repository that speaks the app’s domain so feature code never touches endpoints or URLSession. The HTTPTransport abstraction makes the whole layer testable with mock responses. You now have a complete, clean, testable foundation — typed endpoints, thin client, unified errors — that the advanced features ahead extend rather than fight.


13. Authentication: Bearer Tokens, API Keys, and Headers

Almost every real API requires authentication, and getting auth right — attaching credentials securely, storing them safely, applying them only where needed — is both a correctness and a security concern. This section covers the common authentication schemes (bearer tokens, API keys, basic auth), where to store credentials on iOS, and how to inject auth into the networking layer cleanly without coupling it to your endpoint definitions.

The common authentication schemes

Most APIs use one of a few schemes, all expressed through request headers:

  • Bearer tokens — the dominant scheme for modern APIs (OAuth 2.0, JWT). You send Authorization: Bearer <token>, where the token is an opaque or JWT string the server issued at login. This is what you’ll implement most often.
  • API keys — a static key identifying your app or account, sent either as a header (X-API-Key: <key>) or a query parameter (?api_key=<key>). Common for public/server-to-server APIs.
  • Basic authAuthorization: Basic <base64(username:password)>. Older and less common for mobile, but you’ll meet it. The credentials are base64-encoded (not encrypted — basic auth relies entirely on HTTPS for confidentiality).
// Bearer token
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

// API key as a header
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")

// Basic auth
let credentials = "\(username):\(password)"
let encoded = Data(credentials.utf8).base64EncodedString()
request.setValue("Basic \(encoded)", forHTTPHeaderField: "Authorization")

The mechanism is the same in each case: a header carrying the credential. What differs is the format and where the credential comes from. The rest of this section focuses on bearer tokens (the common case) and, crucially, where the token lives.

Never hardcode credentials; use the Keychain for tokens

A non-negotiable security point: credentials must not be hardcoded in your app, and tokens must not be stored in UserDefaults. A hardcoded API key can be extracted from your app binary with trivial tools. A token in UserDefaults sits in an unencrypted plist any backup or jailbroken device exposes. The right home for authentication tokens on iOS is the Keychain, which is hardware-encrypted and access-controlled:

import Security

struct TokenStore {
    private let account = "api-access-token"
    private let service = "com.example.app"

    func save(_ token: String) throws {
        let data = Data(token.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]
        SecItemDelete(query as CFDictionary)             // remove any existing first
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
    }

    func read() -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var result: AnyObject?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
              let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    func delete() {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        SecItemDelete(query as CFDictionary)
    }
}

enum KeychainError: Error { case unhandled(OSStatus) }

The Keychain is the standard, secure store for tokens. The kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly accessibility means the token is readable after the first unlock following a boot (so background tasks can use it) but never leaves the device — it isn’t synced to iCloud or restored to a different device from a backup, which is the right default for a session token. Store the token on login, read it to authenticate requests, and delete it on logout. An API key that must ship with the app is a harder problem (any embedded secret is extractable); for keys, prefer fetching them from your server after an initial authenticated handshake, or accept that an embedded key is effectively public and scope it minimally on the server.

A token provider

Wrap token access behind a small async interface so the networking layer can ask for the current token without knowing where it’s stored or how it’s refreshed (refresh comes in Section 14):

actor AuthTokenProvider {
    private let store: TokenStore
    private var cachedToken: String?

    init(store: TokenStore = TokenStore()) {
        self.store = store
        self.cachedToken = store.read()
    }

    func currentToken() -> String? {
        cachedToken
    }

    func setToken(_ token: String) throws {
        try store.save(token)
        cachedToken = token
    }

    func clear() {
        store.delete()
        cachedToken = nil
    }
}

Making the provider an actor is deliberate: token state is mutable and accessed from concurrent requests, so the actor serializes access and avoids data races (and it’s exactly where token refresh will need to coordinate, Section 14). It caches the token in memory (reading the Keychain on every request is unnecessary overhead) and persists through the TokenStore.

Injecting auth into the layer with an authenticator

Now the clean way to add auth to the networking layer from Sections 11-12: an authenticator that, given a request and the endpoint’s requiresAuth flag, attaches the credential — sitting between the request builder and the transport, so endpoints stay pure:

struct RequestAuthenticator {
    let tokenProvider: AuthTokenProvider

    func authenticate(_ request: URLRequest, requiresAuth: Bool) async throws -> URLRequest {
        guard requiresAuth else { return request }   // public endpoints pass through unchanged
        guard let token = await tokenProvider.currentToken() else {
            throw NetworkError.unauthorized           // no token but auth required → must log in
        }
        var authed = request
        authed.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return authed
    }
}

The authenticator reads the endpoint’s requiresAuth flag (Section 11) to decide whether to attach the token, leaves public endpoints untouched, and throws .unauthorized if auth is required but no token exists (signaling the app to present login). Wiring it into the client’s send is a one-line insertion after building the request:

// Inside APIClient.send, after building `request`:
let authedRequest = try await authenticator.authenticate(request, requiresAuth: endpoint.requiresAuth)
let (data, response) = try await transport.data(for: authedRequest)

This keeps the concerns cleanly separated: endpoints describe what to request, the builder constructs the request, the authenticator decorates it with credentials at send time, and the client orchestrates. The token’s storage, caching, and (soon) refresh are all behind the provider. No endpoint definition mentions auth beyond a boolean flag, and the credential is injected in exactly one place.

API keys in query parameters

If your API takes its key as a query parameter rather than a header, the injection point is the request builder or authenticator adding a query item. Be aware this puts the key in the URL, which is more likely to be logged (by proxies, server logs, analytics) than a header — prefer header-based keys when the API supports them. If you must use a query-param key:

// In the authenticator or builder, append the key as a query item
var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
components.queryItems = (components.queryItems ?? []) + [URLQueryItem(name: "api_key", value: apiKey)]
authed.url = components.url

This rebuilds the URL with the key appended. Again, header-based credentials are preferable for the logging reason; use query-param keys only when the API requires them.

The login flow

Authentication starts with a login that exchanges credentials for a token. That’s just an unauthenticated POST to a token endpoint, decoding the token from the response, and storing it:

struct LoginBody: Encodable { let email: String; let password: String }
struct TokenResponse: Decodable { let accessToken: String; let refreshToken: String; let expiresIn: Int }

extension Endpoint {
    static func login(_ body: LoginBody) throws -> Endpoint<TokenResponse> {
        Endpoint<TokenResponse>(
            path: "/auth/login",
            method: .post,
            headers: ["Content-Type": "application/json"],
            body: try JSONEncoder.api.encode(body),
            requiresAuth: false           // the login request itself isn't authenticated
        )
    }
}

func login(email: String, password: String, client: APIClient, provider: AuthTokenProvider) async throws {
    let response = try await client.send(try .login(LoginBody(email: email, password: password)))
    try await provider.setToken(response.accessToken)
    // store the refresh token too (Section 14)
}

The login endpoint is flagged requiresAuth: false (you can’t authenticate the request that gets you authentication), posts the credentials, decodes the returned tokens, and stores them in the provider. After this, every authenticated endpoint automatically gets the bearer token via the authenticator. Note the refreshToken in the response — that’s the key to refreshing an expired access token without re-prompting the user, which is the next section.

Authentication pitfalls

Hardcoding API keys or tokens in the app. They’re extractable from the binary. Don’t embed secrets; store tokens in the Keychain and treat any embedded key as public.

Storing tokens in UserDefaults. Unencrypted, exposed in backups. Use the Keychain with ...ThisDeviceOnly accessibility.

Putting Authorization in httpAdditionalHeaders. The token changes (and may refresh); a baked-in header goes stale. Inject auth per request via the authenticator.

Attaching auth to every request indiscriminately. Public endpoints don’t need it, and sending a token where it’s not required leaks it unnecessarily. Use the requiresAuth flag.

API keys in query parameters when a header would do. Query strings are more likely to be logged. Prefer header-based credentials.

Not handling the “no token” case. A request requiring auth with no token should cleanly signal “log in,” not fail obscurely. Throw .unauthorized and route to login.

What to internalize

Authentication is expressed through request headers — most commonly Authorization: Bearer <token> for modern APIs, also X-API-Key headers and basic auth. Store tokens in the hardware-encrypted Keychain (with ...ThisDeviceOnly accessibility), never hardcoded or in UserDefaults, and treat any embedded API key as effectively public. Hide token storage and caching behind an actor-based provider (serializing the mutable token state), and inject credentials into the networking layer through a small authenticator that reads each endpoint’s requiresAuth flag and attaches the bearer token at send time — keeping endpoints pure and the credential applied in exactly one place. Login is an unauthenticated POST that exchanges credentials for an access token (and a refresh token), which you store in the provider. That refresh token is what lets you renew expired access without re-prompting the user — the subject of the next section.


14. Token Refresh and Request Retrying

Access tokens expire. When they do, requests start failing with 401, and a good app silently obtains a fresh token and retries — the user never notices. Doing this correctly is genuinely tricky: many requests can fail with 401 at once, and naively each would trigger its own refresh, producing a stampede of refresh calls and possibly invalidating tokens. This section builds correct token refresh with single-flight coordination, plus a general retry mechanism for transient failures.

The refresh flow

The standard OAuth-style refresh: when an access token expires, you exchange the long-lived refresh token for a new access token (and often a new refresh token) at a refresh endpoint, without the user re-entering credentials. The trigger is a 401 on an authenticated request:

struct RefreshBody: Encodable { let refreshToken: String }

extension Endpoint {
    static func refresh(_ refreshToken: String) throws -> Endpoint<TokenResponse> {
        Endpoint<TokenResponse>(
            path: "/auth/refresh",
            method: .post,
            headers: ["Content-Type": "application/json"],
            body: try JSONEncoder.api.encode(RefreshBody(refreshToken: refreshToken)),
            requiresAuth: false   // the refresh request uses the refresh token in its body, not a bearer header
        )
    }
}

The refresh endpoint isn’t itself bearer-authenticated (the access token is expired) — it authenticates via the refresh token in its body. A successful refresh returns a new token pair, which you store, then retry the original request with the new access token.

The stampede problem

Here’s the subtlety that makes naive refresh wrong. Imagine your app fires five requests concurrently (a screen loading several resources). The access token has just expired, so all five come back 401 at roughly the same time. If each 401 independently triggers a refresh, you make five refresh calls. Best case, that’s four wasted calls; worst case, the server issues five new token pairs and invalidates the earlier ones (many refresh implementations rotate and invalidate the old refresh token), so four of your five refreshes end up with tokens the server has already superseded — and you can cascade into a broken auth state. The fix is single-flight refresh: the first 401 starts a refresh, and any other request that hits a 401 while a refresh is in progress waits for that same refresh rather than starting its own.

A single-flight refreshing token provider

We extend the actor-based token provider from Section 13 to coordinate refresh as a single in-flight operation. The actor’s serialization is exactly what makes this safe — only one task runs the actor’s code at a time:

actor AuthTokenProvider {
    private let store: TokenStore
    private var accessToken: String?
    private var refreshToken: String?
    private var refreshTask: Task<String, Error>?   // the in-flight refresh, if any
    private let client: APIClient

    init(store: TokenStore = TokenStore(), client: APIClient) {
        self.store = store
        self.client = client
        self.accessToken = store.readAccess()
        self.refreshToken = store.readRefresh()
    }

    func validToken() -> String? { accessToken }

    /// Returns a fresh token, coordinating so concurrent callers share ONE refresh.
    func refreshedToken() async throws -> String {
        // If a refresh is already running, await its result instead of starting another.
        if let existing = refreshTask {
            return try await existing.value
        }
        // Start a new refresh and store the task so concurrent callers join it.
        let task = Task { () throws -> String in
            defer { refreshTask = nil }   // clear when done so the next expiry can refresh again
            guard let refresh = refreshToken else { throw NetworkError.unauthorized }
            let response = try await client.send(try .refresh(refresh))
            try store.saveAccess(response.accessToken)
            try store.saveRefresh(response.refreshToken)
            accessToken = response.accessToken
            refreshToken = response.refreshToken
            return response.accessToken
        }
        refreshTask = task
        return try await task.value
    }

    func setTokens(access: String, refresh: String) throws {
        try store.saveAccess(access); try store.saveRefresh(refresh)
        accessToken = access; refreshToken = refresh
    }
    func clear() { store.deleteAll(); accessToken = nil; refreshToken = nil; refreshTask = nil }
}

The crux is refreshedToken(). If a refreshTask already exists, the caller awaits that task’s value — joining the in-flight refresh rather than starting a new one. The first caller creates the task, stores it, and subsequent concurrent callers all await the same task. When it completes, refreshTask is cleared (via defer) so a future expiry can refresh again. Because this all runs inside the actor, the check-and-set of refreshTask is atomic — no two callers can both see nil and both start a refresh. This single-flight pattern is the correct solution to the stampede, and the actor model makes it remarkably clean compared to the lock-juggling you’d need otherwise.

Wiring refresh-on-401 into the client

Now the client retries a request once after refreshing, on a 401. The flow: send with the current token; if it returns 401, refresh (joining any in-flight refresh), then retry once with the new token; if it 401s again, give up and signal re-login:

extension APIClient {
    func sendAuthenticated<Response: Decodable>(_ endpoint: Endpoint<Response>) async throws -> Response {
        do {
            return try await send(endpoint)             // attempt with current token
        } catch NetworkError.unauthorized {
            // Access token likely expired — refresh once and retry.
            _ = try await tokenProvider.refreshedToken()  // single-flight; joins if already refreshing
            do {
                return try await send(endpoint)          // retry with the refreshed token
            } catch NetworkError.unauthorized {
                // Still 401 after refresh — the refresh token is invalid/expired.
                await tokenProvider.clear()
                // Signal the app to present login (e.g., post a notification or set state).
                throw NetworkError.unauthorized
            }
        }
    }
}

The retry happens exactly once. A second 401 after a successful refresh means the refresh token itself is no longer valid (revoked, expired, or rotated out), at which point the only recourse is to clear credentials and prompt the user to log in. Bounding the retry to one attempt is essential — an unbounded retry-on-401 loop would hammer the server forever if auth is genuinely broken. Note the authenticator (Section 13) reads the current token from the provider on each send, so the retry naturally picks up the freshly refreshed token.

A general retry mechanism for transient failures

Beyond auth, transient failures (timeouts, dropped connections, 5xx, 429) deserve a bounded retry with backoff — but only the failures that are actually retryable (recall NetworkError.isRetryable from Section 9). A general retry wrapper:

func withRetry<T>(
    maxAttempts: Int = 3,
    initialDelay: TimeInterval = 0.5,
    _ operation: @Sendable () async throws -> T
) async throws -> T {
    var attempt = 0
    while true {
        do {
            return try await operation()
        } catch let error as NetworkError where error.isRetryable && attempt < maxAttempts - 1 {
            attempt += 1
            // Respect Retry-After for rate limiting; otherwise exponential backoff with jitter.
            let delay: TimeInterval
            if case .rateLimited(let retryAfter) = error, let retryAfter {
                delay = retryAfter
            } else {
                let backoff = initialDelay * pow(2, Double(attempt - 1))   // 0.5, 1.0, 2.0, ...
                let jitter = Double.random(in: 0...(backoff * 0.3))         // avoid thundering herd
                delay = backoff + jitter
            }
            try await Task.sleep(for: .seconds(delay))
            // loop and retry
        }
        // Non-retryable errors (and the final attempt) fall through and throw.
    }
}

This retries only isRetryable errors (transient transport failures, 5xx, 429 — never 4xx or decoding errors, which won’t fix on retry), up to a cap, with exponential backoff plus jitter. Exponential backoff (0.5s, 1s, 2s…) avoids hammering a struggling server; the random jitter prevents many clients that failed simultaneously from all retrying at the same instant (the “thundering herd”). For 429, it respects the server’s Retry-After. Crucially, it does not retry 4xx or decoding errors — those are deterministic failures that a retry can’t fix. Task.sleep is cancellation-aware, so a cancelled task abandons the retry cleanly.

The danger of retrying non-idempotent requests

A critical caveat: retrying is safe for idempotent requests (GET, PUT, DELETE) but dangerous for non-idempotent ones (POST). If a POST that creates a resource times out, you genuinely don’t know whether the server processed it — the request may have succeeded and the response was lost. Retrying blindly can create a duplicate resource. So retry policy must consider the method: freely retry idempotent requests on transient failures, but for POST either don’t auto-retry, or use an idempotency key (a client-generated unique ID sent in a header that the server uses to dedupe — Idempotency-Key: <uuid>), which lets the server recognize and ignore a duplicate. Many payment and creation APIs support idempotency keys precisely for safe retries. Don’t wrap POSTs in blind retry without one.

Combining refresh and retry

In practice, a fully authenticated, resilient request composes both mechanisms: the retry wrapper around the refresh-aware send, so transient failures retry with backoff and 401s trigger refresh:

func resilientSend<Response: Decodable>(_ endpoint: Endpoint<Response>) async throws -> Response {
    try await withRetry {
        try await self.sendAuthenticated(endpoint)
    }
}

This gives an idempotent authenticated GET both behaviors: a transient 5xx or timeout retries with backoff, and an expired token refreshes and retries — all transparently. For POSTs, you’d use a variant without blind retry (or with an idempotency key) to avoid duplicates.

Token refresh and retry pitfalls

Independent refresh per concurrent 401 (the stampede). Multiple refreshes waste calls and can invalidate tokens. Use single-flight refresh (one in-flight Task all callers await).

Unbounded retry-on-401. If auth is genuinely broken, this loops forever hammering the server. Retry once after refresh; then clear and prompt login.

Retrying non-retryable errors. A 4xx or decoding error fails identically on retry. Gate retries on isRetryable.

Blindly retrying POSTs. May create duplicate resources. Don’t auto-retry non-idempotent requests without an idempotency key.

No backoff or jitter. Immediate retries hammer a struggling server, and synchronized retries cause a thundering herd. Use exponential backoff with random jitter.

Ignoring Retry-After on 429. You retry too soon and stay throttled. Honor the header.

What to internalize

Expired access tokens should refresh transparently: a 401 triggers exchanging the refresh token for a new access token, then retrying the original request once (a second 401 means re-login). The essential subtlety is the stampede — concurrent requests all hitting 401 must not each start a refresh; use single-flight refresh where the first 401 starts one Task and all others await it, which an actor makes atomic and clean. Separately, retry transient failures (timeouts, dropped connections, 5xx, 429) with exponential backoff plus jitter, gated on an isRetryable check so 4xx and decoding errors never retry, and respecting Retry-After. Never blindly retry non-idempotent POSTs — they can duplicate resources; use an idempotency key for safe retries. Compose the retry wrapper around the refresh-aware send for fully resilient authenticated requests.


15. Uploading Data: uploadTask and Multipart Form Data

Sending substantial data to a server — an avatar image, a document, a video — uses upload tasks, and when you’re sending files alongside form fields, you build a multipart/form-data body by hand. This section covers uploadTask/upload(for:from:), constructing multipart bodies correctly (boundaries, headers, the exact byte layout that trips people up), tracking upload progress, and streaming large files.

upload vs. data for sending bodies

You’ve already sent small JSON bodies (Section 7). For larger payloads and files, the upload API is the right tool. The async version, upload(for:from:), sends a Data body; upload(for:fromFile:) streams a body from a file URL without loading it into memory:

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

// Upload directly from a file (memory-efficient for large files)
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL)

The difference from data(for:) is that upload tasks are designed for sending bodies: they support progress reporting via delegates (Section 18), can stream from a file without holding it in memory, and — uniquely — are the only task type (along with downloads) supported in background sessions (Section 17). For a large file upload that should survive backgrounding, you need an upload task in a background session. For a modest upload, upload(for:from:) with in-memory data is fine. The fromFile variant is the memory-conscious choice for large files: the system reads the file as it sends, so a 200 MB video doesn’t become a 200 MB Data in RAM.

What multipart/form-data is

When you submit a web form that includes a file (an <input type="file">), the browser encodes the whole form — text fields and file contents together — as multipart/form-data. APIs that accept file uploads alongside metadata expect this format. It’s a single request body composed of multiple “parts,” each separated by a unique boundary string, each part having its own headers describing what it is. The structure looks like this on the wire:

--Boundary-ABC123
Content-Disposition: form-data; name="title"

My Vacation Photo
--Boundary-ABC123
Content-Disposition: form-data; name="image"; filename="photo.jpg"
Content-Type: image/jpeg

<raw JPEG bytes>
--Boundary-ABC123--

Each part starts with -- followed by the boundary, then headers, then a blank line, then the part’s content. Text fields have a Content-Disposition naming the field. File parts add a filename and a Content-Type for the file. The whole body ends with the boundary followed by --. The boundary is a string you choose that must not appear in any part’s content (a UUID makes collision essentially impossible). Getting the exact byte layout right — the \r\n line endings, the blank lines, the trailing -- — is what trips people up, because a misplaced newline produces a body the server can’t parse, with an unhelpful error.

Building a multipart body correctly

Because the byte layout is exacting, encapsulate it in a builder rather than hand-assembling strings at the call site. The critical detail: HTTP uses CRLF (\r\n) line endings, and the structure is precise:

struct MultipartFormData {
    let boundary: String = "Boundary-\(UUID().uuidString)"
    private var body = Data()

    private mutating func append(_ string: String) {
        body.append(Data(string.utf8))
    }

    /// Add a plain text field.
    mutating func addField(name: String, value: String) {
        append("--\(boundary)\r\n")
        append("Content-Disposition: form-data; name=\"\(name)\"\r\n")
        append("\r\n")                              // blank line separates headers from content
        append("\(value)\r\n")
    }

    /// Add a file part with its bytes and MIME type.
    mutating func addFile(name: String, filename: String, mimeType: String, data: Data) {
        append("--\(boundary)\r\n")
        append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n")
        append("Content-Type: \(mimeType)\r\n")
        append("\r\n")
        body.append(data)                           // raw file bytes
        append("\r\n")
    }

    /// Finalize: the closing boundary has a trailing "--".
    mutating func finalize() -> Data {
        append("--\(boundary)--\r\n")
        return body
    }

    var contentType: String { "multipart/form-data; boundary=\(boundary)" }
}

Every line ending is \r\n (CRLF) as HTTP requires; each part begins with --boundary; headers are followed by a blank line; the body ends with --boundary--. The contentType property produces the header value that tells the server which boundary to split on — and it must match the boundary used in the body, which is why both come from the same instance. Encapsulating this means the fiddly byte layout is correct in one place; call sites just add fields and files.

A multipart upload end to end

Putting it together to upload an avatar image with a couple of metadata fields:

func uploadAvatar(imageData: Data, userID: Int, caption: String, token: String) async throws -> User {
    var form = MultipartFormData()
    form.addField(name: "user_id", value: String(userID))
    form.addField(name: "caption", value: caption)
    form.addFile(name: "avatar", filename: "avatar.jpg", mimeType: "image/jpeg", data: imageData)
    let bodyData = form.finalize()

    let url = try APIURLBuilder.url(path: "/users/\(userID)/avatar")
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")   // includes the boundary
    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

    let (data, response) = try await URLSession.shared.upload(for: request, from: bodyData)
    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw NetworkError.invalidResponse
    }
    return try JSONDecoder.api.decode(User.self, from: data)
}

The flow: build the multipart body with fields and the image file, set Content-Type to the form’s content type (which carries the boundary), authenticate, and upload. The server splits the body on the boundary, reads the fields and the file, and returns the updated user (with the new avatar URL), which you decode. The single most important detail is that the Content-Type header’s boundary matches the body’s boundary — using form.contentType guarantees that.

Tracking upload progress

For a sizable upload, users want a progress bar. The async upload methods don’t surface progress directly; you get it through a delegate (urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:), Section 18) or, more conveniently, by observing the task’s progress property. With a delegate-based session:

func urlSession(_ session: URLSession, task: URLSessionTask,
                didSendBodyData bytesSent: Int64,
                totalBytesSent: Int64,
                totalBytesExpectedToSend: Int64) {
    let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
    Task { @MainActor in
        self.uploadProgress = fraction   // drive a progress bar
    }
}

The delegate callback reports bytes sent versus expected, which you turn into a 0…1 fraction for a progress view (hopping to the main actor for the UI update). Alternatively, URLSessionUploadTask exposes a Foundation.Progress object you can observe. We’ll cover the delegate setup in Section 18; the point here is that progress for uploads (and downloads) comes through the delegate model, which is one reason those features still use delegates even in an async world.

Streaming large uploads from a file

For genuinely large files, holding the whole multipart body in memory is wasteful or impossible. Two approaches: use upload(for:fromFile:) with a body you’ve written to a temporary file (the system streams it), or set the request’s httpBodyStream to an InputStream. The file-based approach is simplest:

// Write the multipart body to a temp file, then upload from the file (streamed, low memory)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try form.finalize().write(to: tempURL)
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: tempURL)
try? FileManager.default.removeItem(at: tempURL)   // clean up after

Writing the body to a temp file and uploading fromFile means the system reads and sends the file incrementally rather than keeping it all in RAM — essential for large media. For very large uploads, you’d also want a background session (Section 17) so the transfer survives the app being backgrounded, which additionally requires the file-based form (background uploads must be from a file, not in-memory data). Clean up the temp file when done.

Upload pitfalls

Wrong line endings in the multipart body. HTTP requires CRLF (\r\n); using \n produces a body the server can’t parse. Use \r\n exactly, and encapsulate the layout in a builder.

Mismatched boundary between header and body. If the Content-Type boundary doesn’t match the body’s, the server can’t split the parts. Derive both from the same instance.

Forgetting the trailing -- on the closing boundary. The final boundary is --boundary--; omitting the -- leaves the body unterminated. Finalize correctly.

Loading a huge file into memory as Data. Wastes or exhausts RAM. Use upload(for:fromFile:) to stream from disk.

Expecting progress from the async upload. Async upload doesn’t surface progress directly. Use a delegate (or the task’s Progress) for a progress bar.

Using in-memory data for a background upload. Background uploads must be from a file. Write the body to a temp file first.

What to internalize

Send substantial data with the upload API — upload(for:from:) for in-memory bodies, upload(for:fromFile:) to stream large files without holding them in RAM (and the only option for background uploads). For files-plus-fields, build a multipart/form-data body: multiple parts separated by a unique boundary, each with a Content-Disposition (text fields) or Content-Disposition + Content-Type (file parts), ending with --boundary--, all using CRLF (\r\n) line endings — encapsulate this exacting byte layout in a builder, and set the request’s Content-Type to the form’s content type so the boundary matches. Get upload progress through a delegate callback (bytes sent vs. expected) since the async API doesn’t surface it. For large or background uploads, write the body to a temp file and upload from the file. The recurring failure mode is a malformed body from wrong line endings or a mismatched boundary — let a builder get it right once.


16. Downloading Files: downloadTask and Resumable Downloads

Fetching a large file — a video, a PDF, a database export — to disk uses download tasks rather than data tasks. The difference is fundamental: a data task accumulates the whole response in memory, while a download task streams it straight to a temporary file, so a 500 MB download never becomes a 500 MB Data. This section covers downloadTask/download(for:), the delivered-to-a-temp-file model that surprises everyone once, resuming interrupted downloads, progress, and where to save the result.

Why downloads go to a file

A data task is fine for JSON and small payloads; for large binary files it’s a disaster, because the entire response sits in RAM. Download tasks solve this by writing the response body to a temporary file on disk as it arrives. You get back a file URL, not Data. The async API:

let (tempURL, response) = try await URLSession.shared.download(from: url)
// tempURL points to a temporary file containing the downloaded bytes

download(from:) (and download(for:) taking a URLRequest) returns the URL of a temporary file holding the downloaded content, plus the response. The memory footprint stays tiny regardless of file size because the bytes go to disk, not memory. This is the right tool whenever the response is a file rather than data you’ll immediately parse.

The temp file is deleted when the closure returns — move it immediately

Here is the gotcha that bites everyone once: the temporary file URL is only valid briefly, and the system deletes that temp file as soon as your download handler returns. If you don’t move or copy the file somewhere permanent before the function returns (or before the delegate callback finishes), the file is gone and the URL points at nothing. So the first thing you do with the temp URL is relocate the file:

func downloadArticleAttachment(id: Int) async throws -> URL {
    let url = try APIURLBuilder.url(path: "/articles/\(id)/attachment")
    let (tempURL, response) = try await URLSession.shared.download(from: url)

    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw NetworkError.invalidResponse
    }

    // Move the temp file to a permanent location IMMEDIATELY — before returning.
    let destination = try permanentURL(for: id, suggestedName: response.suggestedFilename)
    try? FileManager.default.removeItem(at: destination)   // replace any existing
    try FileManager.default.moveItem(at: tempURL, to: destination)
    return destination
}

The moveItem (or copyItem) must happen before the function returns, while the temp file still exists. With the async API the window is the body of your function; with the delegate API (Section 18) it’s the body of urlSession(_:downloadTask:didFinishDownloadingTo:). Miss this window and you’ll see a baffling “no such file” when you later try to use the URL. Always relocate first, do everything else after.

Where to save downloaded files

Where you move the file matters for correctness and for how iOS treats it:

func permanentURL(for id: Int, suggestedName: String?) throws -> URL {
    let fileManager = FileManager.default
    // Application Support for files your app needs and manages (backed up by default)
    let supportDir = try fileManager.url(for: .applicationSupportDirectory,
                                         in: .userDomainMask, appropriateFor: nil, create: true)
    let name = suggestedName ?? "attachment-\(id)"
    return supportDir.appendingPathComponent(name)
}

The main directories and their semantics: Application Support (.applicationSupportDirectory) for files your app needs and manages — backed up to iCloud/iTunes by default, so use it for downloads the user would want restored. Caches (.cachesDirectory) for files you can regenerate — not backed up and the system may purge them under storage pressure, so use it for re-downloadable content. Documents (.documentDirectory) for user-visible/user-created files (especially if your app exposes files to the Files app). Avoid the temporary directory for anything you want to keep (the system clears it). A subtlety for App Store guidelines: large re-downloadable files should go in Caches (or be marked with isExcludedFromBackup), because Apple rejects apps that fill up users’ iCloud backups with regenerable data. Set the exclude-from-backup flag when appropriate:

var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
var fileURL = destination
try fileURL.setResourceValues(resourceValues)

Resumable downloads

A large download interrupted partway — the app backgrounded, the connection dropped, the user cancelled — shouldn’t have to start over. URLSession supports resuming via resume data: when a download is cancelled in a way that produces resume data (or fails recoverably), you get a Data blob describing the partial download, which you can hand to a new download task to continue from where it left off. This is delegate/completion-handler territory (the async API doesn’t expose it as cleanly), so here’s the model:

final class ResumableDownloader: NSObject, URLSessionDownloadDelegate {
    private var session: URLSession!
    private var task: URLSessionDownloadTask?
    private var resumeData: Data?

    override init() {
        super.init()
        session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
    }

    func start(url: URL) {
        task = session.downloadTask(with: url)
        task?.resume()
    }

    func pause() {
        // Cancelling this way produces resume data instead of discarding progress.
        task?.cancel(byProducingResumeData: { [weak self] data in
            self?.resumeData = data
        })
    }

    func resumeDownload() {
        guard let resumeData else { return }
        // Create a new task that continues from the saved progress.
        task = session.downloadTask(withResumeData: resumeData)
        self.resumeData = nil
        task?.resume()
    }

    // Delegate: download finished — move the file before this returns.
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        let destination = FileManager.default.temporaryDirectory.appendingPathComponent("final")
        try? FileManager.default.moveItem(at: location, to: destination)
    }
}

The mechanism: cancel(byProducingResumeData:) cancels but hands you a resume-data blob capturing the partial download; downloadTask(withResumeData:) starts a new task that continues rather than restarting. This requires server support (the server must honor HTTP range requests, which most file servers do) — the resume data encodes the byte offset and a validator (an ETag or Last-Modified) so the server can verify the partial file is still valid to continue. If the resource changed on the server, the resume fails and you restart. Resume data also arrives in the error’s userInfo (under NSURLSessionDownloadTaskResumeData) when a download fails recoverably, so you can resume after an unexpected interruption, not just a deliberate pause.

Progress for downloads

Download progress comes through the delegate, just like uploads — urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:) reports bytes written versus expected:

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                didWriteData bytesWritten: Int64,
                totalBytesWritten: Int64,
                totalBytesExpectedToWrite: Int64) {
    guard totalBytesExpectedToWrite > 0 else { return }   // -1 if server didn't send Content-Length
    let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
    Task { @MainActor in self.progress = fraction }
}

The fraction drives a progress bar (hopping to the main actor for the UI). One caveat: totalBytesExpectedToWrite is -1 (technically NSURLSessionTransferSizeUnknown) when the server doesn’t send a Content-Length header, in which case you can’t compute a percentage and should show an indeterminate indicator instead. Guard against that.

Download pitfalls

Using a data task for a large file. The whole response sits in memory — wasteful or fatal. Use a download task that streams to disk.

Not moving the temp file before the handler returns. The system deletes it immediately after; your URL then points at nothing. Move/copy it first thing.

Saving re-downloadable files where they get backed up. Bloats iCloud backups (an App Store rejection risk). Use Caches or set isExcludedFromBackup.

Restarting an interrupted large download from zero. Wastes bandwidth and time. Use cancel(byProducingResumeData:) and downloadTask(withResumeData:).

Computing progress when totalBytesExpectedToWrite is -1. Produces a nonsense percentage. Detect the unknown-size case and show an indeterminate indicator.

Assuming resume always works. If the server resource changed, resume fails. Handle the fallback by restarting.

What to internalize

Large files use download tasks, which stream the response to a temporary file on disk (returning a file URL, not Data) so memory stays flat regardless of size. The defining gotcha: the temp file is deleted the instant your handler returns, so you must moveItem it to a permanent location first, before anything else. Choose that location by semantics — Application Support for managed files worth backing up, Caches (or isExcludedFromBackup) for re-downloadable content to avoid bloating iCloud backups. Resume interrupted downloads with cancel(byProducingResumeData:) and downloadTask(withResumeData:), which continues from the saved offset given server range support (and falls back to restarting if the resource changed). Download progress, like upload progress, comes through a delegate callback — guarding against the -1 unknown-size case. These file-based, delegate-driven transfers are also the foundation for background downloads, next.


17. Background Sessions and Out-of-Process Transfers

When a download or upload must continue even after the user leaves your app — a large video upload, a podcast download, syncing a big dataset — you need a background session. Background sessions hand the transfer to a system process that runs independently of your app, continuing while your app is suspended and even relaunching your app to handle completion. This is powerful but constrained, and getting the lifecycle right (especially the relaunch handshake) is essential. This section covers background configuration, the restrictions, and the AppDelegate wiring that makes it work.

What a background session does

A normal session’s transfers stop when your app is suspended (shortly after the user backgrounds it). A background session, created with URLSessionConfiguration.background(withIdentifier:), instead hands its tasks to the system’s nsurlsessiond daemon, which performs the transfers out of process. The transfer continues regardless of your app’s state — suspended, or even terminated by the system to reclaim memory. When a transfer completes, the system relaunches your app in the background (if needed) and notifies you so you can handle the result. This is how apps download large files or upload media reliably across the unpredictable lifecycle of a backgrounded iOS app.

let identifier = "com.example.app.background"
let config = URLSessionConfiguration.background(withIdentifier: identifier)
config.isDiscretionary = false          // true lets the system pick an optimal time (Wi-Fi, charging)
config.sessionSendsLaunchEvents = true  // relaunch the app in the background on completion
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

isDiscretionary (when true) lets the system defer the transfer to an opportune moment — on Wi-Fi, while charging — which is appropriate for large, non-urgent transfers and is forced true for transfers started while backgrounded. sessionSendsLaunchEvents (the default) means the system relaunches your app to deliver completion events.

The hard restrictions

Background sessions trade convenience for their power, and the restrictions are strict and non-negotiable:

  • A delegate is required; completion handlers are not allowed. You cannot use download(from:) or a completion-handler task with a background session. All results come through delegate callbacks (Section 18). This is because your app may not even be running when the transfer completes — there’s no closure to call, only a delegate the relaunched app reconstructs.
  • Only upload and download tasks — no data tasks. Background sessions don’t support dataTask at all (data tasks accumulate in memory, which doesn’t survive your app being killed). Only file-based downloads and uploads.
  • Uploads must be from a file, not in-memory data. upload(for:fromFile:) only; upload(for:from: someData) isn’t supported in the background, because the in-memory data wouldn’t survive your app’s termination. Write the body to a file first (recall Section 15’s multipart-to-temp-file).
  • One session per identifier. The identifier must be unique and stable; you re-create the same session (same identifier) after relaunch to reconnect to its in-flight tasks. Creating two sessions with the same identifier is an error.

These constraints follow directly from the model: the transfer outlives your app’s process, so everything must be reconstructable from disk and delivered through a delegate the relaunched app sets up.

The relaunch handshake

The trickiest part is handling completion when the system relaunches your app. The flow has three pieces that must fit together:

First, when the system finishes a background transfer and relaunches your app, it calls a UIKit app-lifecycle method handing you a completion handler you must store and call later:

// In AppDelegate
var backgroundCompletionHandler: (() -> Void)?

func application(_ application: UIApplication,
                 handleEventsForBackgroundURLSession identifier: String,
                 completionHandler: @escaping () -> Void) {
    // Store the handler — we'll call it once we've processed all the session's events.
    backgroundCompletionHandler = completionHandler
    // Re-create the SAME background session (same identifier) so its delegate reconnects.
    _ = BackgroundTransferManager.shared   // ensures the session is recreated
}

Second, you re-create the background session with the same identifier so it reconnects to the daemon’s in-flight/completed tasks and starts delivering their delegate callbacks. This is why the manager that owns the session is typically a singleton recreated on launch.

Third, once the session has delivered all its queued events, it calls urlSessionDidFinishEvents(forBackgroundURLSession:), and that is where you invoke the stored completion handler — on the main thread — to tell the system you’re done (which lets it snapshot your UI and re-suspend):

func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
    DispatchQueue.main.async {
        let appDelegate = UIApplication.shared.delegate as? AppDelegate
        appDelegate?.backgroundCompletionHandler?()
        appDelegate?.backgroundCompletionHandler = nil
    }
}

The handshake in plain terms: the system relaunches you and gives you a completion handler; you recreate the session so its delegate callbacks fire (where you move downloaded files, update your store, etc.); when the session signals all events are processed, you call the stored handler to tell the system you’ve finished. Calling that handler is required and must be on the main thread — skipping it makes the system think your app is still working and wastes resources (and eventually it’ll stop relaunching you promptly). In a SwiftUI app without an explicit AppDelegate, you wire handleEventsForBackgroundURLSession via UIApplicationDelegateAdaptor or the scene-based equivalent; the mechanism is the same.

A background transfer manager

Tying it together, a singleton that owns the background session and handles its delegate callbacks:

final class BackgroundTransferManager: NSObject, URLSessionDownloadDelegate {
    static let shared = BackgroundTransferManager()
    private let identifier = "com.example.app.background"
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: identifier)
        config.sessionSendsLaunchEvents = true
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    func download(_ url: URL) {
        let task = session.downloadTask(with: url)
        task.resume()
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        // Runs even if the app was relaunched. Move the file NOW (Section 16's rule).
        let dest = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            .appendingPathComponent(downloadTask.originalRequest?.url?.lastPathComponent ?? "file")
        try? FileManager.default.removeItem(at: dest)
        try? FileManager.default.moveItem(at: location, to: dest)
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error { print("Background transfer failed: \(error)") }
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        DispatchQueue.main.async {
            (UIApplication.shared.delegate as? AppDelegate)?.backgroundCompletionHandler?()
            (UIApplication.shared.delegate as? AppDelegate)?.backgroundCompletionHandler = nil
        }
    }
}

The manager lazily creates the background session with itself as delegate, and its didFinishDownloadingTo moves the downloaded file (following Section 16’s move-immediately rule, which applies here too). Because it’s a stable singleton with a fixed identifier, re-touching BackgroundTransferManager.shared on relaunch recreates the same session and reconnects to its tasks. The completion-handler call in urlSessionDidFinishEvents closes the loop with the system.

When to use background sessions

Background sessions are the right tool for large, must-complete-eventually transfers: uploading a recorded video, downloading offline content (podcasts, video, large datasets), syncing big payloads. They’re overkill — and add real complexity — for ordinary API calls, which complete in milliseconds while the app is foregrounded. Don’t reach for a background session for your JSON endpoints; use a normal session. Reserve background sessions for genuinely large or long transfers where surviving suspension matters, and accept the delegate-only, file-only constraints as the cost of that durability. They also interact with the system’s energy and data policies (isDiscretionary, the system’s scheduling), so a background transfer may not start instantly — which is fine for its use cases.

Background session pitfalls

Trying to use completion handlers or data tasks. Background sessions forbid both — delegate-only, upload/download-only. Use delegate callbacks and file-based tasks.

Uploading in-memory data in the background. Not supported; the data wouldn’t survive termination. Write the body to a file and use upload(fromFile:).

Not re-creating the session with the same identifier on relaunch. The delegate never reconnects and completions are lost. Recreate the same-identifier session at launch.

Forgetting to call the stored background completion handler. The system thinks you’re still working, wastes resources, and deprioritizes future relaunches. Call it (on the main thread) in urlSessionDidFinishEvents.

Not moving the downloaded file in the delegate callback. Same temp-file deletion rule applies — and here the app may have just been relaunched. Move it immediately.

Using a background session for ordinary fast API calls. Needless complexity and scheduling latency. Use a normal session; reserve background for large/long transfers.

What to internalize

A background session (URLSessionConfiguration.background(withIdentifier:)) hands transfers to a system daemon that continues them while your app is suspended or terminated, relaunching your app to handle completion. The cost is strict constraints: a delegate is required (no completion handlers), only upload/download tasks (no data tasks), uploads must be from a file (no in-memory data), and the identifier must be unique and stable so you re-create the same session on relaunch to reconnect. Get the relaunch handshake right: the system calls handleEventsForBackgroundURLSession with a completion handler you store; you recreate the session so its delegate callbacks fire (moving downloaded files immediately); and when the session signals all events processed via urlSessionDidFinishEvents, you call the stored handler on the main thread. Reserve background sessions for large, must-finish transfers — not ordinary fast API calls.


18. URLSessionDelegate and the Delegate Pattern

You’ve now met delegates several times — for upload/download progress, for background sessions, for resume data. The delegate API is the substrate beneath the convenience APIs: every URLSession transfer is, underneath, a sequence of delegate callbacks, and the completion-handler and async methods are conveniences built over them. Understanding the delegate protocols directly is what lets you do progress reporting, redirect control, certificate pinning (Section 19), and background transfers. This section maps the delegate protocol hierarchy, the key callbacks, and the lifecycle concerns (especially the retain behavior that leaks sessions).

The delegate protocol hierarchy

URLSession’s delegate functionality is split across a hierarchy of protocols, each adding callbacks for a category of events. You conform to the ones relevant to your needs:

  • URLSessionDelegate — session-level events: the session becoming invalid, and session-wide authentication challenges (Section 19). The base.
  • URLSessionTaskDelegate — events common to all tasks: task completion (didCompleteWithError), task-level auth challenges, redirects, upload progress (didSendBodyData), and waiting-for-connectivity.
  • URLSessionDataDelegate — events specific to data (and upload) tasks: receiving the response (didReceive response), receiving data chunks (didReceive data), and caching decisions.
  • URLSessionDownloadDelegate — events specific to download tasks: download progress (didWriteData) and finishing (didFinishDownloadingTo).

These compose: a class handling downloads conforms to URLSessionDownloadDelegate (which inherits from the task and session delegates), implementing the callbacks it cares about. You set the delegate when creating the session — it can’t be changed afterward, and URLSession.shared can’t have one (another reason to create your own session):

let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)

The delegateQueue is the OperationQueue your callbacks run on. Passing nil gives you a serial background queue (callbacks are serialized but not on main) — the common choice; you hop to main for UI yourself. Passing .main runs callbacks on the main queue (convenient for UI but risky for heavy work).

Key task-level callbacks

The callbacks you’ll implement most often. Task completion — the delegate equivalent of the completion handler’s final call, reporting success (nil error) or transport failure:

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    if let error = error as? URLError {
        // transport failure (or .cancelled)
    } else {
        // task completed — for a data task, you've accumulated data in didReceive
    }
}

Receiving data in chunks (for data tasks driven via the delegate API, you accumulate the body yourself):

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
    // Called possibly multiple times as data arrives; append to a buffer.
    buffer.append(data)
}

Upload progress (Section 15) and download progress (Section 16) — the byte-count callbacks. Redirect control, which lets you inspect or modify a redirect before it’s followed (or prevent it by completing with nil):

func urlSession(_ session: URLSession, task: URLSessionTask,
                willPerformHTTPRedirection response: HTTPURLResponse,
                newRequest request: URLRequest,
                completionHandler: @escaping (URLRequest?) -> Void) {
    // Follow the redirect as-is:
    completionHandler(request)
    // Or block it: completionHandler(nil)
    // Or modify it: completionHandler(modifiedRequest)
}

By default URLSession follows redirects automatically; implementing this callback lets you take control — useful for security (refusing to follow a redirect to a different host) or for handling auth across redirects. Many delegate callbacks follow this completion-handler-style pattern where you decide something and call back with the decision.

Why delegates remain necessary

In an async world, why keep delegates? Because several capabilities are inherently event-streams or decision-points that a single await can’t express:

  • Progress — uploads and downloads emit a stream of byte-count events over time. A single returned value can’t convey progress; you need the repeated callback.
  • Authentication challenges — when the server presents a TLS challenge, the session must pause and ask your code how to proceed (trust the certificate? provide a credential?). That’s a decision-point callback (Section 19).
  • Redirect decisions — likewise a per-redirect decision-point.
  • Background sessions — your app may not be running; there’s no await to suspend, only a delegate the relaunched app reconstructs (Section 17).
  • Streaming/incremental data — receiving and processing data as it arrives, rather than all at once.

The async APIs handle the common request-response case beautifully and should be your default. You drop to the delegate model specifically for these progress/decision/streaming/background needs. There’s also a middle ground: iOS 15+ lets you pass a per-task delegate to even the async methods (data(for:delegate:)), so you can get, say, progress callbacks for an otherwise-async request — combining the ergonomics of async with the delegate’s event stream where you need it.

The session retain gotcha and invalidation

A genuinely important lifecycle detail: a URLSession created with a delegate keeps a strong reference to that delegate until the session is invalidated. This is unusual — most delegates are weak — and it means a session holding your object can create a retain cycle or simply keep your object (and the session) alive indefinitely. If you create a session per object and never invalidate it, you leak both. The fix is to invalidate the session when you’re done with it, which releases the delegate:

// Finishes in-flight tasks, then invalidates and releases the delegate:
session.finishTasksAndInvalidate()

// Or cancel everything immediately and invalidate:
session.invalidateAndCancel()

finishTasksAndInvalidate() lets outstanding tasks complete, then tears down the session and releases its delegate. invalidateAndCancel() cancels in-flight tasks immediately. Either breaks the retain. After invalidation the session is unusable — you can’t create new tasks on it. For a long-lived session that lives for the app’s lifetime (your main API session), this is moot; the leak matters for sessions tied to a shorter-lived object (a download manager for one screen). The rule: if a session’s lifetime is shorter than the app’s, invalidate it when done, and be mindful that the delegate is retained until you do.

A complete delegate-based download manager

Bringing the pieces together in a download manager that reports progress and handles completion via delegates:

@MainActor
final class DownloadManager: NSObject, ObservableObject, URLSessionDownloadDelegate {
    @Published var progress: Double = 0
    @Published var localURL: URL?
    private var session: URLSession!

    override init() {
        super.init()
        let config = URLSessionConfiguration.default
        session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }

    func start(_ url: URL) {
        session.downloadTask(with: url).resume()
    }

    nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                                didWriteData bytesWritten: Int64,
                                totalBytesWritten: Int64,
                                totalBytesExpectedToWrite: Int64) {
        guard totalBytesExpectedToWrite > 0 else { return }
        let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        Task { @MainActor in self.progress = fraction }
    }

    nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                                didFinishDownloadingTo location: URL) {
        let dest = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        try? FileManager.default.moveItem(at: location, to: dest)   // move before returning
        Task { @MainActor in self.localURL = dest }
    }

    deinit {
        session.invalidateAndCancel()   // release the delegate, avoid the leak
    }
}

The delegate callbacks are nonisolated (they’re called by the session off the main actor) and hop to @MainActor to publish UI state. Progress streams in via didWriteData; completion delivers the file via didFinishDownloadingTo (moving it immediately per Section 16). The deinit invalidates the session to release the retained delegate. This is the canonical shape of a delegate-driven transfer with progress — exactly the kind of thing the async API can’t do alone.

Delegate pattern pitfalls

Expecting a delegate when using URLSession.shared. The shared session can’t have one. Create your own session to use delegates.

Leaking the session by never invalidating it. A delegated session strongly retains its delegate until invalidated. For shorter-lived sessions, call finishTasksAndInvalidate() (or invalidateAndCancel()) when done.

Doing heavy work on a .main delegate queue. Blocks the UI. Use a background delegateQueue (pass nil) and hop to main only for UI updates.

Forgetting that didReceive data can fire multiple times. Each call delivers a chunk; you must accumulate. Append to a buffer, don’t assume one call.

Reaching for delegates when async suffices. The delegate API is more code and easy to get wrong. Use async for plain request-response; use delegates only for progress, challenges, redirects, streaming, or background.

Not handling the redirect callback when security matters. Auto-following redirects can leak credentials to an unexpected host. Implement willPerformHTTPRedirection to control or block redirects when needed.

What to internalize

The delegate model is the substrate beneath URLSession’s convenience APIs: every transfer is underneath a sequence of callbacks across a protocol hierarchy — URLSessionDelegate (session events, auth), URLSessionTaskDelegate (completion, upload progress, redirects), URLSessionDataDelegate (incremental data), URLSessionDownloadDelegate (download progress and finishing). Set the delegate at session creation (so not on URLSession.shared), choosing the delegateQueue (nil for a background serial queue). Use delegates specifically for what await can’t express — progress streams, authentication and redirect decision-points, incremental data, and background transfers — while keeping async as your default for plain request-response (and noting data(for:delegate:) bridges the two). Critically, a delegated session strongly retains its delegate until invalidated, so invalidate shorter-lived sessions (finishTasksAndInvalidate/invalidateAndCancel) to avoid leaks. This delegate foundation is what the next section’s certificate pinning builds on.


19. Authentication Challenges and Certificate Pinning

When your app connects over HTTPS, the system validates the server’s TLS certificate against the trusted certificate authorities — and for most apps, that default validation is exactly right. But security-sensitive apps (banking, healthcare, anything handling credentials or payments) often want certificate pinning: only trusting a specific certificate or public key you’ve embedded, so that even a compromised or fraudulently-issued CA certificate can’t enable a man-in-the-middle attack. Pinning is implemented through the authentication-challenge delegate callback. This section covers TLS challenges, App Transport Security, and how to pin correctly (and the serious risks of doing it wrong).

App Transport Security: the baseline

Before pinning, know the baseline iOS gives you for free. App Transport Security (ATS) requires that network connections use HTTPS with TLS 1.2+, forward secrecy, and strong ciphers — enforced by the system, on by default. You don’t write code for this; ATS simply blocks insecure connections. You can weaken it via Info.plist exceptions (NSAppTransportSecurity), but doing so requires justification at App Review and is almost always the wrong move. The correct posture is: leave ATS on, use HTTPS everywhere, and add pinning on top if your threat model warrants it. ATS protects against passive eavesdropping and weak crypto; pinning protects against a specific, stronger attack (a trusted-but-malicious certificate).

The authentication challenge callback

When the system needs to make a trust or credential decision during a connection, it pauses and asks your delegate via urlSession(_:didReceive:completionHandler:). You inspect the challenge, decide how to respond, and call the completion handler with your disposition:

func urlSession(_ session: URLSession,
                didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    let space = challenge.protectionSpace
    // Is this a server-trust (TLS certificate) challenge?
    guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          let serverTrust = space.serverTrust else {
        completionHandler(.performDefaultHandling, nil)   // not a trust challenge — let the system handle it
        return
    }
    // ... evaluate / pin here ...
}

The protectionSpace.authenticationMethod tells you what kind of challenge it is. NSURLAuthenticationMethodServerTrust is the TLS server-certificate evaluation — the one pinning hooks into. Other methods (...HTTPBasic, ...ClientCertificate) cover HTTP basic auth and mutual TLS. The disposition you return is one of: .performDefaultHandling (let the system do its normal validation), .useCredential (proceed with a credential you provide — for pinning, the validated server trust), .cancelAuthenticationChallenge (reject — fails the connection), or .rejectProtectionSpaceAndContinue. For challenges you don’t handle, return .performDefaultHandling so the system’s normal validation runs.

Public-key pinning, done correctly

The robust form of pinning is public-key pinning (pinning the server’s public key / SPKI hash) rather than pinning the entire certificate. The reason: certificates are routinely renewed (often yearly or more frequently with automated issuance), and a renewed certificate has a new expiry and signature — but usually the same public key. If you pin the whole certificate, every renewal breaks your app until you ship an update; if you pin the public key, renewals with the same key keep working. Here’s the evaluation:

import CryptoKit

func urlSession(_ session: URLSession,
                didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    let space = challenge.protectionSpace
    guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          let serverTrust = space.serverTrust else {
        completionHandler(.performDefaultHandling, nil)
        return
    }

    // 1. First, let the system validate the chain normally (expiry, CA, hostname).
    var error: CFError?
    guard SecTrustEvaluateWithError(serverTrust, &error) else {
        completionHandler(.cancelAuthenticationChallenge, nil)   // chain itself is invalid
        return
    }

    // 2. Extract the server's leaf certificate public key and hash it.
    guard let leaf = SecTrustGetCertificateAtIndex(serverTrust, 0),
          let publicKey = SecCertificateCopyKey(leaf),
          let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
        completionHandler(.cancelAuthenticationChallenge, nil)
        return
    }
    let keyHash = Data(SHA256.hash(data: keyData)).base64EncodedString()

    // 3. Compare against the pinned hash(es) you bundled in the app.
    let pinnedHashes: Set<String> = [
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",   // current key
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="    // backup key (see below)
    ]
    if pinnedHashes.contains(keyHash) {
        completionHandler(.useCredential, URLCredential(trust: serverTrust))   // pin matched → proceed
    } else {
        completionHandler(.cancelAuthenticationChallenge, nil)                 // pin mismatch → refuse
    }
}

The evaluation does two things in order: first it runs the normal system validation (SecTrustEvaluateWithError) so an expired or untrusted-CA certificate is still rejected — pinning is additional to standard validation, never a replacement. Then it extracts the leaf certificate’s public key, hashes it (SHA-256, base64-encoded — the SPKI-pin format), and checks it against the set of hashes you’ve bundled in the app. Match means proceed with the server trust as a credential; mismatch means cancel the connection. Doing the normal validation first is important: pinning alone without chain validation would accept an expired or self-signed certificate as long as its key matched, weakening security.

The backup pin: avoid bricking your app

A critical operational point that pinning newcomers miss: always pin at least two keys — the current one and a backup. If you pin only your current public key and that key is compromised (or you need to rotate it for any reason), you must generate a new key, but every installed copy of your app pins the old one and will refuse to connect to the new certificate — bricking your app for all users until they update, which you can’t force. By pinning a backup key (whose private key you keep offline, unused, in reserve), you can rotate to the backup without an app update: switch the server to a certificate using the backup key, and existing apps accept it. Then ship an app update introducing a new backup. Pinning without a backup is a self-inflicted outage waiting to happen. This operational discipline is as important as the code.

The real risks of pinning

Pinning is a sharp tool, and it’s worth being honest about the downsides, because they’re significant:

  • It can brick your app. As above — a botched key rotation, an expired-and-renewed-with-new-key certificate, or a CDN change can make every installed app refuse to connect, with no server-side fix. This has caused real, severe outages for major apps.
  • It complicates operations. Your certificate/key management and your app release cycle become coupled. Anyone managing the server’s TLS must coordinate with the app team.
  • It’s often unnecessary. ATS plus standard CA validation already protects the vast majority of apps. Pinning earns its complexity only for high-value targets (finance, health, credentials) with a threat model that includes CA compromise.

So the guidance is: don’t pin reflexively. Pin when your threat model genuinely warrants it, do it as public-key pinning with a backup pin, always layer it on top of (not instead of) normal validation, and have a tested rotation plan. For many apps, the right answer is “don’t pin; rely on ATS and CA validation.” Knowing how to pin is valuable; knowing whether to is more so.

Never disable validation

A pattern you’ll see in tutorials and Stack Overflow answers — and must never ship — is disabling certificate validation entirely to “make it work” with a self-signed dev certificate:

// NEVER DO THIS IN PRODUCTION — it accepts ANY certificate, enabling trivial MITM.
completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
// (returning useCredential with the server trust WITHOUT validating it)

Unconditionally trusting whatever certificate the server presents defeats the entire purpose of TLS — any attacker on the network can present their own certificate and decrypt your traffic. This shows up when developers fight a self-signed certificate on a dev server. The right fix for dev is to add the dev CA to the simulator/device trust store, or use a properly-issued certificate (Let’s Encrypt is free), or scope an ATS exception to the dev host only — never to globally disable validation in code, which inevitably ships to production. If you see this pattern, treat it as a critical security bug.

Authentication challenge pitfalls

Disabling certificate validation to bypass a dev-certificate error. Accepts any certificate, enabling trivial MITM, and tends to ship to production. Never do it; fix the dev certificate properly.

Pinning the certificate instead of the public key. Every certificate renewal breaks the app. Pin the public key (SPKI hash), which survives renewals with the same key.

Pinning only one key (no backup). A forced key rotation bricks the app for all users with no server-side fix. Always pin a current key and an offline backup key.

Skipping normal chain validation when pinning. Pinning alone would accept expired or self-signed certificates whose key matches. Run SecTrustEvaluateWithError first, then check the pin.

Returning the wrong disposition for unhandled challenges. Cancelling a challenge you didn’t mean to handle breaks the connection. Return .performDefaultHandling for challenges you don’t pin.

Pinning when you don’t need to. Adds operational risk for apps that ATS + CA validation already protect. Pin only when your threat model warrants it.

What to internalize

iOS gives you strong transport security for free via App Transport Security (HTTPS, TLS 1.2+, on by default) plus standard CA certificate validation — sufficient for most apps; leave it on. Certificate pinning adds protection against a compromised or fraudulent CA by trusting only keys you’ve embedded, implemented in the urlSession(_:didReceive:completionHandler:) challenge callback for NSURLAuthenticationMethodServerTrust. Do it correctly: validate the chain normally first (SecTrustEvaluateWithError), then pin the public key (SHA-256 SPKI hash, not the whole certificate, so renewals don’t break you), and always pin a backup key kept offline so a forced rotation doesn’t brick your app. Never disable validation to bypass a dev-certificate error — that ships to production and defeats TLS entirely. And weigh whether to pin at all: it’s a sharp tool with real bricking risk, justified for high-value apps but unnecessary for many.


20. Caching with URLCache and Cache Policies

Re-fetching data that hasn’t changed wastes bandwidth, battery, and time. HTTP has a rich caching model, and URLSession implements it through URLCache — a transparent store that can satisfy requests from a local cache, revalidate stale entries cheaply, and respect the server’s caching directives, often with no code on your part. Understanding how it works, the cache policies you can set, and where URLCache helps versus where you need app-level caching is the subject of this section.

How HTTP caching works

The foundation is server-provided caching headers. When a server responds, it can include directives telling clients how long the response is fresh and how to revalidate it:

  • Cache-Control: max-age=3600 — the response is fresh for 3600 seconds; within that window a cache can serve it without contacting the server. Also no-store (never cache), no-cache (cache but always revalidate), private/public.
  • ETag: "abc123" — an opaque version identifier for the response. The client can later send If-None-Match: "abc123" and the server replies 304 Not Modified (with no body) if unchanged, saving the payload.
  • Last-Modified: <date> — a timestamp the client echoes in If-Modified-Since for the same revalidation, again yielding 304 if unchanged.

This model means a well-behaved server and client can avoid re-downloading unchanged data: serve from cache while fresh, and when stale, revalidate cheaply (a 304 with no body) rather than re-fetching the whole response. URLSession + URLCache implement the client side of this automatically when you let them.

URLCache: the automatic cache

URLCache is the store URLSession uses to cache responses. A default session has a URLCache configured, and for cacheable responses (the server sent appropriate headers, the request method is cacheable) it transparently stores and serves them. You can inspect or customize the cache via the configuration:

let config = URLSessionConfiguration.default
config.urlCache = URLCache(
    memoryCapacity: 20 * 1024 * 1024,   // 20 MB in memory
    diskCapacity: 100 * 1024 * 1024,    // 100 MB on disk
    directory: nil                       // default location
)
config.requestCachePolicy = .useProtocolCachePolicy   // honor server caching headers (the default)

URLCache has separate memory and disk capacities; responses are stored and evicted under those limits. The default capacities are modest, so if you want to cache more aggressively, bump them. For most apps, the default URLCache behavior — honoring server caching headers — is exactly what you want and requires no code: cacheable GETs are cached and revalidated by the system. You mostly interact with it when you want to change the policy, size, or clear it.

Cache policies

The cachePolicy (on the configuration as requestCachePolicy, or per-request on URLRequest) controls how a request interacts with the cache:

request.cachePolicy = .useProtocolCachePolicy          // (default) honor HTTP caching headers
request.cachePolicy = .reloadIgnoringLocalCacheData    // always hit the network, ignore cache
request.cachePolicy = .returnCacheDataElseLoad         // use cache if present (any age), else network
request.cachePolicy = .returnCacheDataDontLoad         // cache only; fail if not cached (offline mode)

The policies and when to use them: .useProtocolCachePolicy is the default and right answer for most requests — it follows the server’s Cache-Control/ETag directives, serving fresh cache and revalidating stale entries. .reloadIgnoringLocalCacheData forces a network fetch regardless of cache — use it for a pull-to-refresh where the user explicitly wants the latest. .returnCacheDataElseLoad serves any cached copy regardless of freshness, falling back to network only if nothing’s cached — useful for showing something instantly while you refresh in the background. .returnCacheDataDontLoad never touches the network — the basis of an offline mode that shows cached content only. Choosing the policy per use case (default for normal reads, ignore-cache for refresh, cache-only for offline) gives you the behavior you want.

Why URLCache may not be doing what you expect

A frequent source of confusion: developers assume URLCache is caching when it isn’t, or vice versa. The cache only works when several conditions align: the request is a GET (POST responses generally aren’t cached), the server sent cacheable headers (a response with Cache-Control: no-store won’t be cached; a response with no caching headers may or may not be cached heuristically), the response fits within the cache’s capacity, and the cache policy permits it. If your API sends no caching headers, URLCache may not cache at all (or caches heuristically and unpredictably). So if you’re relying on URLCache, verify the server actually sends Cache-Control/ETag — caching is a collaboration between server and client, and the server holds up its end. When the server doesn’t cooperate, URLCache can’t help much, and you need app-level caching instead.

Manual cache control

You can read from and write to the cache directly, and clear it — useful for inspecting behavior, pre-populating, or clearing on logout:

let cache = URLCache.shared   // or your session's config.urlCache

// Look up a cached response for a request
if let cached = cache.cachedResponse(for: request) {
    let data = cached.data
    let response = cached.response
}

// Store a response manually (rare — usually the session does this)
let cachedResponse = CachedURLResponse(response: response, data: data)
cache.storeCachedResponse(cachedResponse, for: request)

// Clear everything (e.g., on logout to drop cached user data)
cache.removeAllCachedResponses()

// Remove a specific entry
cache.removeCachedResponse(for: request)

Clearing the cache on logout is a genuine use case — you don’t want a previous user’s cached responses served to the next user. For most other purposes the automatic behavior suffices and you don’t touch the cache directly.

URLCache versus app-level caching

The important architectural distinction: URLCache is an HTTP response cache — it caches raw responses keyed by request, transparently, governed by HTTP semantics. It’s great for what it does (avoiding re-downloads of unchanged resources, images, etc.) but it has limits for app needs:

  • It caches responses, not your domain models — you still decode every time you read from it (though you skip the network).
  • It’s governed by HTTP headers and capacity, so you don’t fully control retention or invalidation.
  • It can’t express app logic like “show stale data while refreshing, but mark it stale in the UI,” or “cache this list but invalidate it when the user creates a new item.”

For those needs you build app-level caching: store decoded models in memory or a database (Core Data, SwiftData, GRDB, SQLite — your data layer), with your own freshness and invalidation logic, fed by the network. A common pattern is offline-first: read from your local store immediately (instant UI), fetch from network in the background, update the store and UI when fresh data arrives. URLCache complements this (it can still avoid redundant downloads at the HTTP layer) but doesn’t replace it. Use URLCache for transparent HTTP-level efficiency; use an app-level store for offline support, model caching, and app-specific freshness logic.

Caching pitfalls

Assuming URLCache caches without server cooperation. Caching needs the server’s Cache-Control/ETag headers. Verify the server sends them; otherwise URLCache may not cache.

Expecting POST responses to be cached. Generally they aren’t. Cache GETs; don’t rely on caching for non-idempotent requests.

Using URLCache as your app’s data store. It caches responses, not models, governed by HTTP semantics you don’t fully control. Build app-level caching in your data layer for offline and model caching.

Not clearing the cache on logout. A previous user’s responses can leak to the next. removeAllCachedResponses() (or use an ephemeral session for sensitive flows).

Forgetting to bump the default capacities. They’re modest; aggressive caching needs larger memory/disk limits. Configure URLCache capacities to your needs.

Wrong cache policy for the use case. Default for normal reads, ignore-cache for explicit refresh, cache-only for offline. Match the policy to intent.

What to internalize

HTTP caching is a server-client collaboration via Cache-Control (freshness), ETag/Last-Modified (cheap revalidation yielding 304 Not Modified), and URLSession implements the client side through URLCache — transparently caching and revalidating cacheable GETs when the server sends the right headers, usually with no code from you. Control it with cache policies: .useProtocolCachePolicy (default, honors headers) for normal reads, .reloadIgnoringLocalCacheData for explicit refresh, .returnCacheDataElseLoad/.returnCacheDataDontLoad for instant-display and offline modes. Know URLCache’s limits: it caches responses (not your decoded models), needs server cooperation to work at all, and can’t express app-specific freshness/invalidation — so for offline support and model caching, build app-level caching in your data layer, with URLCache complementing it at the HTTP layer. Clear the cache on logout to avoid leaking one user’s data to the next.


21. Cookies, Sessions, and HTTPCookieStorage

Although token-based auth (Sections 13-14) dominates modern mobile APIs, plenty of APIs — especially those shared with a web frontend, or older session-based backends — use cookies to maintain session state. URLSession handles cookies automatically by default, which is convenient but occasionally surprising. This section covers how cookie handling works, HTTPCookieStorage, controlling and disabling cookies, and the session-vs-token distinction.

By default, a URLSession automatically manages cookies: when a server sends a Set-Cookie header in a response, the session stores the cookie, and on subsequent requests to that domain it automatically attaches the stored cookies in the Cookie header. This means a cookie-based login “just works” — you POST credentials, the server sets a session cookie, and your later requests carry it without any code. The cookie store backing this is HTTPCookieStorage, and a .default session uses the shared storage:

// This is automatic — no code needed for the common case.
// After a login response sets a cookie, subsequent requests include it.
let (_, _) = try await session.data(for: loginRequest)   // server sends Set-Cookie; session stores it
let (data, _) = try await session.data(for: protectedRequest)  // session attaches the cookie automatically

For many cookie-based APIs this automatic behavior is all you need. The session and its cookie storage handle the session lifecycle transparently.

HTTPCookieStorage: inspecting and managing cookies

When you need to inspect, add, or remove cookies — debugging a session issue, clearing cookies on logout, or injecting a cookie you obtained elsewhere — you use HTTPCookieStorage:

let storage = HTTPCookieStorage.shared

// Inspect all cookies, or those for a URL
let allCookies = storage.cookies ?? []
let cookiesForHost = storage.cookies(for: URL(string: "https://api.example.com")!) ?? []
for cookie in cookiesForHost {
    print("\(cookie.name)=\(cookie.value), expires \(String(describing: cookie.expiresDate))")
}

// Delete cookies (e.g., on logout)
for cookie in allCookies {
    storage.deleteCookie(cookie)
}

// Add a cookie manually
if let cookie = HTTPCookie(properties: [
    .domain: "api.example.com",
    .path: "/",
    .name: "session",
    .value: "abc123",
    .secure: true
]) {
    storage.setCookie(cookie)
}

HTTPCookieStorage.shared exposes the cookies the session has stored, lets you enumerate them (each HTTPCookie has name, value, domain, expiresDate, isSecure, etc.), and lets you add or delete them. Clearing cookies on logout is the most common manual interaction — like clearing the URLCache, you don’t want a previous user’s session cookie lingering. Manually adding a cookie is occasionally needed when you receive session state through a non-standard channel.

You can adjust or disable cookie handling at the configuration or per-request level. To accept cookies only from the main document domain (rejecting third-party cookies), or to disable cookies entirely:

let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .onlyFromMainDocumentDomain   // or .always, or .never
config.httpShouldSetCookies = true     // whether the session stores Set-Cookie (default true)
config.httpCookieStorage = nil          // nil disables cookie storage entirely for this session

Setting httpCookieStorage = nil makes the session ignore cookies completely — no storing, no sending — which you’d want for a purely token-authenticated API where cookies are irrelevant and you’d rather not carry any. The accept policy controls which cookies are stored. Per-request, URLRequest.httpShouldHandleCookies (default true) lets you opt a single request out of cookie handling. For most apps the defaults are fine; you reach for these when cookies are causing trouble or are simply unwanted.

Ephemeral sessions isolate cookies

Recall the ephemeral configuration (Section 10): an .ephemeral session keeps nothing on disk, including cookies. Its cookies live only in memory and vanish when the session is released. This makes ephemeral sessions a clean way to handle a flow where you want isolated, non-persistent cookie state — a “log in as a different user temporarily” flow, or a sensitive operation you don’t want leaving cookie traces:

let ephemeralSession = URLSession(configuration: .ephemeral)
// Cookies set during this session don't touch disk and disappear when the session is gone.

Because each ephemeral session has its own in-memory cookie store, two ephemeral sessions don’t share cookies — useful when you genuinely need isolated sessions (multiple simultaneous logins, say). With the shared default storage, all default sessions share cookies, which is usually what you want but occasionally not.

Cookies versus tokens

A framing worth making explicit, since you’ll choose between (or encounter both): cookie-based sessions and token-based auth are two approaches to the same problem — keeping a client authenticated across requests. Cookies are implicit (the session attaches them automatically, the server tracks session state) and are the web’s native mechanism; tokens (Sections 13-14) are explicit (you attach a bearer header, the token is often stateless/self-contained like a JWT) and dominate mobile APIs. For a mobile app talking to a modern API, tokens are usually preferable: explicit control, no ambient cookie state, statelessness that scales, and they work cleanly across domains. But if your API is shared with a web app and uses cookie sessions, or it’s an older backend, you’ll work with cookies — and URLSession’s automatic handling makes that straightforward. Some APIs even mix them. Know both; reach for tokens when you have the choice, and let URLSession’s automatic cookie handling carry you when the API is cookie-based.

Not clearing cookies on logout. A previous user’s session cookie can authenticate the next user’s requests. Delete cookies (and clear the cache) on logout, or use isolated sessions.

Unexpected cookie persistence across launches. Default storage persists cookies to disk, so a session cookie can outlive an app launch. Be deliberate: clear on logout, or use ephemeral sessions for non-persistent flows.

Cookies interfering with token auth. An API that sets cookies you don’t want can cause confusing state. Disable cookies (httpCookieStorage = nil or per-request httpShouldHandleCookies = false) for purely token-based sessions.

Assuming cookies are shared where they aren’t (or vice versa). Default sessions share the global storage; ephemeral sessions each have isolated in-memory stores. Know which you’re using.

Manually managing cookies the session already handles. Usually unnecessary and error-prone. Let automatic handling work; intervene only to inspect, clear, or inject.

What to internalize

URLSession handles cookies automatically by default — storing Set-Cookie responses and attaching them to subsequent same-domain requests via HTTPCookieStorage, so cookie-based sessions “just work” with no code. Use HTTPCookieStorage.shared to inspect, add, or (most commonly) clear cookies — clearing on logout so a prior user’s session doesn’t leak. Control behavior via the configuration: accept policy, or httpCookieStorage = nil to disable cookies entirely for a purely token-based API, and per-request httpShouldHandleCookies. Ephemeral sessions keep cookies only in memory and isolated per session — handy for non-persistent or multi-login flows. Conceptually, cookies (implicit, server-stateful, web-native) and tokens (explicit, often stateless, mobile-dominant) solve the same cross-request auth problem; prefer tokens when you can choose, and lean on automatic cookie handling when the API is cookie-based.


22. Streaming Responses with URLSession.bytes

Most requests fetch a complete response, but sometimes you want to process data as it arrives — a large response you don’t want to buffer entirely in memory, a line-delimited stream, or the foundation of server-sent events. Since iOS 15, URLSession.bytes(for:) exposes the response body as an AsyncSequence of bytes, letting you consume it incrementally with for await. This section covers the bytes API, processing line-by-line, and when streaming beats buffering.

The bytes API

bytes(for:) (and bytes(from:)) returns a tuple of an URLSession.AsyncBytes sequence and the URLResponse, immediately — before the body has downloaded. You then iterate the bytes as they arrive:

let (bytes, response) = try await URLSession.shared.bytes(for: request)

guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
    throw NetworkError.invalidResponse
}

var received = 0
for try await byte in bytes {       // each iteration yields one UInt8 as it arrives
    received += 1
    // process the byte...
}

The key difference from data(for:): data(for:) waits for the entire body and hands you a complete Data, while bytes(for:) hands you a sequence you consume incrementally, so you can start processing immediately and never hold the whole body in memory at once. The sequence is an AsyncSequence, so it integrates with for try await, cancellation (cancelling the task ends the iteration), and the rest of structured concurrency. You validate the response’s status from the returned URLResponse before consuming, just as always.

Processing line by line

Iterating one byte at a time is rarely what you want; more often you process lines. AsyncBytes provides a .lines property that yields the response as an AsyncSequence of String lines — ideal for line-delimited formats (NDJSON, logs, server-sent events):

let (bytes, response) = try await URLSession.shared.bytes(for: request)
// ... validate status ...

for try await line in bytes.lines {
    // Each `line` is one line of the response, delivered as it completes.
    print("Line: \(line)")
    // e.g., parse a line of NDJSON:
    if let data = line.data(using: .utf8),
       let article = try? JSONDecoder.api.decode(Article.self, from: data) {
        await handle(article)
    }
}

bytes.lines handles the buffering and splitting on newlines for you, yielding complete lines as they arrive. This is perfect for newline-delimited JSON (NDJSON), where each line is a separate JSON object — you decode and handle each as it streams in, rather than waiting for and parsing a giant array. It’s also the natural foundation for server-sent events (Section 29), which are a line-oriented streaming format. There’s a .characters variant for character-by-character and the raw byte sequence for binary; .lines is the most commonly useful.

When streaming beats buffering

Streaming with bytes is the right choice in specific situations, and data(for:) remains better for the common case. Use bytes when:

  • The response is large and you process it incrementally — a huge NDJSON export you want to ingest record-by-record without holding the whole thing in memory. Streaming keeps memory flat.
  • You want to start showing results before the whole response arrives — rendering items as they stream in, for perceived speed.
  • The connection is long-lived and emits events over time — server-sent events, a streaming API (like a token-by-token LLM response), where there’s no “complete response” to wait for.
  • You need early termination — you can stop iterating (and cancel) once you’ve found what you need, without downloading the rest.

Use data(for:) when the response is a normal, bounded payload you’ll parse as a whole (the vast majority of API calls) — buffering is simpler and there’s no benefit to streaming a small JSON object. The decision is about size and shape: bounded-and-small means data; large-or-incremental-or-unbounded means bytes.

A streaming download with progress, without a delegate

A neat use of bytes is downloading a file with progress without setting up a delegate (Section 18) — you read the expected length from the response and count bytes as you accumulate them:

func downloadWithProgress(_ url: URL, onProgress: @escaping (Double) -> Void) async throws -> Data {
    let (bytes, response) = try await URLSession.shared.bytes(from: url)
    guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
        throw NetworkError.invalidResponse
    }
    let expected = http.expectedContentLength       // -1 if unknown
    var data = Data()
    data.reserveCapacity(expected > 0 ? Int(expected) : 1024)

    var count: Int64 = 0
    for try await byte in bytes {
        data.append(byte)
        count += 1
        if expected > 0 && count % 8192 == 0 {       // throttle progress updates
            onProgress(Double(count) / Double(expected))
        }
    }
    onProgress(1.0)
    return data
}

This accumulates the bytes into Data while reporting progress from the count against expectedContentLength — a delegate-free way to get download progress for a modest file, all in an async function. (For genuinely large files you’d still prefer a download task that streams to disk, Section 16, to avoid building a large in-memory Data — but for moderate downloads where you want the bytes in memory anyway and want progress, this is clean.) Note the throttling: updating progress every byte would spam the UI, so update periodically. It’s a good illustration that bytes gives you the incremental access that previously required a delegate.

Streaming pitfalls

Using bytes for ordinary small responses. Adds complexity for no benefit. Use data(for:) for bounded, small payloads; reserve bytes for large/incremental/unbounded.

Forgetting to validate the status before consuming. A streamed error response would be processed as if valid. Check the returned URLResponse’s status first.

Iterating bytes one at a time when you want lines. Tedious and slow. Use bytes.lines for line-delimited formats.

Updating UI on every byte/line. Floods the main thread. Throttle progress and batch UI updates.

Holding the whole stream in memory anyway. If you accumulate every byte into Data, you’ve lost the memory benefit. Process and discard incrementally, or use a download task for large files.

Not handling cancellation in a long-lived stream. A streaming iteration should end cleanly when cancelled. Rely on structured cancellation (the for try await throws on cancel) and clean up.

What to internalize

URLSession.bytes(for:) returns the response body as an AsyncSequence you consume with for try await as it arrives — letting you process incrementally and keep memory flat, versus data(for:) which buffers the whole body. Validate the returned response’s status before consuming. Use bytes.lines to get complete String lines for line-delimited formats like NDJSON (decode each as it streams) — and it’s the foundation for server-sent events (Section 29). Choose streaming when the response is large, incremental, long-lived, or you want early termination; choose data(for:) for the common bounded-and-small payload. The bytes API also enables delegate-free download progress (count bytes against expectedContentLength, throttling updates), though large file downloads still belong in disk-streaming download tasks. The decision is about response size and shape, not preference.


23. Concurrency: Parallel Requests, Task Groups, and Cancellation

Real screens often need several pieces of data at once — an article, its author, its comments. Fetching them sequentially (await one, then the next, then the next) is needlessly slow when they’re independent; fetching them in parallel can be several times faster. Swift’s structured concurrency makes parallel networking clean and safe, with async let for a fixed set of requests, task groups for a dynamic set, and built-in cancellation throughout. This section covers running requests concurrently and managing their lifecycle correctly.

Sequential is the wrong default for independent requests

First, see the problem. Fetching three independent resources sequentially serializes their latencies:

// SLOW: each await waits for the previous to finish. Total time ≈ sum of all three.
let article = try await repository.article(id: id)
let author = try await repository.user(id: article.authorID)
let comments = try await repository.comments(articleID: id)

If each request takes 200ms, this takes ~600ms even though the requests don’t depend on each other’s results (well, author depends on article.authorID here — but comments don’t depend on either). Running independent requests sequentially wastes time waiting. When requests are independent, run them concurrently.

async let for a fixed set of parallel requests

async let binds a child task that starts immediately and runs concurrently; you await the binding when you need the value. For a known, fixed set of independent requests, it’s the cleanest tool:

func loadArticleScreen(id: Int) async throws -> (Article, [Comment]) {
    // Both requests start immediately and run in parallel.
    async let articleRequest = repository.article(id: id)
    async let commentsRequest = repository.comments(articleID: id)

    // Await both — total time ≈ the SLOWER of the two, not their sum.
    let article = try await articleRequest
    let comments = try await commentsRequest
    return (article, comments)
}

Each async let launches its request concurrently the moment it’s declared; the awaits then collect the results. Because both requests are in flight simultaneously, the total time is roughly the maximum of the two latencies rather than their sum — a ~2x speedup for two requests, more for more. async let is perfect when you know at compile time exactly which requests to run. If one throws, awaiting it propagates the error (and the structured-concurrency machinery cancels the siblings). This is the idiomatic way to parallelize a fixed handful of independent calls.

Dependent requests must still be sequential

A caveat the example above hints at: async let parallelizes independent requests. When one request needs another’s result, you must await the first before starting the second — that dependency is inherent:

func loadArticleWithAuthor(id: Int) async throws -> (Article, User) {
    let article = try await repository.article(id: id)       // must complete first...
    let author = try await repository.user(id: article.authorID)  // ...to know the author ID
    return (article, author)
}

Here the author fetch genuinely depends on the article’s authorID, so it can’t start until the article arrives — sequential is correct. The skill is recognizing which requests are independent (parallelize) versus dependent (sequence). Often a screen has a mix: fetch the independent things in parallel, then the dependent things after. Don’t force parallelism where a real data dependency exists.

Task groups for a dynamic set

When the number of requests isn’t known at compile time — fetching details for each of N article IDs, where N varies — async let doesn’t fit (you can’t write a variable number of bindings). withThrowingTaskGroup runs a dynamic collection of child tasks concurrently and collects their results:

func loadArticles(ids: [Int]) async throws -> [Article] {
    try await withThrowingTaskGroup(of: Article.self) { group in
        for id in ids {
            group.addTask {                       // each runs concurrently
                try await self.repository.article(id: id)
            }
        }
        var articles: [Article] = []
        for try await article in group {          // collect results as they finish
            articles.append(article)
        }
        return articles
    }
}

The task group adds a child task per ID (all running concurrently), then collects results with for try await as each completes. Note results arrive in completion order, not the order you added them — if you need to preserve input order, collect into a dictionary keyed by ID and reorder, or use an indexed result type. If any child throws, the group propagates the error and cancels the remaining children (structured concurrency cleaning up automatically). Task groups are the tool for “do this request for each element of a collection, concurrently.”

Bounding concurrency: don’t launch 500 requests at once

A crucial refinement: a task group with 500 IDs launches 500 concurrent requests, which can overwhelm the server (and trip rate limits, Section 24), exhaust connections, and actually slow down due to contention. You usually want bounded concurrency — at most N requests in flight at once. You implement this by adding only N initial tasks, then adding a new one each time one finishes:

func loadArticles(ids: [Int], maxConcurrent: Int = 6) async throws -> [Article] {
    try await withThrowingTaskGroup(of: Article.self) { group in
        var iterator = ids.makeIterator()
        var results: [Article] = []

        // Seed the group with up to maxConcurrent tasks.
        for _ in 0..<maxConcurrent {
            guard let id = iterator.next() else { break }
            group.addTask { try await self.repository.article(id: id) }
        }
        // As each finishes, collect it and start the next — keeping ≤ maxConcurrent in flight.
        while let article = try await group.next() {
            results.append(article)
            if let id = iterator.next() {
                group.addTask { try await self.repository.article(id: id) }
            }
        }
        return results
    }
}

This keeps at most maxConcurrent requests running: seed N tasks, and each time one completes (group.next()), start the next from the iterator. The result is a sliding window of N in-flight requests, which respects the server (matching the default ~6 connections per host, Section 10), avoids rate limits, and is often faster than unbounded launches because it doesn’t cause contention. Bounding concurrency is the difference between a well-behaved client and one that hammers the server; for any group over a handful of requests, bound it.

Cancellation throughout

Structured concurrency’s cancellation (introduced in Section 5) shines here: cancelling the parent task cancels all child tasks in an async let set or task group, and each in-flight URLSession request is cancelled in turn. You get this for free — no manual bookkeeping of task references. For long-running or expensive work, check cancellation cooperatively:

for id in ids {
    try Task.checkCancellation()        // bail early if cancelled, before starting more work
    let article = try await repository.article(id: id)
    // ...
}

Task.checkCancellation() throws CancellationError if the task was cancelled, letting you stop promptly rather than completing all the work. URLSession requests are inherently cancellation-aware (a cancelled task’s await throws), so a cancelled fetch stops the network request too. The practical payoff: when a user leaves a screen mid-load, cancelling the screen’s task cleanly abandons all its in-flight requests — no wasted bandwidth, no results applied to a gone screen. Design your loading as structured tasks tied to the screen’s lifecycle (the SwiftUI .task modifier does this automatically, Section 30) so cancellation just works.

Avoid unstructured detached tasks

A temptation to resist: spawning Task.detached { } for networking. Detached tasks escape structured concurrency — they’re not cancelled when their “parent” is, don’t inherit context, and become your responsibility to track and cancel. They leak work when a screen disappears (the detached task keeps running) and lose the automatic cancellation that makes structured concurrency safe. Prefer async let and task groups (structured), and tie any standalone Task { } to a lifecycle (store it and cancel it on teardown, or use .task). Reach for Task.detached only for genuinely fire-and-forget work that should outlive its caller (rare in app networking), and even then think twice.

Concurrency pitfalls

Sequencing independent requests. Serializes their latencies needlessly. Use async let (fixed set) or a task group (dynamic set) to run independent requests in parallel.

Parallelizing dependent requests. You can’t start a request that needs another’s result early. Sequence genuine data dependencies; parallelize only the independent parts.

Unbounded task groups. Launching hundreds of concurrent requests overwhelms the server, trips rate limits, and slows down via contention. Bound concurrency with a sliding window of N in-flight tasks.

Assuming task-group results come back in input order. They arrive in completion order. Reorder by collecting into a keyed structure if order matters.

Using detached tasks for screen-scoped work. They escape cancellation and leak when the screen is gone. Use structured concurrency and tie tasks to a lifecycle.

Not checking cancellation in long loops. Work continues after the user left. Call Task.checkCancellation() cooperatively and rely on URLSession’s cancellation-awareness.

What to internalize

Run independent requests concurrently, not sequentially: async let launches a fixed set of parallel requests (total time ≈ the slowest, not the sum), and withThrowingTaskGroup handles a dynamic set (one task per element). Recognize genuine data dependencies and sequence only those. Crucially, bound concurrency for large groups with a sliding window of N in-flight tasks (≈6) — unbounded launches overwhelm the server, trip rate limits, and slow down via contention. Cancellation is automatic in structured concurrency: cancelling the parent cancels all children and their in-flight URLSession requests, so tie loading to the screen’s lifecycle (SwiftUI’s .task does this) and check Task.checkCancellation() in long loops. Avoid Task.detached for screen-scoped networking — it escapes cancellation and leaks work. Structured concurrency makes parallel networking both fast and safe with almost no bookkeeping.


24. Rate Limiting, Throttling, and Request Coalescing

Beyond making requests fast and parallel, a well-behaved client limits its own traffic: respecting server rate limits, throttling rapid-fire requests (like search-as-you-type), and coalescing duplicate in-flight requests so you don’t fetch the same thing twice simultaneously. These client-side disciplines reduce load on both the server and the device, and prevent the self-inflicted problems (429s, wasted work) that come from an unrestrained client. This section covers throttling, debouncing, limiting concurrency, and request coalescing.

Respecting server rate limits

APIs commonly enforce rate limits — “100 requests per minute” — and signal them via 429 Too Many Requests with a Retry-After header (Section 8). The baseline discipline is to respect these: when you get a 429, back off for the indicated time before retrying (the retry mechanism from Section 14 does this). But the better posture is to avoid hitting the limit in the first place, by limiting how fast and how many requests you make — which is what the rest of this section is about. Reacting to 429s is damage control; throttling and coalescing are prevention.

Debouncing search-as-you-type

The canonical client-side throttle is debouncing a search field: the user types “swift networking” character by character, and you don’t want to fire a request on every keystroke (13 requests for 13 characters, 12 of them immediately stale). Debouncing waits until the user pauses typing before firing one request:

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published var results: [Article] = []
    private var searchTask: Task<Void, Never>?

    func queryChanged(_ newValue: String) {
        query = newValue
        searchTask?.cancel()              // cancel the pending search from the previous keystroke
        searchTask = Task {
            // Wait for a pause in typing. If another keystroke arrives, this task is cancelled
            // (above) before the sleep completes, so no request fires.
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await performSearch(newValue)
        }
    }

    private func performSearch(_ text: String) async {
        guard !text.isEmpty else { results = []; return }
        do {
            results = try await repository.search(query: text)
        } catch is CancellationError {
            // superseded by a newer search — ignore
        } catch {
            // handle real errors
        }
    }
}

Each keystroke cancels the previous pending task and starts a new one that waits 300ms before searching. If the user keeps typing, each new keystroke cancels the waiting task before its sleep finishes, so no request fires until they pause for 300ms — then exactly one request goes out, for the final query. This cuts 13 requests to 1, eliminates the stale intermediate results, and gives a snappier feel. The Task-and-cancel pattern is the modern async way to debounce (replacing the old DispatchWorkItem dance), and cancellation makes superseded searches abandon cleanly (note the catch is CancellationError that ignores them).

Throttling and limiting concurrency

Distinct from debouncing (which collapses rapid events to the last one), throttling limits the rate of requests, and concurrency limiting caps how many run at once. The bounded task group from Section 23 is concurrency limiting. For rate limiting (no more than N requests per time window), you can use an actor that paces requests, or for a simpler concurrency cap, an async semaphore pattern:

actor RequestLimiter {
    private let maxConcurrent: Int
    private var current = 0
    private var waiters: [CheckedContinuation<Void, Never>] = []

    init(maxConcurrent: Int) { self.maxConcurrent = maxConcurrent }

    func acquire() async {
        if current < maxConcurrent {
            current += 1
        } else {
            await withCheckedContinuation { waiters.append($0) }   // wait for a slot
            current += 1
        }
    }
    func release() {
        current -= 1
        if !waiters.isEmpty { waiters.removeFirst().resume() }      // wake the next waiter
    }
}

// Usage:
func limitedFetch(_ id: Int, limiter: RequestLimiter) async throws -> Article {
    await limiter.acquire()
    defer { Task { await limiter.release() } }
    return try await repository.article(id: id)
}

The actor-based limiter caps concurrent requests at maxConcurrent: acquire() proceeds if there’s a free slot or suspends until one frees up; release() (in a defer) wakes the next waiter. This is a counting-semaphore for the async world, serialized by the actor. It’s an alternative to the bounded task group when the requests aren’t a single collection but arrive from various places and you want a global cap across all of them. For most cases the bounded task group (Section 23) is simpler; this limiter is for app-wide concurrency control.

Request coalescing (deduplication)

A subtle but valuable optimization: if two parts of your app request the same resource at the same time — two views both load article 42 on appearing — you’d make two identical network requests for the same data. Coalescing (or deduplication) ensures that concurrent requests for the same resource share a single in-flight request, so the second caller awaits the first’s result instead of firing its own. An actor keyed by request makes this clean:

actor RequestCoalescer {
    private var inFlight: [String: Task<Data, Error>] = [:]

    func data(for request: URLRequest, using transport: HTTPTransport) async throws -> Data {
        let key = request.url?.absoluteString ?? UUID().uuidString

        // If an identical request is already in flight, await its result instead of starting a new one.
        if let existing = inFlight[key] {
            return try await existing.value
        }
        let task = Task { () throws -> Data in
            defer { Task { await self.remove(key) } }    // clear when done
            let (data, _) = try await transport.data(for: request)
            return data
        }
        inFlight[key] = task
        return try await task.value
    }
    private func remove(_ key: String) { inFlight[key] = nil }
}

The coalescer keys in-flight requests by URL: if a request with the same key is already running, a new caller awaits that task rather than starting a duplicate. The first caller creates and stores the task; concurrent callers join it; when it completes, the entry is cleared so a later request re-fetches (you’re deduping concurrent requests, not caching). This is the same single-flight pattern as token refresh (Section 14) generalized to arbitrary requests, and the actor makes the check-and-set atomic. It’s especially valuable for shared resources loaded by multiple screens or components — the user opens a screen that loads article 42 while a widget also loads it, and only one request goes out. Note this dedupes only concurrent identical requests; for serving repeat requests over time from a stored result, that’s caching (Section 20 / app-level), a different concern.

Rate limiting pitfalls

Firing a request on every keystroke. Floods the server with stale-by-arrival requests. Debounce: cancel the pending task and wait for a typing pause before firing one request.

Ignoring 429/Retry-After. You stay throttled and may get escalating penalties. Back off for the indicated time (Section 14), and better, throttle to avoid 429s.

Unbounded concurrent requests. Trips rate limits and overwhelms the server. Cap concurrency (bounded task group, or an actor limiter for an app-wide cap).

Duplicate concurrent requests for the same resource. Two screens loading the same thing make two identical requests. Coalesce concurrent identical requests into one in-flight task.

Confusing coalescing with caching. Coalescing dedupes concurrent requests; caching serves repeat requests over time. Use the right one for the need (or both).

Throttling interactive requests too aggressively. Over-debouncing makes the UI feel laggy. Tune the debounce interval (≈300ms for search) to balance responsiveness and request volume.

What to internalize

A well-behaved client limits its own traffic. Debounce rapid-fire input like search-as-you-type by cancelling the pending Task on each keystroke and firing one request only after a typing pause (≈300ms) — collapsing many stale requests to one and feeling snappier, with cancellation abandoning superseded searches cleanly. Bound concurrency (the sliding-window task group of Section 23, or an actor-based limiter for an app-wide cap) so you don’t overwhelm the server or trip rate limits. Coalesce concurrent identical requests with an actor keyed by request, so two callers wanting the same resource share one in-flight task (the single-flight pattern generalized) — distinct from caching, which serves repeat requests over time. Respect 429/Retry-After as damage control, but prefer prevention: throttling and coalescing keep you under limits in the first place, reducing load on both server and device.


25. Reachability and Waiting for Connectivity

Apps run on devices that move through tunnels, lose Wi-Fi, switch to cellular, and enter Low Data Mode. A good networking layer reacts to connectivity: showing an offline state, waiting for a connection rather than failing instantly, and deferring non-essential traffic on expensive or constrained networks. The modern tool for observing connectivity is NWPathMonitor from the Network framework — not the old SCNetworkReachability you’ll find in dated tutorials. This section covers monitoring connectivity, waitsForConnectivity, and respecting the user’s network preferences.

Don’t pre-check reachability before requests

A common anti-pattern, inherited from old reachability libraries, is checking “is the network reachable?” before every request and bailing if not. This is wrong for two reasons. First, it’s racy: the network state can change between your check and the request, so a “reachable” check doesn’t guarantee the request succeeds, and an “unreachable” check might be stale. Second, it’s redundant: URLSession already tells you the network failed (via URLError.notConnectedToInternet), and with waitsForConnectivity it can wait for a connection rather than failing. The right approach is to attempt the request and handle the failure, optionally using connectivity monitoring to inform the UI (an offline banner) rather than to gate requests. Reachability is for displaying state and reacting, not for pre-flighting requests.

NWPathMonitor: the modern way to observe connectivity

NWPathMonitor (Network framework) observes network path changes and reports the current status, whether the connection is expensive (cellular, hotspot) or constrained (Low Data Mode), and which interface is in use. Wrap it in an observable type your UI can react to:

import Network

@MainActor
final class ConnectivityMonitor: ObservableObject {
    @Published private(set) var isConnected = true
    @Published private(set) var isExpensive = false
    @Published private(set) var isConstrained = false

    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "connectivity.monitor")

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            // This runs on the monitor's queue — hop to main for @Published state.
            Task { @MainActor in
                self?.isConnected = path.status == .satisfied
                self?.isExpensive = path.isExpensive
                self?.isConstrained = path.isConstrained
            }
        }
        monitor.start(queue: queue)
    }

    deinit { monitor.cancel() }
}

NWPathMonitor calls its pathUpdateHandler whenever the network path changes, giving you a NWPath whose status is .satisfied (connected), .unsatisfied (no connection), or .requiresConnection. The path also reports isExpensive (cellular or personal hotspot — metered) and isConstrained (the user enabled Low Data Mode). You publish these for the UI to observe — an offline banner when !isConnected, a “you’re on cellular” note, or deferring a large download when isExpensive. The handler runs on the monitor’s queue, so hop to the main actor for @Published updates. This is the framework-blessed replacement for the venerable Reachability/SCNetworkReachability classes; use it for new code.

waitsForConnectivity: let requests wait it out

For the request side, recall waitsForConnectivity from Section 10. Setting it true on the configuration changes the offline behavior from “fail immediately” to “wait for a connection, then proceed”:

let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 120   // but give up after 2 minutes of waiting + transfer
let session = URLSession(configuration: config)

With this, a request made while offline doesn’t fail with .notConnectedToInternet — it waits (up to the resource timeout) for connectivity to return, then runs. This is excellent for non-interactive requests where waiting is better than failing: a background sync, an upload that can wait for Wi-Fi. The session can notify your delegate via urlSession(_:taskIsWaitingForConnectivity:) so you can show a “waiting for connection…” indicator. For interactive requests where the user wants immediate feedback (“you’re offline, try again”), you might leave it false and handle the error. The choice depends on whether waiting or failing-fast better serves the request’s purpose.

Respecting expensive and constrained networks

iOS lets users signal they want to conserve data — Low Data Mode (constrained) — and the system knows when a connection is expensive (cellular/hotspot). A considerate app respects these by deferring or skipping non-essential traffic. The configuration and per-request flags control this:

// Per-request: this request must NOT run on an expensive or constrained network.
var request = URLRequest(url: url)
request.allowsExpensiveNetworkAccess = false       // skip on cellular/hotspot
request.allowsConstrainedNetworkAccess = false     // skip in Low Data Mode

// When the network is disallowed, the request fails with a specific URLError you can detect:
do {
    let (data, _) = try await session.data(for: request)
} catch let error as URLError where error.networkUnavailableReason == .constrained {
    // The user is in Low Data Mode and we declined to use it — defer this non-essential fetch.
} catch let error as URLError where error.networkUnavailableReason == .expensive {
    // Expensive network declined — maybe wait for Wi-Fi.
}

Setting allowsConstrainedNetworkAccess = false on a request tells the system not to run it in Low Data Mode; if that’s the only available network, the request fails with a URLError whose networkUnavailableReason is .constrained, which you detect and handle (defer the fetch, use a lower-quality variant, wait for Wi-Fi). The same applies to .expensive. The pattern for adaptive behavior: try the full-quality request allowing only good networks; if it fails for constrained/expensive reasons, fall back to a smaller request or defer. This is how you implement “download the high-res image on Wi-Fi, the low-res on cellular” cleanly — respecting the user’s data preferences rather than ignoring them.

Reacting to connectivity changes in the UI

Tying monitoring to the UI, a SwiftUI view observes the connectivity monitor and shows state, and can trigger a retry when the connection returns:

struct ContentView: View {
    @StateObject private var connectivity = ConnectivityMonitor()
    @StateObject private var viewModel = ArticlesViewModel()

    var body: some View {
        List(viewModel.articles) { article in
            Text(article.title)
        }
        .overlay(alignment: .top) {
            if !connectivity.isConnected {
                Text("No internet connection")
                    .padding(8).background(.red.opacity(0.8)).foregroundStyle(.white)
            }
        }
        .onChange(of: connectivity.isConnected) { _, isConnected in
            if isConnected { Task { await viewModel.retryIfNeeded() } }   // auto-retry on reconnect
        }
    }
}

The view shows an offline banner driven by the monitor and auto-retries a failed load when connectivity returns. This is the right use of reachability — informing the UI and reacting to changes — as opposed to gating individual requests. The user sees clear feedback about connection state, and the app recovers automatically when the network comes back, which feels polished.

Reachability pitfalls

Pre-checking reachability before each request. Racy and redundant — the state can change, and URLSession already reports failures. Attempt the request; use monitoring for UI, not gating.

Using the old SCNetworkReachability/Reachability class. Dated and less capable. Use NWPathMonitor from the Network framework.

Failing instantly when offline for non-interactive requests. Waiting is often better. Set waitsForConnectivity = true for syncs and deferrable work.

Ignoring Low Data Mode and expensive networks. Burns the user’s cellular data against their wishes. Respect allowsConstrainedNetworkAccess/allowsExpensiveNetworkAccess for non-essential traffic.

Updating UI from the path-update handler directly. It runs off the main thread. Hop to the main actor for @Published/UI state.

Not reacting to reconnection. The user fixes their connection and nothing happens. Observe connectivity and auto-retry failed loads when it returns.

What to internalize

Don’t pre-check reachability before requests — it’s racy and redundant; attempt the request and handle failures, using connectivity monitoring to inform the UI and react. Observe connectivity with NWPathMonitor (Network framework, the modern replacement for SCNetworkReachability): its path reports status (.satisfied/.unsatisfied), isExpensive (cellular/hotspot), and isConstrained (Low Data Mode) — publish these for an offline banner and auto-retry on reconnect, hopping to the main actor for UI. On the request side, waitsForConnectivity = true makes requests wait out brief offline moments instead of failing instantly (good for non-interactive work). Respect the user’s data preferences with per-request allowsExpensiveNetworkAccess/allowsConstrainedNetworkAccess, detecting the resulting URLError.networkUnavailableReason to defer non-essential fetches or fall back to lighter variants — the clean way to adapt quality to the network.


26. Pagination and Loading Large Result Sets

Few APIs return an unbounded list in one response; instead they paginate — returning a page of results plus a way to fetch the next. Implementing pagination well means understanding the two main styles (offset-based and cursor-based), loading pages on demand as the user scrolls, deduplicating and merging, and handling the end of the list. This section covers both pagination styles and the infinite-scroll loading pattern that consumes them.

The two pagination styles

APIs paginate in one of two broad ways, and your client code differs slightly for each:

  • Offset/page-based — you request a page number (or an offset and limit): GET /articles?page=2 or GET /articles?offset=40&limit=20. Simple and stateless, but has a real flaw: if items are inserted or deleted between page loads, you can see duplicates or skips (page 2 shifts when something is added to page 1). Fine for relatively static data, problematic for fast-changing feeds.
  • Cursor/keyset-based — the server returns an opaque cursor (a token, often encoding the last item’s sort key) and you pass it to get the next page: GET /articles?after=eyJpZCI6NDB9. Immune to the insertion/deletion problem because the cursor anchors to a specific position in the data, not a numeric offset. The modern, preferred style for feeds, though slightly more involved.

You decode the next-page indicator (a page number, an offset, or a cursor) from the response envelope (Section 6) and use it for the subsequent request. Which style you use is dictated by the API; recognize both.

Modeling the paginated response

For offset/page-based, the envelope carries the current page and whether more exist (we defined ArticleListResponse in Section 6):

struct ArticleListResponse: Codable {
    let results: [Article]
    let nextPage: Int?      // nil means this is the last page
    let total: Int
    enum CodingKeys: String, CodingKey { case results, total; case nextPage = "next_page" }
}

For cursor-based, the envelope carries the cursor for the next page:

struct CursorPage<Item: Codable>: Codable {
    let results: [Item]
    let nextCursor: String?   // nil means no more pages
    enum CodingKeys: String, CodingKey { case results; case nextCursor = "next_cursor" }
}

In both, the key signal is the optional next indicator: a non-nil nextPage/nextCursor means “there’s more, here’s how to get it”; nil means “you’ve reached the end.” Modeling it as optional makes the end-of-list condition fall out naturally — when it’s nil, stop loading.

A paginating loader

Encapsulate pagination state in a loader that tracks the current position, knows whether more pages exist, and guards against concurrent loads of the same page:

@MainActor
final class ArticleFeedLoader: ObservableObject {
    @Published private(set) var articles: [Article] = []
    @Published private(set) var isLoading = false
    @Published private(set) var hasMore = true

    private var nextPage = 1
    private let repository: ArticleRepository
    private var seenIDs = Set<Int>()      // for deduplication

    init(repository: ArticleRepository = ArticleRepository()) { self.repository = repository }

    func loadNextPage() async {
        // Guard against double-loading (e.g., scroll fires repeatedly) and loading past the end.
        guard !isLoading, hasMore else { return }
        isLoading = true
        defer { isLoading = false }

        do {
            let response = try await repository.articlesPage(page: nextPage)
            // Deduplicate before appending — guards against overlap from concurrent inserts.
            let newOnes = response.results.filter { seenIDs.insert($0.id).inserted }
            articles.append(contentsOf: newOnes)

            if let next = response.nextPage {
                nextPage = next
            } else {
                hasMore = false        // reached the end
            }
        } catch is CancellationError {
            // ignore — superseded or screen dismissed
        } catch {
            // surface the error; leave hasMore as-is so the user can retry
        }
    }

    func refresh() async {
        nextPage = 1; hasMore = true; seenIDs.removeAll(); articles = []
        await loadNextPage()
    }
}

The loader holds the accumulated articles, the nextPage cursor, and flags for isLoading and hasMore. loadNextPage() guards against re-entrancy (the !isLoading check — scroll events can fire rapidly) and against loading past the end (hasMore), fetches the next page, deduplicates against seenIDs before appending (so an item appearing on two pages due to concurrent server-side changes doesn’t show twice), and updates the cursor or marks the end. refresh() resets everything for a pull-to-refresh. The re-entrancy guard and deduplication are the two details that separate robust pagination from buggy pagination — without the guard you fire duplicate page loads; without dedup you get repeated rows.

Driving pagination from a scrolling list

The UI triggers loadNextPage() as the user approaches the bottom of the list. In SwiftUI, the clean way is to start loading when the last (or near-last) item appears:

struct ArticleFeedView: View {
    @StateObject private var loader = ArticleFeedLoader()

    var body: some View {
        List {
            ForEach(loader.articles) { article in
                ArticleRow(article: article)
                    .onAppear {
                        // When the last item appears, load the next page.
                        if article.id == loader.articles.last?.id {
                            Task { await loader.loadNextPage() }
                        }
                    }
            }
            if loader.isLoading {
                ProgressView().frame(maxWidth: .infinity)
            }
        }
        .task { await loader.loadNextPage() }    // initial load
        .refreshable { await loader.refresh() }   // pull to refresh
    }
}

The .onAppear on the last row triggers the next page load as the user scrolls near the bottom — infinite scroll. The re-entrancy guard in the loader makes this safe even though onAppear may fire more than once. The initial load uses .task (which also cancels if the view disappears), and .refreshable wires up pull-to-refresh. A common refinement is to trigger a few items before the last (prefetching) so the next page is loading before the user actually hits the bottom, hiding the latency. This combination — loader with guards and dedup, plus appearance-triggered loading — is the standard, robust infinite-scroll pattern.

Streaming pages with AsyncStream (optional)

For a more compositional approach, you can model the pages as an AsyncStream that yields each page until exhausted, letting consumers iterate pages with for await:

func articlePages() -> AsyncThrowingStream<[Article], Error> {
    AsyncThrowingStream { continuation in
        Task {
            var page: Int? = 1
            do {
                while let current = page {
                    let response = try await repository.articlesPage(page: current)
                    continuation.yield(response.results)
                    page = response.nextPage          // nil ends the loop
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
    }
}
// Consume: for try await page in articlePages() { merge(page) }

This wraps the page-by-page fetching in an AsyncThrowingStream that yields each page and finishes when there’s no next page (or throws on error). It’s an elegant model when you want to process all pages as a stream — though for UI-driven infinite scroll, the on-demand loader above is usually more practical (you load pages in response to scrolling, not eagerly). The stream approach shines for “fetch everything, page by page” batch operations. Both are valid; pick the one that matches whether loading is user-driven (loader) or exhaustive (stream).

Pagination pitfalls

No re-entrancy guard. Scroll events fire rapidly and trigger duplicate page loads. Guard with an isLoading flag (and hasMore).

No deduplication. Concurrent server-side inserts cause items to appear on two pages and show twice. Dedup by ID before appending.

Loading past the end. Without a hasMore/nil-cursor check, you keep requesting nonexistent pages. Stop when the next indicator is nil.

Offset pagination on fast-changing data. Insertions/deletions cause duplicates and skips. Prefer cursor-based for feeds, or accept the limitation for static data.

Not resetting state on refresh. Pull-to-refresh that doesn’t reset the page/cursor/seen-set produces a broken list. Reset everything in refresh().

Triggering load only on the very last item with no prefetch. The user hits the bottom and waits. Trigger a few items early to hide latency.

What to internalize

APIs paginate either offset/page-based (simple, but prone to duplicates/skips when data changes between loads) or cursor/keyset-based (anchors to a position, immune to that problem — preferred for feeds); model the next-page indicator as an optional so nil naturally means end-of-list. Encapsulate pagination in a loader that accumulates results and tracks the cursor and hasMore, with two essential guards: a re-entrancy guard (isLoading) so rapid scroll events don’t fire duplicate loads, and deduplication by ID so overlapping pages don’t show items twice. Drive infinite scroll by loading the next page when the last (or near-last) row appears, with .task for the initial load and .refreshable (resetting all state) for pull-to-refresh — prefetching a few items early to hide latency. For exhaustive batch loading, an AsyncThrowingStream of pages is an elegant alternative to the UI-driven loader.


27. Testing Networking Code with URLProtocol

Networking code that can only be tested against a live server is slow, flaky, and dependent on the network and the server’s state — untestable in CI in any reliable way. The two techniques that make networking testable are the transport-protocol injection we built in Section 12 (a MockTransport) and, for testing closer to URLSession itself, a custom URLProtocol subclass that intercepts requests and returns canned responses. This section covers both, with a focus on URLProtocol since it lets you test code that uses a real URLSession.

Two levels of test seams

There are two places to insert a test seam, and they test different things:

  • At your abstraction (HTTPTransport) — inject a MockTransport (Section 12) that returns canned (Data, URLResponse) without involving URLSession at all. This tests your code (the client’s validation, decoding, error mapping) cleanly and is the simplest approach when your layer is built around an injectable transport. It does not exercise URLSession itself.
  • At URLSession (URLProtocol) — register a custom URLProtocol that intercepts requests made through a real URLSession and returns stubbed responses. This tests code that uses URLSession directly (including code you can’t easily refactor to use a transport protocol), and exercises the real URLSession machinery (request construction, header handling) while stubbing only the actual network I/O.

Use the MockTransport approach when testing your own layer (it’s simpler and you built the seam for it); use URLProtocol when you need to stub a real URLSession, such as testing third-party code or verifying behavior through the genuine session. Both avoid the network entirely, making tests fast and deterministic.

MockTransport: testing your layer

The simplest seam, which we sketched in Section 12. A mock conforming to HTTPTransport returns whatever you configure, letting you test the client’s behavior for any response:

struct MockTransport: HTTPTransport {
    var responses: [(Data, URLResponse)]
    var error: Error?
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        if let error { throw error }
        return responses.first ?? (Data(), URLResponse())
    }
}

@Test func decodesArticleSuccessfully() async throws {
    let json = #"{"id":1,"title":"Test","body":"B","author_id":2,"published_at":"2026-01-01T00:00:00Z","tags":["a"]}"#
    let response = HTTPURLResponse(url: URL(string: "https://api.example.com/v1/articles/1")!,
                                   statusCode: 200, httpVersion: nil, headerFields: nil)!
    let client = APIClient(transport: MockTransport(responses: [(Data(json.utf8), response)]))

    let article = try await client.send(.article(id: 1))
    #expect(article.id == 1)
    #expect(article.title == "Test")
}

@Test func mapsNotFoundTo404Error() async throws {
    let response = HTTPURLResponse(url: URL(string: "https://api.example.com/v1/articles/999")!,
                                   statusCode: 404, httpVersion: nil, headerFields: nil)!
    let client = APIClient(transport: MockTransport(responses: [(Data(), response)]))

    await #expect(throws: NetworkError.self) {
        _ = try await client.send(.article(id: 999))
    }
}

By injecting a MockTransport with a configured response, you test the success path (correct decoding), the 404 path (correct error mapping), the decoding-failure path (malformed JSON), and so on — all without a network, fast and repeatable. Using Swift Testing’s #expect (and #expect(throws:) for error cases) makes the assertions clean. This is the bread-and-butter of testing a well-structured networking layer, and it’s why the transport abstraction was worth building.

URLProtocol: intercepting a real URLSession

When you need to stub a real URLSession — testing code that uses URLSession.shared or a session directly, or verifying the actual request that goes out — a custom URLProtocol is the tool. URLProtocol is the extension point URLSession uses to handle different URL schemes; by registering your own, you intercept requests and respond with stubs. The subclass:

final class MockURLProtocol: URLProtocol {
    // A handler the test sets: given the request, return a response and body (or throw).
    nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        true   // intercept every request
    }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }
    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            fatalError("No request handler set")
        }
        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }
    override func stopLoading() { }   // required override; nothing to do for a stub
}

The four methods you implement: canInit(with:) decides whether to handle a request (returning true intercepts everything); canonicalRequest(for:) returns the request unchanged; startLoading() is where you produce the stubbed response, telling the URLSession machinery (via the client) what “arrived” — the response, the data, and completion (or a failure); stopLoading() handles cancellation (nothing needed for a synchronous stub). The static requestHandler is how each test configures what to return, and it receives the actual URLRequest, so you can also assert on the outgoing request (verify the headers, method, body) — testing not just response handling but request construction.

Wiring URLProtocol into a session for tests

You register the mock protocol on a session’s configuration, then use that session in your code under test:

@Test func sessionReceivesStubbedResponse() async throws {
    // Configure a session that routes through the mock protocol.
    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [MockURLProtocol.self]
    let session = URLSession(configuration: config)

    // Set up the stub: assert on the request, return a canned response.
    MockURLProtocol.requestHandler = { request in
        #expect(request.url?.path == "/v1/articles/1")
        #expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
        let response = HTTPURLResponse(url: request.url!, statusCode: 200,
                                       httpVersion: nil, headerFields: nil)!
        let body = Data(#"{"id":1,"title":"Stubbed","body":"","author_id":1,"published_at":"2026-01-01T00:00:00Z","tags":[]}"#.utf8)
        return (response, body)
    }

    // Inject this session as the client's transport (URLSession conforms to HTTPTransport).
    let client = APIClient(transport: session)
    let article = try await client.send(.article(id: 1))
    #expect(article.title == "Stubbed")
}

Setting config.protocolClasses = [MockURLProtocol.self] makes the session route all requests through your mock protocol, which returns the stub the test configured. Because the test’s handler receives the real URLRequest, it can assert that the client built the request correctly (right path, right Accept header) and return a canned response to test decoding — verifying both directions. Using an .ephemeral configuration keeps the test isolated (no cache/cookie state leaking between tests). This approach exercises the genuine URLSession path while stubbing the network, which is exactly what you want for high-fidelity tests of code built on URLSession.

What to test

A well-tested networking layer covers: successful decoding of representative responses; each error path (401→unauthorized, 404→notFound, 429→rateLimited, 5xx→server, malformed JSON→decoding); the retry logic (a transport failure then success retries; a 4xx doesn’t retry); token refresh (a 401 triggers refresh and retries; concurrent 401s share one refresh); request construction (correct URL, method, headers, body for each endpoint); and edge cases (empty 204, pagination end). Because all of this runs against stubs, the suite is fast and deterministic — it runs in CI on every commit without a network. The investment in a testable layer (the transport abstraction) pays off as a comprehensive, fast test suite that catches regressions in the tricky parts (validation, error mapping, retry, refresh) where bugs are easy to introduce and hard to spot.

Testing pitfalls

Testing against a live server. Slow, flaky, network- and state-dependent. Stub responses with MockTransport or URLProtocol.

Forgetting stopLoading() in the URLProtocol subclass. It’s a required override; omitting it won’t compile (or misbehaves). Implement it even if empty.

Not resetting requestHandler/state between tests. A static handler leaks between tests, causing order-dependent failures. Reset in setup/teardown.

Only testing the success path. The error/retry/refresh paths are where bugs hide. Test each error mapping and the retry/refresh logic explicitly.

Not asserting on the outgoing request. You miss bugs in request construction (wrong header, wrong method). Assert on the URLRequest in the handler.

Sharing cache/cookie state across tests. Causes cross-test contamination. Use .ephemeral configurations for isolation.

What to internalize

Make networking testable with one of two seams. For your own layer, inject a MockTransport conforming to HTTPTransport (Section 12’s abstraction) that returns canned (Data, URLResponse) — simplest, and tests your client’s validation/decoding/error-mapping without any network. To stub a real URLSession (third-party code, or verifying through the genuine session), subclass URLProtocol (canInit to intercept, startLoading to feed a stubbed response through the client, stopLoading required-but-empty) and register it via config.protocolClasses; its handler receives the actual URLRequest, so you assert on request construction and return a canned response. Either way, tests run against stubs — fast, deterministic, CI-friendly. Test every path that matters: successful decoding, each error mapping, retry and token-refresh logic, request construction, and edge cases like empty 204s and pagination ends — because those tricky parts are exactly where bugs hide.


28. WebSockets with URLSessionWebSocketTask

Some features need a persistent, bidirectional connection rather than request-response: a chat, live presence, a collaborative editor, real-time notifications. WebSockets provide a full-duplex channel over a single long-lived connection, and URLSession supports them natively via URLSessionWebSocketTask — no third-party library required. This section covers opening a WebSocket, sending and receiving messages, the receive-loop pattern that trips people up, keepalive pings, and reconnection.

What WebSockets are for

A WebSocket is a persistent connection that stays open and lets both client and server send messages at any time — unlike HTTP request-response, where the client asks and the server answers. This makes WebSockets the right tool when the server needs to push data to the client unprompted (a new chat message, a price update, another user’s edit) and when you need low-latency bidirectional communication. The connection opens with an HTTP handshake (an Upgrade request) then switches protocols to WebSocket. URLSession handles the handshake; you work with a task that sends and receives messages. If your need is one-directional server-to-client streaming, server-sent events (Section 29) may be simpler; WebSockets shine for true bidirectional, low-latency communication.

Opening a WebSocket

You create a URLSessionWebSocketTask from a session with a ws:// or wss:// URL (wss is the TLS-secured form, which you should always use), and resume() to connect:

let url = URL(string: "wss://api.example.com/v1/live")!
let session = URLSession(configuration: .default)
let task = session.webSocketTask(with: url)
task.resume()   // initiates the connection

The task connects when resumed. You can include headers (for auth) by building a URLRequest with the wss URL and passing it instead of the bare URL — useful for sending a bearer token in the handshake:

var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let task = session.webSocketTask(with: request)
task.resume()

Authenticating the handshake with a header is the common way to secure a WebSocket — the server validates the token during the upgrade and rejects unauthorized connections.

Sending messages

Messages are either text or binary. You send with send(_:), which is async and throws:

// Send text
try await task.send(.string("{\"type\":\"subscribe\",\"channel\":\"articles\"}"))

// Send binary data
let data = try JSONEncoder.api.encode(SomePayload(...))
try await task.send(.data(data))

The URLSessionWebSocketTask.Message enum has .string and .data cases; you send whichever the protocol expects (many WebSocket APIs use JSON-as-text). send completes when the message is handed off; an error means the connection failed. Sending is straightforward — the subtlety is on the receive side.

Receiving messages: the loop that everyone gets wrong

Here’s the critical gotcha: receive() returns exactly one message, then you must call it again. It is not a stream you await once — each call yields the next single message. To continuously receive, you call receive() in a loop, re-arming after each message:

func receiveLoop(_ task: URLSessionWebSocketTask) async {
    do {
        while true {
            let message = try await task.receive()   // awaits ONE message
            switch message {
            case .string(let text):
                await handle(text: text)
            case .data(let data):
                await handle(data: data)
            @unknown default:
                break
            }
            // Loop continues — call receive() again for the next message.
            try Task.checkCancellation()
        }
    } catch {
        // The connection closed or errored — exit the loop, trigger reconnection.
        await handleDisconnection(error)
    }
}

The mistake people make is calling receive() once and wondering why they only get one message. You must loop. The while true with try await task.receive() continuously pulls messages until the connection closes (at which point receive() throws, breaking the loop and triggering reconnection). The older completion-handler form of receive(completionHandler:) had the same trap, requiring you to re-call it inside the handler. The async loop is cleaner. Including Task.checkCancellation() lets you stop the loop when the owning task is cancelled (the screen closed). This receive-loop is the essential WebSocket pattern; internalize that one receive() equals one message.

Keepalive with pings

Long-lived connections can be silently dropped by intermediaries (routers, load balancers, the carrier) that time out idle connections. To keep the connection alive and detect a dead one, you send periodic pings; the server responds with a pong, and a failed ping tells you the connection is gone:

func startPinging(_ task: URLSessionWebSocketTask) {
    Task {
        while !Task.isCancelled {
            try? await Task.sleep(for: .seconds(30))
            task.sendPing { error in
                if let error {
                    // Ping failed — the connection is dead; trigger reconnection.
                    print("Ping failed: \(error)")
                }
            }
        }
    }
}

A ping every 30 seconds keeps the connection from being reaped as idle and surfaces a dead connection promptly (a failed ping means reconnect). The interval depends on the network’s idle timeouts; 20-30 seconds is typically safe. Without pings, an idle WebSocket can be silently dropped, and you won’t know until your next send or receive fails — pings make liveness explicit.

Closing the connection

When you’re done (the user leaves the chat, the app backgrounds), close the connection cleanly with a close code:

task.cancel(with: .goingAway, reason: nil)   // or .normalClosure

Closing with .goingAway (or .normalClosure) sends a proper WebSocket close frame so the server knows the disconnect was intentional, not a failure. Just dropping the connection (or relying on deinit) is less clean and may leave the server thinking the client vanished. Close deliberately when the feature’s lifecycle ends.

Wrapping it all in an actor

A robust WebSocket client wraps the connection, send, receive loop, ping, and reconnection in one type — an actor is fitting since the connection state is mutable and accessed concurrently:

actor LiveConnection {
    private var task: URLSessionWebSocketTask?
    private let session = URLSession(configuration: .default)
    private let url: URL
    private var messageContinuation: AsyncStream<String>.Continuation?

    init(url: URL) { self.url = url }

    func connect() -> AsyncStream<String> {
        let (stream, continuation) = AsyncStream<String>.makeStream()
        self.messageContinuation = continuation
        openAndListen()
        return stream
    }

    private func openAndListen() {
        let task = session.webSocketTask(with: url)
        self.task = task
        task.resume()
        Task { await receiveLoop() }
    }

    private func receiveLoop() async {
        guard let task else { return }
        do {
            while true {
                let message = try await task.receive()
                if case .string(let text) = message {
                    messageContinuation?.yield(text)   // surface to consumers
                }
            }
        } catch {
            messageContinuation?.finish()
            await reconnectAfterDelay()                 // attempt reconnection
        }
    }

    private func reconnectAfterDelay() async {
        try? await Task.sleep(for: .seconds(2))         // backoff before reconnecting
        openAndListen()
    }

    func send(_ text: String) async throws {
        try await task?.send(.string(text))
    }

    func disconnect() {
        task?.cancel(with: .goingAway, reason: nil)
        messageContinuation?.finish()
    }
}

This actor exposes incoming messages as an AsyncStream<String> that consumers iterate with for await, hides the receive loop, and reconnects with a delay when the connection drops. Surfacing messages as an AsyncStream is an ergonomic way to bridge the callback-ish WebSocket world into structured concurrency — a SwiftUI view model can for await message in connection.connect() and react to each. Production-grade reconnection would add exponential backoff (like Section 14’s retry) and re-subscribe to channels after reconnecting, but this is the shape: an actor owning the connection, a receive loop yielding to a stream, and automatic reconnection.

WebSocket pitfalls

Calling receive() only once. It returns one message; you get one and then silence. Call it in a loop, re-arming after each message.

No keepalive pings. Idle connections get silently dropped by intermediaries. Send periodic pings (≈30s) and treat a failed ping as a disconnect.

Not handling disconnection/reconnection. WebSockets drop for many reasons; without reconnection the feature silently stops working. Detect the receive-loop error and reconnect (with backoff).

Closing by just dropping the task. Leaves the server thinking the client vanished. Close with .goingAway/.normalClosure to send a proper close frame.

Using ws:// instead of wss://. Unencrypted, and blocked by ATS. Always use wss:// (TLS).

Not authenticating the handshake. An open WebSocket anyone can connect to. Send a bearer token in the handshake request headers.

What to internalize

WebSockets give a persistent, bidirectional channel for real-time features (chat, presence, live updates), supported natively by URLSessionWebSocketTask — open with a wss:// URL (authenticate the handshake with a bearer header), resume() to connect, and send(.string/.data) to send. The essential gotcha: receive() returns exactly one message, so you must call it in a loop, re-arming after each — calling it once is the universal beginner mistake. Keep idle connections alive with periodic pings (≈30s; a failed ping means reconnect), close deliberately with .goingAway/.normalClosure, and always handle disconnection by reconnecting with backoff. Wrap the connection, receive loop, ping, and reconnection in an actor, exposing incoming messages as an AsyncStream so consumers iterate them with for await — bridging WebSockets cleanly into structured concurrency. For one-directional server push, consider server-sent events next; WebSockets are for true bidirectional, low-latency needs.


29. Server-Sent Events and Long-Lived Connections

When you need the server to push a stream of updates to the client but don’t need the client to send anything back over the same channel, server-sent events (SSE) are often a better fit than WebSockets — simpler, built on plain HTTP, and naturally resumable. SSE is also the format behind streaming APIs you’ll increasingly integrate, including token-by-token LLM responses. Built on the URLSession.bytes streaming from Section 22, SSE is straightforward to implement. This section covers the SSE format, parsing the event stream, reconnection with Last-Event-ID, and when to choose SSE over WebSockets.

What SSE is

Server-sent events is a simple protocol for a server to stream a sequence of text events to a client over a single long-lived HTTP response. The client makes an ordinary GET request with Accept: text/event-stream, and the server holds the connection open, writing events as they occur. It’s unidirectional (server to client only) and rides on plain HTTP/HTTPS — no protocol upgrade, no special infrastructure, works through proxies and with standard HTTP auth and headers. Each event is a few lines of text in a defined format, and the stream continues until the connection closes. Because it’s just an HTTP response body delivered incrementally, you consume it with the bytes.lines streaming you already know.

The SSE wire format

An SSE stream is line-oriented text. Each event consists of one or more fields, and a blank line marks the end of an event:

event: articleCreated
data: {"id":42,"title":"New Article"}
id: 1001

event: articleUpdated
data: {"id":42,"title":"Updated Title"}
id: 1002

data: a message with no event type (defaults to "message")
id: 1003

The fields: data: carries the payload (often JSON; can span multiple data: lines that concatenate); event: names the event type (optional, defaults to “message”); id: is an event ID the client tracks for resumption; retry: suggests a reconnection delay in milliseconds. A blank line terminates an event and signals the client to dispatch it. Lines starting with : are comments (often used as keepalive heartbeats). The format is deliberately simple — you parse it line by line, accumulating fields until the blank line.

Parsing an SSE stream

Building on bytes.lines (Section 22), you iterate lines, accumulate the current event’s fields, and dispatch on a blank line:

struct ServerSentEvent {
    var event: String = "message"
    var data: String = ""
    var id: String?
}

func consumeSSE(from request: URLRequest, onEvent: @escaping (ServerSentEvent) async -> Void) async throws {
    var req = request
    req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
    req.setValue("no-cache", forHTTPHeaderField: "Cache-Control")

    let (bytes, response) = try await URLSession.shared.bytes(for: req)
    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
        throw NetworkError.invalidResponse
    }

    var current = ServerSentEvent()
    for try await line in bytes.lines {
        if line.isEmpty {
            // Blank line: dispatch the accumulated event (if it has data), then reset.
            if !current.data.isEmpty {
                await onEvent(current)
            }
            current = ServerSentEvent()
        } else if line.hasPrefix(":") {
            continue   // comment / heartbeat — ignore
        } else if let (field, value) = parseField(line) {
            switch field {
            case "event": current.event = value
            case "data":  current.data += (current.data.isEmpty ? "" : "\n") + value
            case "id":    current.id = value
            case "retry": break   // could update the reconnection delay
            default: break
            }
        }
    }
}

func parseField(_ line: String) -> (String, String)? {
    guard let colon = line.firstIndex(of: ":") else { return (line, "") }
    let field = String(line[..<colon])
    var value = String(line[line.index(after: colon)...])
    if value.hasPrefix(" ") { value.removeFirst() }   // strip one leading space per spec
    return (field, value)
}

The parser iterates lines from bytes.lines: a blank line dispatches the accumulated event and resets; a : line is a comment (heartbeat) to skip; otherwise it splits field: value and accumulates into the current event (multiple data: lines concatenate with newlines, per spec). When an event completes, you hand it to the callback, where you’d decode the data JSON and react. This is the whole of SSE consumption — built directly on the streaming bytes API, it’s notably simpler than WebSockets because there’s no framing, no bidirectional state, just lines of text.

Reconnection with Last-Event-ID

SSE has built-in resumability via event IDs. The client tracks the id of the last event it received, and on reconnection sends it in a Last-Event-ID header; the server resumes from after that event, so you don’t miss events that occurred during the disconnect:

func consumeWithReconnection(_ baseRequest: URLRequest, onEvent: @escaping (ServerSentEvent) async -> Void) async {
    var lastEventID: String?
    while !Task.isCancelled {
        var req = baseRequest
        if let id = lastEventID {
            req.setValue(id, forHTTPHeaderField: "Last-Event-ID")   // resume from here
        }
        do {
            try await consumeSSE(from: req) { event in
                lastEventID = event.id          // remember the latest ID
                await onEvent(event)
            }
            // Stream ended normally — reconnect.
        } catch {
            // Stream errored — reconnect after a delay.
        }
        try? await Task.sleep(for: .seconds(3))   // backoff before reconnecting
    }
}

The reconnection loop tracks lastEventID and, on reconnect, sends it as Last-Event-ID so the server can replay events the client missed during the gap. This gracefully-resumable behavior is a genuine advantage of SSE over a naive WebSocket implementation — the resumption mechanism is part of the protocol. The loop reconnects whenever the stream ends or errors (with a backoff), and stops when the owning task is cancelled. Production code would use exponential backoff and honor the server’s retry: hint, but this is the core pattern.

SSE versus WebSockets

The choice between SSE and WebSockets comes down to directionality and complexity:

  • Use SSE when the data flow is server-to-client only (notifications, live feeds, status updates, streaming LLM tokens), you want simplicity, and you benefit from automatic reconnection/resumption. It’s plain HTTP, so it works with your existing auth, proxies, and infrastructure with no special handling, and it’s markedly less code.
  • Use WebSockets when you need true bidirectional, low-latency communication (chat where the client sends frequently, collaborative editing, gaming, anything where the client pushes data continuously over the same channel). The bidirectional channel justifies the added complexity.

A useful heuristic: if the client mostly listens and only occasionally sends (and can send via normal HTTP requests on the side), SSE is simpler and sufficient. If the client and server are in constant two-way conversation, WebSockets fit. Many apps that reach for WebSockets actually only need server-to-client push, where SSE would be simpler and more robust. Choose based on the actual communication pattern, not the more impressive-sounding technology.

SSE pitfalls

Not setting Accept: text/event-stream. The server may not treat the request as an SSE subscription. Set the header.

Buffering the whole response instead of streaming. Defeats the purpose — you want events as they arrive. Use bytes.lines, not data(for:).

Mishandling multi-line data: fields. Multiple data: lines in one event concatenate with newlines. Accumulate them, don’t overwrite.

Ignoring Last-Event-ID for reconnection. You miss events that occurred during a disconnect. Track the last ID and send it on reconnect.

Not reconnecting at all. Long-lived connections drop; without reconnection the stream silently dies. Loop with a backoff, stopping on cancellation.

Choosing WebSockets when SSE would do. Adds bidirectional complexity for a one-directional need. Use SSE for server-to-client push; reserve WebSockets for true two-way communication.

What to internalize

Server-sent events stream text events from server to client over a single long-lived HTTP response — unidirectional, plain HTTP (works with existing auth/proxies), and naturally resumable. The client GETs with Accept: text/event-stream and consumes the body incrementally via bytes.lines (Section 22): parse line-oriented events (data:, event:, id: fields, blank line dispatches, multi-line data: concatenates, : lines are heartbeats), decoding each event’s payload. Reconnect by tracking the last event id and sending it as Last-Event-ID so the server replays missed events — a built-in resumability advantage over naive WebSockets. Choose SSE for server-to-client push (notifications, live feeds, streaming LLM tokens) where it’s simpler and more robust; choose WebSockets only when you genuinely need bidirectional low-latency communication. Many apps reaching for WebSockets actually need only SSE.


30. Integrating Networking with SwiftUI

All the networking machinery exists to feed your UI, and SwiftUI has specific, ergonomic patterns for driving views from async work: the .task modifier for lifecycle-bound loading, @MainActor view models holding state, .refreshable for pull-to-refresh, and AsyncImage for remote images. Wiring networking into SwiftUI correctly means respecting the view lifecycle (so requests cancel when views disappear), keeping UI state on the main actor, and modeling loading/error states cleanly. This section ties the whole guide to the UI layer.

The @MainActor view model

The standard structure is a view model that owns the screen’s state and the loading logic, marked @MainActor so its state mutations are always on the main thread, holding a repository (the domain layer from Section 12) for data access:

@MainActor
@Observable
final class ArticlesViewModel {
    private(set) var articles: [Article] = []
    private(set) var phase: LoadPhase = .idle
    private let repository: ArticleRepository

    enum LoadPhase { case idle, loading, loaded, failed(String) }

    init(repository: ArticleRepository = ArticleRepository()) {
        self.repository = repository
    }

    func load() async {
        phase = .loading
        do {
            articles = try await repository.articles(page: 1)
            phase = .loaded
        } catch is CancellationError {
            // view disappeared / superseded — leave state as-is, no error
        } catch let error as NetworkError {
            phase = .failed(error.userMessage ?? "Something went wrong")
        } catch {
            phase = .failed("Something went wrong")
        }
    }
}

Using @Observable (the modern macro, iOS 17+) makes the view model observable to SwiftUI with no @Published boilerplate, and @MainActor guarantees its state is mutated on the main thread — so assigning articles or phase is always UI-safe. The explicit phase (idle/loading/loaded/failed) models the screen’s states, which the view renders distinctly. Critically, it catches CancellationError separately and does nothing — a cancelled load (the user navigated away) isn’t an error to display, exactly as Section 9 stressed. The userMessage mapping turns a NetworkError into human text. This view model is the clean bridge between the networking layer and the view.

The .task modifier: lifecycle-bound loading

The single most important SwiftUI-networking integration is the .task modifier. It runs an async closure when the view appears and — crucially — cancels it automatically when the view disappears. This gives you the structured cancellation from Section 23 wired to the view lifecycle for free:

struct ArticlesView: View {
    @State private var viewModel = ArticlesViewModel()

    var body: some View {
        Group {
            switch viewModel.phase {
            case .idle, .loading:
                ProgressView()
            case .loaded:
                List(viewModel.articles) { article in
                    Text(article.title)
                }
            case .failed(let message):
                ErrorView(message: message) {
                    Task { await viewModel.load() }   // retry button
                }
            }
        }
        .task {
            await viewModel.load()   // runs on appear, CANCELS on disappear
        }
    }
}

The .task { await viewModel.load() } is the canonical loading pattern: it starts the load when the view appears and cancels the in-flight request if the user navigates away before it finishes — no wasted bandwidth, no results applied to a gone screen, no manual task management. The switch on phase renders each state distinctly (spinner, list, error-with-retry). Using @State with @Observable is the modern way to own the view model in the view. This combination — .task for lifecycle-bound loading, a phase-driven view, an @Observable @MainActor view model — is the recommended shape for a networked SwiftUI screen, and it gets cancellation right by construction.

.refreshable for pull-to-refresh

SwiftUI’s .refreshable modifier adds pull-to-refresh to a scrollable view, running an async closure and showing the refresh spinner until it completes:

List(viewModel.articles) { article in
    Text(article.title)
}
.refreshable {
    await viewModel.refresh()   // pull down to reload; spinner shows until this returns
}

Because .refreshable takes an async closure and keeps the spinner until it returns, you simply await your reload logic — the spinner timing is handled. The refresh method would re-fetch (often with .reloadIgnoringLocalCacheData, Section 20, to bypass the cache) and update the state. This is the idiomatic, almost-free pull-to-refresh; just provide the async reload.

.task(id:) for reloading on input changes

When a view should reload because some input changed — a different article ID, a new search term — .task(id:) re-runs its closure whenever the id value changes (cancelling the previous run):

struct ArticleDetailView: View {
    let articleID: Int
    @State private var viewModel = ArticleDetailViewModel()

    var body: some View {
        content
            .task(id: articleID) {
                await viewModel.load(id: articleID)   // re-runs (and cancels prior) when articleID changes
            }
    }
}

.task(id: articleID) reloads whenever articleID changes, automatically cancelling the previous load — perfect for a detail screen reused for different items, or a search that reloads when the query changes. It composes the lifecycle-binding of .task with change-driven reloading, again getting cancellation right (the old load is cancelled when the id changes). This is cleaner than manually observing changes and managing tasks.

AsyncImage for remote images

Loading remote images — avatars, article thumbnails — is common enough that SwiftUI provides AsyncImage, which fetches and displays an image URL with a placeholder while loading:

AsyncImage(url: user.avatarURL) { phase in
    switch phase {
    case .empty:
        ProgressView()                          // loading
    case .success(let image):
        image.resizable().scaledToFill()        // loaded
    case .failure:
        Image(systemName: "person.circle")      // failed — fallback
    @unknown default:
        EmptyView()
    }
}
.frame(width: 44, height: 44)
.clipShape(Circle())

AsyncImage handles the fetch, placeholder, success, and failure states for a remote image URL with no view model needed. The phase-based initializer lets you customize each state (spinner while loading, the image on success, a fallback on failure). It’s convenient for simple cases. Its limitations: it doesn’t cache as aggressively as you might want (it relies on URLCache), and it re-fetches on each appearance in some cases — so for image-heavy screens (a feed of thumbnails) you may outgrow it and want a dedicated image-loading solution with proper caching (your own actor-based loader, or a library). For occasional images, AsyncImage is perfect; for a performance-critical image grid, consider more control.

Triggering actions (not just loading)

Beyond loading on appear, user actions (tapping “create,” “delete”) trigger networking from within the view via Task:

Button("Delete") {
    Task {
        await viewModel.delete(article)   // fire the action; view model updates state
    }
}
.disabled(viewModel.isDeleting)

An action wraps the async call in a Task (since button actions are synchronous), and the view model handles the work and state updates (disabling the button while in flight via isDeleting, updating the list on success, surfacing errors). Note this Task isn’t lifecycle-bound like .task — for a quick action that’s usually fine, but for a long action whose result should be abandoned if the view disappears, you’d tie it to the lifecycle or store and cancel it. For most button-triggered mutations, a plain Task is appropriate.

SwiftUI integration pitfalls

Loading in onAppear with a manual Task. Doesn’t cancel when the view disappears, wasting work and risking updates to a gone view. Use .task, which cancels automatically.

Mutating UI state off the main actor. Crashes or corruption. Mark the view model @MainActor so state mutations are main-thread-safe.

Showing an error for CancellationError. A cancelled load (navigated away) isn’t a failure. Catch CancellationError separately and do nothing.

Not modeling loading/error states. A view that only handles “loaded” can’t show a spinner or error. Model an explicit phase and render each state.

Reloading on input change with manual observation. Error-prone task management. Use .task(id:) to reload (and cancel the prior load) when an input changes.

Using AsyncImage for a performance-critical image grid. Its caching is limited and it may re-fetch. For image-heavy screens, use a dedicated cached loader.

What to internalize

SwiftUI integrates networking through a few ergonomic patterns that get the lifecycle and threading right by construction. Own screen state in an @MainActor @Observable view model holding a repository, modeling an explicit phase (idle/loading/loaded/failed) and catching CancellationError separately as a non-error. Drive loading with the .task modifier — it runs on appear and cancels automatically on disappear, wiring structured cancellation to the view lifecycle for free — and .task(id:) to reload (cancelling the prior run) when an input changes. Add pull-to-refresh with .refreshable (just await your reload), trigger user actions via Task in button handlers (with in-flight state to disable controls), and use AsyncImage for occasional remote images (reaching for a cached loader on image-heavy screens). This shape — @MainActor @Observable view model, phase-driven view, .task loading — is the recommended way to build a networked SwiftUI screen.


31. Common Gotchas and Anti-Patterns

You’ve covered the full landscape; this section consolidates the recurring mistakes — the ones that cost the most debugging time, span multiple topics, or are easy to commit even knowing better. Think of it as a pre-flight checklist and a guide to the smells that signal something’s wrong. Several appeared in earlier sections; gathered here, they form a mental model of what to watch for in any networking code, yours or in review.

HTTP errors are not Swift errors (the big one)

The mistake that bites everyone, worth stating once more because it’s the most common and most insidious: a 4xx or 5xx status does not throw and does not populate the completion handler’s error. From URLSession’s view, receiving a 500 Internal Server Error is a successful request — it got a complete response. Code that only checks for a thrown error (or a nil error) treats the 500’s error-page body as success and tries to decode it as your model, producing a confusing DecodingError far from the real cause. Always validate httpResponse.statusCode before trusting the response. If you remember one thing from this guide, make it this.

Forgetting resume() on completion-handler tasks

With the completion-handler API, a created task is suspended and does nothing until resume(). Forgetting it produces a silent hang — no request, no callback, no error. (The async API doesn’t have this trap; awaiting starts the work.) If a completion-handler request “never fires,” check for a missing resume() first.

Building URLs by string interpolation

Interpolating runtime values into a URL string fails to percent-encode them, breaking on spaces, reserved characters, and non-ASCII — silently changing the request or producing a nil URL. Always build URLs with URLComponents and URLQueryItem (Section 2). String-interpolated URLs are the networking equivalent of string-concatenated SQL: wrong, and occasionally a security issue.

Sending a body without a matching Content-Type

A request body needs a Content-Type header telling the server how to parse it. A JSON body with no (or wrong) Content-Type gets rejected (400/415) or misparsed. Always pair httpBody with the correct Content-Type (Section 3, 7). This is a frequent “my perfectly-formed request is rejected” cause.

Retrying non-idempotent requests blindly

Retrying a failed request is safe for idempotent methods (GET, PUT, DELETE) but dangerous for POST: if a create times out, the server may have processed it and you just lost the response — retrying creates a duplicate. Never wrap POSTs in blind retry; use an idempotency key for safe retries (Section 14). A retry policy that ignores method is a duplicate-resource bug waiting to happen.

The token refresh stampede

When many requests fail with 401 simultaneously (an expired token), naive code triggers a refresh per request — wasting calls and often invalidating tokens through rotation. Use single-flight refresh: the first 401 starts one refresh; the rest await it (Section 14). This is subtle enough that it’s worth checking any refresh implementation for explicitly.

Creating a session per request

A URLSession is meant to be long-lived; creating one per request discards connection reuse, caching, and cookie continuity, and wastes resources. Create a small set of long-lived sessions (one default session your client owns, plus a background session if needed) and reuse them (Section 10, 12). A URLSession(configuration:) inside a per-request function is a smell.

Delegate-based session retain cycles

A URLSession created with a delegate strongly retains that delegate until the session is invalidated — unusual, since most delegates are weak. A session tied to a shorter-lived object that’s never invalidated leaks both the session and the delegate. Invalidate shorter-lived sessions (finishTasksAndInvalidate/invalidateAndCancel) when done (Section 18).

Not moving the downloaded temp file in time

A download task delivers the file to a temporary URL that the system deletes the instant your handler returns. Not moving/copying it first leaves you with a URL pointing at nothing — a baffling “no such file” later. Relocate the file as the very first thing in the download handler (Section 16).

Hardcoded secrets and tokens in UserDefaults

Hardcoded API keys are extractable from the app binary; tokens in UserDefaults sit in an unencrypted plist exposed by backups. Store tokens in the Keychain (Section 13); treat any embedded key as effectively public. A secret in source or UserDefaults is a security finding.

Ignoring cancellation / showing errors for it

A cancelled request (the user navigated away) throws CancellationError/URLError.cancelled — which is expected, not a failure to report. Showing “something went wrong” when the user simply left a screen is a bug. Catch cancellation separately and do nothing (Section 9, 30). Conversely, not cancelling — loading in onAppear with an unmanaged Task instead of .task — wastes work and risks updating a gone view.

Blocking the main thread

Decoding a large response or doing synchronous work on the main thread janks the UI. Let async networking and decoding run off the main actor; hop back only to update UI state (Section 5, 30). A synchronous Data(contentsOf:) on a remote URL (which blocks until the download completes) on the main thread is a particularly bad offender — never do synchronous networking on the main thread.

Disabling certificate validation

Accepting any certificate “to make the dev server work” defeats TLS and inevitably ships to production, enabling trivial man-in-the-middle attacks. Never disable validation in code; fix the dev certificate properly (Section 19). This is a critical security bug whenever you see it.

Over-fetching: sequential independent requests, no pagination, no debounce

Performance anti-patterns that compound: fetching independent resources sequentially instead of in parallel (Section 23); loading an entire unbounded list instead of paginating (Section 26); firing a request per keystroke instead of debouncing (Section 24); making duplicate concurrent requests instead of coalescing (Section 24). Each wastes time, bandwidth, and battery, and strains the server. A snappy, well-behaved client parallelizes the independent, paginates the large, debounces the rapid, and coalesces the duplicate.

When you probably want a library

A meta-observation: if you find yourself building extensive request adapters, a full retry-and-refresh framework, multipart helpers, response-validation chains, reachability handling, and image caching — you’re rebuilding Alamofire (or Kingfisher for images). That’s fine if you have reasons (no dependencies, full control, learning), but recognize the signal. The value of having learned raw URLSession is that you can now evaluate such a library from understanding — knowing exactly which of its features map to what you’d otherwise maintain, and dropping to the raw API when the library gets in your way. Building everything yourself is a legitimate choice; doing it unknowingly, reinventing a mature library poorly, is the anti-pattern.

What to internalize

The highest-value habits, distilled: always validate statusCode (HTTP errors aren’t Swift errors — the cardinal rule); build URLs with URLComponents, never interpolation; pair every body with its Content-Type; never blindly retry POSTs (idempotency keys for safe retries); use single-flight token refresh to avoid the stampede; reuse long-lived sessions instead of creating one per request; invalidate delegate-based sessions to avoid leaks; move downloaded temp files immediately; store secrets in the Keychain, never hardcoded or in UserDefaults; treat cancellation as expected (don’t show errors for it) and bind loading to the view lifecycle with .task; keep networking and decoding off the main thread; never disable certificate validation; and parallelize/paginate/debounce/coalesce to be fast and well-behaved. Finally, recognize when you’re rebuilding a library, and choose it (or the raw API) deliberately. These are the difference between networking code that mostly works and code that works correctly, securely, and efficiently under real conditions.


32. Where to Go Deeper

You’ve built a complete picture of networking on iOS with URLSession — from a first request through a full, testable networking layer, authentication and token refresh, uploads and downloads, background transfers, certificate pinning, WebSockets, server-sent events, and SwiftUI integration. This closing section points you to the authoritative sources for going deeper, the standards worth knowing, and a perspective on where to take this knowledge.

Apple’s documentation

The primary references, all worth reading directly:

  • URL Loading System (Apple Developer documentation) — the conceptual overview of URLSession, tasks, configurations, caching, and cookies. The framework reference for URLSession, URLSessionConfiguration, URLSessionTask and its subclasses, URLRequest, URLResponse/HTTPURLResponse, and the delegate protocols is the canonical detail for every API in this guide.
  • Network framework documentation — for NWPathMonitor (reachability, Section 25) and the lower-level networking primitives. If you need raw TCP/UDP/TLS connections (below the HTTP layer URLSession provides), NWConnection is there.
  • Foundation documentation for Codable, JSONDecoder/JSONEncoder, and URLComponents — the encoding/decoding and URL-construction details (Sections 2, 6, 7).
  • Security framework documentation for the Keychain APIs (SecItem..., Section 13) and SecTrust evaluation (certificate pinning, Section 19).

Apple’s documentation has improved markedly and the URL Loading System guide in particular repays a careful read — it explains the model (sessions, tasks, configurations) clearly and covers behaviors this guide summarized.

WWDC sessions

Apple’s WWDC talks are an excellent way to learn the why and the recommended patterns. Sessions worth seeking out (search the developer site by topic): talks on URLSession and the modern async networking APIs; sessions on advancing networking, background transfers, and the Network framework; talks on Codable and JSON; and the structured-concurrency sessions (async/await, task groups, cancellation) that underpin modern networking. These sessions often surface the rationale and edge cases that documentation states tersely, and they show Apple engineers’ recommended approaches.

The standards underneath

Networking sits on web standards, and understanding them makes you better at the platform-specific work:

  • HTTP semantics — the meaning of methods (safe/idempotent), status codes, and headers. The HTTP specifications (RFC 9110 and related) are the authoritative source; even a working knowledge of HTTP semantics (which this guide built) makes status-code handling, caching, and method choice principled rather than rote.
  • HTTP caching — the Cache-Control, ETag, and conditional-request mechanics (Section 20) are defined in the HTTP caching specification; knowing them helps you collaborate with your backend on cacheable APIs.
  • OAuth 2.0 — the framework behind bearer tokens and refresh (Sections 13-14). The OAuth specifications detail the grant types, token endpoints, and refresh flow you implement against.
  • TLS and certificate handling — the basis of HTTPS and pinning (Section 19). A working understanding of the TLS handshake and certificate chains makes pinning comprehensible rather than magical.
  • WebSocket and Server-Sent Events protocols — the WebSocket specification and the SSE/EventSource specification define the wire formats you worked with in Sections 28-29.

You don’t need to read RFCs cover to cover, but knowing they exist and consulting the relevant parts when you hit an edge case turns guesswork into understanding.

When to adopt a library

Having learned raw URLSession, you can evaluate libraries from a position of understanding:

  • Alamofire — the dominant Swift networking library. It provides request adapters and retriers (the auth/refresh and retry patterns of Sections 13-14), response validation, multipart helpers (Section 15), reachability, and more, with a polished API. Adopt it when you want those solved abstractions and the maintenance they save; you now know exactly what each feature corresponds to and can drop to URLSession (which Alamofire exposes) when needed.
  • Image-loading libraries (Kingfisher, Nuke, SDWebImage) — for the image-heavy case where AsyncImage (Section 30) falls short, these provide aggressive caching, downsampling, and prefetching. Worth adopting for feeds of images.
  • Apollo iOS — if your API is GraphQL, Apollo provides typed queries, caching, and a transport built on URLSession. A different paradigm from REST, with its own tooling.
  • Code generators (OpenAPI/Swagger generators, including Apple’s swift-openapi-generator) — if your API has an OpenAPI spec, generating the client (types and endpoints) eliminates the hand-written endpoint layer of Section 11 and keeps the client in sync with the spec.

The judgment is now yours to make from knowledge: raw URLSession plus a thin layer (what you built) for control and zero dependencies, or a library for solved edge cases and saved maintenance. Neither is universally right; the choice depends on your team, constraints, and how much of the layer you want to own. The point of learning the foundation is that you choose deliberately rather than reflexively.

A closing perspective

Networking is one of those areas where the surface API is small but the depth is real — the difference between code that works in the demo and code that works on a subway with spotty signal, an expired token, a slow server, and a user who navigates away mid-load is enormous, and lives entirely in the details this guide covered: status validation, error modeling, retry and refresh, cancellation, caching, connectivity, and testing. You now have both the API knowledge and the judgment about how to apply it — when to parallelize, when to paginate, when to pin, when to reach for a delegate, when a library earns its place. Build the thin layer, test it well, handle the failure modes deliberately, and your app’s networking will be fast, correct, and resilient. The raw URLSession stack, used with understanding, is genuinely capable — and you now understand it.

Good luck, and have fun.

End of Document