Modern Swift Networking: Build a Type-Safe API Client with URLSession and async/await

Build a production-ready networking layer in Swift using URLSession and async/await. Covers type-safe API clients, error handling, auth, parallel requests, retry logic, and unit testing — no third-party dependencies needed.

Every iOS app needs to talk to a server. That's just the reality of modern mobile development — whether you're fetching a list of products, uploading user data, or streaming real-time updates, networking is the backbone of pretty much everything we build. And yet, so many Swift projects still rely on bloated third-party libraries or tangled completion-handler code that's hard to test and even harder to maintain.

Here's the thing, though: you don't need any of that anymore.

With Swift concurrency and the async/await extensions Apple added to URLSession, you can build a production-quality networking layer in pure Swift — zero dependencies. This guide walks you through every step, from the simplest GET request to a fully type-safe, testable API client you can drop into any project. I've been using this pattern across several apps now, and honestly, going back to completion handlers feels painful.

Why URLSession with async/await?

Before Swift 5.5, networking code was dominated by nested completion handlers — the infamous "callback hell." Combine offered a reactive alternative, but its learning curve scared off plenty of teams (mine included, for a while). The async/await model changes everything:

  • Linear readability — asynchronous code reads top-to-bottom, exactly like synchronous code.
  • Native error propagation — use standard try/catch instead of Result types or optional errors.
  • Structured concurrency — child tasks are automatically cancelled when their parent scope exits, preventing resource leaks.
  • Zero dependenciesURLSession ships with every Apple platform. No CocoaPods, no SPM packages to version-manage.

If you're starting a new project in 2026 or migrating an existing codebase, async/await with URLSession is the way to go.

Your First async/await Network Request

The simplest possible network call? Two lines:

let url = URL(string: "https://api.example.com/users")!
let (data, response) = try await URLSession.shared.data(from: url)

The data(from:) method suspends the current task without blocking the thread, then resumes with the downloaded Data and the URLResponse. If the request fails — DNS error, timeout, cancelled task — it throws an error you can catch with standard Swift error handling.

One gotcha that trips people up: a non-2xx status code does not throw an error. You need to inspect the response yourself:

guard let httpResponse = response as? HTTPURLResponse,
      (200...299).contains(httpResponse.statusCode) else {
    throw NetworkError.invalidResponse
}

let users = try JSONDecoder().decode([User].self, from: data)

This two-step pattern — fetch, then validate — is the foundation for everything that follows.

Defining a Clean Error Model

A production networking layer needs clear, actionable errors. I can't stress this enough — define a dedicated error type early so every layer of your app knows exactly what went wrong:

enum NetworkError: LocalizedError {
    case invalidURL
    case invalidResponse
    case httpError(statusCode: Int, data: Data)
    case decodingFailed(Error)
    case noConnection
    case timeout
    case cancelled

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The URL is invalid."
        case .invalidResponse:
            return "The server returned an unexpected response."
        case .httpError(let code, _):
            return "Server returned status code \(code)."
        case .decodingFailed(let error):
            return "Failed to decode response: \(error.localizedDescription)"
        case .noConnection:
            return "No internet connection."
        case .timeout:
            return "The request timed out."
        case .cancelled:
            return "The request was cancelled."
        }
    }
}

By conforming to LocalizedError, your errors work seamlessly with SwiftUI alert views and logging systems. Future you will thank present you for this.

Modeling API Endpoints with Swift Enums

Hard-coding URLs across your codebase is a maintenance nightmare. Seriously, don't do it. Instead, encapsulate each endpoint in an enum that knows how to build its own URLRequest:

enum Endpoint {
    case getUsers
    case getUser(id: Int)
    case createUser(CreateUserRequest)
    case updateUser(id: Int, UpdateUserRequest)
    case deleteUser(id: Int)

    var path: String {
        switch self {
        case .getUsers:              return "/users"
        case .getUser(let id):       return "/users/\(id)"
        case .createUser:            return "/users"
        case .updateUser(let id, _): return "/users/\(id)"
        case .deleteUser(let id):    return "/users/\(id)"
        }
    }

    var method: String {
        switch self {
        case .getUsers, .getUser:   return "GET"
        case .createUser:           return "POST"
        case .updateUser:           return "PUT"
        case .deleteUser:           return "DELETE"
        }
    }

    var body: Encodable? {
        switch self {
        case .createUser(let req):     return req
        case .updateUser(_, let req):  return req
        default:                       return nil
        }
    }
}

Every endpoint is a single case with its associated values. Adding a new endpoint means adding one case and filling in three computed properties — no risk of mismatched URLs and methods scattered across different files. It's one of those patterns that feels obvious once you see it, but it makes a huge difference in practice.

Building the APIClient

Alright, now let's bring it all together in a reusable client. This is the core of your networking layer:

actor APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder

    init(
        baseURL: URL,
        session: URLSession = .shared,
        decoder: JSONDecoder = JSONDecoder()
    ) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = decoder
    }

    func request<T: Decodable>(
        _ endpoint: Endpoint,
        as type: T.Type = T.self
    ) async throws -> T {
        let urlRequest = try buildRequest(for: endpoint)
        let (data, response) = try await session.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.httpError(
                statusCode: httpResponse.statusCode,
                data: data
            )
        }

        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingFailed(error)
        }
    }

    private func buildRequest(for endpoint: Endpoint) throws -> URLRequest {
        guard let url = URL(string: endpoint.path, relativeTo: baseURL) else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        if let body = endpoint.body {
            request.httpBody = try JSONEncoder().encode(body)
        }

        return request
    }
}

A few design decisions worth calling out here:

  • actor isolation — The client is an actor, so all access to its mutable state is automatically thread-safe. No more DispatchQueue synchronization gymnastics.
  • Generic decoding — The request method is generic over any Decodable type, so callers get strongly typed results without any casting.
  • Dependency injection — The URLSession and JSONDecoder are injected through the initializer, making the client trivially testable (more on that later).

Using the APIClient in SwiftUI

So how does this look in practice? Here's a typical SwiftUI view model:

@Observable
class UsersViewModel {
    var users: [User] = []
    var errorMessage: String?
    var isLoading = false

    private let client: APIClient

    init(client: APIClient) {
        self.client = client
    }

    func loadUsers() async {
        isLoading = true
        errorMessage = nil

        do {
            users = try await client.request(.getUsers, as: [User].self)
        } catch let error as NetworkError {
            errorMessage = error.localizedDescription
        } catch {
            errorMessage = "An unexpected error occurred."
        }

        isLoading = false
    }
}

The view model stores the loading state, the fetched data, and any error message. The SwiftUI view observes all three through the @Observable macro and reacts automatically:

struct UsersView: View {
    @State private var viewModel: UsersViewModel

    init(client: APIClient) {
        _viewModel = State(initialValue: UsersViewModel(client: client))
    }

    var body: some View {
        List(viewModel.users) { user in
            Text(user.name)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .alert(
            "Error",
            isPresented: .constant(viewModel.errorMessage != nil)
        ) {
            Button("OK") { viewModel.errorMessage = nil }
        } message: {
            Text(viewModel.errorMessage ?? "")
        }
        .task {
            await viewModel.loadUsers()
        }
    }
}

The .task modifier ties the network call to the view's lifecycle. When the view disappears, the task is cancelled — and the underlying URLSession request gets cancelled with it. No manual cleanup needed, which is honestly one of my favorite things about this approach.

Adding Authentication Headers

Most real-world APIs require an authorization token. Let's extend the APIClient to accept a token provider:

actor APIClient {
    // ... existing properties ...
    private var tokenProvider: (() async -> String?)?

    func setTokenProvider(_ provider: @escaping () async -> String?) {
        self.tokenProvider = provider
    }

    private func buildRequest(for endpoint: Endpoint) async throws -> URLRequest {
        guard let url = URL(string: endpoint.path, relativeTo: baseURL) else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        if let token = await tokenProvider?() {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        if let body = endpoint.body {
            request.httpBody = try JSONEncoder().encode(body)
        }

        return request
    }
}

The token provider is a closure that can fetch the token from Keychain, a secure store, or even trigger a token refresh. Because it's async, it can await network calls itself without blocking. Pretty elegant, right?

Parallel Requests with async let and TaskGroup

One of the biggest wins with Swift concurrency is how naturally it handles parallel requests. If your screen needs data from multiple endpoints, async let makes it almost trivially easy:

func loadDashboard() async throws -> Dashboard {
    async let profile = client.request(.getProfile, as: UserProfile.self)
    async let stats = client.request(.getStats, as: UserStats.self)
    async let notifications = client.request(.getNotifications, as: [Notification].self)

    return try await Dashboard(
        profile: profile,
        stats: stats,
        notifications: notifications
    )
}

All three requests fire simultaneously. The total wait time equals the duration of the slowest request, not the sum of all three. And if any request fails, the others are automatically cancelled. That last part is huge — try implementing that cleanly with completion handlers.

For a dynamic number of requests — say, fetching details for a list of user IDs — use a TaskGroup:

func loadUserDetails(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await self.client.request(.getUser(id: id), as: User.self)
            }
        }

        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

The task group manages concurrency for you. All child tasks are awaited before the group scope exits, and cancellation propagates automatically through the hierarchy.

Upload Requests and Multipart Form Data

URLSession's async API covers uploads too. For simple JSON uploads, the APIClient above already handles them via the endpoint's body property. For file uploads using multipart form data, you'll need to build the body manually:

func uploadAvatar(imageData: Data, userId: Int) async throws {
    let boundary = UUID().uuidString
    var request = URLRequest(url: baseURL.appendingPathComponent("/users/\(userId)/avatar"))
    request.httpMethod = "POST"
    request.setValue(
        "multipart/form-data; boundary=\(boundary)",
        forHTTPHeaderField: "Content-Type"
    )

    var body = Data()
    body.append("--\(boundary)\r\n".data(using: .utf8)!)
    body.append(
        "Content-Disposition: form-data; name=\"avatar\"; filename=\"avatar.jpg\"\r\n".data(using: .utf8)!
    )
    body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
    body.append(imageData)
    body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)

