The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Networking with Async/Await: The Complete REST API Integration Guide

Build a production-grade networking layer in SwiftUI using modern Swift concurrency, URLSession, Codable, and battle-tested patterns for error handling, pagination, caching, and more.

Ahmed GaganAhmed Gagan
15 min read

Every non-trivial iOS app talks to a server. Whether you are fetching a feed, submitting a form, uploading a photo, or streaming chat messages, networking is the backbone of your SwiftUI app. With Swift concurrency (async/await) now mature and URLSession fully integrated, 2026 is the best time to build a clean, testable, production-grade networking layer from scratch. This guide covers everything: from your first URLSession.shared.data(for:) call to retry logic with exponential backoff, multipart uploads, WebSockets, and offline-first patterns.

What you will build: A complete, type-safe API client in Swift that handles authentication, pagination, caching, cancellation, error mapping, and retries. Every code snippet compiles. Every pattern is production-tested.

URLSession with Async/Await Basics

Before Swift 5.5, networking meant nesting completion handlers or chaining Combine publishers. Async/await flattens all of that into linear, readable code. Here is the simplest possible network call in modern Swift:

// Simplest async GET request
func fetchData() async throws -> Data {
    let url = URL(string: "https://api.example.com/items")!
    let (data, response) = try await URLSession.shared.data(from: url)

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

    return data
}

The await keyword suspends the function until the network response arrives. The trykeyword propagates any error (timeout, DNS failure, TLS error) up the call stack. No callbacks, noDispatchQueue.main.async, no retain cycles. The compiler guarantees you handle the error or propagate it.

To call this from a SwiftUI view, use a .task modifier. This is the recommended approach because SwiftUI automatically cancels the task when the view disappears:

struct ItemListView: View {
    @State private var items: [Item] = []
    @State private var error: String?

    var body: some View {
        List(items) { item in
            Text(item.name)
        }
        .overlay {
            if items.isEmpty && error == nil {
                ProgressView()
            }
        }
        .task {
            do {
                items = try await APIClient.shared.fetchItems()
            } catch {
                self.error = error.localizedDescription
            }
        }
    }
}

Building a Type-Safe API Client

A single URLSession.shared.data(from:) call is fine for tutorials. In production, you need a centralized client that handles base URLs, headers, encoding, decoding, authentication, and error mapping. Here is the API client pattern I use in every shipping app:

// APIClient.swift
import Foundation

final class APIClient: Sendable {
    static let shared = APIClient()

    private let session: URLSession
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder
    private let baseURL: URL

    init(
        baseURL: URL = URL(string: "https://api.example.com/v1")!,
        session: URLSession = .shared
    ) {
        self.baseURL = baseURL
        self.session = session

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

        self.encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.keyEncodingStrategy = .convertToSnakeCase
    }

    // MARK: - Core request method
    func request<T: Decodable>(
        endpoint: String,
        method: HTTPMethod = .get,
        body: (any Encodable)? = nil,
        queryItems: [URLQueryItem]? = nil,
        headers: [String: String]? = nil
    ) async throws -> T {
        var url = baseURL.appendingPathComponent(endpoint)

        // Append query parameters
        if let queryItems, !queryItems.isEmpty {
            var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
            components.queryItems = queryItems
            url = components.url!
        }

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

        // Inject auth token if available
        if let token = TokenStore.shared.accessToken {
            urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        // Merge custom headers
        headers?.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) }

        // Encode body
        if let body {
            urlRequest.httpBody = try encoder.encode(body)
        }

        // Execute request
        let (data, response) = try await session.data(for: urlRequest)

        // Validate HTTP response
        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }

        // Map status codes to errors
        guard (200...299).contains(httpResponse.statusCode) else {
            throw APIError.from(statusCode: httpResponse.statusCode, data: data)
        }

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

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case patch = "PATCH"
    case delete = "DELETE"
}

Notice a few design decisions here. The baseURL and session are injected via the initializer, which means you can swap them out in tests. The decoder uses.convertFromSnakeCase so your Swift models use camelCase properties while the JSON uses snake_case keys automatically. The auth token is injected on every request via a centralized TokenStore. And every error path produces a typed APIErrorinstead of a generic Error.

Codable Models: Encoding and Decoding JSON

