Talk to a backend with URLSession and async/await, decode JSON straight into Codable models, handle errors properly, and know where a library like Alamofire fits.
URLSession is the built-in HTTP client, and its async/await API makes a network call read like a normal function: await the data, check the response, decode it. No callbacks, no completion-handler nesting. This concise pattern — request, await, decode — is the foundation of networking in a modern iOS app.
func fetchUsers() async throws -> [User] {
let url = URL(string: "https://api.example.com/users")!
let (data, response) = try await URLSession.shared.data(from: url) // await
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([User].self, from: data) // decode JSON -> models
}
// request -> await -> decode. No callbacks. The foundation of iOS networking.Codable makes turning JSON into typed models nearly automatic: conform your model and JSONDecoder does the rest. CodingKeys map snake_case API fields to Swift property names, and a configured decoder handles date formats. This is why iOS networking has so little boilerplate — the type system does the parsing, safely.
struct User: Codable {
let id: Int
let fullName: String // API sends "full_name"
let joinedAt: Date
enum CodingKeys: String, CodingKey {
case id
case fullName = "full_name" // map snake_case -> camelCase
case joinedAt = "joined_at"
}
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601 // parse ISO date strings
let users = try decoder.decode([User].self, from: data)
// Codable + CodingKeys = typed models from JSON with almost no code.Robust networking distinguishes failure kinds: no connectivity, a non-2xx status, or a decoding mismatch each need different handling. Model them as a typed error and translate outcomes into UI state (an error message, a retry). This is what separates a demo from a shippable app, and it pairs with the MVVM state modeling from earlier.
enum NetworkError: Error { case offline, server(Int), decoding }
func fetch<T: Decodable>(_ url: URL) async -> Result<T, NetworkError> {
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else { return .failure(.offline) }
guard (200..<300).contains(http.statusCode) else {
return .failure(.server(http.statusCode)) // non-2xx
}
return .success(try JSONDecoder().decode(T.self, from: data))
} catch is DecodingError {
return .failure(.decoding) // shape mismatch
} catch {
return .failure(.offline) // no connection
}
}
// distinguish offline / server / decoding -> map each to clear UI state.URLSession covers most needs, but the Alamofire library adds conveniences for complex apps: request/response interceptors (for auth token refresh), multipart uploads, retries, and reachability. For a simple app, plain URLSession with async/await is enough and dependency-free; add Alamofire when its features genuinely save you writing that plumbing yourself.
URLSession (built-in) covers most apps: async/await, Codable, uploads/downloads.
no dependency. Start here.
Alamofire (library) adds convenience for complex networking:
- interceptors (auto-refresh an expired auth token)
- retries + reachability
- richer multipart uploads, response validation
Rule of thumb: plain URLSession until its plumbing gets repetitive; adopt
Alamofire when its features clearly save you writing that yourself.