Start a project

Article · iOS · Networking · URLSession · 14 min read

Reliable iOS networking with URLSession

You do not need Alamofire, or a general-purpose networking framework, to get reliable HTTP in an iOS app. A small client over URLSession — typed requests, typed errors, a careful retry policy, cancellation that actually stops the work, and tests that do not hit the live network — will carry a typical indie or client app for years. The hard parts are the policy and the edges, not the library.

On this page 10 sections
  1. A small client, not a framework
  2. Typed requests, typed errors
  3. Retries that do not make outages worse
  4. Cancellation and the task that owns the call
  5. Decoding, dates and the awkward JSON
  6. Auth headers, tokens and 401s
  7. Logging without leaking personal data
  8. Testing with a URLProtocol stub
  9. Key takeaways
  10. Frequently asked questions

This sits alongside Swift concurrency in practice, which covers async/await and task isolation but not HTTP client habits. What follows is the client I keep reaching for: small, explicit, and boring to debug.

A small client, not a framework

The client should do a short list of jobs and no others:

Anything else — image pipelines, websocket graphs, a magic “sync engine” — belongs in a different type. If you outgrow this, you will know, because you will have a second transport, not because a blog post said you needed a framework on day one.

A reasonable shape is a APIClient that holds a URLSession, a URL base, a JSON decoder, and a way to supply credentials:

import Foundation

struct APIClient: Sendable {
    var session: URLSession
    var baseURL: URL
    var decoder: JSONDecoder
    var credentials: @Sendable () async throws -> String?

    func send<R: Decodable>(_ endpoint: Endpoint<R>) async throws -> R {
        let request = try await request(for: endpoint)
        return try await perform(request, as: R.self, retry: endpoint.isIdempotent)
    }
}

Use the shared session for ordinary, short calls, or a session you own if you need a custom URLSessionConfiguration (timeouts, cellular policy, a test protocol). Do not create a new URLSession per request; you will lose connection reuse, and you will forget to invalidate it.

Keep the base URL in one place, including the scheme and host. Concatenating path strings ad hoc is how you end up with a double slash or a missing /v1.

Typed requests, typed errors

An endpoint is a value: method, path, query, body, and whether a retry is safe. The response type is a generic parameter, so a call site cannot decode the same payload as two different types by accident.

struct Endpoint<Response: Decodable>: Sendable {
    var method: String
    var path: String
    var query: [URLQueryItem] = []
    var body: Data? = nil
    var isIdempotent: Bool
}

enum APIError: Error, Equatable {
    case badURL
    case http(status: Int, body: Data)
    case decoding
    case transport(URLError.Code)
    case cancelled
    case unauthorized
    case rejected(message: String)
}

Mark GET, HEAD and PUT (when PUT is truly idempotent for your API) as safe to retry. Mark POST as not idempotent unless the server honours an Idempotency-Key you also control. That one flag will prevent a lot of double charges and double creates.

Map status codes on purpose:

Transport errors from URLError need the same care. cancelled should become APIError.cancelled, not a generic failure banner. notConnectedToInternet and timedOut are the errors an offline UI should see. serverCertificateUntrusted is not something to retry in a loop.

If the API returns an error JSON, decode that type on the failure path and wrap it in rejected(message:). Guessing from a raw string is how you show the user a stack trace.

Retries that do not make outages worse

A retry can turn a brief outage into a stampede, or a “create order” button into two orders. The policy should be conservative and written down.

Rules I actually use:

func perform<R: Decodable>(_ request: URLRequest, as type: R.Type, retry: Bool) async throws -> R {
    var attempt = 0
    let maxAttempts = retry ? 3 : 1
    var lastError: Error = APIError.transport(.unknown)

    while attempt < maxAttempts {
        attempt += 1
        do {
            try Task.checkCancellation()
            let (data, response) = try await session.data(for: request)
            return try decode(data: data, response: response, as: type)
        } catch is CancellationError {
            throw APIError.cancelled
        } catch let error as APIError where !isRetryable(error) || attempt == maxAttempts {
            throw error
        } catch let error as URLError where error.code == .cancelled {
            throw APIError.cancelled
        } catch {
            lastError = error
            let delay = backoff(attempt: attempt, retryAfter: retryAfter(from: lastError))
            try await Task.sleep(for: .seconds(delay))
        }
    }
    throw lastError
}

