Back to Blog

Networking in modern iOS: typed endpoints, retries/backoff, and observability without bloat

A practical URLSession setup that scales: typed endpoints and decoding, retry rules that do not create duplicate side effects, and lightweight logging/metrics so you can measure reliability and latency.

6 min read

Networking code tends to start simple and then quietly become your app’s least testable, least observable subsystem.

You do not need a “networking layer framework” to fix that.

What you need is:

  • a typed way to describe requests and decode responses
  • explicit rules for retries and backoff
  • enough instrumentation to answer: “what failed, for whom, and how often?”

This post outlines a small set of patterns that stay readable in a product codebase.

1) Model the API as typed endpoints

A typed endpoint is a request description that can build a URLRequest and knows the expected response type.

Keep it boring:

  • no global mutable state
  • no magic stringly paths spread across features
  • no decoding hidden inside view models

A minimal endpoint definition:

import Foundation

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

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

    func makeRequest(baseURL: URL) throws -> URLRequest {
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
        components?.queryItems = query.isEmpty ? nil : query

        guard let url = components?.url else {
            throw URLError(.badURL)
        }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = body
        headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
        return request
    }
}

Usage stays straightforward:

struct UserDTO: Decodable {
    let id: String
    let email: String
}

extension Endpoint where Response == UserDTO {
    static func user(id: String) -> Self {
        Endpoint(method: .get, path: "/v1/users/\(id)")
    }
}

The point is not purity. The point is to centralize the request shape and response type.

2) One API client: decoding, errors, and cancellation

A good client does three things well:

  1. executes a URLRequest
  2. decodes success responses
  3. produces an error that is useful to log and to show

Start with an error type you can reason about:

enum APIError: Error {
    case transport(URLError)
    case server(status: Int, body: Data?)
    case decoding(Error)
    case invalidResponse
}

Client implementation:

import Foundation

final class APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder

    init(baseURL: URL, session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = decoder
    }

    func send<Response: Decodable>(_ endpoint: Endpoint<Response>) async throws -> Response {
        let request = try endpoint.makeRequest(baseURL: baseURL)

        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                throw APIError.invalidResponse
            }

            guard (200...299).contains(http.statusCode) else {
                throw APIError.server(status: http.statusCode, body: data)
            }

            do {
                return try decoder.decode(Response.self, from: data)
            } catch {
                throw APIError.decoding(error)
            }
        } catch let urlError as URLError {
            throw APIError.transport(urlError)
        }
    }
}

This buys you:

  • consistent error mapping
  • correct cancellation behavior via async/await
  • a single place to add headers like auth and request IDs

3) Retries and backoff: decide what is safe

Retries are not an “on/off” feature.

The only correct retry policy is one that encodes which failures are transient and which requests are safe to repeat.

A practical policy:

  • retry on transport errors like .timedOut and .networkConnectionLost
  • retry on 502/503/504 with exponential backoff and jitter
  • do not retry requests with side effects unless they are idempotent

A simple retry wrapper:

struct RetryPolicy {
    var maxAttempts: Int = 3
    var baseDelaySeconds: Double = 0.4

    func shouldRetry(error: Error, attempt: Int, request: URLRequest) -> Bool {
        guard attempt < maxAttempts else { return false }

        // Only retry safe methods by default.
        let method = request.httpMethod?.uppercased()
        let isSafeMethod = (method == "GET" || method == "HEAD")

        if isSafeMethod == false {
            return false
        }

        if let api = error as? APIError {
            switch api {
            case .transport(let urlError):
                switch urlError.code {
                case .timedOut, .networkConnectionLost, .notConnectedToInternet, .cannotFindHost, .cannotConnectToHost:
                    return true
                default:
                    return false
                }

            case .server(let status, _):
                return status == 502 || status == 503 || status == 504

            default:
                return false
            }
        }

        return false
    }

    func delaySeconds(attempt: Int) -> Double {
        // Exponential backoff with jitter.
        let exp = baseDelaySeconds * pow(2.0, Double(attempt - 1))
        let jitter = Double.random(in: 0...0.2)
        return exp + jitter
    }
}

And an API client method that uses it:

extension APIClient {
    func send<Response: Decodable>(
        _ endpoint: Endpoint<Response>,
        retryPolicy: RetryPolicy
    ) async throws -> Response {
        let request = try endpoint.makeRequest(baseURL: baseURL)

        var attempt = 1
        while true {
            do {
                return try await send(endpoint)
            } catch {
                if retryPolicy.shouldRetry(error: error, attempt: attempt, request: request) {
                    let delay = retryPolicy.delaySeconds(attempt: attempt)
                    try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                    attempt += 1
                    continue
                }
                throw error
            }
        }
    }
}

Concrete failure mode: duplicate side effects caused by retries

A common production incident:

  • You introduce retries globally.
  • A POST /purchase times out on a slow cellular network.
  • The client retries.
  • The server processes both requests.

Users see duplicate receipts or duplicate credits.

Diagnosis path that works:

  1. Add a client-generated request ID header (for example X-Request-ID) on every request.
  2. Log it on the server alongside the operation identifier (order id, purchase id).
  3. When an incident happens, search logs for the same user and two different X-Request-ID values that map to the same operation time window.

Fix:

  • for side-effecting endpoints, require an idempotency key (for example Idempotency-Key) and have the server deduplicate
  • if you cannot guarantee idempotency, do not auto-retry that endpoint

4) Observability without bloat: log what you need, not everything

Two goals:

  • developers can debug individual failures
  • the team can measure trends (error rate, latency, retry rate)

Add request correlation

At minimum:

  • X-Request-ID: unique per attempt
  • X-Session-ID or user identifier: only if your privacy model allows it

Keep request IDs in logs, crash reports, and bug reports.

Record per-request metrics

URLSessionTaskMetrics gives you timing and network details for each task. You can use it to answer:

  • are we DNS bound?
  • is TLS handshaking the bottleneck?
  • did we hit HTTP/2 connection reuse?

A lightweight verification step you can do this week:

  1. In a debug build, attach a URLSessionTaskDelegate and log taskMetrics.transactionMetrics.first?.fetchStartDate and responseEndDate for a representative endpoint.
  2. Run the same flow 10 times on Wi‑Fi and 10 times on cellular.
  3. Compute p50 and p95 durations.
  4. After introducing a retry policy or caching change, repeat and compare.

If you cannot reproduce the difference locally, you likely need server-side instrumentation too.

5) Keep feature code clean: inject the client, do not singleton it

A typed endpoint setup pays off when features stay dumb:

  • feature owns its endpoint definitions
  • app layer owns baseURL, auth, session configuration
  • tests can inject a stub session or a fake client

If you want a single rule to keep the layer from expanding forever:

  • endpoints describe requests
  • client executes and maps errors
  • features decide what to do with the result

That separation is what keeps networking maintainable six months later.