Skip to content
Commitwise
Menu
iOS Development

Networking with URLSession and Codable

Load JSON from an API with URLSession and async/await, decode it with Codable, and handle status codes, loading states, errors and cancellation in SwiftUI.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Almost every app talks to a server. And almost every app has shipped one of these bugs: a spinner that never stops, a list that’s silently empty when the server returns an error, or a crash after the backend renamed one JSON field.

Swift gives you everything you need without a third-party library: URLSession with async/awaitasync/awaitWriting asynchronous code that reads top to bottom like normal code, with await marking where it may pause.Open in glossary → to load data, and Codable to turn JSON into Swift types. This tutorial shows how to use them so that failures are visible, handled and testable.

New to some of these? Open what you need, or skip ahead.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Basics:What does asynchronous mean?

Synchronous code does one thing at a time and waits for each step to finish. Asynchronous code starts something slow (a download), lets other work continue in the meantime, and continues when the result is ready.

Swift (async/await), Kotlin (suspend functions in coroutines) and Java (virtual threads, futures) each have their own way to write asynchronous code that still reads top to bottom, like normal code. The waiting doesn’t freeze the app.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What does a type in angle brackets mean?

Types like List<String>, Array<Int>, Map<String, User> or Result<Profile, Error> are generic: the type in angle brackets says what they contain. List<String> is a list of strings; you can’t put a number into it, and everything you take out is a string without any conversion.

Functions can be generic too: func load<T: Decodable>(…) -> T works for any type T that can be decoded, and the caller decides which one. The Generics tutorial explains it in detail.

Part Not started

Loading JSON in three steps

Loading data from an API is like ordering food for delivery:

  1. Place the order: send a request to a URL.
  2. Wait without blocking anything else: with await, your app stays responsive while the data travels.
  3. Unpack and check: did the right thing arrive (the HTTP status), and can you read it (decode the JSON into your types)?

Most networking bugs come from skipping part of step 3.

URLSession.data(for:) is an async throws API that integrates with structured concurrency: it suspends without blocking a thread and is cancelled when the calling task is cancelled (a SwiftUI .task going away cancels the request). It throws only for transport failures (URLError); HTTP error statuses arrive as successful responses you must check. Codable synthesizes decoding from your property names, with JSONDecoder strategies for keys and dates.

The production concerns are where defaults surprise you: synthesized Decodable ignores property defaults, the default date strategy isn’t ISO 8601, decoding large payloads on the main actor causes hitches, and testability needs a seam (URLProtocol stubs). The Detailed version covers them.

struct Product: Decodable, Identifiable {
    let id: Int
    let name: String
    let price: Double
}

enum APIError: Error {
    case badStatus(Int)
}

func loadProducts() async throws -> [Product] {
    let url = URL(string: "https://api.example.com/products")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
        throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
    }
    return try JSONDecoder().decode([Product].self, from: data)
}

Line by line:

  • Decodable tells Swift to generate the code that reads a Product from JSON. The property names must match the JSON keys: {"id": 1, "name": "Mug", "price": 9.5}.
  • try await URLSession.shared.data(from:) sends the request and waits for the answer. try, because it fails when there’s no connection or the request times out.
  • The guard checks the status code. A 404 or a 500 is still a “response”, so URLSession doesn’t throw for it. You have to check.
  • JSONDecoder().decode turns the bytes into [Product], and throws if the JSON doesn’t match.

The response nobody checked

Spot the bugAn empty list when the server is down

Tap the line with the bug.

func loadOrders() async throws -> [Order] {
    let url = URL(string: "https://api.example.com/orders")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let page = try? JSONDecoder().decode(OrderPage.self, from: data)
    return page?.orders ?? []
}

Matching real JSON

APIs rarely use Swift’s naming style. Codable has three tools for the difference:

// {"id": 7, "display_name": "Ana", "created_at": "2026-11-01T09:30:00Z", "avatar_url": null}
struct User: Decodable {
    let id: Int
    let displayName: String     // from display_name, via the key strategy
    let createdAt: Date         // from an ISO 8601 string, via the date strategy
    let avatarURL: URL?         // optional: null or a missing key both become nil