Swift's Codable protocol is the standard for JSON serialization. Every model your networking layer touches should conform to Codable (which is just Encodable & Decodable). Here is a practical set of models for a typical REST API:

// Models.swift
import Foundation

// MARK: - API response wrapper
struct APIResponse<T: Decodable>: Decodable {
    let data: T
    let meta: Meta?

    struct Meta: Decodable {
        let currentPage: Int
        let totalPages: Int
        let totalCount: Int
    }
}

// MARK: - Domain models
struct Item: Codable, Identifiable {
    let id: UUID
    var name: String
    var description: String?
    var price: Decimal
    var imageUrl: URL?
    var category: Category
    var tags: [String]
    var createdAt: Date
    var updatedAt: Date

    enum Category: String, Codable {
        case electronics, clothing, books, food, other
    }
}

// MARK: - Request body for creating an item
struct CreateItemRequest: Encodable {
    let name: String
    let description: String?
    let price: Decimal
    let category: Item.Category
    let tags: [String]
}

Custom CodingKeys for Non-Standard JSON

Not every API returns clean, predictable JSON. When keys do not match Swift conventions or when the server returns abbreviated field names, use custom CodingKeys:

struct User: Codable, Identifiable {
    let id: Int
    var fullName: String
    var emailAddress: String
    var avatarUrl: URL?
    var isPremium: Bool
    var registrationDate: Date

    enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"
        case emailAddress = "email"       // API returns "email", not "email_address"
        case avatarUrl = "avatar"          // API returns "avatar", not "avatar_url"
        case isPremium = "is_pro"          // API uses "is_pro", we prefer "isPremium"
        case registrationDate = "reg_date" // Abbreviated server-side key
    }
}

// Nested JSON with custom decoding:
struct ServerTimestamp: Decodable {
    let date: Date

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let timestamp = try container.decode(Double.self)
        date = Date(timeIntervalSince1970: timestamp)
    }
}

Pro tip: If you set keyDecodingStrategy = .convertFromSnakeCase on your decoder (as we did in the API client above), you do not need CodingKeys for standardsnake_case to camelCase conversions. Only use custom CodingKeyswhen the mapping is non-obvious.

Error Handling: Custom Error Types and HTTP Status Codes

A generic catch { print(error) } is not good enough for production. Your networking layer should produce typed, actionable errors so your UI can display the right message and your analytics can track failure modes. Here is the error type I use:

// APIError.swift
import Foundation

enum APIError: LocalizedError {
    case invalidResponse
    case decodingFailed(Error)
    case unauthorized           // 401
    case forbidden              // 403
    case notFound               // 404
    case conflict               // 409
    case rateLimited(retryAfter: Int?) // 429
    case serverError(Int)       // 500-599
    case clientError(Int, String?) // 400, 422, etc.
    case noInternet
    case timeout

    static func from(statusCode: Int, data: Data) -> APIError {
        let message = try? JSONDecoder().decode(ErrorBody.self, from: data).message

        switch statusCode {
        case 401: return .unauthorized
        case 403: return .forbidden
        case 404: return .notFound
        case 409: return .conflict
        case 429: return .rateLimited(retryAfter: nil)
        case 400, 422: return .clientError(statusCode, message)
        case 500...599: return .serverError(statusCode)
        default: return .clientError(statusCode, message)
        }
    }

    var errorDescription: String? {
        switch self {
        case .invalidResponse: return "Invalid server response."
        case .decodingFailed: return "Failed to parse server data."
        case .unauthorized: return "Session expired. Please sign in again."
        case .forbidden: return "You do not have permission."
        case .notFound: return "The requested resource was not found."
        case .conflict: return "A conflict occurred. Try again."
        case .rateLimited: return "Too many requests. Please wait."
        case .serverError(let code): return "Server error (\(code)). Try again later."
        case .clientError(_, let msg): return msg ?? "Something went wrong."
        case .noInternet: return "No internet connection."
        case .timeout: return "Request timed out."
        }
    }
}

struct ErrorBody: Decodable {
    let message: String?
}

HTTP Status Codes Reference

Here is a quick reference of HTTP status codes and what they mean for your iOS app. Bookmark this table:

