DEV Community

Anubhav
Anubhav

Posted on

Retrofit's `callFactory`: What `.client(okHttpClient)` Is really doing

 Look at the two lines you write in almost every Retrofit setup:

Retrofit.Builder()
    .baseUrl(BASE_URL)
    .client(okHttpClient)
    .build()
Enter fullscreen mode Exit fullscreen mode

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:

  • OkHttpClient is 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.
  • Request is an immutable description of one HTTP request: a URL, a method, headers, an optional body. Building a Request sends nothing over the network.
  • Call is a Request bound to an OkHttp engine (a specific OkHttpClient), primed to fire but not yet fired. Single-use.
  • Response is what comes back after a Call fires: 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:

  • Request is inert data; Call is a fireable action. A Request on its own has no engine and cannot send itself anywhere. A Call is what you get when you hand a Request to an OkHttpClient, which binds it to the engine that will actually fire it.
  • OkHttpClient is 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, call client.newBuilder(), which produces a variant that shares the underlying pool, cache, and dispatcher.
  • Request is immutable too. To tweak one, call request.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())
Enter fullscreen mode Exit fullscreen mode

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 a Call. 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

when you call api.getUser("42"), Retrofit internally:

  1. Builds a Request from the @GET("user/{id}") annotation and the argument. (Step 2 of that lifecycle.)
  2. Gets a Call by calling newCall(request) on a factory it holds. (Step 3.)
  3. Fires it with execute() or enqueue(), depending on whether the method returns Call<T> or is suspend. (Step 4.)
  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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

.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();
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

.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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

@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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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, and Response carry the whole OkHttp model. Request is inert data. Call is a Request bound to an OkHttpClient, ready to fire.
  • Call exists as an object because cancellation and in-flight inspection need a handle that is neither a Request (no engine) nor a Response (already over).
  • Call.Factory is a one-method fun interface whose whole contract is newCall(request): Call. OkHttpClient implements it. When you wrote client.newCall(request), you were already calling into Call.Factory.
  • Retrofit's field is typed Call.Factory, not OkHttpClient. It only ever calls newCall. 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 outgoing Call.
  • Reserve callFactory for 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)