    let (_, response) = try await session.upload(for: request, from: body)

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw NetworkError.invalidResponse
    }
}

It's a bit verbose, I know. For large files, consider using URLSession.upload(for:fromFile:) instead — it streams directly from disk and avoids loading the entire file into memory.

Configuring URLSession for Production

The shared session works fine for prototyping, but production apps benefit from custom configurations:

let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 300
configuration.waitsForConnectivity = true
configuration.requestCachePolicy = .returnCacheDataElseLoad
configuration.httpAdditionalHeaders = [
    "X-App-Version": Bundle.main.appVersion,
    "X-Platform": "iOS"
]

let session = URLSession(configuration: configuration)

Key settings worth tweaking:

  • timeoutIntervalForRequest — How long to wait for the server to begin responding. The default is 60 seconds, which is honestly too generous for most apps.
  • waitsForConnectivity — When true, the session waits for a network connection instead of immediately failing. This is a lifesaver for apps used on spotty cellular networks.
  • requestCachePolicy — Controls whether cached responses are returned. Use .returnCacheDataElseLoad if you want some offline support.

Implementing Retry Logic

Transient network failures happen — especially on mobile. A simple retry mechanism can dramatically improve reliability:

func requestWithRetry<T: Decodable>(
    _ endpoint: Endpoint,
    as type: T.Type = T.self,
    maxRetries: Int = 3
) async throws -> T {
    var lastError: Error?

    for attempt in 0..<maxRetries {
        do {
            return try await request(endpoint, as: type)
        } catch let error as NetworkError {
            lastError = error

            switch error {
            case .httpError(let code, _) where code >= 500:
                // Server error — worth retrying
                break
            case .timeout, .noConnection:
                // Transient failure — worth retrying
                break
            default:
                // Client error or decoding failure — don't retry
                throw error
            }
        }

        // Exponential backoff: 1s, 2s, 4s
        let delay = UInt64(pow(2.0, Double(attempt))) * 1_000_000_000
        try await Task.sleep(nanoseconds: delay)
    }

    throw lastError ?? NetworkError.invalidResponse
}

The key insight here is that you should only retry errors that are actually likely to succeed on a subsequent attempt. Server errors (5xx) and timeouts? Sure, retry those. A 404 or a decoding failure? Don't bother — those won't magically fix themselves.

Testing Your Networking Layer

Testability is honestly the primary reason to build your own networking layer rather than scattering URLSession calls throughout your app. Start by defining a protocol to abstract the session:

protocol URLSessionProtocol: Sendable {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

extension URLSession: URLSessionProtocol {}

Then create a mock for your tests:

final class MockURLSession: URLSessionProtocol, @unchecked Sendable {
    var mockData: Data?
    var mockResponse: URLResponse?
    var mockError: Error?

    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        if let error = mockError { throw error }

        let data = mockData ?? Data()
        let response = mockResponse ?? HTTPURLResponse(
            url: request.url!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )!

