URLSession on macOS: Transfer Lifecycles, Delegate Ownership, and Caching
Build predictable macOS networking with URLSession task ownership, response validation, cancellation, background-transfer boundaries, and explicit cache policy.
URLSession owns connection behavior for a group of HTTP transfers. Its configuration determines policies such as cache use, timeouts, connectivity constraints, and whether a session is intended for foreground or background work. A task represents one transfer, but the lifetime of the transfer is not the same as the lifetime of the view that started it. Production code should assign an owner, cancellation rule, response policy, and retry policy before a request becomes user-visible state.
Do not use a successful TCP or TLS connection as the definition of a successful application request. A task can complete with a transport error, an HTTP response that represents failure, an unexpected content type, an incomplete body, or a response that no longer corresponds to the current model revision. Each boundary needs separate validation.
Configure once, then create the session
An URLSessionConfiguration is copied when a session is created. Changing the configuration object later does not update an already-created session. Create a configuration for a specific policy, then retain the resulting session in a service whose lifecycle is understood. The shared session is convenient for basic requests, but a delegate-based transfer manager needs an explicit session and a retained delegate.
import Foundation
struct JSONClient {
private let session: URLSession
init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.requestCachePolicy = .useProtocolCachePolicy
session = URLSession(configuration: configuration)
}
func get(_ url: URL) async throws -> Data {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}
}
This is a small client, not a complete protocol layer. Production code should also bound response size, validate the content type and schema, apply authentication policy where required, and map server errors into useful domain errors. For endpoints that return large files, use a download task and process its temporary file instead of accumulating the entire response body in memory.
Task states and ownership
Tasks can be suspended, running, cancelling, or completed. A task begins suspended; call resume() to start it. Cancelling is an asynchronous request to stop work, not proof that no callback can still arrive. Keep completion handling idempotent, release task bookkeeping only after completion, and check that the result still belongs to the active request generation before updating UI.
An owner such as an account sync service, document model, or transfer coordinator should retain task identifiers and cancellation tokens. A view disappearing may mean “stop rendering this result” rather than “cancel the shared download”; choose based on product semantics. Conversely, a user pressing Cancel should cancel the task and ensure that a late completion does not show the operation as successful.
For delegate-based sessions, the session retains its delegate. A delegate that retains the session can therefore create a cycle unless invalidation explicitly breaks ownership. Keep the delegate object alive for as long as callbacks are needed, then call the appropriate invalidation method when the service is done. Do not create one unretained delegate per request and expect callbacks to remain valid.
HTTP status, redirects, and response bodies
URLSession reports transport completion separately from HTTP status. A response with a 404 or 503 can arrive without a network error. Validate status codes according to the endpoint contract; distinguish retryable server responses from permanent client errors and do not blindly retry malformed requests. Respect Retry-After when the protocol and response provide it, and use bounded exponential backoff with jitter only for operations that are safe to retry.
Redirect behavior is also policy. If a request carries credentials or targets a sensitive host boundary, define how redirects are handled rather than assuming that every redirect should be followed. For uploads, understand whether the request body can be replayed. A non-replayable input stream or one-shot body may make automatic retry impossible without rebuilding the body from a durable source.
For large downloads, URLSessionDownloadTask writes to a temporary file and delivers a URL in its completion/delegate path. Move or copy the file to durable app storage before returning from the callback because the temporary location is not a permanent artifact. Verify expected size, type, and integrity before replacing an existing destination. Use a staging file and atomic replacement when appropriate so a partial transfer never masquerades as a completed file.
Foreground and background session boundaries
A default or ephemeral session is appropriate for transfers managed while the app is active. A background session configuration asks the system to perform transfers in a separate process and can continue work when the app is suspended or terminated under documented conditions. It requires a stable identifier and relaunch handling that reconnects the app to the session and processes delivered events. Do not promise an uninterrupted transfer under every shutdown, force-quit, network, or power condition; test the lifecycle the OS documents for the deployment target.
Background transfer is not a generic way to make arbitrary computation run after the app closes. It is designed for supported upload/download tasks. Persist enough task and destination metadata to reconcile callbacks after relaunch. A user deleting the owning account or document should invalidate the associated transfer according to the app’s policy.
Cache behavior is part of correctness
The default request cache policy follows protocol cache directives. A cache hit is not necessarily fresh merely because data is available locally; HTTP freshness, validators, request headers, and the configured URLCache all influence behavior. Choose a policy based on endpoint semantics rather than using “ignore cache” everywhere. For rapidly changing data, conditional validation is often more efficient than downloading every response again.
Do not use a local cache as a source of truth for mutations. A successful write can require invalidating or updating related cached reads. If an endpoint is user-specific, ensure the cache configuration and request headers cannot cause one identity’s response to be reused under another. If responses must never be stored, configure and verify that policy explicitly rather than inferring it from the session name.
Timeouts, reachability, and retry boundaries
Request timeout and resource timeout are different controls. A short request timeout can reject slow but healthy transfers; a long resource timeout can leave a user waiting indefinitely if the task is not cancellable. Set time budgets for the actual product operation, observe progress when appropriate, and expose cancellation.
Do not gate every request on a reachability check. Network availability can change between a check and the request, and a path that is “satisfied” does not prove the remote service is healthy. Attempt the operation, handle waiting/failure, and retry within an explicit budget. If the product uses Network.framework path monitoring, treat it as context for UX and scheduling rather than proof that an HTTP request will succeed.
Retry only when the operation is idempotent or has a server-supported idempotency key. A POST that charges a card or creates a record must not be repeated solely because the client timed out before seeing the response. Preserve an operation identifier and reconcile state with the server before retrying ambiguous writes.
Diagnostics and acceptance tests
Record task identifier, request class, duration, response status, byte counts, cache disposition where available, and normalized error category. Avoid logging credentials, URL query secrets, or full response bodies. Use URLSessionTaskMetrics where useful to separate DNS, connection establishment, TLS, request, and transfer time; a single aggregate duration does not identify where latency occurred.
Test slow response, connection loss, redirect, non-2xx response, malformed body, cancellation race, response arriving after the view changes, cache revalidation, disk full during download, app relaunch for background work, and duplicate retry. Assert both the final visible state and the durable stored artifact. Include HTTP tests that return a body with an error status, because treating all transport-success callbacks as business success is a common defect.
The operational rule is to make session policy stable, task ownership explicit, response success semantic, and retries safe. URLSession can manage transport mechanics; the application still has to preserve state across cancellation, delayed callbacks, cache behavior, and ambiguous server outcomes.
Related:
- macOS Network.framework: Path Monitoring, Connection State, and Recovery
- Bonjour on macOS: Service Discovery, Resolution, and Network Changes
Sources: