iosNudge

Design the Networking Layer for a Large App

MediumSystem designNetworkingArchitectureTestingConcurrencySecurity 45 min read

Requirements

You design the networking code that every feature team in a big app uses. Bad design here spreads everywhere: copy-pasted requests, random retries, token bugs, and tests that hit the real server.

Functional

  • Typed requests: each endpoint knows its path, method, body and response type.
  • JSON encoding and decoding.
  • Add common headers: auth token, app version, language, request id.
  • Refresh the access token on 401, once, even when 10 requests fail at the same time.
  • Retry safe requests on temporary errors, with backoff.
  • Cancellation: leaving a screen cancels its requests.
  • Different environments: dev, staging, production.

Non-functional

  • Easy to use: one line per call for feature code.
  • Testable: no real network in unit tests.
  • Swift 6 concurrency-safe.
  • Observable: logs and metrics, without leaking tokens or personal data.
  • Small: built on URLSession, no heavy dependencies.

Out of scope

  • GraphQL, WebSockets, offline sync (other layers can build on this one).

Questions to ask the interviewer

  • How many teams and modules use it? (Then it is its own Swift package.)
  • REST only? What does the server's error JSON look like?
  • What auth? OAuth 2 with access and refresh tokens?
  • Minimum iOS version? (async/await needs iOS 15 for URLSession.)
  • Do we need background uploads or downloads?

High-level architecture

 Feature code (ViewModel / Repository)
        |
        |   let user = try await client.send(GetUser(id: "42"))
        v
+------------------+
|    APIClient     |  Endpoint -> URLRequest, runs the chain, maps errors, decodes
+--------+---------+
         |
         v
+-----------------------------------------------------------------+
|  Middleware chain (order matters)                               |
|  [Headers] -> [Auth] -> [Retry] -> [Logging] -> transport       |
+-------------------------------------------+---------------------+
                                            |
                 +--------------------------+     +----------------------+
                 |                                |   AuthManager        |
                 v                                |   (actor) tokens,    |
        +------------------+                      |   single refresh,    |
        |  HTTPTransport   |                      |   Keychain           |
        |  (URLSession)    |                      +----------------------+
        +------------------+
PartJob
EndpointDescribes one API call and its response type.
APIClientBuilds the request, runs middlewares, checks status, decodes.
MiddlewareOne small job each: headers, auth, retry, logging.
HTTPTransportSends a URLRequest. URLSession in the app, a fake in tests.
AuthManagerHolds tokens. Makes sure only one refresh runs at a time.

Why a chain of middlewares? Each concern is small and tested alone. New needs (metrics, mock headers in debug) are a new middleware, not changes in a giant function.

Data model

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

protocol Endpoint: Sendable {
    associatedtype Response: Decodable & Sendable
    var method: HTTPMethod { get }
    var path: String { get }
    var query: [URLQueryItem] { get }
    var body: (any Encodable & Sendable)? { get }
    var requiresAuth: Bool { get }
    var isIdempotent: Bool { get }          // safe to send twice?
}

extension Endpoint {
    var query: [URLQueryItem] { [] }
    var body: (any Encodable & Sendable)? { nil }
    var requiresAuth: Bool { true }
    var isIdempotent: Bool { method != .post && method != .patch }
}

struct EmptyResponse: Decodable, Sendable {}

// One endpoint = one small type.
struct GetUser: Endpoint {
    typealias Response = User
    let id: String
    var method: HTTPMethod { .get }
    var path: String { "/v1/users/\(id)" }
}

struct UpdateName: Endpoint {
    typealias Response = User
    let name: String
    var method: HTTPMethod { .patch }
    var path: String { "/v1/me" }
    var body: (any Encodable & Sendable)? { ["name": name] }
}

Errors are one clear enum. The UI can switch on it and show the right message.

enum APIError: Error, Sendable {
    case offline
    case timeout
    case cancelled
    case unauthorized                         // refresh failed: user must log in again
    case server(status: Int, body: ServerErrorBody?)
    case decoding(String)                     // a message, so the enum stays Sendable
    case transport(URLError)

    init(_ error: URLError) {
        switch error.code {
        case .notConnectedToInternet, .networkConnectionLost: self = .offline
        case .timedOut: self = .timeout
        case .cancelled: self = .cancelled
        default: self = .transport(error)
        }
    }
}

struct ServerErrorBody: Decodable, Sendable {
    let code: String                          // "email_taken"
    let message: String
}

API / interfaces

Server contract (agree on it with the backend team):