CodeNameWhat It Means for Your App
200OKRequest succeeded. Parse the response body.
201CreatedResource created successfully (POST). Body usually contains the new object.
204No ContentSuccess but no body (DELETE, some PUTs). Do not try to decode.
400Bad RequestValidation error. Show the server message to the user.
401UnauthorizedToken expired or missing. Refresh the token or redirect to login.
403ForbiddenAuthenticated but no permission. Show "access denied" UI.
404Not FoundResource does not exist. Remove from local cache or show empty state.
409ConflictDuplicate or stale data. Refetch and retry.
422Unprocessable EntitySemantic validation error. Parse field-level errors and highlight in the form.
429Too Many RequestsRate limited. Read Retry-After header and back off.
500Internal Server ErrorServer crashed. Show generic error. Retry with backoff.
502Bad GatewayUpstream failure. Transient. Retry after a short delay.
503Service UnavailableServer overloaded or in maintenance. Show maintenance banner.
504Gateway TimeoutUpstream timeout. Retry with longer timeout or show error.

Request Interceptors and Auth Token Injection

Most APIs require authentication. Instead of manually adding the Authorization header to every request, centralize token management in a store and inject it automatically. Here is a complete token store with automatic refresh:

// TokenStore.swift
import Foundation
import Security

actor TokenStore {
    static let shared = TokenStore()

    private(set) var accessToken: String?
    private(set) var refreshToken: String?
    private var isRefreshing = false
    private var refreshContinuations: [CheckedContinuation<String, Error>] = []

    func setTokens(access: String, refresh: String) {
        self.accessToken = access
        self.refreshToken = refresh
        // Persist to Keychain in production
        KeychainHelper.save(key: "access_token", value: access)
        KeychainHelper.save(key: "refresh_token", value: refresh)
    }

    func clearTokens() {
        accessToken = nil
        refreshToken = nil
        KeychainHelper.delete(key: "access_token")
        KeychainHelper.delete(key: "refresh_token")
    }

    func loadFromKeychain() {
        accessToken = KeychainHelper.load(key: "access_token")
        refreshToken = KeychainHelper.load(key: "refresh_token")
    }

    /// Refresh the access token. Coalesces multiple concurrent calls.
    func refreshAccessToken() async throws -> String {
        if isRefreshing {
            return try await withCheckedThrowingContinuation { continuation in
                refreshContinuations.append(continuation)
            }
        }

        isRefreshing = true

        do {
            guard let refreshToken else { throw APIError.unauthorized }

            // Call your auth endpoint to get a new access token
            var request = URLRequest(url: URL(string: "https://api.example.com/v1/auth/refresh")!)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONEncoder().encode(["refresh_token": refreshToken])

            let (data, _) = try await URLSession.shared.data(for: request)
            let tokens = try JSONDecoder().decode(TokenResponse.self, from: data)

            setTokens(access: tokens.accessToken, refresh: tokens.refreshToken)
            isRefreshing = false

            // Resume all waiting callers
            for continuation in refreshContinuations {
                continuation.resume(returning: tokens.accessToken)
            }
            refreshContinuations.removeAll()

            return tokens.accessToken
        } catch {
            isRefreshing = false
            for continuation in refreshContinuations {
                continuation.resume(throwing: error)
            }
            refreshContinuations.removeAll()
            throw error
        }
    }
}

struct TokenResponse: Decodable {
    let accessToken: String
    let refreshToken: String
}

The key pattern here is coalescing concurrent refresh calls. If five requests fail with a 401 at the same time, you do not want five separate token refreshes. The isRefreshing flag and continuation array ensure only one refresh runs, and all waiting callers get the new token.

Pagination: Cursor-Based and Offset-Based

