iOS Networking
Irbisa · cheatsheetSeptember 13, 2026

iOS Networking

URLSession, Codable, async APIs, interceptors, error handling

Middle Developer20 itemscompressed for a skim
  1. 01

    What is URLSession and how do you make a GET request with async/await?

    Easy

    URLSession 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)
    }
    …
  2. 02

    How do you build a typed APIClient layer in Swift?

    Medium

    A 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> {
    …
  3. 03

    How do you cancel network requests and handle timeouts in Swift?

    Medium

    Cancellation 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()
    …
  4. 04

    How do you handle authentication tokens and automatic refresh?

    Medium

    One 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 }
    …
  5. 05

    How do you upload a file as multipart/form-data with URLSession?

    Medium

    URLSession 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
    …
  6. 06

    How do you implement certificate pinning in iOS?

    Hard

    Certificate 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,
    …
  7. 07

    The feed endpoint returns identical JSON most of the time. How do you stop re-downloading it?

    Medium

    URLSession 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
    …
  8. 08

    Which failed requests do you retry, and how do you retry without stampeding the server?

    Medium

    Retry 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 {
    …
  9. 09

    The user walks into a tunnel mid-request. What should the networking layer do?

    Easy

    Let the request wait instead of pre-checking the network: with waitsForConnectivity set 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. 10

    A two-gigabyte video must finish downloading even if the user leaves the app. How do you do it?

    Hard

    A 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. 11

    Live prices must stream into a screen. What does a WebSocket client built on URLSession look like?

    Hard

    URLSessionWebSocketTask gives 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. 12

    How do you test code that calls URLSession without touching the network?

    Medium

    Two techniques cover it: inject a protocol you can fake when you are testing your own client, and register a URLProtocol subclass 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. 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?

    Easy

    The 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 from URLComponents and URLQueryItem, 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. 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

    .iso8601 is ISO8601DateFormatter with its default options, and those do not include fractional seconds, so 2026-03-01T12:00:00.123Z throws while 2026-03-01T12:00:00Z decodes.

    nonisolated(unsafe) private let fractional: ISO8601DateFormatter = {
      let f = ISO8601DateFormatter()
      f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
      return f
    }()
    nonisolated(unsafe) private let plain = ISO8601DateFormatter()   // no fractions
    …
  15. 15

    Memory climbs as the user browses and Instruments shows a fresh TLS handshake on every call — what is the networking layer doing wrong?

    Medium

    It is creating a URLSession per 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. 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?

    Medium

    Decoding an array is all-or-nothing: JSONDecoder throws 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. 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?

    Medium

    data(for:) buffers the whole body in memory before it returns, while bytes(for:) hands you the response as an AsyncSequence you 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. 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?

    Medium

    Everything the session owns outlives your token wipe: URLCache still holds the response bodies on disk, HTTPCookieStorage still 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. 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?

    Hard

    Model each concern as a middleware that receives the request plus a next closure 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. 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?

    Hard

    URLSession 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,
    …