Authorization: Bearer <accessToken>
X-Request-ID: <uuid>            same id in app logs and server logs
Accept-Language: en-US
Errors:  { "code": "email_taken", "message": "This email is already used." }
POST /oauth/token  grant_type=refresh_token&refresh_token=...  -> new access + refresh token

Swift interfaces

typealias Next = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)

struct RequestContext: Sendable {
    let requiresAuth: Bool
    let isIdempotent: Bool
}

protocol Middleware: Sendable {
    func intercept(_ request: URLRequest, context: RequestContext,
                   next: Next) async throws -> (Data, HTTPURLResponse)
}

protocol HTTPTransport: Sendable {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

struct URLSessionTransport: HTTPTransport {
    let session: URLSession                    // ONE shared session, not one per request

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
        return (data, http)
    }
}

The client puts it all together:

struct APIClient: Sendable {
    let baseURL: URL
    let transport: HTTPTransport
    let middlewares: [any Middleware]

    func send<E: Endpoint>(_ endpoint: E) async throws -> E.Response {
        let request = try makeRequest(for: endpoint)
        let context = RequestContext(requiresAuth: endpoint.requiresAuth,
                                     isIdempotent: endpoint.isIdempotent)

        // Build the chain from the inside out. The transport is the last step.
        var next: Next = { [transport] in try await transport.send($0) }
        for middleware in middlewares.reversed() {
            let inner = next
            next = { try await middleware.intercept($0, context: context, next: inner) }
        }

        let result: (Data, HTTPURLResponse)
        do {
            result = try await next(request)
        } catch let error as URLError {
            throw APIError(error)
        }
        return try decode(E.Response.self, from: result.0, response: result.1)
    }

    private func makeRequest<E: Endpoint>(for endpoint: E) throws -> URLRequest {
        var components = URLComponents(url: baseURL.appending(path: endpoint.path),
                                       resolvingAgainstBaseURL: false)
        if !endpoint.query.isEmpty { components?.queryItems = endpoint.query }
        guard let url = components?.url else { throw URLError(.badURL) }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        if let body = endpoint.body {
            request.httpBody = try JSONEncoder().encode(body)
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        return request
    }
}

Deep dives

1. Token refresh: only one at a time

The classic bug: the token expires. Ten requests get 401 at the same time. All ten call "refresh". The server rotates the refresh token on the first call, so the other nine fail, and the user is logged out.

The fix is a single-flight refresh: the first caller starts it, the others wait for the same task.

struct Tokens: Codable, Sendable {
    let access: String
    let refresh: String
    let expiresAt: Date
}

protocol TokenRefreshing: Sendable {
    /// Throws APIError.unauthorized when the server rejects the refresh token.
    func refresh(using refreshToken: String) async throws -> Tokens
}

actor AuthManager {
    private var tokens: Tokens?
    private var refreshTask: Task<Tokens, Error>?
    private let refresher: TokenRefreshing
    private let onLogout: @Sendable () async -> Void

    init(tokens: Tokens?, refresher: TokenRefreshing,
         onLogout: @escaping @Sendable () async -> Void) {
        self.tokens = tokens
        self.refresher = refresher
        self.onLogout = onLogout
    }

    func validAccessToken() async throws -> String {
        if let refreshTask { return try await refreshTask.value.access }      // join
        guard let tokens else { throw APIError.unauthorized }
        if tokens.expiresAt > Date.now.addingTimeInterval(60) { return tokens.access }
        return try await refresh(after: tokens.access)                       // refresh early
    }

    func refresh(after failedToken: String) async throws -> String {
        if let refreshTask { return try await refreshTask.value.access }      // join
        guard let current = tokens else { throw APIError.unauthorized }
        if current.access != failedToken { return current.access }           // already refreshed

        let task = Task { try await refresher.refresh(using: current.refresh) }
        refreshTask = task              // set BEFORE the await, so other callers join it
        defer { refreshTask = nil }
        do {
            let new = try await task.value
            tokens = new                // also save to the Keychain here
            return new.access
        } catch APIError.unauthorized {
            // Only a rejected refresh token logs out. Offline or timeout errors
            // are thrown as they are, and the tokens stay.
            tokens = nil
            await onLogout()
            throw APIError.unauthorized
        }
    }
}

Three things to say out loud:

  • The actor protects tokens, but actors are reentrant: other calls run during await. That is why refreshTask is set before the first await.
  • refresh(after: failedToken): a request that failed with an old token must not refresh again.
  • Log out only when the server rejects the refresh token, never because the phone was offline.

The auth middleware uses it, and retries once after a refresh:

struct AuthMiddleware: Middleware {
    let auth: AuthManager