Any list endpoint needs pagination. The two dominant patterns are offset-based (page number + size) and cursor-based (pass the last item's ID to get the next batch). Cursor-based is better for feeds because inserts do not shift the page window, but offset is simpler and works well for static data.

Offset-Based Pagination

// PaginatedList.swift — Offset-based
@Observable
final class PaginatedItemList {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var hasMore = true
    private var currentPage = 1
    private let pageSize = 20

    func loadNextPage() async {
        guard !isLoading, hasMore else { return }
        isLoading = true

        do {
            let response: APIResponse<[Item]> = try await APIClient.shared.request(
                endpoint: "items",
                queryItems: [
                    URLQueryItem(name: "page", value: "\(currentPage)"),
                    URLQueryItem(name: "per_page", value: "\(pageSize)")
                ]
            )

            items.append(contentsOf: response.data)
            hasMore = currentPage < (response.meta?.totalPages ?? 1)
            currentPage += 1
        } catch {
            // Handle error — show toast, log to analytics
        }

        isLoading = false
    }

    func refresh() async {
        items.removeAll()
        currentPage = 1
        hasMore = true
        await loadNextPage()
    }
}

Cursor-Based Pagination

// CursorPaginatedList.swift
@Observable
final class CursorPaginatedFeed {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var hasMore = true
    private var cursor: String? = nil

    func loadNextPage() async {
        guard !isLoading, hasMore else { return }
        isLoading = true

        do {
            var queryItems = [URLQueryItem(name: "limit", value: "20")]
            if let cursor {
                queryItems.append(URLQueryItem(name: "after", value: cursor))
            }

            let response: CursorResponse<[Item]> = try await APIClient.shared.request(
                endpoint: "feed",
                queryItems: queryItems
            )

            items.append(contentsOf: response.data)
            cursor = response.nextCursor
            hasMore = response.nextCursor != nil
        } catch {
            // Handle error
        }

        isLoading = false
    }
}

struct CursorResponse<T: Decodable>: Decodable {
    let data: T
    let nextCursor: String?
}

To wire either into a SwiftUI List, use the .onAppear modifier on the last item to trigger the next page load:

List(viewModel.items) { item in
    ItemRow(item: item)
        .onAppear {
            if item.id == viewModel.items.last?.id {
                Task { await viewModel.loadNextPage() }
            }
        }
}

Image Downloading and Caching

SwiftUI's built-in AsyncImage works for simple cases, but it has no disk caching and reloads images when the view rebuilds. For production apps, you need a proper caching layer. Here is a lightweight solution using NSCache and the file system:

// ImageCache.swift
import SwiftUI

actor ImageCache {
    static let shared = ImageCache()

    private let memoryCache = NSCache<NSString, UIImage>()
    private let fileManager = FileManager.default
    private let cacheDirectory: URL

    init() {
        let paths = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
        cacheDirectory = paths[0].appendingPathComponent("ImageCache")
        try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
        memoryCache.countLimit = 100
    }

    func image(for url: URL) async throws -> UIImage {
        let key = url.absoluteString as NSString

        // 1. Check memory cache
        if let cached = memoryCache.object(forKey: key) {
            return cached
        }

        // 2. Check disk cache
        let filePath = cacheDirectory.appendingPathComponent(url.absoluteString.hashValue.description)
        if let data = try? Data(contentsOf: filePath),
           let image = UIImage(data: data) {
            memoryCache.setObject(image, forKey: key)
            return image
        }

        // 3. Download from network
        let (data, _) = try await URLSession.shared.data(from: url)
        guard let image = UIImage(data: data) else {
            throw ImageCacheError.invalidImageData
        }

        // Store in both caches
        memoryCache.setObject(image, forKey: key)
        try? data.write(to: filePath)

        return image
    }
}

enum ImageCacheError: Error {
    case invalidImageData
}

// SwiftUI view that uses the cache:
struct CachedAsyncImage: View {
    let url: URL?
    @State private var image: UIImage?

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else {
                Rectangle()
                    .fill(Color.white.opacity(0.05))
                    .overlay(ProgressView())
            }
        }
        .task(id: url) {
            guard let url else { return }
            image = try? await ImageCache.shared.image(for: url)
        }
    }
}

Request Cancellation with Task

Swift concurrency supports cooperative cancellation. When a user navigates away or types a new search query, you should cancel in-flight requests to avoid wasted bandwidth and stale data overwrites. The.task modifier handles this automatically for view lifecycle, but for search-as-you-type, you need manual control:

@Observable
final class SearchViewModel {
    var query: String = "" {
        didSet { debounceSearch() }
    }
    private(set) var results: [Item] = []
    private(set) var isSearching = false
    private var searchTask: Task<Void, Never>?

    private func debounceSearch() {
        // Cancel previous in-flight search
        searchTask?.cancel()

        searchTask = Task {
            // Debounce: wait 300ms before firing
            try? await Task.sleep(for: .milliseconds(300))

            // Check if this task was cancelled during the sleep
            guard !Task.isCancelled else { return }
            guard !query.isEmpty else {
                results = []
                return
            }

            isSearching = true
            do {
                let response: APIResponse<[Item]> = try await APIClient.shared.request(
                    endpoint: "search",
                    queryItems: [URLQueryItem(name: "q", value: query)]
                )
                // Final cancellation check before updating UI
                guard !Task.isCancelled else { return }
                results = response.data
            } catch is CancellationError {
                // Expected — do nothing
            } catch {
                // Handle real error
            }
            isSearching = false
        }
    }
}

