iOS Networking
URLSession, Codable, async APIs, interceptors, error handling
- 01
What is URLSession and how do you make a GET request with async/await?
EasyURLSession is Apple's HTTP stack, and since Swift Concurrency arrived the everyday API is a pair of async methods that return
(Data, URLResponse)and throw on failure.// Simple GET let url = URL(string: "https://api.example.com/users")! let (data, response) = try await URLSession.shared.data(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw URLError(.badServerResponse) } … - 02
How do you build a typed APIClient layer in Swift?
MediumA reusable APIClient handles common concerns once: base URL, auth headers, retries, decoding, error mapping.
protocol Networking { func data(for request: URLRequest) async throws -> (Data, URLResponse) } extension URLSession: Networking {} struct Endpoint<Response: Decodable> { … - 03
How do you cancel network requests and handle timeouts in Swift?
MediumCancellation and timeouts are separate mechanisms in Swift, and a solid client uses both: cancellation stops work you no longer want, timeouts stop work that is never coming back.
// Cancel-previous typeahead final class Search { private var task: Task<Void, Never>? func update(_ q: String) { task?.cancel() … - 04
How do you handle authentication tokens and automatic refresh?
MediumOne auth interceptor in the API client attaches the bearer token to every request and, on a 401, refreshes once and retries — no call site should ever know that tokens exist.
actor TokenStore { private var refreshTask: Task<String, Error>? private(set) var accessToken: String? let refresh: () async throws -> String init(refresh: @escaping () async throws -> String) { self.refresh = refresh } … - 05
How do you upload a file as multipart/form-data with URLSession?
MediumURLSession has no multipart builder, so you assemble the body yourself: a unique boundary string, one part per field carrying its own Content-Disposition header, and a closing boundary that ends in two extra dashes.
func uploadAvatar(_ image: Data, name: String) async throws -> Data { let boundary = "Boundary-\(UUID().uuidString)" var body = Data() func append(_ s: String) { body.append(s.data(using: .utf8)!) } // Text field … - 06
How do you implement certificate pinning in iOS?
HardCertificate pinning hardens TLS by trusting only specific server certificates / public keys, defending against MITM with rogue CAs.
final class PinnedDelegate: NSObject, URLSessionDelegate { let pinnedSPKIHashes: Set<Data> init(pinnedSPKIHashes: Set<Data>) { self.pinnedSPKIHashes = pinnedSPKIHashes } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, … - 07
The feed endpoint returns identical JSON most of the time. How do you stop re-downloading it?
MediumURLSession already ships an HTTP cache, and most apps get nothing from it because the server sends no validators and the client never asks for one.
let config = URLSessionConfiguration.default config.urlCache = URLCache(memoryCapacity: 32 << 20, diskCapacity: 256 << 20) config.requestCachePolicy = .useProtocolCachePolicy let session = URLSession(configuration: config) // Explicit conditional request, when you keep the copy yourself … - 08
Which failed requests do you retry, and how do you retry without stampeding the server?
MediumRetry only failures a later attempt could plausibly fix, and space attempts with exponential backoff plus random jitter so a recovering server is not hit by every device at the same instant.
func withRetry<T>( attempts: Int = 3, base: Duration = .milliseconds(300), isRetryable: (Error) -> Bool = APIError.isTransient, operation: () async throws -> T ) async throws -> T { … - 09
The user walks into a tunnel mid-request. What should the networking layer do?
EasyLet the request wait instead of pre-checking the network: with
waitsForConnectivityset on the configuration, URLSession holds the task until a usable path appears rather than failing immediately.let config = URLSessionConfiguration.default config.waitsForConnectivity = true // queue rather than fail config.timeoutIntervalForResource = 120 // but not forever config.allowsConstrainedNetworkAccess = false // skip Low Data Mode let session = URLSession(configuration: config, delegate: monitorDelegate, delegateQueue: nil) … - 10
A two-gigabyte video must finish downloading even if the user leaves the app. How do you do it?
HardA background session hands the transfer to a system daemon that keeps working while your app is suspended or terminated, and reports back by relaunching the app.
final class Downloader: NSObject, URLSessionDownloadDelegate { static let shared = Downloader() var backgroundCompletion: (() -> Void)? private lazy var session: URLSession = { let config = URLSessionConfiguration.background(withIdentifier: "com.example.downloads") … - 11
Live prices must stream into a screen. What does a WebSocket client built on URLSession look like?
HardURLSessionWebSocketTaskgives you a connection with send and receive, and nothing else — the read loop, the keepalive and the reconnect logic are all yours to write.final class PriceSocket { private let session: URLSession private var task: URLSessionWebSocketTask? init(session: URLSession = .shared) { self.session = session } … - 12
How do you test code that calls URLSession without touching the network?
MediumTwo techniques cover it: inject a protocol you can fake when you are testing your own client, and register a
URLProtocolsubclass when the real URL loading path is what you need to exercise.final class StubURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func stopLoading() {} … - 13
A search for "C++ & Swift" comes back empty on device while the same URL works in a browser — how was the request built wrong?
EasyThe query was pasted into a string, so
URL(string:)either returned nil or handed the server a value it read as extra parameters — queries are built fromURLComponentsandURLQueryItem, never by interpolation.let term = "C++ & Swift" // WRONG — interpolation: & splits the value, # truncates it, a space returns nil let bad = URL(string: "https://api.example.com/search?q=\(term)&page=1")! // RIGHT — URLComponents encodes each value … - 14
Decoding fails with "Expected date string to be ISO8601-formatted" although created_at looks like a perfectly normal ISO date — what does JSONDecoder object to?
Easy.iso8601isISO8601DateFormatterwith its default options, and those do not include fractional seconds, so2026-03-01T12:00:00.123Zthrows while2026-03-01T12:00:00Zdecodes.nonisolated(unsafe) private let fractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() nonisolated(unsafe) private let plain = ISO8601DateFormatter() // no fractions … - 15
Memory climbs as the user browses and Instruments shows a fresh TLS handshake on every call — what is the networking layer doing wrong?
MediumIt is creating a
URLSessionper request: each session brings its own connection pool, cache and cookie storage, so nothing is ever reused, and a session created with a delegate holds that delegate strongly until you invalidate it.// WRONG — a session per call: new pool, new handshake, and the delegate is // retained forever because nobody invalidates the session. func load(_ url: URL) async throws -> Data { let session = URLSession(configuration: .default, delegate: Progress(), delegateQueue: nil) return try await session.data(from: url).0 } … - 16
The backend adds a new post type and the whole feed goes blank instead of skipping one row — how do you make decoding survive it?
MediumDecoding an array is all-or-nothing:
JSONDecoderthrows on the first element it cannot build and you lose the other forty-nine, so make unknown values decodable and isolate the failure to a single element.enum PostKind: String, Decodable { case text, photo, video, unknown init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) self = PostKind(rawValue: raw) ?? .unknown // new server value -> unknown … - 17
An export endpoint streams 400 MB of NDJSON and data(for:) gets the app killed for memory on older phones — what do you call instead?
Mediumdata(for:)buffers the whole body in memory before it returns, whilebytes(for:)hands you the response as anAsyncSequenceyou consume as it arrives, so peak memory is one record instead of the entire payload.struct Row: Decodable { let id: String; let amount: Decimal } func importRows(from url: URL, session: URLSession, store: RowStore) async throws { let (bytes, response) = try await session.bytes(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw URLError(.badServerResponse) // check before consuming … - 18
After one user logs out and another signs in, the first account's data flashes on screen and old requests still come back authenticated — where is it hiding?
MediumEverything the session owns outlives your token wipe:
URLCachestill holds the response bodies on disk,HTTPCookieStoragestill holds the session cookie, and in-flight tasks started by the previous user are still running — clearing the Keychain touches none of it.@MainActor final class SessionOwner { private(set) var http = SessionOwner.makeSession() private static func makeSession() -> URLSession { let config = URLSessionConfiguration.default … - 19
Auth, logging, retry and metrics all have to wrap every request without turning the client into a god object — how do you compose them?
HardModel each concern as a middleware that receives the request plus a
nextclosure it must call, then fold the array into one send function — the position in the array is the nesting order, and the nesting order is the behaviour.typealias Send = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) protocol HTTPMiddleware: Sendable { func intercept(_ request: URLRequest, next: Send) async throws -> (Data, HTTPURLResponse) } … - 20
The login POST works from curl but on device the server logs a GET with no body — what did URLSession do between those two attempts?
HardURLSession follows redirects automatically, and for a 301, 302 or 303 the follow-up request is rewritten as a GET with the body dropped — curl never followed the redirect at all unless you passed
-L.final class RedirectPolicy: NSObject, URLSessionTaskDelegate { private let apiHost = "api.example.com" func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, …