Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

iOS Networking with Async/Await and Actors

May 11, 2025 · 11 min read

iOS, Swift, Networking, async/await, Interview Prep

Introduction

Networking is fundamental to most iOS apps, and Swift's modern concurrency model has transformed how we write asynchronous code. Understanding async/await, actors, and structured concurrency is essential for iOS interviews in 2025-26. With Swift 6's stricter concurrency checking now standard, interviewers expect you to write thread-safe code by default.

Building Glucoplate's networking layer taught me the practical tradeoffs between different approaches. This post covers what you need to know.


Async/Await Fundamentals

Async/await replaces completion handlers with linear, readable code:

// Before: Completion handler hell
func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
    fetchToken { tokenResult in
        switch tokenResult {
        case .success(let token):
            fetchProfile(token: token) { profileResult in
                switch profileResult {
                case .success(let profile):
                    fetchPreferences(userId: profile.id) { prefsResult in
                        // Even more nesting...
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

// After: Linear and readable
func fetchUser() async throws -> User {
    let token = try await fetchToken()
    let profile = try await fetchProfile(token: token)
    let preferences = try await fetchPreferences(userId: profile.id)
    return User(profile: profile, preferences: preferences)
}

Interview Insight

Interviewers frequently ask you to convert callback-based code to async/await. The key insight: async functions suspend without blocking threads. When you await, the current task yields its thread so other work can proceed. This is fundamentally different from blocking calls.


URLSession with Async/Await

Basic networking is now straightforward:

func fetchMeals() async throws -> [Meal] {
    let url = URL(string: "https://api.example.com/meals")!

    let (data, response) = try await URLSession.shared.data(from: url)

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

    return try JSONDecoder().decode([Meal].self, from: data)
}

Configuring URLSession

let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
configuration.waitsForConnectivity = true

let session = URLSession(configuration: configuration)

Actors for Thread Safety

Actors ensure data race safety by serializing access to mutable state:

actor APIClient {
    private let session: URLSession
    private let tokenProvider: TokenProvider

    init(session: URLSession = .shared, tokenProvider: TokenProvider) {
        self.session = session
        self.tokenProvider = tokenProvider
    }

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var request = try endpoint.urlRequest()

        // Actor-isolated: Only one caller accesses token at a time
        if let token = await tokenProvider.accessToken {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        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:
            return try JSONDecoder.api.decode(T.self, from: data)
        case 401:
            try await tokenProvider.refreshToken()
            return try await request(endpoint)  // Retry
        default:
            throw NetworkError.serverError(httpResponse.statusCode)
        }
    }
}

Why Actors Matter

// Without actors - potential data race
class TokenManager {
    var token: String?

    func refreshToken() async {
        token = await fetchNewToken()  // Race condition!
    }
}

// With actors - thread-safe by design
actor TokenManager {
    var token: String?

    func refreshToken() async {
        token = await fetchNewToken()  // Only one caller at a time
    }
}

// Access requires await
let manager = TokenManager()
await manager.refreshToken()
let currentToken = await manager.token

Swift 6 Concurrency

In Swift 6, data race safety is compiler-enforced. Actors replace manual locking (NSLock, DispatchQueue). If you're used to writing queue.sync { }, actors provide the same serialization with compile-time guarantees.


Codable for JSON

Codable enables type-safe JSON parsing:

struct Meal: Codable, Identifiable {
    let id: UUID
    var name: String
    var calories: Int
    var protein: Double
    var carbs: Double
    var fat: Double
    var mealType: MealType
    var loggedAt: Date

    enum MealType: String, Codable {
        case breakfast, lunch, dinner, snack
    }
}

// Decoder configuration
extension JSONDecoder {
    static let api: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }()
}

extension JSONEncoder {
    static let api: JSONEncoder = {
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }()
}

Custom Decoding

struct APIResponse<T: Decodable>: Decodable {
    let data: T
    let meta: Meta?

    struct Meta: Decodable {
        let total: Int
        let page: Int
        let pageSize: Int
    }
}

// For edge cases requiring manual control
struct FlexibleDate: Codable {
    let date: Date

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        // Try ISO8601 first
        if let isoDate = try? container.decode(Date.self) {
            date = isoDate
            return
        }

        // Fallback to timestamp
        let timestamp = try container.decode(Double.self)
        date = Date(timeIntervalSince1970: timestamp)
    }
}

Endpoint Pattern

Type-safe endpoint definitions:

protocol Endpoint {
    var baseURL: URL { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var headers: [String: String] { get }
    var queryItems: [URLQueryItem]? { get }
    var body: Data? { get }
}

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

extension Endpoint {
    var baseURL: URL {
        URL(string: "https://api.example.com")!
    }

    var headers: [String: String] {
        ["Content-Type": "application/json"]
    }

    var queryItems: [URLQueryItem]? { nil }
    var body: Data? { nil }

    func urlRequest() throws -> URLRequest {
        var components = URLComponents(
            url: baseURL.appendingPathComponent(path),
            resolvingAgainstBaseURL: true
        )
        components?.queryItems = queryItems

        guard let url = components?.url else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.httpBody = body

        for (key, value) in headers {
            request.setValue(value, forHTTPHeaderField: key)
        }

        return request
    }
}

// Concrete endpoints
enum MealsEndpoint: Endpoint {
    case list(date: Date?)
    case get(id: UUID)
    case create(meal: Meal)
    case update(meal: Meal)
    case delete(id: UUID)

    var path: String {
        switch self {
        case .list: return "/meals"
        case .get(let id): return "/meals/\(id)"
        case .create: return "/meals"
        case .update(let meal): return "/meals/\(meal.id)"
        case .delete(let id): return "/meals/\(id)"
        }
    }

    var method: HTTPMethod {
        switch self {
        case .list, .get: return .get
        case .create: return .post
        case .update: return .put
        case .delete: return .delete
        }
    }

    var body: Data? {
        switch self {
        case .create(let meal), .update(let meal):
            return try? JSONEncoder.api.encode(meal)
        default:
            return nil
        }
    }
}

Error Handling

Comprehensive error types:

enum NetworkError: LocalizedError {
    case invalidURL
    case invalidResponse
    case unauthorized
    case serverError(statusCode: Int, message: String?)
    case decodingError(Error)
    case networkUnavailable
    case timeout

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid URL"
        case .invalidResponse:
            return "Invalid server response"
        case .unauthorized:
            return "Please sign in again"
        case .serverError(let code, let message):
            return message ?? "Server error (\(code))"
        case .decodingError(let error):
            return "Data parsing error: \(error.localizedDescription)"
        case .networkUnavailable:
            return "No internet connection"
        case .timeout:
            return "Request timed out"
        }
    }
}

Retry with Exponential Backoff

actor RetryableClient {
    private let apiClient: APIClient
    private let maxRetries: Int
    private let retryableStatuses: Set<Int> = [408, 429, 500, 502, 503, 504]

    init(apiClient: APIClient, maxRetries: Int = 3) {
        self.apiClient = apiClient
        self.maxRetries = maxRetries
    }

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var lastError: Error?

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

                if case .serverError(let code, _) = error,
                   retryableStatuses.contains(code) {
                    let delay = pow(2.0, Double(attempt))
                    try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                    continue
                }

                throw error
            }
        }

        throw lastError ?? NetworkError.invalidResponse
    }
}

Structured Concurrency

Task Groups for Parallel Requests

func fetchDashboardData() async throws -> DashboardData {
    async let meals = fetchTodaysMeals()
    async let goals = fetchNutritionGoals()
    async let steps = fetchStepCount()

    // All three requests run concurrently
    return try await DashboardData(
        meals: meals,
        goals: goals,
        steps: steps
    )
}

// With TaskGroup for dynamic parallelism
func fetchMealsForDates(_ dates: [Date]) async throws -> [Date: [Meal]] {
    try await withThrowingTaskGroup(of: (Date, [Meal]).self) { group in
        for date in dates {
            group.addTask {
                let meals = try await self.fetchMeals(for: date)
                return (date, meals)
            }
        }

        var results: [Date: [Meal]] = [:]
        for try await (date, meals) in group {
            results[date] = meals
        }
        return results
    }
}

Task Cancellation

func searchFoods(query: String) async throws -> [Food] {
    // Check if task was cancelled before expensive operation
    try Task.checkCancellation()

    let foods = try await apiClient.search(query: query)

    // Check again after network call
    try Task.checkCancellation()

    return foods
}

// In ViewModel
class SearchViewModel {
    private var searchTask: Task<Void, Never>?

    func search(query: String) {
        // Cancel previous search
        searchTask?.cancel()

        searchTask = Task {
            try? await Task.sleep(nanoseconds: 300_000_000)  // Debounce

            guard !Task.isCancelled else { return }

            do {
                let results = try await apiClient.search(query: query)
                await MainActor.run {
                    self.results = results
                }
            } catch {
                // Handle error
            }
        }
    }
}

Interview Questions

Q1: What's the difference between async/await and completion handlers?

Answer: Key differences:

AspectCompletion HandlersAsync/Await
SyntaxNested callbacksLinear code
Error handlingResult types, manualStandard try/catch
CancellationManual implementationBuilt-in Task cancellation
Thread behaviorCallbacks on various threadsStructured with MainActor
DebuggingComplex stack tracesClear call stacks

Async functions suspend without blocking threads. When you await, the task yields so other work can proceed. This is fundamentally different from blocking calls.

Q2: What is an Actor and when would you use one?

Answer: Actors provide data race safety for shared mutable state by serializing access:

actor TokenManager {
    private var token: String?

    func getToken() async -> String? {
        // Only one caller can execute this at a time
        return token
    }

    func setToken(_ newToken: String) {
        token = newToken
    }
}

Use actors when:

  • Multiple tasks access shared mutable state
  • Managing caches, connection pools, or singletons
  • Thread-safe token management
  • Any state that needs synchronized access

In Swift 6, actors replace manual locking patterns.

Q3: How does Task cancellation work?

Answer: Cancellation in Swift is cooperative:

func fetchData() async throws -> Data {
    // Option 1: Check and throw
    try Task.checkCancellation()

    // Option 2: Check without throwing
    guard !Task.isCancelled else {
        return Data()
    }

    // Network calls check cancellation automatically
    let (data, _) = try await URLSession.shared.data(from: url)

    return data
}

// Cancel a task
let task = Task {
    try await fetchData()
}
task.cancel()  // Sets isCancelled flag

Tasks don't immediately stop - they must check for cancellation. URLSession and other Apple APIs handle this automatically.

Q4: Explain @MainActor and when to use it.

Answer: @MainActor ensures code runs on the main thread:

@MainActor
class ViewModel: ObservableObject {
    @Published var data: [Item] = []

    func loadData() async {
        // Already on main thread due to @MainActor
        let items = try? await apiClient.fetchItems()
        data = items ?? []  // UI update is safe
    }
}

// Or for specific methods
func updateUI() async {
    await MainActor.run {
        // Explicit main thread context
        self.label.text = "Updated"
    }
}

With Swift 6.2, new projects default to @MainActor isolation, so you don't need to mark everything explicitly.


Common Mistakes

1. Blocking Main Thread

// ❌ Synchronous Data call
let data = try Data(contentsOf: url)

// ✅ Async URLSession
let (data, _) = try await URLSession.shared.data(from: url)

2. Force Unwrapping URLs

// ❌ Crashes on invalid URL
let url = URL(string: userInput)!

// ✅ Handle gracefully
guard let url = URL(string: userInput) else {
    throw NetworkError.invalidURL
}

3. Not Handling Token Refresh

// ❌ Just fail on 401
if httpResponse.statusCode == 401 {
    throw NetworkError.unauthorized
}

// ✅ Attempt refresh and retry
if httpResponse.statusCode == 401 {
    try await tokenProvider.refreshToken()
    return try await request(endpoint)
}

4. Ignoring Cancellation

// ❌ Continues after view dismissed
func loadData() async {
    let data = try? await fetchLargeDataset()
    processData(data)  // May run after view gone
}

// ✅ Check cancellation
func loadData() async {
    let data = try? await fetchLargeDataset()
    guard !Task.isCancelled else { return }
    processData(data)
}

Summary

ConceptPurpose
async/awaitLinear asynchronous code
ActorsThread-safe shared state
URLSessionModern networking API
CodableType-safe JSON parsing
Endpoint patternOrganized API definitions
Task cancellationCooperative cancellation
@MainActorMain thread isolation
TaskGroupParallel request execution

Modern iOS networking combines async/await for clean asynchronous code with actors for thread safety. With Swift 6's strict concurrency, these patterns are the standard expectation in interviews.


Part 4 of the iOS Interview Prep series.

← Previous

iOS Data Persistence: Core Data, SwiftData, and Beyond

Next →

SwiftUI Navigation and Architecture Patterns