Skip to main content
Technology & EngineeringIos Swift359 lines

Networking

URLSession networking patterns for building robust API clients and handling data transfer in iOS

Quick Summary18 lines
You are an expert in networking and URLSession for building iOS applications with Swift.

## Key Points

- **Use an `actor` for your API client** to serialize token refresh and prevent duplicate auth requests.
- **Model errors explicitly** with a typed `NetworkError` enum so callers can handle specific failure modes (no connection, auth expired, server error).
- **Configure `JSONDecoder` once** with date/key strategies and reuse it. Creating a decoder per request is wasteful.
- **Set timeouts on `URLSessionConfiguration`.** The default 60-second timeout is too long for most API calls. Use 15-30 seconds for API requests.
- **Use `URLSession.shared` for simple requests** and custom sessions only when you need delegate callbacks (progress, background transfers, certificate pinning).
- **Respect cancellation.** URLSession tasks support cancellation natively. Cancelling a `Task` that is awaiting `URLSession.data(for:)` cancels the underlying network request.
- **Use `URLCache` and HTTP caching headers** for read-heavy endpoints instead of building a custom cache.
- **Not checking the HTTP status code.** `URLSession` does not throw on 4xx/5xx responses. Always verify the status code before decoding.
- **Decoding on the main thread.** `JSONDecoder.decode` for large payloads blocks the main thread. Perform decoding in a background context or use `Task.detached` for heavy parsing.
- **Ignoring `URLSession` delegate retain cycles.** A `URLSession` strongly retains its delegate. Invalidate the session when done (`finishTasksAndInvalidate()`).
- **Hardcoding base URLs.** Use build configurations or environment-based injection so staging, QA, and production URLs are separate.
- **Forgetting background session rules.** Background `URLSession` uploads/downloads must use file-based transfer (not data tasks). The system relaunches your app to deliver results.
skilldb get ios-swift-skills/NetworkingFull skill: 359 lines
Paste into your CLAUDE.md or agent config

Networking — iOS/Swift

You are an expert in networking and URLSession for building iOS applications with Swift.

Core Philosophy

A well-designed networking layer is a thin, focused boundary between your app and the outside world. Its job is to translate between HTTP (URLs, status codes, JSON bytes) and your domain types (models, errors, results). Everything above this boundary should work in terms of domain types; everything below should be pure HTTP. When this separation is clean, you can swap HTTP libraries, add caching, or change serialization formats without any code above the boundary noticing.

Errors in networking are not exceptional -- they are expected. Network requests fail routinely due to connectivity issues, server outages, authentication expiry, and timeouts. A production networking layer models these failure modes explicitly with a typed error enum rather than relying on generic Error or raw status codes propagating up to the UI. Each error case should carry enough information for the caller to decide what to do: retry, show a message, redirect to login, or fail silently.

URLSession is deliberately minimal. It gives you a raw (Data, URLResponse) tuple and trusts you to interpret it. This means you must always check the HTTP status code yourself -- URLSession does not throw on 4xx or 5xx responses. Building a robust API client means wrapping this tuple-checking, JSON decoding, and error mapping into a reusable layer so that every call site does not repeat the same boilerplate.

Anti-Patterns

  • Not checking the HTTP status code: URLSession succeeds as long as it receives a response, even if that response is a 500 Internal Server Error. Attempting to decode a 4xx or 5xx response body as your expected model type produces confusing decoding errors instead of a clear "server returned an error" message.

  • Creating a new JSONDecoder for every request: JSONDecoder configuration (date strategy, key decoding strategy) should be set once and reused. Creating a fresh decoder per request wastes allocations and risks inconsistent configuration across calls.

  • Hardcoding base URLs throughout the codebase: Scattering "https://api.example.com" across multiple files makes it impossible to switch between staging, QA, and production environments. Inject the base URL through configuration so environment switching is a single change.

  • Ignoring cancellation in async/await contexts: When a user navigates away from a screen, any in-flight requests should be cancelled to save bandwidth and prevent stale data from updating the UI. Using .task {} in SwiftUI handles this automatically, but manually created Task blocks require explicit cancellation management.

  • Performing JSON decoding on the main thread for large payloads: While small responses decode instantly, a 2MB JSON payload decoded on the main thread causes visible UI hitches. For large responses, decode in a Task.detached or on a background context to keep the main thread responsive.

Overview

URLSession is Foundation's networking API for HTTP/HTTPS requests, file downloads/uploads, and WebSocket connections. Combined with Swift Concurrency, Codable, and modern error handling, it enables clean, testable networking layers. This skill covers building production-grade API clients, handling authentication, uploading and downloading, and structuring a networking layer.

Core Concepts

Basic Requests with async/await

// Simple GET
func fetchData<T: Decodable>(from url: URL, as type: T.Type) async throws -> T {
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }
    guard (200..<300).contains(http.statusCode) else {
        throw NetworkError.httpError(statusCode: http.statusCode, data: data)
    }

    return try JSONDecoder().decode(T.self, from: data)
}

// POST with body
func createItem(_ item: NewItem) async throws -> Item {
    var request = URLRequest(url: URL(string: "https://api.example.com/items")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(item)

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
        throw NetworkError.invalidResponse
    }

    return try JSONDecoder().decode(Item.self, from: data)
}

Endpoint Abstraction

A clean pattern for defining API endpoints:

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

struct Endpoint {
    let path: String
    let method: HTTPMethod
    let queryItems: [URLQueryItem]?
    let body: Encodable?
    let headers: [String: String]

    init(
        path: String,
        method: HTTPMethod = .get,
        queryItems: [URLQueryItem]? = nil,
        body: Encodable? = nil,
        headers: [String: String] = [:]
    ) {
        self.path = path
        self.method = method
        self.queryItems = queryItems
        self.body = body
        self.headers = headers
    }

    func urlRequest(baseURL: URL) throws -> URLRequest {
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)!
        components.queryItems = queryItems

        var request = URLRequest(url: components.url!)
        request.httpMethod = method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        for (key, value) in headers {
            request.setValue(value, forHTTPHeaderField: key)
        }

        if let body {
            request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }

        return request
    }
}

Error Modeling

enum NetworkError: LocalizedError {
    case invalidResponse
    case httpError(statusCode: Int, data: Data)
    case decodingError(DecodingError)
    case noConnection
    case timeout
    case cancelled

    var errorDescription: String? {
        switch self {
        case .invalidResponse: return "Invalid server response"
        case .httpError(let code, _): return "HTTP error \(code)"
        case .decodingError(let error): return "Decoding failed: \(error.localizedDescription)"
        case .noConnection: return "No internet connection"
        case .timeout: return "Request timed out"
        case .cancelled: return "Request was cancelled"
        }
    }

    static func from(_ error: Error) -> NetworkError {
        if let networkError = error as? NetworkError { return networkError }
        if let decodingError = error as? DecodingError { return .decodingError(decodingError) }

        let nsError = error as NSError
        switch nsError.code {
        case NSURLErrorNotConnectedToInternet: return .noConnection
        case NSURLErrorTimedOut: return .timeout
        case NSURLErrorCancelled: return .cancelled
        default: return .invalidResponse
        }
    }
}

Implementation Patterns

Production API Client

actor APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    private let tokenProvider: TokenProvider

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

    func send<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var request = try endpoint.urlRequest(baseURL: baseURL)

        // Attach auth token
        let token = try await tokenProvider.validToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        let (data, response) = try await session.data(for: request)

        guard let http = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        switch http.statusCode {
        case 200..<300:
            return try decoder.decode(T.self, from: data)
        case 401:
            // Token expired — refresh and retry once
            try await tokenProvider.refresh()
            return try await send(endpoint)
        default:
            throw NetworkError.httpError(statusCode: http.statusCode, data: data)
        }
    }

    func sendVoid(_ endpoint: Endpoint) async throws {
        var request = try endpoint.urlRequest(baseURL: baseURL)
        let token = try await tokenProvider.validToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        let (_, response) = try await session.data(for: request)

        guard let http = response as? HTTPURLResponse,
              (200..<300).contains(http.statusCode) else {
            throw NetworkError.invalidResponse
        }
    }
}