The pattern is: store a reference to the Task, cancel the previous one before starting a new one, and check Task.isCancelled before updating state. This prevents a slow first request from overwriting the results of a fast second request.

Retry Logic with Exponential Backoff

Network requests fail. Servers return 500s. Connections drop. A production networking layer retries transient failures automatically. Here is a generic retry function with exponential backoff and jitter:

// Retry.swift
func withRetry<T>(
    maxAttempts: Int = 3,
    initialDelay: Duration = .milliseconds(500),
    maxDelay: Duration = .seconds(10),
    retryableError: (Error) -> Bool = { error in
        if let apiError = error as? APIError {
            switch apiError {
            case .serverError, .rateLimited, .timeout:
                return true
            default:
                return false
            }
        }
        return (error as? URLError)?.code == .timedOut
    },
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?

    for attempt in 0..<maxAttempts {
        do {
            return try await operation()
        } catch {
            lastError = error

            guard retryableError(error), attempt < maxAttempts - 1 else {
                throw error
            }

            // Exponential backoff with jitter
            let baseDelay = initialDelay * Int(pow(2.0, Double(attempt)))
            let cappedDelay = min(baseDelay, maxDelay)
            let jitter = Duration.milliseconds(Int.random(in: 0...500))
            let totalDelay = cappedDelay + jitter

            try await Task.sleep(for: totalDelay)
        }
    }

    throw lastError ?? APIError.invalidResponse
}

// Usage:
let items: [Item] = try await withRetry {
    try await APIClient.shared.request(endpoint: "items")
}

The jitter prevents a thundering herd problem: if your server goes down and 10,000 clients all retry at exactly the same intervals, the server gets slammed the moment it recovers. Random jitter spreads the retries out.

Practical Examples

Fetching a List of Items (GET)

// Convenience method on APIClient
extension APIClient {
    func fetchItems(page: Int = 1, perPage: Int = 20) async throws -> APIResponse<[Item]> {
        try await request(
            endpoint: "items",
            queryItems: [
                URLQueryItem(name: "page", value: "\(page)"),
                URLQueryItem(name: "per_page", value: "\(perPage)")
            ]
        )
    }
}

POST with JSON Body

extension APIClient {
    func createItem(_ item: CreateItemRequest) async throws -> Item {
        try await request(
            endpoint: "items",
            method: .post,
            body: item
        )
    }
}

// Calling it:
let newItem = try await APIClient.shared.createItem(
    CreateItemRequest(
        name: "Wireless Charger",
        description: "Fast 15W MagSafe compatible",
        price: 29.99,
        category: .electronics,
        tags: ["charger", "magsafe", "wireless"]
    )
)

Authenticated Request (Bearer Token)

extension APIClient {
    func fetchCurrentUser() async throws -> User {
        // Token is injected automatically via the core request method
        try await request(endpoint: "users/me")
    }

    func updateProfile(name: String, bio: String) async throws -> User {
        try await request(
            endpoint: "users/me",
            method: .patch,
            body: ["name": name, "bio": bio]
        )
    }
}

Multipart Form Upload

File uploads require multipart/form-data encoding. URLSession does not provide a built-in multipart encoder, so you need to construct the body manually. Here is a clean, reusable implementation:

// MultipartUpload.swift
import Foundation
import UIKit

struct MultipartFormData {
    private let boundary = UUID().uuidString
    private var body = Data()

    var contentType: String {
        "multipart/form-data; boundary=\(boundary)"
    }

    mutating func addField(name: String, value: String) {
        body.append("--\(boundary)\r\n")
        body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
        body.append("\(value)\r\n")
    }

    mutating func addFile(
        name: String,
        filename: String,
        mimeType: String,
        data: Data
    ) {
        body.append("--\(boundary)\r\n")
        body.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n")
        body.append("Content-Type: \(mimeType)\r\n\r\n")
        body.append(data)
        body.append("\r\n")
    }

