Logo for tanaschita.com

Building a reusable API client with URLSession in Swift

Learn how to structure networking code with URLSession, async/await, response validation, and JSON decoding.

02 Aug 2026 · 7 min read

URLSession is the foundation framework for loading data over the network in Swift. We can use it directly, but networking code can quickly become repetitive.

Most endpoints need the same steps: create a request, call URLSession, validate the HTTP response, decode JSON, and handle errors. Instead of repeating this flow for every endpoint, we can move the shared behavior into a small reusable API client.

In this article, we'll build a lightweight APIClient with URLSession and async/await.

Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE

Starting with a request

For very simple GET requests, URLSession provides data(from:), which only needs a URL. In most apps, data(for:) is more useful because it accepts a URLRequest.

With a URLRequest, we can configure details like the HTTP method, headers, timeout, cache policy, or request body:

let url = URL(string: "https://example.com/api/articles")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)

The method returns a tuple containing the response body as Data and a URLResponse. Calling data(for:) also creates and resumes a data task internally, so we don't need to call resume() ourselves.

This already works, but the same pattern usually appears in many places. Let's move it into a reusable client.

Creating an API client

The first version of the client can take a URLRequest, load the data, validate the response, and decode the result.

First, let's define a small error type for response validation:

enum NetworkError: Error {
case invalidResponse
case invalidStatusCode(Int)
}

Next, we can define a lightweight endpoint type. It describes what is specific to one request, while the client owns shared configuration like the base URL and default headers.

enum HTTPMethod: String {
case get = "GET"
case post = "POST"
}
struct Endpoint {
let path: String
let method: HTTPMethod
var headers: [String: String] = [:]
}

Now we can use it inside the client:

struct APIClient {
let session: URLSession
let decoder: JSONDecoder
let baseURL: URL
let defaultHeaders: [String: String]
init(
baseURL: URL,
session: URLSession = .shared,
decoder: JSONDecoder = JSONDecoder(),
defaultHeaders: [String: String] = ["Accept": "application/json"]
) {
self.baseURL = baseURL
self.session = session
self.decoder = decoder
self.defaultHeaders = defaultHeaders
}
func send<Value: Decodable>(
_ request: URLRequest,
decode type: Value.Type = Value.self
) async throws -> Value {
let (data, response) = try await session.data(for: request)
try validate(response)
return try decoder.decode(Value.self, from: data)
}
private func validate(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidStatusCode(httpResponse.statusCode)
}
}
func request(for endpoint: Endpoint) -> URLRequest {
let url = baseURL.appending(path: endpoint.path)
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
let headers = defaultHeaders.merging(endpoint.headers) { _, endpointValue in
endpointValue
}
for (field, value) in headers {
request.setValue(value, forHTTPHeaderField: field)
}
return request
}
}

This gives the app one place for the repeated networking behavior. The client knows the base URL, applies shared headers, validates responses, and decodes the received data.

Why response validation matters

URLSession throws for transport-level errors, such as a failed connection or cancellation. An HTTP error status code is different. A 404 or 500 response is still a server response, so it will not automatically throw just because of the status code. This is why the client checks the HTTPURLResponse before decoding.

Loading decoded models

Now the client can return decoded models instead of raw Data.

For example, we might have an Article type:

struct Article: Decodable {
let id: Int
let title: String
}

Then an endpoint-specific function can create the request and ask the client to send it:

func loadArticles(client: APIClient) async throws -> [Article] {
let endpoint = Endpoint(path: "/api/articles", method: .get)
let request = client.request(for: endpoint)
return try await client.send(request, decode: [Article].self)
}

At the call site, we can use the function from another async context:

do {
let articles = try await loadArticles()
// Update state with articles.
} catch {
// Show an error state.
}

Adding endpoint-specific headers

The default headers are applied by the client. If an endpoint needs additional headers, or needs to override a default value, it can still provide its own headers.

Now loading articles becomes a little more focused:

let client = APIClient(baseURL: URL(string: "https://example.com")!)
let endpoint = Endpoint(
path: "/api/articles",
method: .get
)
let request = client.request(for: endpoint)
let articles = try await client.send(request, decode: [Article].self)

And if one endpoint needs a custom header, we can keep that local to the endpoint:

let endpoint = Endpoint(
path: "/api/profile",
method: .get,
headers: ["Authorization": "Bearer token"]
)

Configuring URLSession

If we need more control, we can use a configured session instead of URLSession.shared:

let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
configuration.requestCachePolicy = .reloadRevalidatingCacheData
let session = URLSession(configuration: configuration)
let client = APIClient(
baseURL: URL(string: "https://example.com")!,
session: session
)

Since the client receives the session in its initializer, we can also pass a custom session in tests.

Cancellation

The async URLSession methods work naturally with Swift concurrency cancellation. If the task waiting for the request is cancelled, the underlying network request can be cancelled too.

For example, SwiftUI cancels work started with the task modifier when the view disappears:

struct ArticlesView: View {
@State private var articles: [Article] = []
var body: some View {
List(articles, id: \.id) { article in
Text(article.title)
}
.task {
do {
articles = try await loadArticles()
} catch is CancellationError {
// The request was cancelled because the result is no longer needed.
} catch {
// Show an error state.
}
}
}
}

Cancellation is not necessarily an error the user needs to see. In many cases, it simply means that the result is no longer needed, for example because the view disappeared or a newer request replaced the old one.

Summary

URLSession gives us the low-level building blocks for networking, but a small API client can make the app code easier to maintain. By moving request sending, response validation, and JSON decoding into one place, each feature can focus on the endpoint it needs.

Sponsorship logo
Preparing for a technical iOS job interview
Preparing for a technical iOS Job Interview with over 300 questions & answers. Covering Swift & Objective-C, SwiftUI & UIKit, Combine, HTTP Networking, iOS File System, Core Data, Concurrency with async/await, Security, Automated Testing, Dependency Management, AI & Machine Learning and more.
LEARN MORE
Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE
Sponsorship logo
Become a sponsor of tanaschita.com
By publishing an article on different iOS topics every week, tanaschita.com is constantly growing in the developer community and may provide a great audience for you as a sponsor.
CLICK TO LEARN MORE

Newsletter

Image of a reading marmot
Subscribe

Like to support my work?

Say hi

Related tags

Articles with related topics

swiftui

swift

concurrency

async/await

ios

Building responsive and reliable search in SwiftUI

Learn how to debounce search input, cancel obsolete requests and prevent stale results using Swift concurrency.

14 Sep 2026 · 6 min read

Latest articles and tips

© 2026 tanaschita.com

Privacy policy

Impressum