Modern Networking in iOS with URLSession and async/await: A Practical Guide

Networking is at the core of most iOS apps. Almost every modern application needs to talk to remote services, fetch or send data, and present fetched information in real time. This makes it essential to have a reliable, safe, and maintainable approach to performing HTTP requests. Apple provides URLSession as the standard way to handle network communication in iOS. It's robust, battle-tested, and highly configurable. Whether you need to fetch JSON, download files, or upload data, URLSession is the foundation you'll build on. In the past, developers relied on completion handlers or Combine to manage these asynchronous calls. While functional, those methods introduced complexity and verbosity. Enter async/await, introduced in Swift 5.5. This modern concurrency model transforms asynchronous code into something that reads and behaves like synchronous code. With async/await, networking becomes: Easier to read and maintain Safer and less error-prone More Swift-native and familiar This tutorial will walk you through building a modern, fully async network layer using URLSession and async/await and display the fetched data in a SwiftUI list. The APIs We’ll Use The following public testing APIs will be used GET https://jsonplaceholder.typicode.com/users GET https://jsonplaceholder.typicode.com/posts GET https://jsonplaceholder.typicode.com/comments They return dummy data, which we will use to display lists of users, posts and comments Step 1: Create the Models Before we can decode any JSON, we need to define some models that mirror the structure of the API responses. In Swift, we use the Codable protocol to enable easy JSON parsing and Identifiable to make integration with SwiftUI lists seamless. Codable allows us to convert between JSON data and Swift structs automatically. Identifiable is required for SwiftUI's List view to uniquely identify each element in a collection. struct User: Codable, Identifiable { let id: Int let name: String let username: String let email: String } struct Post: Codable, Identifiable { let id: Int let userId: Int let title: String let body: String } struct Comment: Codable, Identifiable { let id: Int let postId: Int let name: String let email: String let body: String } Step 2: Create the Networking Layer We use two powerful Swift features: enums and generics to create a scalable and maintainable networking layer. enum Endpoint The Endpoint enum encapsulates all supported API paths in a single type-safe structure. Each case in the enum maps to a specific endpoint (e.g. .users, .comments(postId:)), avoiding stringly-typed URLs scattered throughout your code. It helps centralize logic like HTTP method selection and URL path construction. enum Endpoint { case users case posts case comments(postId: Int) var path: String { switch self { case .users: return "/users" case .posts: return "/posts" case .comments(let postId): return "/posts/\(postId)/comments" } } var method: HTTPMethod { switch self { case .users, .posts, .comments: return .GET } } } enum NetworkError Structured error handling: Clearly categorizes different failure scenarios such as URL construction issues, server errors, and decoding problems. Better debugging: By distinguishing error types, you can display user-friendly messages or handle specific issues differently (e.g., retry on .serverError). Swift-native: Conforms to Swift’s Error protocol and integrates seamlessly with try/catch. enum NetworkError: Error { case invalidURL case decodingError case serverError(statusCode: Int) case unknown(Error) } enum HTTPMethod enum HTTPMethod: String { case GET, POST, PUT, DELETE } Clarity: This enum defines common HTTP methods as strongly-typed cases instead of using raw strings like "GET" or "POST" throughout your code. Safety: Prevents typos and allows the compiler to catch invalid method usage. Scalability: Easy to extend by adding methods like .PATCH or .HEAD later. protocol APIRequest protocol APIRequest { associatedtype Response: Decodable var endpoint: Endpoint { get } } extension APIRequest { var url: URL? { var components = URLComponents() components.scheme = "https" components.host = "jsonplaceholder.typicode.com" components.path = endpoint.path return components.url } } Generic Request Definition: This protocol defines the structure for any type of API request. associatedtype Response: This allows each request to specify the exact data type it expects to receive. For example, one request might return [User], another might return [Post]. Decodable constraint: Ensures the response can be parsed from JSON using Swift’s Decodable system. Extensibility: By conf

Apr 5, 2025 - 20:20
 0
Modern Networking in iOS with URLSession and async/await: A Practical Guide

Networking is at the core of most iOS apps. Almost every modern application needs to talk to remote services, fetch or send data, and present fetched information in real time. This makes it essential to have a reliable, safe, and maintainable approach to performing HTTP requests.

Apple provides URLSession as the standard way to handle network communication in iOS. It's robust, battle-tested, and highly configurable. Whether you need to fetch JSON, download files, or upload data, URLSession is the foundation you'll build on. In the past, developers relied on completion handlers or Combine to manage these asynchronous calls. While functional, those methods introduced complexity and verbosity.

Enter async/await, introduced in Swift 5.5. This modern concurrency model transforms asynchronous code into something that reads and behaves like synchronous code. With async/await, networking becomes:

  • Easier to read and maintain
  • Safer and less error-prone
  • More Swift-native and familiar

This tutorial will walk you through building a modern, fully async network layer using URLSession and async/await and display the fetched data in a SwiftUI list.

The APIs We’ll Use

The following public testing APIs will be used

GET https://jsonplaceholder.typicode.com/users
GET https://jsonplaceholder.typicode.com/posts
GET https://jsonplaceholder.typicode.com/comments

They return dummy data, which we will use to display lists of users, posts and comments

Step 1: Create the Models

Before we can decode any JSON, we need to define some models that mirror the structure of the API responses. In Swift, we use the Codable protocol to enable easy JSON parsing and Identifiable to make integration with SwiftUI lists seamless.

  • Codable allows us to convert between JSON data and Swift structs automatically.
  • Identifiable is required for SwiftUI's List view to uniquely identify each element in a collection.
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let username: String
    let email: String
}

struct Post: Codable, Identifiable {
    let id: Int
    let userId: Int
    let title: String
    let body: String
}

struct Comment: Codable, Identifiable {
    let id: Int
    let postId: Int
    let name: String
    let email: String
    let body: String
}

Step 2: Create the Networking Layer

We use two powerful Swift features: enums and generics to create a scalable and maintainable networking layer.

enum Endpoint

  • The Endpoint enum encapsulates all supported API paths in a single type-safe structure.
  • Each case in the enum maps to a specific endpoint (e.g. .users, .comments(postId:)), avoiding stringly-typed URLs scattered throughout your code.
  • It helps centralize logic like HTTP method selection and URL path construction.
enum Endpoint {
    case users
    case posts
    case comments(postId: Int)

    var path: String {
        switch self {
        case .users: return "/users"
        case .posts: return "/posts"
        case .comments(let postId): return "/posts/\(postId)/comments"
        }
    }

    var method: HTTPMethod {
        switch self {
        case .users, .posts, .comments: return .GET
        }
    }
}

enum NetworkError

  • Structured error handling: Clearly categorizes different failure scenarios such as URL construction issues, server errors, and decoding problems.
  • Better debugging: By distinguishing error types, you can display user-friendly messages or handle specific issues differently (e.g., retry on .serverError).
  • Swift-native: Conforms to Swift’s Error protocol and integrates seamlessly with try/catch.
enum NetworkError: Error {
    case invalidURL
    case decodingError
    case serverError(statusCode: Int)
    case unknown(Error)
}

enum HTTPMethod

enum HTTPMethod: String {
    case GET, POST, PUT, DELETE
}
  • Clarity: This enum defines common HTTP methods as strongly-typed cases instead of using raw strings like "GET" or "POST" throughout your code.
  • Safety: Prevents typos and allows the compiler to catch invalid method usage.
  • Scalability: Easy to extend by adding methods like .PATCH or .HEAD later.

protocol APIRequest

protocol APIRequest {
    associatedtype Response: Decodable
    var endpoint: Endpoint { get }
}

extension APIRequest {
    var url: URL? {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "jsonplaceholder.typicode.com"
        components.path = endpoint.path
        return components.url
    }
}
  • Generic Request Definition: This protocol defines the structure for any type of API request.
  • associatedtype Response: This allows each request to specify the exact data type it expects to receive. For example, one request might return [User], another might return [Post].
  • Decodable constraint: Ensures the response can be parsed from JSON using Swift’s Decodable system.
  • Extensibility: By conforming to this protocol, you can add any number of request types (GetPostsRequest, GetCommentsRequest, etc.) without modifying the networking logic.
  • Extension with default URL construction: Instead of requiring every request to build its own URL, we use a protocol extension to provide a shared implementation.
  • Encapsulation of base URL logic: Centralizes the host and scheme configuration (e.g. "https://jsonplaceholder.typicode.com"), keeping the logic DRY (Don't Repeat Yourself).
  • Reusability: Any request that conforms to APIRequest gets this default URL computation for free.
struct GetUserRequest: APIRequest {
    typealias Response = [User]
    let endpoint: Endpoint = .users
}

struct GetPostsRequest: APIRequest {
    typealias Response = [Post]
    let endpoint: Endpoint = .posts
}

struct GetCommentsRequest: APIRequest {
    typealias Response = [Comment]
    let endpoint: Endpoint = .users
}

func send

  • This is a generic function that works with any type conforming to APIRequest, as long as it defines what type of response it expects.
  • Using generics allows the networking client to remain decoupled from specific model types like User, Post, or Comment.
  • This makes the network layer reusable and extensible: just create new request types for different endpoints and the same send function will work with them.
  • async: Indicates this function performs asynchronous work.
  • throws: Indicates this function can fail and will throw errors you need to handle.
  • Returns: The decoded result of the API call, e.g., [User] or [Post].
final class NetworkClient {
    func send<T: APIRequest>(_ request: T) async throws -> T.Response {
        guard let url = request.url else {
            throw NetworkError.invalidURL
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.endpoint.method.rawValue

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await URLSession.shared.data(for: urlRequest)
        } catch {
            throw NetworkError.unknown(error)
        }

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.unknown(NSError(domain: "Invalid response", code: 0))
        }

        guard (200..<300).contains(httpResponse.statusCode) else {
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }

        do {
            return try JSONDecoder().decode(T.Response.self, from: data)
        } catch {
            throw NetworkError.decodingError
        }
    }
}

In the above snippet, note the:

(data, response) = try await URLSession.shared.data(for: urlRequest)
  • The call is made asynchronously (as promised by async) thanks to await.
  • It suspends execution until the response is received, meaning the next line in the code won't run until await is done.
  • If any network error occurs (e.g. no connection, timeout), it throws — and we catch it below. Wrapping the error into our custom .unknown error case, preserving the original error for context.
} catch {
    throw NetworkError.unknown(error)
}

Next, we validate the response:

guard let httpResponse = response as? HTTPURLResponse else {
    throw NetworkError.unknown(NSError(domain: "Invalid response", code: 0))
}

guard (200..<300).contains(httpResponse.statusCode) else {
    throw NetworkError.serverError(statusCode: httpResponse.statusCode)
}
  • First checks if the response is an HTTP response.
  • Then ensures the HTTP status code is in the success range (200–299).
  • If not, throws a .serverError(statusCode:), making it easier to handle in UI or logs later.

After validation, we are trying to decode the response:

return try JSONDecoder().decode(T.Response.self, from: data)
  • Tries to decode the received Data into the expected response type (T.Response) using Swift’s Decodable.
  • If decoding fails, it’s caught and rethrown as our custom .decodingError.
} catch {
    throw NetworkError.decodingError
}

Step 3: Integrating with SwiftUI

ViewModel

import SwiftUI

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

    private let client = NetworkClient()

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

        do {
            let users = try await client.send(GetUsersRequest())
            self.users = users
        } catch {
            errorMessage = "Failed to load users: \(error.localizedDescription)"
        }

        isLoading = false
    }
}

View

import SwiftUI

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

    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading...")
                } else if let error = viewModel.errorMessage {
                    Text(error).foregroundColor(.red)
                } else {
                    List(viewModel.users) { user in
                        VStack(alignment: .leading) {
                            Text(user.name).bold()
                            Text(user.email).font(.subheadline).foregroundColor(.gray)
                        }
                    }
                }
            }
            .navigationTitle("Users")
            .task {
                await viewModel.loadUsers()
            }
        }
    }
}

And we are done!

Image description

You can checkout the full project on GitHub.