Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 027 / 100

Networking in Swift with URLSession: a step-by-step guide

Call REST APIs cleanly.

Expona Admin9 min read

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:

swift
{
  "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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
@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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles