Networking with URLSession and async/await

Decode JSON with Codable, write typed async requests, model errors properly, cancel work when a view disappears, paginate, and load images without blocking the UI.

Codable models and typed requests

struct Article: Decodable, Identifiable {
    let id: Int
    let title: String
    let publishedAt: Date
    let author: Author?

    struct Author: Decodable { let name: String }

    enum CodingKeys: String, CodingKey {
        case id, title, author
        case publishedAt = "published_at"
    }
}

enum APIError: Error {
    case http(Int)
    case decoding(Error)
    case transport(Error)
}

struct APIClient {
    var baseURL = URL(string: "https://api.example.com")!
    var session: URLSession = .shared
    var decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.keyDecodingStrategy = .convertFromSnakeCase
        d.dateDecodingStrategy = .iso8601
        return d
    }()

    func articles(page: Int) async throws -> [Article] {
        var components = URLComponents(url: baseURL.appendingPathComponent("articles"),
                                       resolvingAgainstBaseURL: false)!
        components.queryItems = [URLQueryItem(name: "page", value: String(page))]

        do {
            let (data, response) = try await session.data(from: components.url!)
            guard let http = response as? HTTPURLResponse else { throw APIError.http(0) }
            guard (200..<300).contains(http.statusCode) else { throw APIError.http(http.statusCode) }
            return try decoder.decode([Article].self, from: data)
        } catch let error as DecodingError {
            throw APIError.decoding(error)
        } catch let error as APIError {
            throw error
        } catch {
            throw APIError.transport(error)
        }
    }
}

Tasks, cancellation and the view model

@Observable
final class ArticleListModel {
    private(set) var articles: [Article] = []
    private(set) var errorMessage: String?
    private(set) var isLoading = false
    private var page = 1
    private var isLastPage = false

    private let client: APIClient
    init(client: APIClient = APIClient()) { self.client = client }

    @MainActor
    func loadFirstPage() async {
        page = 1
        isLastPage = false
        articles = []
        await loadNextPage()
    }

    @MainActor
    func loadNextPage() async {
        guard !isLoading, !isLastPage else { return }
        isLoading = true
        defer { isLoading = false }
        do {
            let batch = try await client.articles(page: page)
            isLastPage = batch.isEmpty
            articles.append(contentsOf: batch)
            page += 1
        } catch is CancellationError {
            // the view went away; not an error the user should see
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}
  • Mark the model @MainActor so mutations happen on the main thread without manual dispatch.
  • defer guarantees the loading flag is cleared on every exit path, including thrown errors.
  • Catch CancellationError separately — a cancelled request is normal, not a failure to report.
  • Hold the task so it can be cancelled from .task: SwiftUI cancels it automatically when the view disappears.

Images, caching and retries

struct ArticleRow: View {
    let article: Article
    let model: ArticleListModel

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: URL(string: article.imageURL)) { phase in
                switch phase {
                case .success(let image):
                    image.resizable().scaledToFill()
                case .failure:
                    Image(systemName: "photo").foregroundStyle(.secondary)
                case .empty:
                    ProgressView()
                @unknown default:
                    Color.secondary.opacity(0.15)
                }
            }
            .frame(width: 64, height: 64)
            .clipShape(RoundedRectangle(cornerRadius: 8))

            Text(article.title).lineLimit(2)
        }
        .task { await model.loadNextPage() }
    }
}
💡
AsyncImage uses the shared URLCache, which is small by default. For a scrolling feed configure a dedicated cache with a sensible memoryCapacity and diskCapacity, or a third-party image library, otherwise you re-download every image on every launch.

FAQ

How do I avoid decoding errors caused by one bad field?
Make the field optional in the model rather than loosening the whole decoder, and log the DecodingError path — it names the exact key and type that failed.
When should I retry a failed request?
Only for transient failures: timeouts, connection loss and 5xx responses, with exponential backoff and a cap. Never retry a 401 or 422 — the request will keep failing and you amplify load.

Architecture: MVVM, observation and dependency injection Concurrency: actors, tasks and MainActor

Last refreshed 2026-09-18.