    var finalized: Data {
        var result = body
        result.append("--\(boundary)--\r\n")
        return result
    }
}

extension Data {
    mutating func append(_ string: String) {
        if let data = string.data(using: .utf8) {
            append(data)
        }
    }
}

// Usage: Upload an image with metadata
extension APIClient {
    func uploadImage(
        image: UIImage,
        title: String,
        description: String
    ) async throws -> Item {
        guard let imageData = image.jpegData(compressionQuality: 0.8) else {
            throw APIError.clientError(400, "Could not encode image")
        }

        var form = MultipartFormData()
        form.addField(name: "title", value: title)
        form.addField(name: "description", value: description)
        form.addFile(
            name: "image",
            filename: "photo.jpg",
            mimeType: "image/jpeg",
            data: imageData
        )

        let url = baseURL.appendingPathComponent("items/upload")
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")

        if let token = await TokenStore.shared.accessToken {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        request.httpBody = form.finalized

        let (data, response) = try await session.data(for: request)

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

        return try decoder.decode(Item.self, from: data)
    }
}

WebSocket Basics with URLSessionWebSocketTask

For real-time features like chat, live notifications, or collaborative editing, WebSockets provide a persistent bidirectional connection. URLSession has native WebSocket support since iOS 13:

// WebSocketService.swift
import Foundation

actor WebSocketService {
    private var webSocketTask: URLSessionWebSocketTask?
    private let session = URLSession(configuration: .default)

    func connect(url: URL) {
        webSocketTask = session.webSocketTask(with: url)
        webSocketTask?.resume()
        listenForMessages()
    }

    func send(text: String) async throws {
        guard let task = webSocketTask else { return }
        try await task.send(.string(text))
    }

    func send(data: Data) async throws {
        guard let task = webSocketTask else { return }
        try await task.send(.data(data))
    }

    func disconnect() {
        webSocketTask?.cancel(with: .normalClosure, reason: nil)
        webSocketTask = nil
    }

    private func listenForMessages() {
        Task {
            guard let task = webSocketTask else { return }
            do {
                let message = try await task.receive()
                switch message {
                case .string(let text):
                    await handleTextMessage(text)
                case .data(let data):
                    await handleDataMessage(data)
                @unknown default:
                    break
                }
                // Continue listening
                listenForMessages()
            } catch {
                // Connection closed or error
                await handleDisconnect(error: error)
            }
        }
    }

    private func handleTextMessage(_ text: String) async {
        // Parse and dispatch to observers
    }

    private func handleDataMessage(_ data: Data) async {
        // Decode binary message
    }

    private func handleDisconnect(error: Error) async {
        // Implement reconnection logic with backoff
    }
}

Network Monitoring with NWPathMonitor

Your app should know whether the device has a network connection before making requests.NWPathMonitor from the Network framework provides real-time connectivity status:

// NetworkMonitor.swift
import Network
import Foundation

@Observable
final class NetworkMonitor {
    static let shared = NetworkMonitor()

    private(set) var isConnected = true
    private(set) var connectionType: ConnectionType = .unknown
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    enum ConnectionType {
        case wifi, cellular, wiredEthernet, unknown
    }

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
                self?.connectionType = self?.getConnectionType(path) ?? .unknown
            }
        }
        monitor.start(queue: queue)
    }

    private func getConnectionType(_ path: NWPath) -> ConnectionType {
        if path.usesInterfaceType(.wifi) { return .wifi }
        if path.usesInterfaceType(.cellular) { return .cellular }
        if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
        return .unknown
    }
}

// Use in SwiftUI:
struct ContentView: View {
    var network = NetworkMonitor.shared

    var body: some View {
        VStack {
            if !network.isConnected {
                HStack {
                    Image(systemName: "wifi.slash")
                    Text("No internet connection")
                }
                .padding()
                .background(.red.opacity(0.2))
                .cornerRadius(8)
            }
            // ... rest of your UI
        }
    }
}

Offline-First Pattern

An offline-first architecture shows cached data immediately and syncs with the server in the background. This dramatically improves perceived performance and handles flaky connections gracefully:

// OfflineFirstRepository.swift
import Foundation

protocol LocalStore {
    func loadItems() async throws -> [Item]
    func saveItems(_ items: [Item]) async throws
}

@Observable
final class OfflineFirstRepository {
    private(set) var items: [Item] = []
    private(set) var isRefreshing = false
    private(set) var lastSynced: Date?

    private let api = APIClient.shared
    private let localStore: LocalStore
    private let network = NetworkMonitor.shared

    init(localStore: LocalStore) {
        self.localStore = localStore
    }

    /// Load from cache first, then refresh from network
    func load() async {
        // 1. Show cached data immediately
        if let cached = try? await localStore.loadItems() {
            items = cached
        }

        // 2. Refresh from network if connected
        guard network.isConnected else { return }

        isRefreshing = true
        do {
            let response: APIResponse<[Item]> = try await api.request(endpoint: "items")
            items = response.data
            try? await localStore.saveItems(response.data)
            lastSynced = Date()
        } catch {
            // Cached data is still showing — log error silently
        }
        isRefreshing = false
    }
}

Testing Network Code with URLProtocol

You should never hit real APIs in unit tests. URLProtocol lets you intercept and mock any URLSession request. Here is a reusable mock protocol and a test example:

// MockURLProtocol.swift
import Foundation

final class MockURLProtocol: URLProtocol {
    static var mockResponses: [URL: (Data, HTTPURLResponse)] = [:]

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let url = request.url,
              let (data, response) = Self.mockResponses[url] else {
            client?.urlProtocol(self, didFailWithError: URLError(.badURL))
            return
        }

        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}
}

// Test example:
import XCTest

final class APIClientTests: XCTestCase {
    var sut: APIClient!

    override func setUp() {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol.self]
        let session = URLSession(configuration: config)
        sut = APIClient(
            baseURL: URL(string: "https://api.test.com/v1")!,
            session: session
        )
    }

    func testFetchItemsDecodesCorrectly() async throws {
        // Arrange
        let mockItems = [Item.mock()]
        let jsonData = try JSONEncoder().encode(
            APIResponse(data: mockItems, meta: nil)
        )
        let url = URL(string: "https://api.test.com/v1/items?page=1&per_page=20")!
        MockURLProtocol.mockResponses[url] = (
            jsonData,
            HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
        )

        // Act
        let response: APIResponse<[Item]> = try await sut.request(
            endpoint: "items",
            queryItems: [
                URLQueryItem(name: "page", value: "1"),
                URLQueryItem(name: "per_page", value: "20")
            ]
        )

        // Assert
        XCTAssertEqual(response.data.count, 1)
        XCTAssertEqual(response.data.first?.name, mockItems.first?.name)
    }
}

Networking Approaches Compared: URLSession vs Alamofire vs Moya

Should you use raw URLSession or a third-party library? Here is an honest comparison based on shipping real apps with all three:

CriteriaURLSession (Native)AlamofireMoya
DependenciesZero1 package2 packages (Moya + Alamofire)
Async/AwaitNative supportFull support since v5.5+Supported via Alamofire
Learning CurveMedium (boilerplate)Low (ergonomic API)Medium (enum-based targets)
Multipart UploadManual encodingBuilt-inBuilt-in via Alamofire
Request RetriesManualBuilt-in RequestInterceptorBuilt-in via Alamofire
Certificate PinningURLSessionDelegateServerTrustManagerVia Alamofire
TestabilityURLProtocol mockingURLProtocol mockingStub providers built-in
Binary Size Impact0 KB~500 KB~700 KB
Type-Safe RoutesDIYDIYEnum-based TargetType
Best ForApps wanting zero deps, full controlTeams wanting ergonomics with maturityLarge apps needing strict API contracts

My recommendation for 2026: Use raw URLSession with the API client pattern shown in this guide. Async/await has eliminated most of the boilerplate that made Alamofire attractive in the first place. You get zero dependencies, full control over request/response handling, and no upgrade headaches when Apple changes URLSession APIs. If your team has more than three developers or your API surface is enormous (50+ endpoints), Moya's enum-based routing adds valuable structure.

Putting It All Together: Complete Working Example

Here is a fully wired example that uses the API client, pagination, error handling, and cancellation together in a SwiftUI view with a ViewModel:

// ItemsViewModel.swift
import Foundation

@Observable
final class ItemsViewModel {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var error: APIError?
    private(set) var hasMore = true
    private var currentPage = 1

