Should you make OkHttp calls synchronously?

Yes — but only on a thread that is allowed to wait. A synchronous OkHttp call blocks the thread that runs it until the whole exchange is over, which makes it beautifully simple in a unit test, a command-line tool or a piece of background server code, and a serious problem on an app’s main thread. Asynchronous calls hand the waiting to OkHttp instead: the call runs on a background thread and reports back through a callback when it finishes. The question in the title therefore has no single answer — the right style depends entirely on which thread you are standing on when you ask.

Both styles run the identical request through the identical machinery. The difference is purely who waits, and where. This article compares the two, explains the dispatcher that schedules asynchronous work, and finishes with the situations where blocking is not a sin but the sensible choice.

Two ways to fire the same call

Every request in OkHttp becomes a Call object, built from your client and your request. From there you choose one of two doors. Calling execute() runs the call synchronously and hands you the response when it is ready. Calling enqueue() schedules the call asynchronously and hands you nothing now — instead you supply a callback object with two methods, one for failure and one for a response.

Nothing else about the call changes. The URL, headers, interceptors, timeouts and caching all behave the same either way. Choosing between the styles is a threading decision, not a networking one, and the same feature can legitimately use both styles in different parts of a codebase.

What execute() does to your thread

A synchronous call parks your thread for the full life of the request: connecting, sending, waiting for a server that may be slow, and downloading the body. Ten milliseconds on a fast local network, ten seconds on a bad one — your thread is occupied for all of it, doing nothing but waiting.

In exchange, the code reads like a straight line. The response arrives as a return value, failures arrive as thrown exceptions, and there is no callback to structure your logic around. One responsibility comes with that simplicity: the response body is a live stream, and your code must read and close it. Wrap the response in a construct that closes it automatically, and the connection returns to the pool for the next call.

The wait is not literally unbounded. The client’s timeouts still govern a synchronous call: if connecting takes too long, or the server goes quiet mid-response, the call fails with an exception instead of parking the thread forever. That is some comfort, but a timeout measured in tens of seconds is an eternity for an interface thread, which is why the next sections treat where the waiting happens as the real decision.

What enqueue() does instead

An asynchronous call returns immediately. OkHttp places the call in a queue, runs it on one of its own worker threads when a slot is free, and invokes your callback when there is an outcome. A transport failure — no route, a refused connection, a timeout — arrives through the failure callback as an exception. A completed exchange, even one with an error status like 404 or 500, arrives through the response callback, where it is your job to check whether the status actually represents success.

Here is the whole pattern in its canonical shape:

client.newCall(request).enqueue(new Callback() {
    @Override public void onFailure(Call call, IOException e) {
        // the call could not be completed at all
    }

    @Override public void onResponse(Call call, Response response) {
        // a response arrived; read and close it here
    }
});

The callback style costs some structure — logic that continues after the response has to live inside the callback — and buys total freedom for the calling thread, which can return to its real work instantly.

The dispatcher behind both styles

Scheduling is handled by the client’s dispatcher. For asynchronous calls it keeps a queue of ready calls and a set of running ones, feeding them to an internal thread pool. Two limits apply by default: at most 64 asynchronous calls running at once across the whole client, and at most 5 running at once toward any single host. Calls beyond the limits wait in the queue rather than failing, which is why firing off a hundred image requests does not melt either the device or the server.

Synchronous calls are tracked by the dispatcher too, but they run on the caller’s thread, so the thread-pool limits are aimed at the asynchronous side. Because the dispatcher belongs to the client, sharing one client means sharing one dispatcher — one set of limits and one thread pool for the entire app, instead of every class running its own uncoordinated fleet.

Queued calls are not forgotten or failed; they simply start later, as running calls finish and free their slots. One practical consequence is that a burst of calls to a single busy host can wait behind that host’s five-call limit while calls to quieter hosts proceed around them. Nothing is broken in that picture — it is the dispatcher favouring fairness and server politeness over raw eagerness.

Why the main thread is off limits

On Android, the rule is enforced by the platform: performing network work on the main thread throws NetworkOnMainThreadException. The reason for the strictness is simple. The main thread is the thread that draws the interface and responds to touches; a thread parked on a slow server cannot do either, so the screen freezes for exactly as long as the network feels like taking.

Even where nothing throws, the user experience makes the argument. An interface that stalls for seconds at a time reads as a crashed app. Synchronous calls belong on threads whose only job is the work itself — never on a thread that a person is waiting on.

Callbacks do not run on the UI thread

The mirror-image mistake catches people just as often. Because enqueue() is usually called from the main thread, it is tempting to assume the callback returns there. It does not: both callback methods run on the dispatcher’s worker thread that executed the call.

So touching the interface directly inside a response callback is unsafe; hand the result back to the main thread with whatever posting mechanism your UI layer uses, and do the visual update there. The work of the callback itself should stay modest — read the body, close the response, pass the data along. Heavy processing on the callback thread delays other queued calls, because that thread is shared infrastructure, not your private workspace.

Cancelling calls in both styles

Both styles support cancellation through the call object: invoke cancel(), and the call stops as soon as it reasonably can. For an asynchronous call that never started, cancellation is instant. For one already running, the underlying work is interrupted and the failure callback fires with a cancellation error. A synchronous call can also be cancelled, from a different thread than the one blocked in execute().

Cancellation is a feature to design with, not an afterthought. When a user leaves a screen, the requests that screen started are usually worthless, and cancelling them frees dispatcher slots, connections and battery for work that matters. Calls can also carry a tag, which lets an app cancel groups of related calls — everything belonging to one screen, for example — in one move, and the dispatcher offers a cancel-everything call for shutdown situations.

Where synchronous calls genuinely fit

Plenty of code has no interface to freeze. Tests are the clearest case: a test that fires a request and asserts on the response reads naturally as synchronous code, and OkHttp calls work well against local test servers in exactly this style. Command-line tools and one-off scripts are the same — the program exists to do the fetch, so blocking is the design.

Background server code and worker threads in apps qualify too: a thread whose whole assignment is “fetch this, process it, store it” may as well do so in straight-line code. There is also a hidden synchronous corner inside OkHttp itself — the mechanism that answers authentication challenges runs blocking by design, because the call cannot proceed until it produces new credentials.

Wrapping calls: a third style in practice

Many codebases wrap the choice away. The common wrappers turn an asynchronous call into something that fits the host language’s own async style — a suspending function in coroutine-based code, or a future in code built around them. Underneath, the wrapper is still enqueue(): it registers a callback and resumes your code when the callback fires. Understanding the two native styles still matters, because the threading rules and cancellation behaviour of the wrapper are inherited from them.

Some features sidestep the question altogether. Real-time messaging does not fit a request-response rhythm at all, and WebSocket connections in OkHttp handle it differently: an ordinary-looking call upgrades into a long-lived two-way channel whose events arrive through a listener for as long as the connection stays open.

Mistakes to avoid in both styles

Creating a new client for every call is the classic own goal: every client drags its own dispatcher and thread pool behind it, so limits stop limiting and threads multiply. One shared client gives both styles their intended behaviour. A second frequent error is treating the async failure callback as the place HTTP errors arrive — it is not; a 500 response is a successful delivery of an unhappy message, and it belongs to the response callback.

The remaining mistakes are about loose ends. Reading a response body lazily after the callback or the execute() call has returned invites errors, because the stream may already be closed. Failing to cancel calls when their owner goes away wastes work. And blocking — whether a main thread with execute(), or a callback thread with a long computation — is the one mistake both styles share, because in the end every style is a decision about which thread waits.

Frequently Asked Questions

Can I use execute() in an Android app at all?

Yes — on any thread except the main one. A background worker that performs a sync, a scheduled job, or a repository method already running off the main thread can all use synchronous calls safely. The prohibition is about the UI thread, not about the style itself.

Why did updating the UI from my callback go wrong?

Because callbacks execute on a dispatcher worker thread, and user interfaces may only be touched from the main thread. Collect the data in the callback, then post the visual update to the main thread and apply it there.

How many asynchronous calls can run at the same time?

By default the dispatcher runs up to 64 asynchronous calls at once, with no more than 5 of them aimed at the same host. Calls beyond those limits wait in the ready queue and start as slots free up. Both limits can be raised or lowered on the dispatcher if an unusual workload demands it.

If enqueue() fails to reach the server, does OkHttp give up immediately?

Not always. OkHttp applies its built-in connection recovery before reporting failure — if the first route or address fails, it can try alternatives. The failure callback fires when the call truly cannot be completed, not at the first sign of trouble.

Conclusion

So, should you make OkHttp calls synchronously? When the thread is yours to block — tests, tools, background workers — yes, and enjoy the simpler code. When the thread belongs to a user interface, no: enqueue the call, respect the callback’s thread, and cancel when the work stops mattering. The request is the same either way; the craft is in choosing whose time the network is allowed to spend.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top