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.

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 invalidResponsecase 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: Stringlet method: HTTPMethodvar headers: [String: String] = [:]}
Now we can use it inside the client:
struct APIClient {let session: URLSessionlet decoder: JSONDecoderlet baseURL: URLlet defaultHeaders: [String: String]init(baseURL: URL,session: URLSession = .shared,decoder: JSONDecoder = JSONDecoder(),defaultHeaders: [String: String] = ["Accept": "application/json"]) {self.baseURL = baseURLself.session = sessionself.decoder = decoderself.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.rawValuelet headers = defaultHeaders.merging(endpoint.headers) { _, endpointValue inendpointValue}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: Intlet 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.defaultconfiguration.timeoutIntervalForRequest = 30configuration.timeoutIntervalForResource = 60configuration.requestCachePolicy = .reloadRevalidatingCacheDatalet 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 inText(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.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



