iOS with SwiftUI · Navigation, Networking, Persistence and Concurrency

Networking with URLSession, async/await and Codable

Fetch JSON from a REST API with URLSession and async/await, decode it with Codable, check HTTP status codes, map errors to messages, and load data with the .task modifier.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

The repositories screen's data layer: an API client that fetches and decodes JSON, validates the HTTP response, maps errors to messages users understand, and a view model that loads when the screen appears and cancels when it disappears.

The idea

  • URLSession.shared.data(from:) is an async function: let (data, response) = try await .... It throws for transport errors (offline, timeout), but not for HTTP errors, so check the status code yourself.
  • JSONDecoder with Codable types turns the JSON into Swift values; .convertFromSnakeCase maps stargazers_count to stargazersCount.
  • Put networking behind a protocol (RepoService) so view models can be tested with a fake.
  • In SwiftUI, start loading with .task { await model.load() }: it runs when the view appears and is cancelled automatically when it disappears.
  • iOS requires HTTPS by default (App Transport Security).

The code

struct Repo: Codable, Identifiable {
    let id: Int
    let fullName: String
    let stargazersCount: Int
}

struct SearchResponse: Codable { let items: [Repo] }

enum APIError: Error { case badStatus(Int), offline, decoding }

protocol RepoService: Sendable {
    func topRepos() async throws(APIError) -> [Repo]
}

struct GitHubService: RepoService {
    func topRepos() async throws(APIError) -> [Repo] {
        let url = URL(string: "https://api.github.com/search/repositories?q=language:swift&per_page=20")!
        let data: Data, response: URLResponse
        do {
            (data, response) = try await URLSession.shared.data(from: url)
        } catch {
            throw .offline
        }
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw .badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
        }
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        do { return try decoder.decode(SearchResponse.self, from: data).items } catch { throw .decoding }
    }
}

@MainActor @Observable
final class RepoListModel {
    enum State { case loading, loaded([Repo]), failed(String) }
    private(set) var state = State.loading
    private let service: any RepoService
    init(service: any RepoService) { self.service = service }

    func load() async {
        state = .loading
        do { state = .loaded(try await service.topRepos()) }
        catch { state = .failed(error.userMessage) }
    }
}

struct RepoListView: View {
    @State var model: RepoListModel
    var body: some View {
        Group {
            switch model.state {
            case .loading: ProgressView()
            case .loaded(let repos): List(repos) { Text($0.fullName) }
            case .failed(let message):
                ContentUnavailableView("Can't load", systemImage: "wifi.slash", description: Text(message))
            }
        }
        .task { await model.load() }
        .refreshable { await model.load() }
    }
}

Try the logic

Decoding a response, checking status codes and mapping errors, with a fake transport instead of the network:

Decoding, status codes and error messages
import Foundation

struct Repo: Codable {
    let id: Int
    let fullName: String
    let stargazersCount: Int
}
struct SearchResponse: Codable { let items: [Repo] }

enum APIError: Error {
    case badStatus(Int), offline, decoding
    var userMessage: String {
        switch self {
        case .offline: "You're offline. Pull to refresh when you're connected."
        case .badStatus(let code) where code >= 500: "The server is having trouble. Try again soon."
        case .badStatus(404): "Not found."
        case .badStatus: "Something went wrong."
        case .decoding: "The app couldn't read the server's response."
        }
    }
}

func decodeRepos(status: Int, body: String) throws(APIError) -> [Repo] {
    guard (200..<300).contains(status) else { throw .badStatus(status) }
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    do { return try decoder.decode(SearchResponse.self, from: Data(body.utf8)).items } catch { throw .decoding }
}

let ok = #"{"total_count": 2, "items": [{"id": 1, "full_name": "apple/swift", "stargazers_count": 69000, "private": false}]}"#
for (status, body) in [(200, ok), (503, ""), (404, ""), (200, #"{"items": "nope"}"#)] {
    do {
        let repos = try decodeRepos(status: status, body: body)
        print("\(status): \(repos.map { "\($0.fullName) (\($0.stargazersCount) stars)" })")
    } catch {
        print("\(status): \(error.userMessage)")
    }
}
print(APIError.offline.userMessage)

Output

200: ["apple/swift (69000 stars)"]
503: The server is having trouble. Try again soon.
404: Not found.
200: The app couldn't read the server's response.
You're offline. Pull to refresh when you're connected.

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Not checking the HTTP status. data(from:) succeeds for a 500 page; decoding then fails with a confusing error.
  • Updating UI state off the main actor. Make view models @MainActor so state changes happen on the main thread.
  • Starting tasks in onAppear without cancelling them. .task cancels automatically; a Task {} in onAppear keeps running after the screen is gone.
  • Showing raw error descriptions. Map errors to messages users understand and log the details.

Key points

  • URLSession with async/await plus Codable is the standard networking stack; no third-party library is needed.
  • Validate status codes and map errors to typed, user-friendly cases.
  • Hide networking behind a protocol for testability.
  • Load with .task so work is cancelled with the view.

Further reading: Fetching website data into memory, Encoding and decoding custom types.

Practice questions

Does URLSession's data(from:) throw for a 404 or 500 response?

Answer

No. It throws only for transport-level failures such as no connection, timeouts or TLS errors. HTTP error status codes are successful transfers, so you must cast the response to HTTPURLResponse and check statusCode yourself.

Why load data with the .task modifier instead of onAppear?

Answer

.task starts an async task tied to the view's lifetime and cancels it automatically when the view disappears (or when its id changes), so requests for screens the user has left don't keep running or update state later.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?