    func intercept(_ request: URLRequest, context: RequestContext,
                   next: Next) async throws -> (Data, HTTPURLResponse) {
        guard context.requiresAuth else { return try await next(request) }

        let token = try await auth.validAccessToken()
        var authed = request
        authed.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        let (data, response) = try await next(authed)
        guard response.statusCode == 401 else { return (data, response) }

        let newToken = try await auth.refresh(after: token)
        authed.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
        return try await next(authed)                // only one retry: no endless loop
    }
}

2. Retries and backoff

Retry only when it is safe and useful:

  • Safe: the request is idempotent (GET, PUT, DELETE), or a POST has an Idempotency-Key header.
  • Useful: the error is temporary. Timeouts, a lost connection, 408, 429, 502, 503, 504.
  • Not useful: 400, 404, 422. Sending the same bad request again gives the same answer.
struct RetryMiddleware: Middleware {
    var maxAttempts = 3
    var sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }

    func intercept(_ request: URLRequest, context: RequestContext,
                   next: Next) async throws -> (Data, HTTPURLResponse) {
        var attempt = 1
        while true {
            do {
                let (data, response) = try await next(request)
                let retryable = [408, 429, 502, 503, 504].contains(response.statusCode)
                guard retryable, context.isIdempotent, attempt < maxAttempts else {
                    return (data, response)
                }
                let retryAfter = response.value(forHTTPHeaderField: "Retry-After")
                try await sleep(delay(attempt: attempt, retryAfter: retryAfter))
            } catch let error as URLError
                where error.isTransient && context.isIdempotent && attempt < maxAttempts {
                try await sleep(delay(attempt: attempt, retryAfter: nil))
            }
            attempt += 1
        }
    }

    private func delay(attempt: Int, retryAfter: String?) -> Duration {
        if let retryAfter, let seconds = Double(retryAfter) { return .seconds(min(seconds, 30)) }
        let base = 0.5 * pow(2, Double(attempt - 1))                // 0.5 s, 1 s, 2 s
        return .seconds(base + Double.random(in: 0...(base / 2)))   // + jitter
    }
}

extension URLError {
    var isTransient: Bool {
        let codes: [URLError.Code] = [.timedOut, .networkConnectionLost,
                                      .cannotConnectToHost, .dnsLookupFailed]
        return codes.contains(code)
    }
}

Details that matter:

  • Jitter (a random extra wait) stops all phones from retrying at the same moment after an outage.
  • Task.sleep throws when the task is cancelled. So leaving the screen also stops the retries.
  • sleep is injected. Tests run instantly and check the delays.
  • .notConnectedToInternet is not retried here. Show "You are offline", or use waitsForConnectivity for background work.
  • Order: Retry is inside Auth, so every retry already has the token, and a 401 goes back up to Auth to refresh. Logging is innermost, so every real attempt is logged.

3. Decoding

Set up the decoder once, the same way for every call:

extension APIClient {
    func decode<T: Decodable>(_ type: T.Type, from data: Data,
                              response: HTTPURLResponse) throws -> T {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .custom { decoder in
            let text = try decoder.singleValueContainer().decode(String.self)
            let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
            if let date = try? Date(text, strategy: withFraction) { return date }
            return try Date(text, strategy: Date.ISO8601FormatStyle())
        }

        switch response.statusCode {
        case 200..<300:
            let body = data.isEmpty ? Data("{}".utf8) : data      // 204 -> EmptyResponse
            do { return try decoder.decode(T.self, from: body) }
            catch { throw APIError.decoding("\(T.self): \(error)") }
        case 401:
            throw APIError.unauthorized
        default:
            let serverError = try? decoder.decode(ServerErrorBody.self, from: data)
            throw APIError.server(status: response.statusCode, body: serverError)
        }
    }
}

Gotchas interviewers like:

  • The built-in .iso8601 strategy fails on fractional seconds (2026-10-16T10:00:00.123Z). That is why the custom strategy tries both.
  • One bad item should not break a whole list. Decode lists "lossy": skip items that fail.
struct LossyArray<Element: Decodable & Sendable>: Decodable, Sendable {
    let elements: [Element]

    private struct Skip: Decodable {        // reads nothing: just moves past one item
        init(from decoder: Decoder) throws {}
    }

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var result: [Element] = []
        while !container.isAtEnd {
            if let item = try? container.decode(Element.self) {
                result.append(item)
            } else {
                _ = try container.decode(Skip.self)    // skip the bad item, or we loop forever
            }
        }
        elements = result
    }
}
  • Log decoding errors with the type name and coding path. They are almost always a contract change.