Download with Progress

class DownloadManager: NSObject, ObservableObject {
    @Published var progress: Double = 0
    @Published var isDownloading = false
    private var downloadTask: URLSessionDownloadTask?
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.default
        return URLSession(configuration: config, delegate: self, delegateQueue: .main)
    }()

    func download(from url: URL, to destination: URL) async throws -> URL {
        isDownloading = true
        defer { isDownloading = false }

        return try await withCheckedThrowingContinuation { continuation in
            let task = session.downloadTask(with: url) { tempURL, response, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let tempURL else {
                    continuation.resume(throwing: NetworkError.invalidResponse)
                    return
                }
                do {
                    if FileManager.default.fileExists(atPath: destination.path) {
                        try FileManager.default.removeItem(at: destination)
                    }
                    try FileManager.default.moveItem(at: tempURL, to: destination)
                    continuation.resume(returning: destination)
                } catch {
                    continuation.resume(throwing: error)
                }
            }
            self.downloadTask = task
            task.resume()
        }
    }

    func cancel() {
        downloadTask?.cancel()
    }
}

extension DownloadManager: URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        // Handled in completion handler
    }
}

Multipart Upload

func uploadImage(_ image: UIImage, to url: URL) async throws -> UploadResult {
    let boundary = UUID().uuidString
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

    guard let imageData = image.jpegData(compressionQuality: 0.8) else {
        throw NetworkError.invalidResponse
    }

    var body = Data()
    body.append("--\(boundary)\r\n".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"image\"; filename=\"photo.jpg\"\r\n".data(using: .utf8)!)
    body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
    body.append(imageData)
    body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)

    let (data, _) = try await URLSession.shared.upload(for: request, from: body)
    return try JSONDecoder().decode(UploadResult.self, from: data)
}

Request Retry with Exponential Backoff

func withRetry<T>(
    maxAttempts: Int = 3,
    initialDelay: Duration = .seconds(1),
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?
    var delay = initialDelay

    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch {
            lastError = error
            let nsError = error as NSError
            let isRetryable = [NSURLErrorTimedOut, NSURLErrorNetworkConnectionLost]
                .contains(nsError.code)

            if !isRetryable || attempt == maxAttempts { throw error }

            try await Task.sleep(for: delay)
            delay *= 2
        }
    }

    throw lastError!
}

Best Practices

  • Use an actor for your API client to serialize token refresh and prevent duplicate auth requests.
  • Model errors explicitly with a typed NetworkError enum so callers can handle specific failure modes (no connection, auth expired, server error).
  • Configure JSONDecoder once with date/key strategies and reuse it. Creating a decoder per request is wasteful.
  • Set timeouts on URLSessionConfiguration. The default 60-second timeout is too long for most API calls. Use 15-30 seconds for API requests.
  • Use URLSession.shared for simple requests and custom sessions only when you need delegate callbacks (progress, background transfers, certificate pinning).
  • Respect cancellation. URLSession tasks support cancellation natively. Cancelling a Task that is awaiting URLSession.data(for:) cancels the underlying network request.
  • Use URLCache and HTTP caching headers for read-heavy endpoints instead of building a custom cache.

Common Pitfalls

  • Not checking the HTTP status code. URLSession does not throw on 4xx/5xx responses. Always verify the status code before decoding.
  • Decoding on the main thread. JSONDecoder.decode for large payloads blocks the main thread. Perform decoding in a background context or use Task.detached for heavy parsing.
  • Ignoring URLSession delegate retain cycles. A URLSession strongly retains its delegate. Invalidate the session when done (finishTasksAndInvalidate()).
  • Hardcoding base URLs. Use build configurations or environment-based injection so staging, QA, and production URLs are separate.
  • Forgetting background session rules. Background URLSession uploads/downloads must use file-based transfer (not data tasks). The system relaunches your app to deliver results.

Install this skill directly: skilldb add ios-swift-skills

Get CLI access →