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:
| Aspect | Completion Handlers | Async/Await |
|---|---|---|
| Syntax | Nested callbacks | Linear code |
| Error handling | Result types, manual | Standard try/catch |
| Cancellation | Manual implementation | Built-in Task cancellation |
| Thread behavior | Callbacks on various threads | Structured with MainActor |
| Debugging | Complex stack traces | Clear 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
| Concept | Purpose |
|---|---|
| async/await | Linear asynchronous code |
| Actors | Thread-safe shared state |
| URLSession | Modern networking API |
| Codable | Type-safe JSON parsing |
| Endpoint pattern | Organized API definitions |
| Task cancellation | Cooperative cancellation |
| @MainActor | Main thread isolation |
| TaskGroup | Parallel 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.