4. Testing

Nothing in the layer needs the real network. The transport is a protocol, so tests use a fake:

actor MockTransport: HTTPTransport {
    private let handler: @Sendable (URLRequest) -> (Int, Data)
    private(set) var requests: [URLRequest] = []

    init(handler: @escaping @Sendable (URLRequest) -> (Int, Data)) {
        self.handler = handler
    }

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        requests.append(request)
        let (status, data) = handler(request)
        let response = HTTPURLResponse(url: request.url!, statusCode: status,
                                       httpVersion: nil, headerFields: nil)!
        return (data, response)
    }
}

The most valuable test: many parallel 401s cause one refresh.

import Testing

@Test func parallel401sRefreshOnlyOnce() async throws {
    let refresher = CountingRefresher(returning: .fresh)      // test helper: counts calls
    let auth = AuthManager(tokens: .rejectedByServer, refresher: refresher, onLogout: {})
    let transport = MockTransport { request in
        request.value(forHTTPHeaderField: "Authorization") == "Bearer fresh"
            ? (200, Data(#"{"id":"1","name":"Ana"}"#.utf8))
            : (401, Data())
    }
    let client = APIClient(baseURL: URL(string: "https://api.test")!,
                           transport: transport,
                           middlewares: [AuthMiddleware(auth: auth)])

    async let a = client.send(GetUser(id: "1"))
    async let b = client.send(GetUser(id: "2"))
    async let c = client.send(GetUser(id: "3"))
    _ = try await (a, b, c)

    #expect(await refresher.callCount == 1)
}

Other tests:

  • Each middleware alone, with a fake next closure.
  • Retry: inject sleep that records delays. Check that POST is not retried.
  • Decoding: real JSON samples from the server in test fixtures (contract tests).
  • SwiftUI previews and UI tests: a MockTransport that returns fixture files.

5. Observability and security

Logging middleware. Log method, path, status and time. Never log the Authorization header, tokens or request bodies with personal data.

import os

struct LoggingMiddleware: Middleware {
    private let logger = Logger(subsystem: "app.network", category: "http")

    func intercept(_ request: URLRequest, context: RequestContext,
                   next: Next) async throws -> (Data, HTTPURLResponse) {
        let start = ContinuousClock.now
        let path = request.url?.path ?? "?"
        let (data, response) = try await next(request)
        let elapsed = (ContinuousClock.now - start).formatted()
        logger.info("\(request.httpMethod ?? "") \(path) -> \(response.statusCode) in \(elapsed)")
        return (data, response)
    }
}
  • A headers middleware adds X-Request-ID. Support can find the same request in server logs.
  • Track error rate and latency per endpoint. URLSessionTaskMetrics gives DNS, TLS and transfer times.
  • Tokens live in the Keychain, not UserDefaults. Use kSecAttrAccessibleAfterFirstUnlock if background tasks need them.
  • HTTPS only (App Transport Security). Never put tokens in URLs: URLs end up in logs.
  • Certificate pinning (trust only your server's key) blocks some attacks, but a wrong pin after a certificate change breaks the app for everyone. Pin the public key, keep a backup key, and have a remote kill switch.

Trade-offs

DecisionOption AOption BUsual pick
Endpoint styleOne big enum APIOne type per endpointType per endpoint (scales across teams)
Cross-cutting logicOne big send functionMiddleware chainMiddleware chain
LibraryAlamofireURLSession + small layerURLSession (fewer dependencies)
Async styleCompletion handlers / Combineasync/awaitasync/await
Token refreshEach request refreshesSingle-flight in an actorSingle-flight
RetriesEvery failureIdempotent + transient onlyIdempotent + transient
Decoding failuresFail the whole listLossy arraysLossy for lists, strict for single objects

What interviewers look for

  • A typed Endpoint with an associated Response, so callers cannot decode the wrong type.
  • Small middlewares with a clear order, and you can explain why the order matters.
  • Single-flight token refresh in an actor, with correct reentrancy handling.
  • Logout only on a rejected refresh token, not on network errors.
  • Retries only for idempotent requests and temporary errors, with backoff, jitter and Retry-After.
  • One clear error type that the UI can map to messages.
  • Testability: transport behind a protocol, injected sleep, fixture-based decoding tests.
  • One shared URLSession, cancellation through structured concurrency.
  • Security basics: Keychain, no secrets in logs or URLs, pinning trade-offs.
  • Follow-ups: request priorities, response caching with ETag, background uploads, modularizing per feature, and how to roll out a breaking API change.