    func loadItems() async {
        guard !isLoading else { return }
        isLoading = true
        error = nil

        do {
            let response: APIResponse<[Item]> = try await withRetry {
                try await APIClient.shared.fetchItems(page: 1)
            }
            items = response.data
            hasMore = (response.meta?.totalPages ?? 1) > 1
            currentPage = 2
        } catch let err as APIError {
            error = err
        } catch {
            self.error = .clientError(0, error.localizedDescription)
        }

        isLoading = false
    }

    func loadNextPage() async {
        guard !isLoading, hasMore else { return }
        isLoading = true

        do {
            let response: APIResponse<[Item]> = try await APIClient.shared.fetchItems(
                page: currentPage
            )
            items.append(contentsOf: response.data)
            hasMore = currentPage < (response.meta?.totalPages ?? 1)
            currentPage += 1
        } catch {
            // Silently fail for pagination — data is still on screen
        }

        isLoading = false
    }
}

// ItemsScreen.swift
import SwiftUI

struct ItemsScreen: View {
    @State private var viewModel = ItemsViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if let error = viewModel.error, viewModel.items.isEmpty {
                    errorView(error)
                } else {
                    itemList
                }
            }
            .navigationTitle("Items")
            .task {
                await viewModel.loadItems()
            }
            .refreshable {
                await viewModel.loadItems()
            }
        }
    }

    private var itemList: some View {
        List {
            ForEach(viewModel.items) { item in
                VStack(alignment: .leading, spacing: 4) {
                    Text(item.name).font(.headline)
                    if let desc = item.description {
                        Text(desc)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    Text(item.price, format: .currency(code: "USD"))
                        .font(.caption)
                        .foregroundStyle(.green)
                }
                .onAppear {
                    if item.id == viewModel.items.last?.id {
                        Task { await viewModel.loadNextPage() }
                    }
                }
            }

            if viewModel.isLoading {
                HStack {
                    Spacer()
                    ProgressView()
                    Spacer()
                }
            }
        }
    }

    private func errorView(_ error: APIError) -> some View {
        ContentUnavailableView {
            Label("Something Went Wrong", systemImage: "wifi.exclamationmark")
        } description: {
            Text(error.localizedDescription)
        } actions: {
            Button("Try Again") {
                Task { await viewModel.loadItems() }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

Tips and Common Gotchas

  • Always decode on a background thread. URLSession.shared.data(for:)returns on a background thread by default. If you use JSONDecoder right after, decoding also runs off the main thread. Only switch to @MainActor when you assign the result to a published property.
  • Use .task instead of .onAppear for async work. The.task modifier ties the async work to the view lifecycle and cancels it automatically. With .onAppear, you need to manually cancel in .onDisappear.
  • Do not ignore CancellationError. When a task is cancelled, alltry await calls throw CancellationError. Catch it explicitly and do not show it to the user as an error.
  • Set timeouts on URLRequest, not URLSession. Setting timeoutIntervalon the request gives you per-request control. Setting it on the session configuration applies globally, which is rarely what you want.
  • Never force-unwrap URLs from string literals in production code. Use aURL(string:) wrapper that throws or fatalErrors with a clear message during development.
  • Handle 204 No Content responses. Some endpoints (DELETE, certain PUTs) return 204 with an empty body. If you try to decode an empty body, JSONDecoder will throw. Create a separate requestVoid method that does not decode the response.
  • Use keyDecodingStrategy = .convertFromSnakeCase globally. Set it once on your decoder and forget about it. Only use custom CodingKeys when the server key names are truly non-standard (abbreviated, different naming entirely).
  • Log network requests in DEBUG builds only. Use #if DEBUG to print request URLs, response codes, and timing. Strip all logging in release builds to avoid leaking endpoints or tokens into device logs.

Skip the Boilerplate — Use The Swift Kit

Everything in this guide — the type-safe API client, token management, retry logic, image caching, error mapping, pagination patterns — is already built, tested, and production-ready in The Swift Kit. The networking layer comes pre-wired with Supabase integration for auth, database, and storage, plus a clean MVVM architecture that makes every service injectable and testable.

You also get streaming support for AI chat features with ChatGPT, offline caching, and a complete set of request interceptors for authentication. Instead of spending a week building and debugging your networking layer, paste your API base URL into the config file and start building the features your users actually care about.

Check out the full feature list or see pricing to get started. One-time purchase. Lifetime updates. Zero recurring fees.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit