Look at the two lines you write in almost every Retrofit setup:
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.build()
The .client(okHttpClient) line is one you probably wrote once and never thought about again. It has a sibling on the same builder called .callFactory(...), and most Android developers never touch it. This article is about what .callFactory is, why it exists, and the things it lets you do that no interceptor ever will.
To get there properly, we need to start at a lower altitude than Retrofit and climb back up. By the time we reach .callFactory, it will feel obvious what it is.
The four types that carry the whole model
Before we talk about factories at all, it helps to know what actually composes an OkHttp request. Making an HTTP call breaks down into a handful of concerns: describing what you want to send, having something that can actually send it, holding onto that specific send while it is in flight, and reading what comes back. OkHttp gives each of those its own type:
-
OkHttpClientis the HTTP engine. It owns the connection pool, the dispatcher (a thread pool), the cache, the timeouts, and the interceptor list. Building one is expensive, so you make one and share it across your app. -
Requestis an immutable description of one HTTP request: a URL, a method, headers, an optional body. Building aRequestsends nothing over the network. -
Callis aRequestbound to an OkHttp engine (a specificOkHttpClient), primed to fire but not yet fired. Single-use. -
Responseis what comes back after aCallfires: status, headers, body.
A useful mental model: OkHttpClient is the post office. Request is a sealed, addressed envelope sitting on your desk. Call is that envelope in the outbox, assigned to a specific post office, waiting for the mailman. Response is the letter that arrives back.
A few points worth holding onto before we move on:
-
Requestis inert data;Callis a fireable action. ARequeston its own has no engine and cannot send itself anywhere. ACallis what you get when you hand aRequestto anOkHttpClient, which binds it to the engine that will actually fire it. -
OkHttpClientis meant to be a singleton. The connection pool and thread pool are the reason it is cheap to reuse and expensive to duplicate. To vary its configuration, callclient.newBuilder(), which produces a variant that shares the underlying pool, cache, and dispatcher. -
Requestis immutable too. To tweak one, callrequest.newBuilder(), change what you need, and.build()a new instance.
Sending a request without Retrofit
Here is the OkHttp lifecycle with no Retrofit in the picture:
// 1. The engine. Expensive; build once, share everywhere.
val client = OkHttpClient()
// 2. Describe WHAT to send. Just data: a URL, a method, headers.
// Building this sends nothing.
val request = Request.Builder()
.url("https://api.example.com/user/42")
.get()
.header("Accept", "application/json")
.build()
// 3. Hand the request to the engine. It returns a Call.
// Still nothing on the network. The Call is primed but unfired.
val call = client.newCall(request)
// 4. Fire it. THIS is where a socket opens and bytes move.
val response = call.execute()
// 5. Read the result.
println(response.body?.string())
Read the shape of it deliberately:
- Step 2 builds a description. No I/O. You are writing the envelope.
- Step 3,
client.newCall(request), produces aCall. Still no I/O. The request is now bound to this specific engine and ready. - Step 4,
call.execute(), is the only line that touches the network.
The interesting question is why step 3 exists as a separate step. Why isn't there just one method, client.send(request): Response? The answer to that question is the entire point of this article.
Why Call exists as an object
Between the moment you decide to send a request and the moment the response arrives, a lot can happen. You might want to cancel it. You might want to check whether it has been fired yet. You might want to clone it and send it again. All of that needs an object that represents the request while it is in flight, something you can hold a reference to and call methods on. That object is Call.
The interface, trimmed to essentials:
interface Call : Cloneable {
fun request(): Request // read back the request I represent
fun execute(): Response // fire synchronously; block until the response returns
fun enqueue(responseCallback: Callback) // fire asynchronously; call me back later
fun cancel() // abort, even mid-flight
fun isExecuted(): Boolean // have I already been fired?
fun isCanceled(): Boolean
fun clone(): Call // give me a fresh, unfired copy of the same request
}
Notice how many of these methods only make sense on something in flight. Take cancel(): you cannot cancel a Response, because by the time you hold one, the request is already over. Take isExecuted(): you cannot ask a Request whether it has been fired, because a Request has no engine and no live state. It is just data describing what to send. These are questions about the request's live state, not about its inputs or outputs. They need somewhere to live that is neither the Request (which has no engine) nor the Response (which is too late). The Call is that somewhere.
Before we move on to factories, two properties of Call are worth calling out.
A Call fires exactly once. Calling execute() or enqueue() on a Call that has already been fired throws IllegalStateException: Already Executed. To send the same request again, make a new Call, either by calling client.newCall(request) again or by calling call.clone():
val call = client.newCall(request)
call.execute()
call.execute() // throws IllegalStateException: Already Executed
val fresh = call.clone() // a new, unfired Call for the same request
fresh.execute() // fine
A Call can be fired synchronously or asynchronously, and the only difference is which thread runs the network call. execute() is synchronous. The calling thread blocks until the full response arrives, which on Android means calling it from the main thread throws NetworkOnMainThreadException. enqueue() is asynchronous. You hand OkHttp a Callback, the line returns immediately, and OkHttp runs the request on one of its own dispatcher threads. Same Call, same request, only the waiting model differs. Retrofit's suspend functions use enqueue under the hood and bridge the callback into coroutine suspension.
Call.Factory: the formal name for what you have been doing
Now the reframe. Look at step 3 again:
val call = client.newCall(request)
In plain English: "client, take this request and produce a Call for me." The client, in that moment, is doing exactly one job. It is manufacturing a Call from a Request. OkHttp has a one-method interface for exactly that responsibility, nested inside the Call interface itself:
interface Call {
// ... execute(), enqueue(), cancel(), and the rest we saw earlier
fun interface Factory {
fun newCall(request: Request): Call
}
}
That nested interface is the whole contract. One method. Request in, Call out.
You have been using this interface the whole time, because OkHttpClient implements it:
open class OkHttpClient : Call.Factory, WebSocket.Factory {
override fun newCall(request: Request): Call =
RealCall(this, request, forWebSocket = false)
}
That gives OkHttpClient two identities at once:
- As the HTTP engine, it owns the pool, dispatcher, cache, timeouts, and interceptors.
- As a
Call.Factory, it exposes exactly one method:newCall(request): Call.
Why fun interface matters
The declaration says fun interface, not plain interface. That marks it a functional interface with a single abstract method, and it changes what the Kotlin compiler will accept. You can pass a lambda anywhere a Call.Factory is expected, and the compiler will wrap the lambda body as the implementation of newCall. The two forms below are identical to the compiler:
// Explicit object
val f: Call.Factory = object : Call.Factory {
override fun newCall(request: Request): Call = someClient.newCall(request)
}
// Lambda; Kotlin's SAM conversion produces exactly the object above
val f: Call.Factory = Call.Factory { request -> someClient.newCall(request) }
This is what makes the .callFactory { ... } lambda form on Retrofit's builder compile so cleanly. The lambda's request parameter is newCall's argument. Whatever the lambda returns is the returned Call. Hold onto this. It is why the code later in the article is as short as it is.
Small note on versioning: Call.Factory was declared fun interface in OkHttp 4.9. If you are on an older version, you need the anonymous-object form instead of the lambda form.
What Retrofit actually needs from OkHttp
Retrofit's job is to turn a Kotlin interface method into the five-step lifecycle we walked through earlier under Sending a request without Retrofit. Given:
interface UserApi {
@GET("user/{id}")
suspend fun getUser(@Path("id") id: String): UserDto
}
when you call api.getUser("42"), Retrofit internally:
- Builds a
Requestfrom the@GET("user/{id}")annotation and the argument. (Step 2 of that lifecycle.) - Gets a
Callby callingnewCall(request)on a factory it holds. (Step 3.) - Fires it with
execute()orenqueue(), depending on whether the method returnsCall<T>or issuspend. (Step 4.) - Parses the body through your converter factory into
UserDto. (Step 5.)
Step 2 is where the interesting design choice lives. To do newCall(request), Retrofit needs something that has a newCall method. In other words, a Call.Factory. And Retrofit stores exactly that, as the interface type:
// Simplified from retrofit2.Retrofit
class Retrofit {
val callFactory: okhttp3.Call.Factory
// ...
}
Notice the type. It is not OkHttpClient. It is Call.Factory.
"Retrofit depends on the interface, not the concrete client"
That phrase gets thrown around, and it is worth unpacking precisely. Retrofit's field is typed Call.Factory. Ask what Retrofit actually needs from the client, and the answer is: only the ability to turn a Request into a Call. Retrofit never reads the client's .cache(), .dispatcher(), .connectionPool(), or its timeouts. Its entire per-request interaction with the client is one line:
val call: Call = callFactory.newCall(request)
Typing the field as OkHttpClient would demand more than Retrofit actually uses. Typing it as Call.Factory, the smallest interface that provides newCall, means Retrofit accepts any implementation. The real OkHttpClient, a custom class you wrote, a test double, a lambda. This is dependency inversion in one line: depend on the capability (newCall), not on the class that happens to provide it. And that openness is exactly what makes the .callFactory(...) hook meaningful. You are allowed to substitute your own implementation.
.client() is sugar for .callFactory()
Here is the reveal that makes everything else click. Look at Retrofit's builder in the current source:
// From retrofit2.Retrofit.Builder, actual code, not simplified
public Builder client(OkHttpClient client) {
return callFactory(Objects.requireNonNull(client, "client == null"));
}
public Builder callFactory(okhttp3.Call.Factory factory) {
this.callFactory = Objects.requireNonNull(factory, "factory == null");
return this;
}
.client(okHttpClient) is pure sugar. It works because OkHttpClient is a Call.Factory, so Retrofit drops it straight into the callFactory field. There is no separate "client path" inside Retrofit. Everything funnels to that one field. If you supply neither, build() fabricates a default:
okhttp3.Call.Factory callFactory = this.callFactory;
if (callFactory == null) {
callFactory = new OkHttpClient();
}
Three ways in, one field, one method eventually invoked:
| You wrote |
callFactory holds |
Per request, Retrofit runs |
|---|---|---|
| nothing | a default OkHttpClient()
|
defaultClient.newCall(request) |
.client(myClient) |
your OkHttpClient
|
myClient.newCall(request) |
.callFactory(f) |
whatever f is |
f.newCall(request) |
Every row terminates in something.newCall(request). That is the same call you wrote by hand at Step 3 of the lifecycle in Sending a request without Retrofit. Retrofit is doing that step for you on every method call. .callFactory(...) is you choosing which factory it uses.
The two builds below are behaviorally identical:
// A: the sugar
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.build()
// B: the same thing, longhand. The lambda is a pass-through that adds nothing.
Retrofit.Builder()
.baseUrl(BASE_URL)
.callFactory { request -> okHttpClient.newCall(request) }
.build()
.client(x) is .callFactory { x.newCall(it) } with nothing in the middle. So why would you ever write form B? Because the lambda is a hook. It is a line of your own code that runs at the moment each Call is created. That one line, executed per outgoing request, is what the three use cases below exploit.
What you can do in the hook
Use case 1: defer the cost of building the client
Building an OkHttpClient with a disk Cache touches the filesystem. Filesystem I/O is work you probably do not want on your app-startup critical path. But Retrofit is often built eagerly in a DI graph at startup, and .client(x) demands a fully-built client right then.
The hook lets you defer building the client until the first request actually fires:
// The client is NOT built here. lazy {} only stores the recipe.
val lazyClient = lazy {
OkHttpClient.Builder()
.cache(Cache(cacheDir, MAX_CACHE_SIZE)) // disk I/O we want off the startup path
.addInterceptor(authInterceptor)
.build()
}
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.callFactory { request ->
// .value runs the recipe the first time a request fires,
// then caches the client forever.
lazyClient.value.newCall(request)
}
.addConverterFactory(...)
.build()
The timing shift is where the win comes from:
.client(okHttpClient) build client → build Retrofit → (later) first request
▲ disk cache opens at STARTUP
.callFactory { lazy.value } build Retrofit → (later) first request → build client HERE
▲ disk cache opens on first use
Same field on Retrofit, same eventual newCall(request). Only when the expensive object is constructed moves. The hook is what let it move.
Use case 2: use different clients for different requests
This is the strongest justification for callFactory existing at all, and it is worth spending the most time on.
Some request-level concerns are actually client-level properties in OkHttp. Timeouts are the clearest example. readTimeout, writeTimeout, connectTimeout, and callTimeout are all set on OkHttpClient.Builder. They are baked into the client at construction time.
That leads to a very common problem. Suppose you have one endpoint that runs an on-demand report and reliably takes ninety seconds to respond. Every other endpoint in the app responds in under a second, and you have set a 10-second read timeout on your main client to catch stuck requests early. The report endpoint will always time out.
You cannot solve this with an interceptor. An interceptor runs inside the client. It has access to the request and the response. It does not have access to the timeouts of the client hosting it, and even if it did, changing them mid-chain would not be safe. The property lives one level above where the interceptor executes.
What you need is to pick a different client instance for that one call. That is exactly what the hook is for.
Step 1: create two client instances. Use newBuilder() so that the second client shares the connection pool, dispatcher, and cache with the first. This matters. A fresh OkHttpClient.Builder().build() would create a second, independent connection pool and thread pool, which is wasteful and defeats the point of OkHttpClient being a heavy singleton.
val baseClient = OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS)
.addInterceptor(authInterceptor)
.build()
// newBuilder() clones the config but SHARES the underlying resources.
// This is a config variant, not a second HTTP stack.
val longRunningClient = baseClient.newBuilder()
.readTimeout(120, TimeUnit.SECONDS)
.build()
Step 2: mark the endpoint that needs the second client. Retrofit's @Tag parameter annotation attaches an object to the underlying OkHttp Request as a tag. That tag is later readable via request.tag(SomeClass::class.java), which is exactly what the hook can inspect. Define a marker singleton and take a parameter of that type on the endpoint:
// A marker singleton, used only as a request tag.
object LongRunning
interface ReportApi {
@GET("reports/{id}/export")
suspend fun exportReport(
@Path("id") id: String,
@Tag marker: LongRunning = LongRunning // default so callers do not pass it
): ReportDto
}
@Tag is a parameter annotation on Retrofit interfaces.
Step 3: in the hook, inspect the tag and pick the right client.
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.callFactory { request ->
val client = if (request.tag(LongRunning::class.java) != null) {
longRunningClient
} else {
baseClient
}
client.newCall(request)
}
.build()
Every request that flows through exportReport now gets the 120-second timeout client. Every other request keeps the 10-second timeout. Both share the same connection pool underneath.
This pattern generalises well. The client variant can differ in anything that lives on OkHttpClient.Builder, not just timeouts. A different event listener for a specific set of endpoints, a different SSL configuration for a legacy backend, a different socket factory for a niche transport case. The mechanism is always the same: a marker tag on the request, a branch in the hook, a newCall on the chosen client.
This is the use case an interceptor structurally cannot cover. It is where callFactory earns its place in the API.
Use case 3: rewriting the request before it becomes a Call
This one is included for completeness because you will see it in the wild, not because it is the recommended pattern. In the hook, you have the Request in your hand. You can rewrite it before handing it to the client:
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.callFactory { request ->
val rewritten = request.newBuilder()
.url(rewriteHost(request.url)) // route to a regional host at runtime
.build()
client.newCall(rewritten)
}
.build()
This works. It also almost always belongs in an interceptor instead. The next section is about why.
callFactory vs interceptors
Both callFactory and interceptors let you touch a request on its way out, so they look interchangeable. They are not. The decisive difference is where in the lifecycle each one runs:
callFactory hook interceptor chain
│ │
BEFORE a Call exists INSIDE a Call, during execution
runs ONCE per call app interceptor: once per call
network interceptor: once PER network request
(each redirect, each retry)
The fuller picture:
| Capability |
Call.Factory hook |
Application interceptor | Network interceptor |
|---|---|---|---|
| Runs | once, before the Call exists |
once per Call
|
once per network request |
| Sees redirects and retries | no | no | yes, fires again on each |
| Can retry or short-circuit | no | yes | yes |
| Can read/rewrite headers and URL | yes | yes | yes |
| Can swap client-level config (timeouts, pool) | yes | no | no |
| Can choose which client instance runs the call | yes | no | no |
Access to the served Response
|
no | yes | yes |
Read the table as two columns of "only here":
Use an interceptor when the job concerns the request or response content or flow: adding auth headers, logging, retrying on 401, caching decisions, rewriting URLs or headers, short-circuiting with a canned response. Interceptors are strictly more capable for these. They can see the response, they can retry, they can short-circuit. callFactory can do none of those. Request mutation (Use case 3 above) belongs here, not in the factory.
Use callFactory only when the job is structurally impossible for an interceptor because it concerns the client object itself, not the request flowing through it:
- Deferring client construction (Use case 1). An interceptor cannot defer building the very client it lives inside.
- Choosing which client instance handles the call (Use case 2). Timeouts, socket factories, and pools are client properties. An interceptor runs inside one fixed client and cannot switch to another.
The one-line rule: callFactory is a strictly weaker hook than an interceptor for anything to do with request or response content, so reach for it only for the things an interceptor cannot do. If you see callFactory used just to add a header or log a URL, an interceptor would have been the cleaner choice.
Summary
-
OkHttpClient,Request,Call, andResponsecarry the whole OkHttp model.Requestis inert data.Callis aRequestbound to anOkHttpClient, ready to fire. -
Callexists as an object because cancellation and in-flight inspection need a handle that is neither aRequest(no engine) nor aResponse(already over). -
Call.Factoryis a one-methodfun interfacewhose whole contract isnewCall(request): Call.OkHttpClientimplements it. When you wroteclient.newCall(request), you were already calling intoCall.Factory. - Retrofit's field is typed
Call.Factory, notOkHttpClient. It only ever callsnewCall. That is why Retrofit accepts any implementation, including a lambda. -
.client(x)is a one-line forwarder to.callFactory(x). Same field. The lambda form of.callFactory { ... }is a hook where your own code runs on every outgoingCall. - Reserve
callFactoryfor the things interceptors cannot do: deferring client construction, and choosing which client instance runs the request. Everything else about the request or response belongs in an interceptor.
Top comments (0)