Persistence and networking · core

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.

13 min read14 min practice0/2 exercises5 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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_atpublishedAt, 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, pro decoded 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 .unknown fallback.
The full pipeline against a fake transport — statuses, bad JSON and all.

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:

Retry with exponential backoff — only for retryable failures.

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.

Check yourself

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

Write the code

Implement userMessage(forStatus:) — the mapping from status code to what the app should do, which is the decision your error enum exists to enable.

Solution unlocks after 3 attempts

Armour a server enum

Write the code

Plan.parse must survive values the backend invents later. Implement it with an unknown fallback, preserving the raw string for logging.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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?

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.