func backoff(attempt: Int, retryAfter: TimeInterval?) -> TimeInterval {
    if let retryAfter, retryAfter > 0, retryAfter < 30 {
        return retryAfter
    }
    let base = pow(2.0, Double(attempt - 1))
    let jitter = Double.random(in: 0...0.4)
    return min(8, base + jitter)
}

The Retry-After cap in that snippet is deliberate. A 30-minute wait inside a screen’s .task is not a retry; it is a stuck spinner. If the server asks for a long wait, surface the error and let the user try again.

Retries and background delivery are different jobs. A BGAppRefreshTask may start a fetch; it should still use this client, and it should not multiply the client’s retries into a long background overrun. There is more on that in the BGTaskScheduler guide.

Cancellation and the task that owns the call

The async URLSession methods participate in Swift concurrency cancellation. If you call them from a SwiftUI .task, or from a Task you cancel in onDisappear or in a model’s invalidate(), the in-flight request is cancelled. That is what you want when the user leaves a screen.

Two ways this goes wrong:

URLSession.shared is fine here. If you use your own session, call finishTasksAndInvalidate() only when the client itself is being torn down, not after each request.

For file uploads and downloads, prefer upload(for:from:) and download(for:) (or the delegate-based transfer APIs) so the work can be a background URLSession if it must continue after the app is suspended. Ordinary JSON fetches should not use a background session; they will not finish more reliably, and they are harder to cancel and to test.

Decoding, dates and the awkward JSON

Most production bugs in a client are decoding bugs. The payload you were promised is not the payload you got.

Set the decoder up once, explicitly:

func makeDecoder() -> JSONDecoder {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    decoder.dateDecodingStrategy = .custom { decoder in
        let container = try decoder.singleValueContainer()
        let raw = try container.decode(String.self)
        if let date = ISO8601DateFormatter.full.date(from: raw) {
            return date
        }
        throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unrecognised date: \(raw)")
    }
    return decoder
}

Pin the date format to what the server actually sends. “ISO 8601” in conversation often means several slightly different strings (fractional seconds, or not; Z versus an offset). One custom strategy you have tested against real fixtures is better than .iso8601 plus a stack of production crashes.

Awkward JSON is normal: a field that is a string or a number, a missing key that should have been null, an envelope { "data": …, "error": … }. Handle those with a small Decodable wrapper, not with JSONSerialization sprinkled in the view model. When I am staring at a fixture, I tidy it in the JSON Formatter so I can see the keys, then I add a test for that fixture. Do not decode only the happy example from the documentation.

If a field can arrive in two shapes, write a init(from:) that tries both and add a test for each. If a field is unused, you do not have to model it. If a field is required for the screen to make sense, do not supply a default that hides a server bug — fail decoding, log it, and show a recoverable error.

Auth headers, tokens and 401s

Attach tokens in the client, not in each view. A credentials closure, or a small AuthProviding type, keeps the rest of the app from reaching into the keychain.

func request<R>(for endpoint: Endpoint<R>) async throws -> URLRequest {
    guard var components = URLComponents(url: baseURL.appending(path: endpoint.path), resolvingAgainstBaseURL: false) else {
        throw APIError.badURL
    }
    if !endpoint.query.isEmpty {
        components.queryItems = endpoint.query
    }
    guard let url = components.url else { throw APIError.badURL }
    var request = URLRequest(url: url)
    request.httpMethod = endpoint.method
    request.httpBody = endpoint.body
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    if endpoint.body != nil {
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    }
    if let token = try await credentials() {
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    }
    return request
}

On 401, try a single token refresh, then retry the original request once, and only then sign the user out. The important word is single: if three screens fire at once, they must share one refresh in flight, otherwise you will rotate a refresh token twice and lock the user out. A Task stored on the client, reused by concurrent callers, is a usual way to do that.

If the access token is a JWT, you can read the expiry from the payload when debugging — the JWT decoder is built for that — but do not invent your own crypto, and do not log the raw token. Trust the 401 from the server as the source of truth; a client-side expiry check is an optimisation, not a guarantee, because of clock skew.

