Networking in Swift with URLSession: a step-by-step guide
Most iOS apps talk to a backend—whether it's fetching user profiles, posting orders, or streaming live data. URLSession is Apple's native HTTP client, and when you set it up cleanly, your networking code becomes testable, maintainable, and resilient. In this guide, we'll walk through building a production-grade API client that handles Codable models, proper error handling, and sensible configuration.
Why URLSession matters
URLSession is built into Foundation and handles the heavy lifting: connection pooling, cookie management, redirect handling, and background transfers. Unlike third-party libraries, it's always available, receives OS-level updates, and integrates with system security policies. The challenge isn't that URLSession is hard—it's that naive implementations scatter API calls throughout your view controllers, mix parsing logic with UI code, and fail silently when the network hiccups.
Codable models — Decode JSON into structs
Everything starts with data models. When your backend returns JSON, Swift's Codable protocol decodes it directly into strongly-typed structs. This gives you compile-time safety and keeps invalid data out of your app.
Let's say you're building a task management app. Your backend returns users and their tasks:
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"tasks": [
{
"id": 101,
"title": "Review PR",
"completed": false,
"dueDate": "2025-01-30"
}
]
}Define Codable structs that match this shape:
struct User: Codable {
let id: Int
let name: String
let email: String
let tasks: [Task]
}
struct Task: Codable {
let id: Int
let title: String
let completed: Bool
let dueDate: String
}If your JSON keys use snake_case but Swift convention is camelCase, use CodingKeys:
struct Task: Codable {
let id: Int
let title: String
let completed: Bool
let dueDate: String
enum CodingKeys: String, CodingKey {
case id
case title
case completed
case dueDate = "due_date"
}
}For optional fields that might be null or missing, use optional properties:
struct User: Codable {
let id: Int
let name: String
let email: String
let avatar: String?
let tasks: [Task]
}Codable handles the serialization and deserialization automatically via JSONDecoder and JSONEncoder. This keeps your data layer predictable and your types safe.
An API client — A small type with async methods
Rather than spreading URLSession calls across your app, create a focused API client that owns all network communication. A well-designed client is a single source of truth for your API contract and makes testing straightforward.
Here's a clean API client for our task app:
actor APIClient {
private let session: URLSession
private let baseURL: URL
init(session: URLSession = .shared, baseURL: URL) {
self.session = session
self.baseURL = baseURL
}
func fetchUser(id: Int) async throws -> User {
let url = baseURL.appendingPathComponent("users/\(id)")
let (data, response) = try await session.data(from: url)
try validateResponse(response)
return try JSONDecoder().decode(User.self, from: data)
}
func createTask(userId: Int, title: String, dueDate: String) async throws -> Task {
let url = baseURL.appendingPathComponent("users/\(userId)/tasks")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload = ["title": title, "due_date": dueDate]
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try JSONDecoder().decode(Task.self, from: data)
}
private func validateResponse(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw NetworkError.httpError(statusCode: http.statusCode)
}
}
}We use actor for thread-safe access and async/await for clean, linear code flow. Each method builds its request, fetches data, validates the response, and decodes the result. The client owns the URLSession and base URL, so callers never construct requests themselves.
When you want to inject a different session for testing, you can pass a mock:
let testClient = APIClient(
session: URLSession(configuration: mockConfig),
baseURL: URL(string: "http://localhost:8080")!
)Errors — Check HTTP status codes before decoding
A 500 error from the server will fail to decode as valid JSON. Similarly, a 404 means the resource doesn't exist. Always validate the HTTP response before attempting to parse the body.
Define a custom error type so callers can handle different failure modes:
enum NetworkError: Error, LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case decodingError(Error)
case noData
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .noData:
return "No data received"
}
}
}In your API client, validate status codes before decoding:
private func validateResponse(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw NetworkError.httpError(statusCode: http.statusCode)
}
}
func fetchUser(id: Int) async throws -> User {
let url = baseURL.appendingPathComponent("users/\(id)")
let (data, response) = try await session.data(from: url)
try validateResponse(response)
do {
return try JSONDecoder().decode(User.self, from: data)
} catch {
throw NetworkError.decodingError(error)
}
}This separation makes debugging easier: you know exactly whether a failure was a network problem, an HTTP error, or malformed JSON. In your views, you can present appropriate messages to the user.
Configuration — Timeouts and caching with URLSessionConfiguration
URLSession's default configuration works for many apps, but production systems often need tuning. URLSessionConfiguration lets you set timeouts, cache policies, and headers globally so you don't repeat them in every request.
Create a configuration suited to your backend:
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 60
config.waitsForConnectivity = true
config.urlCache = URLCache(
memoryCapacity: 20 * 1024 * 1024,
diskCapacity: 100 * 1024 * 1024,
diskPath: "api_cache"
)
config.httpShouldUsePipelining = true
config.httpMaximumConnectionsPerHost = 6
var headers = HTTPHeaderFields.default
headers.setValue("MyApp/1.0", forName: "User-Agent")
config.httpAdditionalHeaders = headers.dictionary
let session = URLSession(configuration: config)Let's break down what each setting does:
- timeoutIntervalForRequest: If a request takes longer than 15 seconds, cancel it. Good for detecting hung connections.
- timeoutIntervalForResource: Total time for a task (e.g., a large download) before cancellation. Longer than request timeout for bulk operations.
- waitsForConnectivity: Don't fail immediately if the network is slow—wait for the connection to stabilize.
- urlCache: Cache responses in memory and on disk. Useful for images, user lists, and other stable data. Respect HTTP cache headers from your server.
- httpShouldUsePipelining: Send multiple requests on the same connection without waiting for responses. Reduces latency.
- httpMaximumConnectionsPerHost: Limit concurrent connections to your host to avoid overwhelming the server.
If you're using authentication, add it to the configuration or per-request:
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.data(for: request)For APIs that require custom headers on every call, configure them once:
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = [
"Authorization": "Bearer \(token)",
"X-API-Version": "2024-01-01"
]
let session = URLSession(configuration: config)Putting it together
Here's how a SwiftUI view uses the client:
@main
struct TaskApp: App {
let apiClient = APIClient(
baseURL: URL(string: "https://api.example.com")!
)
var body: some Scene {
WindowGroup {
UserDetailView()
.environment(\.apiClient, apiClient)
}
}
}
struct UserDetailView: View {
@Environment(\.apiClient) var apiClient
@State var user: User?
@State var error: String?
@State var isLoading = false
var body: some View {
VStack {
if isLoading {
ProgressView()
} else if let error = error {
Text("Error: \(error)").foregroundColor(.red)
} else if let user = user {
Text("User: \(user.name)")
List(user.tasks) { task in
TaskRow(task: task)
}
}
}
.task {
await loadUser()
}
}
private func loadUser() async {
isLoading = true
defer { isLoading = false }
do {
user = try await apiClient.fetchUser(id: 1)
} catch let error as NetworkError {
self.error = error.errorDescription
} catch {
self.error = "Unknown error"
}
}
}Common mistakes
- Blocking the main thread: Never call URLSession methods synchronously. Always use async/await or completion handlers. If you're forced to block, you have a design problem elsewhere.
- Ignoring HTTP status codes: A 4xx or 5xx response often doesn't throw in URLSession—you get valid data with a bad status code. Always validate before decoding.
- Sharing session configuration incorrectly: Each URLSession instance gets its own configuration snapshot. Changing the original config after creating the session has no effect. Create the session after finalizing configuration.
- Missing error context: Generic "network error" messages frustrate users. Use specific error types so you can show actionable messages (e.g., "Check your internet" for connectivity, "Service unavailable, try again later" for 503).
- No timeout strategy: Leaving timeouts at defaults means slow or stuck requests hang indefinitely. Always set explicit timeouts based on your API's typical response time.
- Over-caching: Not all responses should be cached. A user's balance or order status shouldn't come from cache. Use HTTP cache headers and understand your cache policy.
Key takeaways
- Define Codable structs that match your API's JSON shape, using CodingKeys when field names differ.
- Build a single API client actor with async methods—never scatter URLSession calls throughout your app.
- Always validate HTTP status codes before attempting to decode the response body.
- Use URLSessionConfiguration to set timeouts, caching, headers, and connection limits once, globally.
- Handle errors specifically: distinguish between network issues, HTTP errors, and decoding failures so you can respond appropriately.
- Prefer async/await over completion handlers for cleaner, more maintainable code.






Comments
Loading comments…