        return (data, response)
    }
}

With this mock, you can test every path through your APIClient without ever hitting a real server:

@Test func loadUsersDecodesCorrectly() async throws {
    let mockSession = MockURLSession()
    mockSession.mockData = """
    [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
    """.data(using: .utf8)

    let client = APIClient(
        baseURL: URL(string: "https://api.example.com")!,
        session: mockSession
    )

    let users: [User] = try await client.request(.getUsers)
    #expect(users.count == 2)
    #expect(users[0].name == "Alice")
}

This test runs in milliseconds, never touches the network, and is completely deterministic. Exactly what you want in a CI pipeline.

Monitoring Network Calls with URLSessionTaskMetrics

Understanding how your network calls perform in the wild is critical (and something a lot of developers skip). URLSessionTaskMetrics gives you detailed timing data for every request:

final class MetricsDelegate: NSObject, URLSessionTaskDelegate {
    func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didFinishCollecting metrics: URLSessionTaskMetrics
    ) {
        for metric in metrics.transactionMetrics {
            let dns = metric.domainLookupEndDate?.timeIntervalSince(
                metric.domainLookupStartDate ?? Date()
            ) ?? 0
            let connect = metric.connectEndDate?.timeIntervalSince(
                metric.connectStartDate ?? Date()
            ) ?? 0
            let request = metric.responseStartDate?.timeIntervalSince(
                metric.requestStartDate ?? Date()
            ) ?? 0

            print("DNS: \(dns)s | Connect: \(connect)s | TTFB: \(request)s")
        }
    }
}

Hook this delegate into your session during development to spot slow endpoints. In production, feed the metrics into your analytics system — you'd be surprised how much you can learn about your users' experience from network timing data alone.

Handling Cancellation Gracefully

Swift concurrency cancellation integrates seamlessly with URLSession. When a Task is cancelled, any in-flight request is cancelled too. But — and this is important — you should handle CancellationError explicitly in your UI layer. Otherwise, you'll end up showing error alerts when the user simply navigated away from the screen:

func loadUsers() async {
    isLoading = true
    defer { isLoading = false }

    do {
        users = try await client.request(.getUsers, as: [User].self)
    } catch is CancellationError {
        // User navigated away — silently ignore
        return
    } catch {
        errorMessage = error.localizedDescription
    }
}

The .task modifier on SwiftUI views automatically cancels its task when the view disappears, so this pattern is essential for any view that triggers network requests. Miss this, and your users will see random error dialogs popping up as they navigate. Not a great look.

Frequently Asked Questions

Should I use Alamofire or another third-party library instead of URLSession?

For most projects in 2026, URLSession with async/await provides everything you need. Apple's native framework supports all HTTP methods, authentication challenges, background transfers, streaming, and certificate pinning out of the box. Third-party libraries made sense when completion handlers were the only option, but async/await has eliminated the primary reason to add an external dependency. That said, if you need very specific features like complex automatic retry policies or sophisticated network reachability monitoring, a battle-tested library might still save you some time.

How do I handle token refresh when a request returns 401 Unauthorized?

Implement a token refresh flow in your APIClient by catching 401 errors and calling your refresh endpoint before retrying the original request. The actor's built-in serialization prevents multiple simultaneous refresh attempts — if two requests both get a 401 at the same time, the actor ensures only one refresh call is made and the other waits for it to complete. It's one of those cases where actors really shine.

Can I use URLSession for WebSocket connections?

Yes! URLSession supports WebSockets natively through URLSessionWebSocketTask. Call session.webSocketTask(with: url) to create a connection, then use send() and receive() with async/await. Works great for real-time features like chat, live notifications, or collaborative editing.

How do I download large files without loading them into memory?

Use URLSession.shared.download(from:) instead of data(from:). The download method writes the response directly to a temporary file on disk and returns the file URL. You can then move the file to your app's documents directory. For background downloads that continue when the app is suspended, create a session with URLSessionConfiguration.background.

Is URLSession thread-safe with async/await?

URLSession is designed to be called from any thread or concurrency context. When used with async/await, it manages its own internal queue for network operations. By wrapping your APIClient in an actor, you add an extra layer of safety for any mutable state like tokens or configuration — making your networking layer completely data-race free under Swift 6 strict concurrency checking.

About the Author Editorial Team

Our team of expert writers and editors.