    enum CodingKeys: String, CodingKey {
        case id, displayName, createdAt
        case avatarURL = "avatarUrl"   // after snake case conversion, avatar_url becomes avatarUrl
    }
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601

let user = try decoder.decode(User.self, from: data)
  • keyDecodingStrategy = .convertFromSnakeCase maps display_name to displayName for every key.
  • CodingKeys lets you rename individual properties. Note the gotcha above: avatar_url converts to avatarUrl, not avatarURL, so acronyms need an explicit key.
  • Optionals decode null and missing keys as nil. Everything that isn’t optional must be present.

That last rule has a trap:

Predict the output

The server sends {"name": "Mug"}. What happens?

struct Product: Decodable {
    let name: String
    var discount: Double = 0
}

let product = try JSONDecoder().decode(Product.self, from: Data(#"{"name": "Mug"}"#.utf8))
print(product.name, product.discount)

Dates by default

Predict the output

The server sends {"createdAt": "2026-11-01T10:00:00Z"}. What happens with a default JSONDecoder?

struct Event: Decodable {
    let createdAt: Date
}

let event = try JSONDecoder().decode(Event.self, from: Data(#"{"createdAt": "2026-11-01T10:00:00Z"}"#.utf8))
print(event.createdAt)

Testing networking code

Two seams cover nearly everything:

  • A protocol or closure in front of the client (var send: (URLRequest) async throws -> (Data, URLResponse)) for view-model tests that only care about results.
  • A URLProtocol subclass registered on a URLSessionConfiguration to test the real client end to end: it intercepts requests and returns canned data, status codes and errors, so status handling, decoding and error mapping are all covered without a network.

Deep dives

Deep diveReuse one URLSessionInterview favorite

Each URLSession has its own connection pool. Creating a new session per request throws away open connections, so every request pays for a new TCP and TLS handshake and loses HTTP/2 multiplexing. Use URLSession.shared or one long-lived session per configuration (for example, one with your timeouts and cache).

A session created with a delegate keeps a strong reference to that delegate until the session is invalidated (finishTasksAndInvalidate() or invalidateAndCancel()). Creating sessions with self as the delegate and never invalidating them leaks both, which is a common finding in memory graphs.

  • data(for:) throws only for transport failures; check HTTPURLResponse.statusCode. Never try? a decode in a loading path.
  • Synthesized Decodable ignores property defaults (missing key throws); the default date strategy is seconds since 2001; .convertFromSnakeCase turns avatar_url into avatarUrl.
  • Test with a URLProtocol stub on a custom URLSessionConfiguration.

Check this part

  1. The server answers 404. Does URLSession.data(from:) throw?

  2. Why is try? around decoding dangerous in a loading function?

  3. What does JSONDecoder's default date strategy expect?

  4. A model has var discount: Double = 0, and the JSON has no "discount" key. What does synthesized decoding do?

Send the request with async URLSession, check the HTTP status yourself (4xx and 5xx don't throw), and decode with Codable. Never hide failures with try?: they become empty screens.

Part Not started

Showing it in SwiftUI

A screen that loads data has (at least) three states: loading, loaded and failed. Model them as an enum, and let .task start the request when the view appears:

struct ProductListView: View {
    enum LoadState {
        case loading
        case loaded([Product])
        case failed(String)
    }

    @State private var state = LoadState.loading

    var body: some View {
        Group {
            switch state {
            case .loading:
                ProgressView()
            case .loaded(let products):
                List(products) { product in
                    LabeledContent(product.name, value: product.price, format: .currency(code: "EUR"))
                }
            case .failed(let message):
                ContentUnavailableView {
                    Label("Couldn't load products", systemImage: "wifi.exclamationmark")
                } description: {
                    Text(message)
                } actions: {
                    Button("Try again") { Task { await load() } }
                }
            }
        }
        .task { await load() }               // starts on appear, cancelled on disappear
    }

    private func load() async {
        state = .loading
        do {
            state = .loaded(try await loadProducts())
        } catch is CancellationError {
            return                           // the user left; nothing to show
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}

.task is the right place to start loading: it runs when the view appears and cancels the request automatically if the user navigates away before it finishes. The do/catch turns every failure into a state the screen can show, with a retry button, so the spinner can never spin forever.

Practice: a small API client

Calling URLSession.shared and JSONDecoder() from every screen spreads the same checks everywhere. Let’s put them in one place, step by step:

  1. One decoder, configured once, with the key and date strategies your API uses.
  2. One generic get function that builds the URL, checks the status and decodes any Decodable type.
  3. One error type for the failures screens care about.
  4. Inject the session, so tests can use a fake one.
struct APIClient {
    var baseURL = URL(string: "https://api.example.com")!
    var session: URLSession = .shared

    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.keyDecodingStrategy = .convertFromSnakeCase
        d.dateDecodingStrategy = .iso8601
        return d
    }()

    enum Failure: Error {
        case badStatus(Int)
        case decoding(DecodingError)
    }

    func get<T: Decodable>(_ path: String, as type: T.Type = T.self) async throws -> T {
        let (data, response) = try await session.data(from: baseURL.appending(path: path))
        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        guard (200..<300).contains(status) else { throw Failure.badStatus(status) }
        do {
            return try decoder.decode(T.self, from: data)
        } catch let error as DecodingError {
            throw Failure.decoding(error)
        }
    }
}

// Usage: one line per endpoint, with the type deciding what's decoded.
let products: [Product] = try await api.get("products")
let user = try await api.get("me", as: User.self)

Now every screen gets status checks and consistent errors for free, and a test can pass an APIClient whose session returns canned responses.

One more common bug, in how the request is started:

Spot the bugA spinner that never stops

Tap the line with the bug.

struct OrdersView: View {
    @State private var orders: [Order] = []
    @State private var isLoading = true

    var body: some View {
        List(orders) { OrderRow(order: $0) }
            .overlay { if isLoading { ProgressView() } }
            .onAppear {
                Task {
                    orders = try await api.get("orders")
                    isLoading = false
                }
            }
    }
}

Exerciseeasy

A profile with posts

An API returns a user profile with their latest posts:

{
  "id": 42,
  "display_name": "Ana Horvat",
  "joined_at": "2024-03-15T08:00:00Z",
  "bio": null,
  "posts": [
    { "id": 1, "title": "Hello", "like_count": 12, "published_at": "2026-10-30T18:20:00Z" },
    { "id": 2, "title": "SwiftUI tips", "like_count": 40, "published_at": "2026-10-31T09:05:00Z" }
  ]
}
  1. Write Decodable models for it.
  2. Write loadProfile(id:) using the APIClient above.
  3. Build a view that shows a spinner, the profile with its posts, or an error with a retry button.
›

The posts are an array of their own type inside the profile. bio can be null, so it’s optional. The client’s decoder already converts snake case and ISO 8601 dates.

›

Reuse the LoadState idea: an enum with loading, loaded(Profile) and failed(String), set from a load() function called in .task and by the retry button.

SolutionTry it yourself first
struct Profile: Decodable {
    let id: Int
    let displayName: String
    let joinedAt: Date
    let bio: String?
    let posts: [Post]
}

struct Post: Decodable, Identifiable {
    let id: Int
    let title: String
    let likeCount: Int
    let publishedAt: Date
}

extension APIClient {
    func loadProfile(id: Int) async throws -> Profile {
        try await get("users/\(id)")
    }
}

struct ProfileView: View {
    let userID: Int
    let api: APIClient

    enum LoadState { case loading, loaded(Profile), failed(String) }
    @State private var state = LoadState.loading

    var body: some View {
        Group {
            switch state {
            case .loading:
                ProgressView()
            case .loaded(let profile):
                List {
                    Section {
                        Text(profile.displayName).font(.title2.bold())
                        if let bio = profile.bio { Text(bio) }
                        Text("Joined \(profile.joinedAt.formatted(date: .abbreviated, time: .omitted))")
                            .foregroundStyle(.secondary)
                    }
                    Section("Posts") {
                        ForEach(profile.posts) { post in
                            LabeledContent(post.title, value: "\(post.likeCount) likes")
                        }
                    }
                }
            case .failed(let message):
                ContentUnavailableView {
                    Label("Couldn't load the profile", systemImage: "person.crop.circle.badge.exclamationmark")
                } description: {
                    Text(message)
                } actions: {
                    Button("Try again") { Task { await load() } }
                }
            }
        }
        .task(id: userID) { await load() }     // reloads if the view is reused for another user
    }

    private func load() async {
        state = .loading
        do {
            state = .loaded(try await api.loadProfile(id: userID))
        } catch is CancellationError {
            return
        } catch {
            state = .failed("Check your connection and try again.")
        }
    }
}

The models mirror the JSON’s shape (a profile containing posts) while the decoder handles its spelling (snake case, date strings). .task(id:) matters when the same view is reused for a different user, as in a split view on iPad: a new id cancels the old request and starts a new one.

Where decoding runs

URLSession suspends without blocking any thread, but decoding is ordinary synchronous CPU work and runs wherever the calling code is isolated. In a @MainActor view model, try decoder.decode(...) runs on the main thread: invisible for a few KB, a dropped-frame hitch for a few MB (a feed, a product catalog, a map overlay). Keep it off the main actor:

  • a nonisolated function on a Sendable helper (and in Swift 6.2 with NonisolatedNonsendingByDefault, mark it @concurrent so it doesn’t inherit the caller’s actor),
  • or decode inside the API client, which shouldn’t be main-actor-isolated in the first place, returning Sendable models to the UI.

A hitch on every refresh

Spot the bugScrolling stutters when the feed refreshes

Tap the line with the bug.

@MainActor @Observable
final class FeedModel {
    private(set) var items: [FeedItem] = []
    private let decoder = JSONDecoder()

    func refresh() async throws {
        let (data, _) = try await URLSession.shared.data(from: feedURL)
        items = try decoder.decode([FeedItem].self, from: data)    // 4 MB of JSON
    }
}

Caching, timeouts and connectivity

  • HTTP caching works out of the box through URLCache when the server sends Cache-Control and ETag; URLSession revalidates with If-None-Match and serves 304s from cache. The default cache is small (memory and disk limits): configure a larger one for image-heavy apps.
  • Timeouts: timeoutIntervalForRequest (default 60 s) is the idle time between packets, not the total; timeoutIntervalForResource (default 7 days) is the total. Set both deliberately for user-facing requests.
  • waitsForConnectivity makes a request wait for the network instead of failing immediately offline, good for background syncs, bad for a screen that should show “you’re offline” now.
  • Background URLSessions keep large uploads and downloads running after the app is suspended, but they’re delegate-based (no async data(for:)) and the system decides scheduling.
Exercisemedium

Offline-first product catalog

A shopping app shows a catalog of 5,000 products. It must:

  • open instantly with the last known catalog, even offline,
  • refresh in the background, without downloading the whole catalog when nothing changed,
  • show a small “offline” or “updated just now” hint, never a full-screen error once something is cached.

Design the data flow: what’s stored where, what the request looks like, and how the UI gets updates.

›

Separate what the UI shows (a local store) from how it’s refreshed (a network sync). The UI never waits for the network if there’s local data.

›

HTTP already has “nothing changed” built in: ETag with If-None-Match, answered by 304 Not Modified. For a catalog that changes a little, an incremental endpoint (?changedSince=) avoids downloading 5,000 items for 3 updates.

SolutionTry it yourself first
@MainActor @Observable
final class CatalogStore {
    enum SyncStatus: Equatable { case idle, syncing, offline, updated(Date) }

    private(set) var products: [Product] = []
    private(set) var status: SyncStatus = .idle

    private let cache: CatalogCache          // SwiftData or a JSON file: products + etag + lastSync
    private let api: CatalogAPI

    init(cache: CatalogCache, api: CatalogAPI) {
        self.cache = cache
        self.api = api
        products = cache.loadProducts()      // instant: whatever we had last time
    }

    func refresh() async {
        status = .syncing
        do {
            switch try await api.catalog(ifNoneMatch: cache.etag) {
            case .notModified:
                status = .updated(.now)
            case .changed(let fresh, let etag):
                try await cache.save(fresh, etag: etag)
                products = fresh
                status = .updated(.now)
            }
        } catch is CancellationError {
            status = .idle
        } catch let error as URLError where error.code == .notConnectedToInternet {
            status = .offline                // keep showing cached products
        } catch {
            status = products.isEmpty ? .offline : .idle
            Log.sync.error("Catalog refresh failed: \(error)")
        }
    }
}

enum CatalogResponse: Sendable {
    case notModified
    case changed([Product], etag: String?)
}

struct CatalogAPI: Sendable {
    let session: URLSession

    func catalog(ifNoneMatch etag: String?) async throws -> CatalogResponse {
        var request = URLRequest(url: URL(string: "https://api.example.com/catalog")!)
        request.cachePolicy = .reloadIgnoringLocalCacheData          // we manage our own store
        if let etag { request.setValue(etag, forHTTPHeaderField: "If-None-Match") }

        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
        switch http.statusCode {
        case 304:
            return .notModified
        case 200..<300:
            let products = try JSONDecoder().decode([Product].self, from: data)
            return .changed(products, etag: http.value(forHTTPHeaderField: "ETag"))
        default:
            throw APIClient.Failure.badStatus(http.statusCode)
        }
    }
}

The decisions behind it:

  • The local store is the source of truth for the UI. The screen reads products, which is filled from disk at launch, so cold start and offline both show content immediately. The network only ever updates the store.
  • Conditional requests turn “nothing changed” into a few hundred bytes. If the catalog changes often but slightly, add an incremental endpoint (changedSince + tombstones for deletions) and merge by id.
  • Errors are status, not screens, once there’s data: a toolbar hint instead of a full-screen error. A full-screen error only makes sense when the store is empty.
  • Saving and decoding run off the main actor (the cache’s save is async for that reason), and only the final assignment touches UI state.
  • When to refresh: on launch, on scenePhase becoming active (throttled), on pull-to-refresh, and with BGAppRefreshTask for a fresh catalog before the user opens the app.
NeedAPI
Load datatry await session.data(for: request), cancelled with the calling task
Check status(response as? HTTPURLResponse)?.statusCode: URLSession doesn’t throw for 4xx/5xx
DecodeJSONDecoder with .convertFromSnakeCase, .iso8601 (no fractional seconds) or .custom
Optional JSON fieldsSwift optionals; defaults on non-optional properties are ignored by synthesized Decodable
Start from SwiftUI.task / .task(id:), never onAppear { Task { } } with throwing code
Off the main actordecode in a nonisolated (@concurrent in Swift 6.2) function, return Sendable models
CachingURLCache + ETag/If-None-Match, Cache-Control from the server
TestingURLProtocol stub on a custom URLSessionConfiguration

Gotchas: default date strategy is seconds since 2001; avatar_url becomes avatarUrl with snake case conversion; try? hides server errors; Task {} drops thrown errors; decoding megabytes on the main actor hitches scrolling.

func get<T: Decodable & Sendable>(_ request: URLRequest, as: T.Type) async throws -> T {
    let (data, response) = try await session.data(for: request)
    guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
        throw APIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, body: data)
    }
    return try decoder.decode(T.self, from: data)   // decoder configured once, not main-actor isolated
}

Check this part

  1. Where should a SwiftUI view start loading data?

  2. Why model the screen as an enum of loading, loaded and failed?

  3. Why can decoding cause scroll hitches in a @MainActor view model?

  4. How do you avoid downloading an unchanged catalog?

Model a screen's states (loading, loaded, failed) as an enum and start loading in .task, which cancels when the view goes away. Put status checks and decoding in one API client, and keep decoding of big payloads off the main actor.

Key takeaways

  • Load with try await URLSession.shared.data(from:) and decode with JSONDecoder into Decodable types.
  • Always check the HTTP status: URLSession doesn’t throw for 404 or 500.
  • Model loading, loaded and failed as an enum, start loading in .task, and handle errors so the spinner can’t spin forever.
  • Use .convertFromSnakeCase, .iso8601 and optionals to match real JSON; don’t hide errors with try?.
  • Status codes, decoding and error mapping belong in one client; screens receive typed failures.
  • Synthesized Decodable ignores defaults, the default date strategy isn’t ISO 8601, and acronyms need explicit keys.
  • Decode large payloads off the main actor and return Sendable value models.
  • For resilient apps: local store as the UI’s source of truth, conditional requests, and URLProtocol-based tests.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.