Making network requests is one of those things every iOS developer needs to master. Whether you're fetching user data, posting to an API, or downloading images, you'll spend a lot of time writing networking code. URLSession is Apple's built-in solution, and with modern async/await syntax, it's gotten a lot better than it used to be.
I've been writing Swift for years, and I've seen the networking landscape evolve from completion handlers to Combine to async/await. The good news? You don't need third-party libraries for most networking tasks anymore. URLSession has everything you need, and it's more powerful than many developers realize.
What you'll learn
In this guide, I'll walk you through making network requests in Swift using URLSession and async/await. We'll cover basic GET and POST requests, error handling, concurrent requests, and some techniques that'll make your networking code more robust. By the end, you'll know when to reach for URLSession versus when you might want a library like Alamofire.
Why URLSession matters
URLSession is part of the Foundation framework, which means it's already in your project—no dependencies to manage, no external libraries to update. It handles all the core networking tasks: data requests, file uploads and downloads, background transfers, and WebSocket communication.
The async/await syntax introduced in Swift 5.5 transformed how we write networking code. Gone are the messy completion handlers and deeply nested callbacks. Now you can write asynchronous code that looks synchronous, making it easier to read and maintain.
Here's the thing though: while URLSession is powerful, it does require you to understand how it works. You're working at a lower level than frameworks like Alamofire, which means more control but also more responsibility.
Making your first GET request
Let's start with the basics. A GET request fetches data from a server. Here's the simplest possible example using async/await:
func fetchUserData() async throws -> Data {
let url = URL(string: "https://api.example.com/user")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
That's it. Three lines of actual code. The data(from:)
method handles the entire request lifecycle and returns a tuple containing the response data and metadata.
But wait—there's a problem with this approach. We're ignoring the response entirely. In real applications, you need to validate that the server actually returned success. Here's a better version:
func fetchUserData() async throws -> Data {
let url = URL(string: "https://api.example.com/user")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
return data
}
Now we're checking that the response is an HTTPURLResponse (it almost always will be for HTTP requests) and that the status code is in the 200-299 success range. If not, we throw a custom error.
The reason we validate the status code is important: URLSession won't throw an error for unsuccessful HTTP status codes like 404 or 500. It only throws errors for lower-level networking failures—DNS resolution issues, timeout, no internet connection, etc. Status code validation is your job.
Decoding JSON responses
Getting raw Data back isn't useful by itself. You need to decode it into Swift types. This is where Codable shines:
struct User: Codable {
let id: Int
let name: String
let email: String
}
func fetchUser() async throws -> User {
let url = URL(string: "https://api.example.com/user")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: data)
return user
}
This pattern—fetch, validate, decode—is what you'll use constantly. The nice thing about async/await is that errors propagate automatically. If decoding fails, the function throws without extra error handling code.
One trick I use: if your API uses snake_case (like user_name
) but you want camelCase in Swift (like userName
), configure the decoder:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)
This automatically converts keys between the two formats without manual mapping.
Making POST requests
POST requests are more involved because you need to configure the request with a body and headers. Here's how to post JSON data:
struct CreateUserRequest: Codable {
let name: String
let email: String
}
func createUser(name: String, email: String) async throws -> User {
let url = URL(string: "https://api.example.com/users")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody = CreateUserRequest(name: name, email: email)
request.httpBody = try JSONEncoder().encode(requestBody)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
let decoder = JSONDecoder()
return try decoder.decode(User.self, from: data)
}
Notice we're using data(for:)
instead of data(from:)
because we're passing a configured URLRequest rather than just a URL.
The pattern is: create a URLRequest, configure it (method, headers, body), encode your parameters as JSON, make the request, validate, decode. You'll write this hundreds of times.
Building a reusable network client
Writing all that boilerplate for every request gets old fast. Here's a cleaner approach—a simple network client that handles common tasks:
enum NetworkError: Error {
case invalidURL
case invalidResponse
case httpError(Int)
case decodingError
}
class NetworkClient {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func request<T: Decodable>(
_ url: String,
method: String = "GET",
body: Data? = nil
) async throws -> T {
guard let url = URL(string: url) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.httpBody = body
if body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError
}
}
}
Now making requests becomes much cleaner:
let client = NetworkClient()
// GET request
let user: User = try await client.request("https://api.example.com/user")
// POST request
let requestBody = CreateUserRequest(name: "Jane", email: "jane@example.com")
let bodyData = try JSONEncoder().encode(requestBody)
let newUser: User = try await client.request(
"https://api.example.com/users",
method: "POST",
body: bodyData
)
This is still pretty basic, but it eliminates repetition and gives you a single place to add authentication headers, retry logic, or logging.
Handling authentication
Most APIs require authentication. Bearer tokens are common:
class AuthenticatedNetworkClient {
private let session: URLSession
private var token: String?
init(session: URLSession = .shared) {
self.session = session
}
func setAuthToken(_ token: String) {
self.token = token
}
func request<T: Decodable>(
_ url: String,
method: String = "GET",
body: Data? = nil
) async throws -> T {
guard let url = URL(string: url) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.httpBody = body
if let token = token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
}
}
The key is storing the token and adding it to every request's Authorization header. In production apps, you'd store tokens securely in the Keychain and handle token refresh when they expire.
Running multiple requests concurrently
Here's where async/await really shines. Say you need to fetch multiple resources at once. With completion handlers, this was painful. With async/await, it's straightforward:
func fetchUserAndPosts() async throws -> (User, [Post]) {
async let user = fetchUser()
async let posts = fetchUserPosts()
return try await (user, posts)
}
The async let
syntax starts both requests concurrently. The await
waits for both to complete. This is much faster than sequential requests and cleaner than juggling multiple completion handlers.
For a dynamic number of requests, use task groups:
func fetchMultipleUsers(ids: [Int]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await self.fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}
Task groups let you spawn multiple concurrent tasks and collect their results. This is perfect for batch operations like fetching data for multiple IDs.
Custom URLSession configurations
URLSession.shared is convenient but limited. For more control, create a custom session:
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: configuration)
The configuration lets you set timeouts, caching policies, whether to wait for connectivity, and more. This is useful when you need different behavior than the defaults.
For background downloads (files that download even when your app isn't running), use a background configuration:
let configuration = URLSessionConfiguration.background(
withIdentifier: "com.yourapp.background"
)
let session = URLSession(
configuration: configuration,
delegate: self,
delegateQueue: nil
)
Background sessions are more complex because they require delegate methods, but they're essential for large file downloads.
Uploading and downloading files
Uploading files uses upload(for:from:)
:
func uploadImage(_ imageData: Data) async throws {
let url = URL(string: "https://api.example.com/upload")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.upload(for: request, from: imageData)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
}
For downloads, especially large files, use download tasks which write directly to disk:
func downloadFile(from urlString: String) async throws -> URL {
let url = URL(string: urlString)!
let (localURL, response) = try await URLSession.shared.download(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.invalidResponse
}
// Move file to permanent location
let documentsURL = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0]
let destinationURL = documentsURL.appendingPathComponent(url.lastPathComponent)
try FileManager.default.moveItem(at: localURL, to: destinationURL)
return destinationURL
}
The download method returns a temporary URL that gets deleted after the function returns, so you need to move the file to a permanent location.
URLSession vs Alamofire: when to use each
You might be wondering: should I just use Alamofire? It depends.
Use URLSession when:
- You want zero external dependencies
- You need low-level control over requests
- Your networking needs are straightforward
- You're comfortable with the URLSession API
Consider Alamofire when:
- You need advanced features like automatic retries, request chaining, or sophisticated parameter encoding
- You want cleaner syntax out of the box
- Your team is more productive with Alamofire's API
- You're doing complex multipart uploads
The truth is, with modern Swift and async/await, URLSession is perfectly capable for most apps. Alamofire was a lifesaver in the NSURLConnection days, but URLSession has caught up. I've built production apps with both, and for most projects, URLSession is enough.
That said, Alamofire isn't dead. It's still maintained, supports async/await, and adds convenience. If your project already uses it or your team loves it, there's no rush to rip it out. But if you're starting fresh, give URLSession a serious look before adding a dependency.
Common pitfalls and how to avoid them
Not validating status codes: URLSession doesn't throw errors for HTTP error status codes. Always check httpResponse.statusCode
.
Blocking the main thread: Never make synchronous network calls. Always use async/await or completion handlers. Blocking the main thread freezes your UI.
Ignoring errors: Handle networking errors gracefully. Networks fail. APIs change. Users lose connectivity. Show appropriate error messages.
Not testing with poor connections: Use Network Link Conditioner on macOS or the Developer menu on iOS to simulate slow networks. Your app should handle timeouts and retries.
Leaking memory with strong self: If you're still using completion handlers, remember [weak self]
in closures to avoid retain cycles.
Debugging network requests
When requests fail, you need to know why. Here's how to debug:
Enable verbose logging:
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [/* logging protocol */]
Use breakpoints on response validation to inspect the actual HTTP status code and headers.
Check with a network debugging tool like Charles Proxy or Proxyman to see the raw HTTP traffic. This catches issues with headers, request bodies, or API responses that aren't what you expect.
Print the raw response:
let (data, _) = try await URLSession.shared.data(from: url)
if let responseString = String(data: data, encoding: .utf8) {
print("Response: \(responseString)")
}
This helps when the API returns error messages in the body that you're not decoding.
Performance tips
Reuse URLSession instances: Creating new sessions is expensive. Create one and reuse it.
Use connection pooling: URLSession automatically reuses connections. Don't disable this unless you have a specific reason.
Implement caching: For data that doesn't change often, configure cache policies:
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .returnCacheDataElseLoad
configuration.urlCache = URLCache(
memoryCapacity: 10_000_000, // 10 MB memory
diskCapacity: 100_000_000 // 100 MB disk
)
Cancel unnecessary requests: When users navigate away, cancel in-flight requests:
let task = Task {
let user = try await fetchUser()
// use user
}
// Later, if needed:
task.cancel()
Batch requests when possible: Instead of making 10 separate requests, see if the API supports batch endpoints that return multiple resources in one call.
Error handling strategies
Good error handling separates okay apps from great ones. Here's a robust approach:
enum NetworkError: Error {
case invalidURL
case noData
case decodingError(Error)
case httpError(statusCode: Int, data: Data?)
case networkFailure(Error)
}
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL:
return "The URL is invalid"
case .noData:
return "No data was returned from the server"
case .decodingError:
return "Failed to decode the response"
case .httpError(let statusCode, _):
return "Server error: \(statusCode)"
case .networkFailure:
return "Network connection failed"
}
}
}
Then catch and handle appropriately:
do {
let user = try await fetchUser()
// use user
} catch let error as NetworkError {
switch error {
case .httpError(404, _):
showAlert("User not found")
case .networkFailure:
showAlert("Check your internet connection")
default:
showAlert(error.localizedDescription)
}
} catch {
showAlert("An unexpected error occurred")
}
This gives you specific error types you can handle differently, rather than showing generic error messages for everything.
Building a production-ready client
Here's a more complete network client that incorporates what we've covered:
actor NetworkClient {
private let session: URLSession
private var authToken: String?
init(session: URLSession = .shared) {
self.session = session
}
func setAuthToken(_ token: String) {
self.authToken = token
}
func request<T: Decodable>(
endpoint: String,
method: String = "GET",
body: Encodable? = nil,
headers: [String: String] = [:]
) async throws -> T {
guard let url = URL(string: endpoint) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
// Add custom headers
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
// Add auth token if available
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Encode body if provided
if let body = body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(
statusCode: httpResponse.statusCode,
data: data
)
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(error)
}
}
}
Note the actor
keyword—this makes the client thread-safe, which is important when multiple parts of your app make concurrent requests.
Wrapping up
Networking in Swift has come a long way. URLSession with async/await is powerful, clean, and doesn't require external dependencies. You can build robust networking layers without reaching for third-party frameworks.
The key is understanding the fundamentals: creating requests, validating responses, handling errors, and decoding data. Once you have those down, you can build on them with custom clients, concurrent requests, and advanced configurations.
Start simple. Make a few basic GET and POST requests. Add error handling. Build up from there. Before long, you'll have a networking layer that fits your app's needs perfectly.
Related reading:
- Swift Concurrency: async/await explained
- Building a network layer with URLSession
- Understanding Codable in Swift
This article covers networking with URLSession in Swift using modern async/await patterns. The techniques here work for iOS 15+ and provide a solid foundation for building networked iOS apps without external dependencies.