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
@MainActorso mutations happen on the main thread without manual dispatch. deferguarantees the loading flag is cleared on every exit path, including thrown errors.- Catch
CancellationErrorseparately — 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.
Related
Architecture: MVVM, observation and dependency injection Concurrency: actors, tasks and MainActor
Last refreshed 2026-09-18.