DEV Community

Daniil Kornilov
Daniil Kornilov

Posted on Edited on

Async/await networking in Swift: an illustrative network layer

Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.

The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.

Why Async/Await?

Before Swift 5.5, we used completion handlers:

// Old way - callback hell
func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        // Handle response...
        completion(.success(user))
    }.resume()
}
Enter fullscreen mode Exit fullscreen mode

Now with async/await:

// Modern way - clean and readable
func fetchUser() async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}
Enter fullscreen mode Exit fullscreen mode

Step 1: Define the API Endpoint

enum Endpoint {
    case users
    case user(id: Int)
    case posts
    case createPost(title: String, body: String)

    var path: String {
        switch self {
        case .users: return "/users"
        case .user(let id): return "/users/\(id)"
        case .posts: return "/posts"
        case .createPost: return "/posts"
        }
    }

    var method: HTTPMethod {
        switch self {
        case .users, .user, .posts: return .get
        case .createPost: return .post
        }
    }

    var body: Data? {
        switch self {
        case .createPost(let title, let body):
            let dict = ["title": title, "body": body]
            return try? JSONSerialization.data(withJSONObject: dict)
        default:
            return nil
        }
    }
}

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Custom Errors

enum NetworkError: LocalizedError {
    case invalidURL
    case invalidResponse
    case decodingError
    case serverError(statusCode: Int)
    case noData
    case unauthorized

    var errorDescription: String? {
        switch self {
        case .invalidURL: return "Invalid URL"
        case .invalidResponse: return "Invalid server response"
        case .decodingError: return "Failed to decode response"
        case .serverError(let code): return "Server error: \(code)"
        case .noData: return "No data received"
        case .unauthorized: return "Unauthorized access"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the Network Service

actor NetworkService {
    static let shared = NetworkService()

    private let baseURL = "https://jsonplaceholder.typicode.com"
    private let decoder = JSONDecoder()
    private let session: URLSession

    private init() {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30
        config.waitsForConnectivity = true
        self.session = URLSession(configuration: config)
    }

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        let request = try buildRequest(for: endpoint)

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

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

        switch httpResponse.statusCode {
        case 200...299:
            do {
                return try decoder.decode(T.self, from: data)
            } catch {
                throw NetworkError.decodingError
            }
        case 401:
            throw NetworkError.unauthorized
        default:
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }
    }

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

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

        return request
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Create Models

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
    let username: String
}

struct Post: Codable, Identifiable {
    let id: Int
    let userId: Int
    let title: String
    let body: String
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Use in SwiftUI

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

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

        do {
            users = try await NetworkService.shared.request(.users)
        } catch let error as NetworkError {
            errorMessage = error.errorDescription
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }
}

struct UsersView: View {
    @State private var viewModel = UsersViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView()
                } else if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        "Error",
                        systemImage: "wifi.slash",
                        description: Text(error)
                    )
                } else {
                    List(viewModel.users) { user in
                        VStack(alignment: .leading) {
                            Text(user.name).font(.headline)
                            Text(user.email).font(.caption).foregroundStyle(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Users")
            .task {
                await viewModel.loadUsers()
            }
            .refreshable {
                await viewModel.loadUsers()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Parallel Requests

Need multiple API calls at once? Use async let:

func loadDashboard() async throws -> Dashboard {
    async let users: [User] = NetworkService.shared.request(.users)
    async let posts: [Post] = NetworkService.shared.request(.posts)

    return try await Dashboard(users: users, posts: posts)
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Use actor isolation when protecting shared mutable state; an actor is not required merely to call URLSession.
  2. Create custom errors for better error handling
  3. Use generics for reusable request methods
  4. Leverage async let for parallel requests
  5. Add retry logic for unreliable connections

Scope of the code examples

These are separate teaching snippets, not a complete tested application or a production-ready package. Match availability to your deployment target, provide omitted model/service definitions, and check errors, cancellation, accessibility and persistence before adapting them.

The callback example deliberately omits response handling. The request examples need a cancellation and overlapping-load policy before production use. Do not automatically retry a create/update request unless the service contract supports safe retries, for example through an idempotency key.

Top comments (0)