Every non-trivial iOS app talks to a server. Whether you are fetching a feed, submitting a form, uploading a photo, or streaming chat messages, networking is the backbone of your SwiftUI app. With Swift concurrency (async/await) now mature and URLSession fully integrated, 2026 is the best time to build a clean, testable, production-grade networking layer from scratch. This guide covers everything: from your first URLSession.shared.data(for:) call to retry logic with exponential backoff, multipart uploads, WebSockets, and offline-first patterns.
What you will build: A complete, type-safe API client in Swift that handles authentication, pagination, caching, cancellation, error mapping, and retries. Every code snippet compiles. Every pattern is production-tested.
URLSession with Async/Await Basics
Before Swift 5.5, networking meant nesting completion handlers or chaining Combine publishers. Async/await flattens all of that into linear, readable code. Here is the simplest possible network call in modern Swift:
// Simplest async GET request
func fetchData() async throws -> Data {
let url = URL(string: "https://api.example.com/items")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}The await keyword suspends the function until the network response arrives. The trykeyword propagates any error (timeout, DNS failure, TLS error) up the call stack. No callbacks, noDispatchQueue.main.async, no retain cycles. The compiler guarantees you handle the error or propagate it.
To call this from a SwiftUI view, use a .task modifier. This is the recommended approach because SwiftUI automatically cancels the task when the view disappears:
struct ItemListView: View {
@State private var items: [Item] = []
@State private var error: String?
var body: some View {
List(items) { item in
Text(item.name)
}
.overlay {
if items.isEmpty && error == nil {
ProgressView()
}
}
.task {
do {
items = try await APIClient.shared.fetchItems()
} catch {
self.error = error.localizedDescription
}
}
}
}Building a Type-Safe API Client
A single URLSession.shared.data(from:) call is fine for tutorials. In production, you need a centralized client that handles base URLs, headers, encoding, decoding, authentication, and error mapping. Here is the API client pattern I use in every shipping app:
// APIClient.swift
import Foundation
final class APIClient: Sendable {
static let shared = APIClient()
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private let baseURL: URL
init(
baseURL: URL = URL(string: "https://api.example.com/v1")!,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
}
// MARK: - Core request method
func request<T: Decodable>(
endpoint: String,
method: HTTPMethod = .get,
body: (any Encodable)? = nil,
queryItems: [URLQueryItem]? = nil,
headers: [String: String]? = nil
) async throws -> T {
var url = baseURL.appendingPathComponent(endpoint)
// Append query parameters
if let queryItems, !queryItems.isEmpty {
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
components.queryItems = queryItems
url = components.url!
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method.rawValue
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
// Inject auth token if available
if let token = TokenStore.shared.accessToken {
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Merge custom headers
headers?.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) }
// Encode body
if let body {
urlRequest.httpBody = try encoder.encode(body)
}
// Execute request
let (data, response) = try await session.data(for: urlRequest)
// Validate HTTP response
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
// Map status codes to errors
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.from(statusCode: httpResponse.statusCode, data: data)
}
// Decode response
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingFailed(error)
}
}
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}Notice a few design decisions here. The baseURL and session are injected via the initializer, which means you can swap them out in tests. The decoder uses.convertFromSnakeCase so your Swift models use camelCase properties while the JSON uses snake_case keys automatically. The auth token is injected on every request via a centralized TokenStore. And every error path produces a typed APIErrorinstead of a generic Error.
Codable Models: Encoding and Decoding JSON
Swift's Codable protocol is the standard for JSON serialization. Every model your networking layer touches should conform to Codable (which is just Encodable & Decodable). Here is a practical set of models for a typical REST API:
// Models.swift
import Foundation
// MARK: - API response wrapper
struct APIResponse<T: Decodable>: Decodable {
let data: T
let meta: Meta?
struct Meta: Decodable {
let currentPage: Int
let totalPages: Int
let totalCount: Int
}
}
// MARK: - Domain models
struct Item: Codable, Identifiable {
let id: UUID
var name: String
var description: String?
var price: Decimal
var imageUrl: URL?
var category: Category
var tags: [String]
var createdAt: Date
var updatedAt: Date
enum Category: String, Codable {
case electronics, clothing, books, food, other
}
}
// MARK: - Request body for creating an item
struct CreateItemRequest: Encodable {
let name: String
let description: String?
let price: Decimal
let category: Item.Category
let tags: [String]
}Custom CodingKeys for Non-Standard JSON
Not every API returns clean, predictable JSON. When keys do not match Swift conventions or when the server returns abbreviated field names, use custom CodingKeys:
struct User: Codable, Identifiable {
let id: Int
var fullName: String
var emailAddress: String
var avatarUrl: URL?
var isPremium: Bool
var registrationDate: Date
enum CodingKeys: String, CodingKey {
case id
case fullName = "full_name"
case emailAddress = "email" // API returns "email", not "email_address"
case avatarUrl = "avatar" // API returns "avatar", not "avatar_url"
case isPremium = "is_pro" // API uses "is_pro", we prefer "isPremium"
case registrationDate = "reg_date" // Abbreviated server-side key
}
}
// Nested JSON with custom decoding:
struct ServerTimestamp: Decodable {
let date: Date
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let timestamp = try container.decode(Double.self)
date = Date(timeIntervalSince1970: timestamp)
}
}Pro tip: If you set keyDecodingStrategy = .convertFromSnakeCase on your decoder (as we did in the API client above), you do not need CodingKeys for standardsnake_case to camelCase conversions. Only use custom CodingKeyswhen the mapping is non-obvious.
Error Handling: Custom Error Types and HTTP Status Codes
A generic catch { print(error) } is not good enough for production. Your networking layer should produce typed, actionable errors so your UI can display the right message and your analytics can track failure modes. Here is the error type I use:
// APIError.swift
import Foundation
enum APIError: LocalizedError {
case invalidResponse
case decodingFailed(Error)
case unauthorized // 401
case forbidden // 403
case notFound // 404
case conflict // 409
case rateLimited(retryAfter: Int?) // 429
case serverError(Int) // 500-599
case clientError(Int, String?) // 400, 422, etc.
case noInternet
case timeout
static func from(statusCode: Int, data: Data) -> APIError {
let message = try? JSONDecoder().decode(ErrorBody.self, from: data).message
switch statusCode {
case 401: return .unauthorized
case 403: return .forbidden
case 404: return .notFound
case 409: return .conflict
case 429: return .rateLimited(retryAfter: nil)
case 400, 422: return .clientError(statusCode, message)
case 500...599: return .serverError(statusCode)
default: return .clientError(statusCode, message)
}
}
var errorDescription: String? {
switch self {
case .invalidResponse: return "Invalid server response."
case .decodingFailed: return "Failed to parse server data."
case .unauthorized: return "Session expired. Please sign in again."
case .forbidden: return "You do not have permission."
case .notFound: return "The requested resource was not found."
case .conflict: return "A conflict occurred. Try again."
case .rateLimited: return "Too many requests. Please wait."
case .serverError(let code): return "Server error (\(code)). Try again later."
case .clientError(_, let msg): return msg ?? "Something went wrong."
case .noInternet: return "No internet connection."
case .timeout: return "Request timed out."
}
}
}
struct ErrorBody: Decodable {
let message: String?
}HTTP Status Codes Reference
Here is a quick reference of HTTP status codes and what they mean for your iOS app. Bookmark this table:
| Code | Name | What It Means for Your App |
|---|---|---|
| 200 | OK | Request succeeded. Parse the response body. |
| 201 | Created | Resource created successfully (POST). Body usually contains the new object. |
| 204 | No Content | Success but no body (DELETE, some PUTs). Do not try to decode. |
| 400 | Bad Request | Validation error. Show the server message to the user. |
| 401 | Unauthorized | Token expired or missing. Refresh the token or redirect to login. |
| 403 | Forbidden | Authenticated but no permission. Show "access denied" UI. |
| 404 | Not Found | Resource does not exist. Remove from local cache or show empty state. |
| 409 | Conflict | Duplicate or stale data. Refetch and retry. |
| 422 | Unprocessable Entity | Semantic validation error. Parse field-level errors and highlight in the form. |
| 429 | Too Many Requests | Rate limited. Read Retry-After header and back off. |
| 500 | Internal Server Error | Server crashed. Show generic error. Retry with backoff. |
| 502 | Bad Gateway | Upstream failure. Transient. Retry after a short delay. |
| 503 | Service Unavailable | Server overloaded or in maintenance. Show maintenance banner. |
| 504 | Gateway Timeout | Upstream timeout. Retry with longer timeout or show error. |
Request Interceptors and Auth Token Injection
Most APIs require authentication. Instead of manually adding the Authorization header to every request, centralize token management in a store and inject it automatically. Here is a complete token store with automatic refresh:
// TokenStore.swift
import Foundation
import Security
actor TokenStore {
static let shared = TokenStore()
private(set) var accessToken: String?
private(set) var refreshToken: String?
private var isRefreshing = false
private var refreshContinuations: [CheckedContinuation<String, Error>] = []
func setTokens(access: String, refresh: String) {
self.accessToken = access
self.refreshToken = refresh
// Persist to Keychain in production
KeychainHelper.save(key: "access_token", value: access)
KeychainHelper.save(key: "refresh_token", value: refresh)
}
func clearTokens() {
accessToken = nil
refreshToken = nil
KeychainHelper.delete(key: "access_token")
KeychainHelper.delete(key: "refresh_token")
}
func loadFromKeychain() {
accessToken = KeychainHelper.load(key: "access_token")
refreshToken = KeychainHelper.load(key: "refresh_token")
}
/// Refresh the access token. Coalesces multiple concurrent calls.
func refreshAccessToken() async throws -> String {
if isRefreshing {
return try await withCheckedThrowingContinuation { continuation in
refreshContinuations.append(continuation)
}
}
isRefreshing = true
do {
guard let refreshToken else { throw APIError.unauthorized }
// Call your auth endpoint to get a new access token
var request = URLRequest(url: URL(string: "https://api.example.com/v1/auth/refresh")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["refresh_token": refreshToken])
let (data, _) = try await URLSession.shared.data(for: request)
let tokens = try JSONDecoder().decode(TokenResponse.self, from: data)
setTokens(access: tokens.accessToken, refresh: tokens.refreshToken)
isRefreshing = false
// Resume all waiting callers
for continuation in refreshContinuations {
continuation.resume(returning: tokens.accessToken)
}
refreshContinuations.removeAll()
return tokens.accessToken
} catch {
isRefreshing = false
for continuation in refreshContinuations {
continuation.resume(throwing: error)
}
refreshContinuations.removeAll()
throw error
}
}
}
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String
}The key pattern here is coalescing concurrent refresh calls. If five requests fail with a 401 at the same time, you do not want five separate token refreshes. The isRefreshing flag and continuation array ensure only one refresh runs, and all waiting callers get the new token.
Pagination: Cursor-Based and Offset-Based
Any list endpoint needs pagination. The two dominant patterns are offset-based (page number + size) and cursor-based (pass the last item's ID to get the next batch). Cursor-based is better for feeds because inserts do not shift the page window, but offset is simpler and works well for static data.
Offset-Based Pagination
// PaginatedList.swift — Offset-based
@Observable
final class PaginatedItemList {
private(set) var items: [Item] = []
private(set) var isLoading = false
private(set) var hasMore = true
private var currentPage = 1
private let pageSize = 20
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
do {
let response: APIResponse<[Item]> = try await APIClient.shared.request(
endpoint: "items",
queryItems: [
URLQueryItem(name: "page", value: "\(currentPage)"),
URLQueryItem(name: "per_page", value: "\(pageSize)")
]
)
items.append(contentsOf: response.data)
hasMore = currentPage < (response.meta?.totalPages ?? 1)
currentPage += 1
} catch {
// Handle error — show toast, log to analytics
}
isLoading = false
}
func refresh() async {
items.removeAll()
currentPage = 1
hasMore = true
await loadNextPage()
}
}Cursor-Based Pagination
// CursorPaginatedList.swift
@Observable
final class CursorPaginatedFeed {
private(set) var items: [Item] = []
private(set) var isLoading = false
private(set) var hasMore = true
private var cursor: String? = nil
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
do {
var queryItems = [URLQueryItem(name: "limit", value: "20")]
if let cursor {
queryItems.append(URLQueryItem(name: "after", value: cursor))
}
let response: CursorResponse<[Item]> = try await APIClient.shared.request(
endpoint: "feed",
queryItems: queryItems
)
items.append(contentsOf: response.data)
cursor = response.nextCursor
hasMore = response.nextCursor != nil
} catch {
// Handle error
}
isLoading = false
}
}
struct CursorResponse<T: Decodable>: Decodable {
let data: T
let nextCursor: String?
}To wire either into a SwiftUI List, use the .onAppear modifier on the last item to trigger the next page load:
List(viewModel.items) { item in
ItemRow(item: item)
.onAppear {
if item.id == viewModel.items.last?.id {
Task { await viewModel.loadNextPage() }
}
}
}Image Downloading and Caching
SwiftUI's built-in AsyncImage works for simple cases, but it has no disk caching and reloads images when the view rebuilds. For production apps, you need a proper caching layer. Here is a lightweight solution using NSCache and the file system:
// ImageCache.swift
import SwiftUI
actor ImageCache {
static let shared = ImageCache()
private let memoryCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let cacheDirectory: URL
init() {
let paths = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
cacheDirectory = paths[0].appendingPathComponent("ImageCache")
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
memoryCache.countLimit = 100
}
func image(for url: URL) async throws -> UIImage {
let key = url.absoluteString as NSString
// 1. Check memory cache
if let cached = memoryCache.object(forKey: key) {
return cached
}
// 2. Check disk cache
let filePath = cacheDirectory.appendingPathComponent(url.absoluteString.hashValue.description)
if let data = try? Data(contentsOf: filePath),
let image = UIImage(data: data) {
memoryCache.setObject(image, forKey: key)
return image
}
// 3. Download from network
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageCacheError.invalidImageData
}
// Store in both caches
memoryCache.setObject(image, forKey: key)
try? data.write(to: filePath)
return image
}
}
enum ImageCacheError: Error {
case invalidImageData
}
// SwiftUI view that uses the cache:
struct CachedAsyncImage: View {
let url: URL?
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Rectangle()
.fill(Color.white.opacity(0.05))
.overlay(ProgressView())
}
}
.task(id: url) {
guard let url else { return }
image = try? await ImageCache.shared.image(for: url)
}
}
}Request Cancellation with Task
Swift concurrency supports cooperative cancellation. When a user navigates away or types a new search query, you should cancel in-flight requests to avoid wasted bandwidth and stale data overwrites. The.task modifier handles this automatically for view lifecycle, but for search-as-you-type, you need manual control:
@Observable
final class SearchViewModel {
var query: String = "" {
didSet { debounceSearch() }
}
private(set) var results: [Item] = []
private(set) var isSearching = false
private var searchTask: Task<Void, Never>?
private func debounceSearch() {
// Cancel previous in-flight search
searchTask?.cancel()
searchTask = Task {
// Debounce: wait 300ms before firing
try? await Task.sleep(for: .milliseconds(300))
// Check if this task was cancelled during the sleep
guard !Task.isCancelled else { return }
guard !query.isEmpty else {
results = []
return
}
isSearching = true
do {
let response: APIResponse<[Item]> = try await APIClient.shared.request(
endpoint: "search",
queryItems: [URLQueryItem(name: "q", value: query)]
)
// Final cancellation check before updating UI
guard !Task.isCancelled else { return }
results = response.data
} catch is CancellationError {
// Expected — do nothing
} catch {
// Handle real error
}
isSearching = false
}
}
}The pattern is: store a reference to the Task, cancel the previous one before starting a new one, and check Task.isCancelled before updating state. This prevents a slow first request from overwriting the results of a fast second request.
Retry Logic with Exponential Backoff
Network requests fail. Servers return 500s. Connections drop. A production networking layer retries transient failures automatically. Here is a generic retry function with exponential backoff and jitter:
// Retry.swift
func withRetry<T>(
maxAttempts: Int = 3,
initialDelay: Duration = .milliseconds(500),
maxDelay: Duration = .seconds(10),
retryableError: (Error) -> Bool = { error in
if let apiError = error as? APIError {
switch apiError {
case .serverError, .rateLimited, .timeout:
return true
default:
return false
}
}
return (error as? URLError)?.code == .timedOut
},
operation: () async throws -> T
) async throws -> T {
var lastError: Error?
for attempt in 0..<maxAttempts {
do {
return try await operation()
} catch {
lastError = error
guard retryableError(error), attempt < maxAttempts - 1 else {
throw error
}
// Exponential backoff with jitter
let baseDelay = initialDelay * Int(pow(2.0, Double(attempt)))
let cappedDelay = min(baseDelay, maxDelay)
let jitter = Duration.milliseconds(Int.random(in: 0...500))
let totalDelay = cappedDelay + jitter
try await Task.sleep(for: totalDelay)
}
}
throw lastError ?? APIError.invalidResponse
}
// Usage:
let items: [Item] = try await withRetry {
try await APIClient.shared.request(endpoint: "items")
}The jitter prevents a thundering herd problem: if your server goes down and 10,000 clients all retry at exactly the same intervals, the server gets slammed the moment it recovers. Random jitter spreads the retries out.
Practical Examples
Fetching a List of Items (GET)
// Convenience method on APIClient
extension APIClient {
func fetchItems(page: Int = 1, perPage: Int = 20) async throws -> APIResponse<[Item]> {
try await request(
endpoint: "items",
queryItems: [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "per_page", value: "\(perPage)")
]
)
}
}POST with JSON Body
extension APIClient {
func createItem(_ item: CreateItemRequest) async throws -> Item {
try await request(
endpoint: "items",
method: .post,
body: item
)
}
}
// Calling it:
let newItem = try await APIClient.shared.createItem(
CreateItemRequest(
name: "Wireless Charger",
description: "Fast 15W MagSafe compatible",
price: 29.99,
category: .electronics,
tags: ["charger", "magsafe", "wireless"]
)
)Authenticated Request (Bearer Token)
extension APIClient {
func fetchCurrentUser() async throws -> User {
// Token is injected automatically via the core request method
try await request(endpoint: "users/me")
}
func updateProfile(name: String, bio: String) async throws -> User {
try await request(
endpoint: "users/me",
method: .patch,
body: ["name": name, "bio": bio]
)
}
}Multipart Form Upload
File uploads require multipart/form-data encoding. URLSession does not provide a built-in multipart encoder, so you need to construct the body manually. Here is a clean, reusable implementation:
// MultipartUpload.swift
import Foundation
import UIKit
struct MultipartFormData {
private let boundary = UUID().uuidString
private var body = Data()
var contentType: String {
"multipart/form-data; boundary=\(boundary)"
}
mutating func addField(name: String, value: String) {
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
body.append("\(value)\r\n")
}
mutating func addFile(
name: String,
filename: String,
mimeType: String,
data: Data
) {
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n")
body.append("Content-Type: \(mimeType)\r\n\r\n")
body.append(data)
body.append("\r\n")
}
var finalized: Data {
var result = body
result.append("--\(boundary)--\r\n")
return result
}
}
extension Data {
mutating func append(_ string: String) {
if let data = string.data(using: .utf8) {
append(data)
}
}
}
// Usage: Upload an image with metadata
extension APIClient {
func uploadImage(
image: UIImage,
title: String,
description: String
) async throws -> Item {
guard let imageData = image.jpegData(compressionQuality: 0.8) else {
throw APIError.clientError(400, "Could not encode image")
}
var form = MultipartFormData()
form.addField(name: "title", value: title)
form.addField(name: "description", value: description)
form.addFile(
name: "image",
filename: "photo.jpg",
mimeType: "image/jpeg",
data: imageData
)
let url = baseURL.appendingPathComponent("items/upload")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
if let token = await TokenStore.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.httpBody = form.finalized
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw APIError.invalidResponse
}
return try decoder.decode(Item.self, from: data)
}
}WebSocket Basics with URLSessionWebSocketTask
For real-time features like chat, live notifications, or collaborative editing, WebSockets provide a persistent bidirectional connection. URLSession has native WebSocket support since iOS 13:
// WebSocketService.swift
import Foundation
actor WebSocketService {
private var webSocketTask: URLSessionWebSocketTask?
private let session = URLSession(configuration: .default)
func connect(url: URL) {
webSocketTask = session.webSocketTask(with: url)
webSocketTask?.resume()
listenForMessages()
}
func send(text: String) async throws {
guard let task = webSocketTask else { return }
try await task.send(.string(text))
}
func send(data: Data) async throws {
guard let task = webSocketTask else { return }
try await task.send(.data(data))
}
func disconnect() {
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
}
private func listenForMessages() {
Task {
guard let task = webSocketTask else { return }
do {
let message = try await task.receive()
switch message {
case .string(let text):
await handleTextMessage(text)
case .data(let data):
await handleDataMessage(data)
@unknown default:
break
}
// Continue listening
listenForMessages()
} catch {
// Connection closed or error
await handleDisconnect(error: error)
}
}
}
private func handleTextMessage(_ text: String) async {
// Parse and dispatch to observers
}
private func handleDataMessage(_ data: Data) async {
// Decode binary message
}
private func handleDisconnect(error: Error) async {
// Implement reconnection logic with backoff
}
}Network Monitoring with NWPathMonitor
Your app should know whether the device has a network connection before making requests.NWPathMonitor from the Network framework provides real-time connectivity status:
// NetworkMonitor.swift
import Network
import Foundation
@Observable
final class NetworkMonitor {
static let shared = NetworkMonitor()
private(set) var isConnected = true
private(set) var connectionType: ConnectionType = .unknown
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
enum ConnectionType {
case wifi, cellular, wiredEthernet, unknown
}
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
self?.connectionType = self?.getConnectionType(path) ?? .unknown
}
}
monitor.start(queue: queue)
}
private func getConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
return .unknown
}
}
// Use in SwiftUI:
struct ContentView: View {
var network = NetworkMonitor.shared
var body: some View {
VStack {
if !network.isConnected {
HStack {
Image(systemName: "wifi.slash")
Text("No internet connection")
}
.padding()
.background(.red.opacity(0.2))
.cornerRadius(8)
}
// ... rest of your UI
}
}
}Offline-First Pattern
An offline-first architecture shows cached data immediately and syncs with the server in the background. This dramatically improves perceived performance and handles flaky connections gracefully:
// OfflineFirstRepository.swift
import Foundation
protocol LocalStore {
func loadItems() async throws -> [Item]
func saveItems(_ items: [Item]) async throws
}
@Observable
final class OfflineFirstRepository {
private(set) var items: [Item] = []
private(set) var isRefreshing = false
private(set) var lastSynced: Date?
private let api = APIClient.shared
private let localStore: LocalStore
private let network = NetworkMonitor.shared
init(localStore: LocalStore) {
self.localStore = localStore
}
/// Load from cache first, then refresh from network
func load() async {
// 1. Show cached data immediately
if let cached = try? await localStore.loadItems() {
items = cached
}
// 2. Refresh from network if connected
guard network.isConnected else { return }
isRefreshing = true
do {
let response: APIResponse<[Item]> = try await api.request(endpoint: "items")
items = response.data
try? await localStore.saveItems(response.data)
lastSynced = Date()
} catch {
// Cached data is still showing — log error silently
}
isRefreshing = false
}
}Testing Network Code with URLProtocol
You should never hit real APIs in unit tests. URLProtocol lets you intercept and mock any URLSession request. Here is a reusable mock protocol and a test example:
// MockURLProtocol.swift
import Foundation
final class MockURLProtocol: URLProtocol {
static var mockResponses: [URL: (Data, HTTPURLResponse)] = [:]
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let url = request.url,
let (data, response) = Self.mockResponses[url] else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
// Test example:
import XCTest
final class APIClientTests: XCTestCase {
var sut: APIClient!
override func setUp() {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
sut = APIClient(
baseURL: URL(string: "https://api.test.com/v1")!,
session: session
)
}
func testFetchItemsDecodesCorrectly() async throws {
// Arrange
let mockItems = [Item.mock()]
let jsonData = try JSONEncoder().encode(
APIResponse(data: mockItems, meta: nil)
)
let url = URL(string: "https://api.test.com/v1/items?page=1&per_page=20")!
MockURLProtocol.mockResponses[url] = (
jsonData,
HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
// Act
let response: APIResponse<[Item]> = try await sut.request(
endpoint: "items",
queryItems: [
URLQueryItem(name: "page", value: "1"),
URLQueryItem(name: "per_page", value: "20")
]
)
// Assert
XCTAssertEqual(response.data.count, 1)
XCTAssertEqual(response.data.first?.name, mockItems.first?.name)
}
}Networking Approaches Compared: URLSession vs Alamofire vs Moya
Should you use raw URLSession or a third-party library? Here is an honest comparison based on shipping real apps with all three:
| Criteria | URLSession (Native) | Alamofire | Moya |
|---|---|---|---|
| Dependencies | Zero | 1 package | 2 packages (Moya + Alamofire) |
| Async/Await | Native support | Full support since v5.5+ | Supported via Alamofire |
| Learning Curve | Medium (boilerplate) | Low (ergonomic API) | Medium (enum-based targets) |
| Multipart Upload | Manual encoding | Built-in | Built-in via Alamofire |
| Request Retries | Manual | Built-in RequestInterceptor | Built-in via Alamofire |
| Certificate Pinning | URLSessionDelegate | ServerTrustManager | Via Alamofire |
| Testability | URLProtocol mocking | URLProtocol mocking | Stub providers built-in |
| Binary Size Impact | 0 KB | ~500 KB | ~700 KB |
| Type-Safe Routes | DIY | DIY | Enum-based TargetType |
| Best For | Apps wanting zero deps, full control | Teams wanting ergonomics with maturity | Large apps needing strict API contracts |
My recommendation for 2026: Use raw URLSession with the API client pattern shown in this guide. Async/await has eliminated most of the boilerplate that made Alamofire attractive in the first place. You get zero dependencies, full control over request/response handling, and no upgrade headaches when Apple changes URLSession APIs. If your team has more than three developers or your API surface is enormous (50+ endpoints), Moya's enum-based routing adds valuable structure.
Putting It All Together: Complete Working Example
Here is a fully wired example that uses the API client, pagination, error handling, and cancellation together in a SwiftUI view with a ViewModel:
// ItemsViewModel.swift
import Foundation
@Observable
final class ItemsViewModel {
private(set) var items: [Item] = []
private(set) var isLoading = false
private(set) var error: APIError?
private(set) var hasMore = true
private var currentPage = 1
func loadItems() async {
guard !isLoading else { return }
isLoading = true
error = nil
do {
let response: APIResponse<[Item]> = try await withRetry {
try await APIClient.shared.fetchItems(page: 1)
}
items = response.data
hasMore = (response.meta?.totalPages ?? 1) > 1
currentPage = 2
} catch let err as APIError {
error = err
} catch {
self.error = .clientError(0, error.localizedDescription)
}
isLoading = false
}
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
do {
let response: APIResponse<[Item]> = try await APIClient.shared.fetchItems(
page: currentPage
)
items.append(contentsOf: response.data)
hasMore = currentPage < (response.meta?.totalPages ?? 1)
currentPage += 1
} catch {
// Silently fail for pagination — data is still on screen
}
isLoading = false
}
}
// ItemsScreen.swift
import SwiftUI
struct ItemsScreen: View {
@State private var viewModel = ItemsViewModel()
var body: some View {
NavigationStack {
Group {
if let error = viewModel.error, viewModel.items.isEmpty {
errorView(error)
} else {
itemList
}
}
.navigationTitle("Items")
.task {
await viewModel.loadItems()
}
.refreshable {
await viewModel.loadItems()
}
}
}
private var itemList: some View {
List {
ForEach(viewModel.items) { item in
VStack(alignment: .leading, spacing: 4) {
Text(item.name).font(.headline)
if let desc = item.description {
Text(desc)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(item.price, format: .currency(code: "USD"))
.font(.caption)
.foregroundStyle(.green)
}
.onAppear {
if item.id == viewModel.items.last?.id {
Task { await viewModel.loadNextPage() }
}
}
}
if viewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
}
private func errorView(_ error: APIError) -> some View {
ContentUnavailableView {
Label("Something Went Wrong", systemImage: "wifi.exclamationmark")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Try Again") {
Task { await viewModel.loadItems() }
}
.buttonStyle(.borderedProminent)
}
}
}Tips and Common Gotchas
- Always decode on a background thread.
URLSession.shared.data(for:)returns on a background thread by default. If you useJSONDecoderright after, decoding also runs off the main thread. Only switch to@MainActorwhen you assign the result to a published property. - Use
.taskinstead of.onAppearfor async work. The.taskmodifier ties the async work to the view lifecycle and cancels it automatically. With.onAppear, you need to manually cancel in.onDisappear. - Do not ignore
CancellationError. When a task is cancelled, alltry awaitcalls throwCancellationError. Catch it explicitly and do not show it to the user as an error. - Set timeouts on URLRequest, not URLSession. Setting
timeoutIntervalon the request gives you per-request control. Setting it on the session configuration applies globally, which is rarely what you want. - Never force-unwrap URLs from string literals in production code. Use a
URL(string:)wrapper that throws or fatalErrors with a clear message during development. - Handle 204 No Content responses. Some endpoints (DELETE, certain PUTs) return 204 with an empty body. If you try to decode an empty body,
JSONDecoderwill throw. Create a separaterequestVoidmethod that does not decode the response. - Use
keyDecodingStrategy = .convertFromSnakeCaseglobally. Set it once on your decoder and forget about it. Only use customCodingKeyswhen the server key names are truly non-standard (abbreviated, different naming entirely). - Log network requests in DEBUG builds only. Use
#if DEBUGto print request URLs, response codes, and timing. Strip all logging in release builds to avoid leaking endpoints or tokens into device logs.
Skip the Boilerplate — Use The Swift Kit
Everything in this guide — the type-safe API client, token management, retry logic, image caching, error mapping, pagination patterns — is already built, tested, and production-ready in The Swift Kit. The networking layer comes pre-wired with Supabase integration for auth, database, and storage, plus a clean MVVM architecture that makes every service injectable and testable.
You also get streaming support for AI chat features with ChatGPT, offline caching, and a complete set of request interceptors for authentication. Instead of spending a week building and debugging your networking layer, paste your API base URL into the config file and start building the features your users actually care about.
Check out the full feature list or see pricing to get started. One-time purchase. Lifetime updates. Zero recurring fees.