Networking with URLSession
The five lines that fetch and decode JSON, and the six failure modes around them that separate a demo from an app.
By the end you will be able to
- Fetch and decode JSON with async URLSession, checking what actually needs checking
- Design the error type a networking layer should throw
- Handle the real-world failures — status codes, bad payloads, cancellation, retries
URLSession.shared.data(from: url) returns without throwing. The server actually responded with 404 Not Found. What is in your hands?
The happy path is five lines
struct Article: Codable {
let id: Int
let title: String
let publishedAt: Date
}
func fetchArticles() async throws -> [Article] {
let url = URL(string: "https://api.example.com/articles")!
let (data, response) = try await URLSession.shared.data(from: url)
// …status check goes here — next section…
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode([Article].self, from: data)
}
Async URLSession gives you (Data, URLResponse); the decoder strategies handle the two most common impedance mismatches (published_at → publishedAt, ISO-8601 strings → Date). This much appears in every tutorial. Everything that distinguishes production code happens around these five lines.
The status check, and the error type worth having
Design the error enum first — it is the API of your networking layer. Each case answers a different user-facing question:
enum APIError: Error {
case invalidURL
case transport(underlying: Error) // offline, timeout — retryable, show "check connection"
case serverError(status: Int) // 5xx — their fault, retry later
case clientError(status: Int) // 4xx — our fault, do NOT blind-retry
case decoding(underlying: Error) // payload surprise — log loudly, file a bug
}
func fetchArticles() async throws -> [Article] {
// …
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(underlying: URLError(.badServerResponse))
}
switch http.statusCode {
case 200...299: break
case 500...599: throw APIError.serverError(status: http.statusCode)
default: throw APIError.clientError(status: http.statusCode)
}
// …decode…
}
The 4xx/5xx split is not pedantry — it drives real behaviour: a 503 deserves a retry with backoff; a 401 means re-authenticate; a 404 means the content is gone and retrying is harmful noise. Collapsing them into one "network error" forfeits the ability to do the right thing, and "the right thing per failure" is most of what users experience as app quality.
Decoding failures are information
JSONDecoder errors are unusually good — they name the exact key path that surprised you. Swallowing them into "Something went wrong" converts a 30-second diagnosis into an afternoon:
do {
return try decoder.decode([Article].self, from: data)
} catch let error as DecodingError {
// .keyNotFound("title", path: [2]) — the third article is missing a title
logger.error("decode failed: \(error)")
throw APIError.decoding(underlying: error)
}
Two habits keep decoding resilient against a backend that evolves without asking you:
- Model optionality honestly. A field the backend may omit is
let summary: String?— not a crash and not a made-up default. The default's world is the sneaky one:summary ?? ""decodes without complaint and renders a permanently blank card nobody ever files a bug about, while the optional forces the UI to design the missing-summary case. Broken loudly beats wrong quietly. - Never decode server enums directly.
case free, prodecoded from a string dies the day the backend adds"enterprise"— one unknown value fails the whole payload. Decode the raw string, map it to your enum with an.unknownfallback.
Note what the status check saved us from with BrokenServer: without it, the maintenance HTML would have reached the decoder and produced "bad line: \<html\>maintenance\</html\>" — a decoding error for what is actually a server problem, sending you debugging in exactly the wrong direction.
Retries — for the failures where retrying helps
Transient failures (timeouts, 5xx) deserve another attempt; permanent ones (4xx) must not get one — hammering a 401 can lock an account. Exponential backoff spaces the attempts so a struggling server is not made worse:
Real deployments add jitter (randomising each delay ±20% so a thousand clients do not retry in lockstep) and honour Task.checkCancellation() between attempts — Task.sleep already throws on cancellation, which is why try? there is doing quiet but real work: a user who leaves the screen cancels the whole retry loop mid-sleep.
Where this meets SwiftUI
The pieces from earlier lessons assemble without new ideas — screen state is the enum from the enums lesson, work runs in .task, and the model owns the sequence:
@Observable
final class FeedModel {
enum State {
case loading
case loaded([Article])
case failed(String) // the user-facing message, chosen from APIError
}
private(set) var state: State = .loading
private let client: ArticleFetching
init(client: ArticleFetching) { self.client = client }
func load() async {
state = .loading
do {
state = .loaded(try await client.fetchArticles())
} catch APIError.transport {
state = .failed("You appear to be offline.")
} catch {
state = .failed("Couldn't load the feed.")
}
}
}
struct FeedView: View {
let model: FeedModel
var body: some View {
content
.task { await model.load() } // cancelled automatically on disappear
.refreshable { await model.load() }
}
// …switch over model.state…
}
.task's automatic cancellation is structured concurrency paying rent: navigate away mid-request and the request dies with the view, no [weak self] gymnastics, no updates arriving to a screen that no longer exists.
Your app decodes let plan: Plan where enum Plan: String, Codable { case free, pro }. Marketing launches an "enterprise" tier. What happens to existing app versions fetching an enterprise user's profile?
Sort failures into behaviour
Implement userMessage(forStatus:) — the mapping from status code to what the app should do, which is the decision your error enum exists to enable.
Armour a server enum
Plan.parse must survive values the backend invents later. Implement it with an unknown fallback, preserving the raw string for logging.
Sketch the networking layer for an offline-tolerant notes app: the Transport protocol, the error enum, which failures retry and which don't, and what each failure shows the user. Then the hard part: when a save fails offline, what happens to the note — and what does the user see right now?
You can now:
- Fetch and decode with async URLSession, checking status before decoding
- Design an error enum whose cases map to different app behaviour
- Armour models against evolving backends — honest optionals, unknown-tolerant enums
- Retry transient failures with backoff, and let cancellation flow through
Next up: SwiftData — persisting models so the app works before the network does.