Never put a token in the query string. It will appear in logs, in referrers, and in the HTTP header inspector output when someone pastes a URL they should not have pasted.

Logging without leaking personal data

You will need logs. You should not need a GDPR incident.

Log the method, the path (not the full URL if the query contains names or tokens), the status code, the attempt number, and the duration. Do not log Authorization, cookie headers, request bodies that might contain passwords or personal data, or full response bodies in production.

In debug builds it is reasonable to log a truncated body when decoding fails. Even then, strip known sensitive keys. A simple allow-list of headers to print (Content-Type, Retry-After, Cache-Control) is safer than a block-list you will forget to update.

os.Logger with a subsystem and a category (network) is enough. Privacy levels on the logged values exist for a reason; use the private tier for anything that might identify a person, and keep the status code public so a device log still tells you “401 on /account”.

If you ever capture a failing request to share with a backend developer, paste it into a header inspector and remove credentials first. A redacted curl is a courtesy; an unredacted one is a leak.

Testing with a URLProtocol stub

Do not hit the live API in unit tests. Register a URLProtocol subclass on a URLSessionConfiguration, and have it return fixtures you own.

final class StubProtocol: URLProtocol, @unchecked Sendable {
    static var request: URLRequest?
    static var response: (Data, HTTPURLResponse)?
    static var error: Error?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        Self.request = request
        if let error = Self.error {
            client?.urlProtocol(self, didFailWithError: error)
            return
        }
        if let (data, response) = Self.response {
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
            return
        }
        client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
    }

    override func stopLoading() {}
}

func makeTestClient() -> APIClient {
    let config = URLSessionConfiguration.ephemeral
    config.protocolClasses = [StubProtocol.self]
    let session = URLSession(configuration: config)
    return APIClient(
        session: session,
        baseURL: URL(string: "https://api.example.com")!,
        decoder: makeDecoder(),
        credentials: { "test-token" }
    )
}

With that in place you can write Swift Testing cases for: a 200 with a fixture, a 204, a 401, a 429 with Retry-After, a timeout, a cancelled task, a date the decoder must accept, and a body the decoder must reject. Assert that the Authorization header was set, and that a non-idempotent POST was not retried after a 500.

Reset the stub’s static state at the start of each test, or better, store the stub state in a context object if you start to see cross-test flakes. Static state is convenient and also a foot-gun in parallel tests; if you enable parallelisation, give each test its own configuration object rather than a global.

You still want one or two end-to-end checks against a staging server, run less often, to catch a contract change. Those are not a substitute for the stubbed tests, which should be the ones that run on every commit.

Key takeaways

If you want a second pair of eyes on a client or a retry policy, tell me about it.

Frequently asked questions

How do you retry a failed URLSession request in Swift?

Catch the failure, decide whether it is retryable (idempotent method, and a transport, 408, 429 or 5xx error), wait with backoff and jitter, and try again up to a small cap. Honour Retry-After when it is a short delay. Stop if the task is cancelled. Do not retry a POST that might have already created something, unless you have an idempotency key the server honours.

Should you use Alamofire or URLSession?

URLSession is enough for most apps, especially if you wrap it once. Alamofire can still help if you already use it, or if you need a feature you do not want to write yourself, but it is not a requirement for retries, typed errors or tests. Do not add a library only because a template included it.

How do you cancel a URLSession task when a view disappears?

Call the async URLSession APIs from a SwiftUI .task or from a Task you cancel when the view goes away. Those APIs honour cooperative cancellation and will fail with a cancelled URLError. Do not start a detached task you never store, and do not present cancellation as a user-facing error.

How should iOS apps handle offline API errors?

Map URLError.notConnectedToInternet, timeouts and similar transport codes to a typed error, and show a state the user can retry. Do not spin through your retry budget as if the next attempt will find a network that is not there; one or two tries with backoff is enough, then wait for a genuine retry action or a path change. Cache or queue only if you have a clear write model; guesswork offline queues are a good way to double-submit later.

How do you decode JSON errors from an API in Swift?

On a non-success status, try to decode a small error type from the body and wrap the message in your own error enum. If that fails, keep the status code and the raw body for logs (redacted), and show a generic message. Do not run your success Decodable type on a 4xx body and hope.

Written by Lee Johnson. Published 10 November 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project