URLProtocol is one of the most underused APIs in Foundation. It lets you intercept every network request made through URLSession — before it ever touches the network. Most iOS developers only discover it when they need to mock requests in tests, but it has a broader set of uses: logging, header injection, custom caching, and offline stubs.
This post covers what URLProtocol is, how requests flow through it, and how to implement one correctly in Swift.
URLProtocol is an abstract class in Foundation that lets you intercept URL loading requests before they reach the network.
Every URLSession operates with a list of registered protocol classes. When a request is made, the system iterates through this list and asks each one: "can you handle this request?"
This happens via canInit(with:). The first protocol that returns true takes over the request entirely — from that point on, the system hands control to your implementation.
You never create instances of URLProtocol directly. You register the class, and the system instantiates it per request when needed.
This applies to all URLSession instances in the process unless explicitly overridden.
In practice, global registration is rarely a good idea. Once registered, every request flows through your protocol — including internal requests you trigger inside startLoading(). Without careful guards, this leads to infinite interception loops and hard-to-debug behavior.
Avoid global registration outside of very controlled environments.
let config = URLSessionConfiguration.ephemeralconfig.protocolClasses = [MockURLProtocol.self]let session = URLSession(configuration: config)
This scopes the protocol to a specific session, giving you:
Isolation — especially useful in test suites
Predictable behavior with no side effects on other sessions
Easy teardown once the session is released
This is the recommended approach in nearly all cases.
Once your protocol opts into handling a request, the system does not continue default loading for you.
You are responsible for either forwarding the request or generating a response manually.
If you do neither, the request never completes.
Once you understand where URLProtocol sits in the stack, implementing one is straightforward. The confusion usually comes from the lifecycle model — it's different from typical networking code.
You subclass URLProtocol and override a small set of methods that define the lifecycle:
final class LoggingURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { // Return true to intercept this request return true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { // Normalize or modify the request if needed return request } override func startLoading() { // Perform the request or return a custom response } override func stopLoading() { // Cancel in-flight work and clean up }}
Think of these as phases rather than random overrides:
Decision phase
canInit(with:) — should I intercept this request?
canonicalRequest(for:) — normalize or tweak the request before handling
Execution phase
startLoading() — actually handle the request
stopLoading() — cancel or clean up
canInit(with:) can be called multiple times. If your protocol forwards the request using another URLSession, that new request will come back through your protocol again — creating an infinite loop.
Guard against this by marking requests as already handled:
URLProtocol is a low-level interception mechanism. It has no awareness of your app's business logic, no access to your dependency graph, and no natural place to manage shared state.
Avoid using it for:
Business logic — it has no context about your app's intent
Retry orchestration — hard to manage concurrency and request coordination
URLProtocol is a focused tool. It intercepts URLSession requests, hands you full control over the response, and then steps aside.
The lifecycle maps directly to four methods:
canInit → decide
canonicalRequest → prepare
startLoading → execute
client callbacks → complete
Everything else — logging, mocking, header injection — is just a variation of this pattern. Register per-session, guard against re-entrant interception, and keep business logic out of the protocol layer.
If you found this useful, you might like what I'm building: