DEV Community

Async/Await Networking in Swift: From Zero to API Calls

Modern iOS development requires clean, efficient networking code. Swift's async/await makes API calls readable and maintainable. Let me show you how to build a complete networking layer from scratch.

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

Building the Network Layer

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

@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

Advanced: Request Retry

extension NetworkService {
    func requestWithRetry<T: Decodable>(
        _ endpoint: Endpoint,
        maxRetries: Int = 3
    ) async throws -> T {
        var lastError: Error?

        for attempt in 1...maxRetries {
            do {
                return try await request(endpoint)
            } catch {
                lastError = error
                if attempt < maxRetries {
                    try await Task.sleep(for: .seconds(Double(attempt)))
                }
            }
        }

        throw lastError ?? NetworkError.invalidResponse
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Use actor for thread-safe network services
  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

Want a production-ready networking layer?

Check out my SwiftUI Starter Kit Pro - it includes:

  • Complete NetworkManager with async/await
  • Error handling and retry logic
  • Request caching
  • Mock service for testing
  • 5 screens ready to use

Start building your app with a solid foundation!

Top comments (0)