Learn when asynchronous request handling actually helps in Clojure web systems, how servlet async support differs from event-loop servers, and where async design goes wrong.
Asynchronous request handling: A design where the request does not have to occupy a worker thread for the entire lifetime of slow I/O or long-lived connection work.
Async web handling is useful, but it is also widely misunderstood. It does not magically make code faster. It helps when the main problem is waiting: waiting on upstream APIs, waiting on sockets, waiting on streaming clients, or keeping many connections open at once. If the bottleneck is CPU work, async alone will not save you.
This matters in Clojure because the ecosystem gives you several different async shapes: servlet async support on container-based servers, event-loop servers such as http-kit and Aleph, and orchestration tools such as core.async. They are related, but they are not the same thing.
A simple synchronous handler is easy to reason about, but it ties a worker thread to the whole request.
That becomes expensive when the handler spends most of its time waiting on:
If the server has to keep one thread parked per slow request, throughput falls and queueing rises.
In a servlet container such as Jetty or Tomcat, async support means the request can be suspended while work continues elsewhere, then resumed when the response is ready. That is different from plain blocking servlet handling, where the container thread remains occupied for the whole request.
In the broader Clojure ecosystem, you will also see:
respond and raise callbacksThose models solve overlapping problems, but they do not make the same trade-offs.
The practical question is less “is this async?” and more “which execution context is doing the waiting, and what else is competing for it?”
It helps to separate three common models:
None of these models is universally best. A straightforward CRUD application behind a database often does well with ordinary blocking handlers and disciplined timeouts. A streaming or fan-out-heavy service often needs a more deliberate async shape.
This is the most important practical rule.
If a handler uses an async API but still performs blocking work on the request thread, the benefits shrink quickly. Moving slow I/O behind an async-looking wrapper does not change the cost if the same small pool is still blocked underneath.
1(defn handler
2 ([request]
3 {:status 200
4 :body "ok"})
5 ([request respond raise]
6 (future
7 (try
8 (let [result (fetch-upstream-data request)]
9 (respond {:status 200
10 :body result}))
11 (catch Exception ex
12 (raise ex))))))
The point of this shape is not the syntax. The point is that response delivery is decoupled from the original request thread.
Async request handling makes it easier to hide the fact that no one owns timeout policy. That is dangerous.
For each slow dependency, decide:
Without those rules, async systems often look healthy until load rises and abandoned work keeps piling up behind the scenes.
Async handling is strongest when you have:
It is weaker when the request mostly performs CPU-heavy transformation or large in-memory computation. In those cases, explicit parallelism, background job design, or workload isolation may matter more than async request plumbing.
An easy mistake is treating every slow request as an async candidate. Some requests are slow because they are doing too much coordination, too many remote calls, or too much work before the first byte of the response. Fixing the request path often matters more than changing the server model.
core.async Is Not the Entire Web Stackcore.async is very useful for coordinating workflows, fan-in and fan-out, throttling, or backpressure-aware internal pipelines. It is less useful as a reason by itself to make every request path asynchronous.
One of the most common mistakes in Clojure web code is mixing up these layers:
They interact, but they are not interchangeable.
core.async is at its best when it makes pressure visible:
It is much weaker when used merely to make straightforward request logic look more sophisticated.
Some workloads should leave the request lifecycle entirely:
In those cases, a better design is often:
That is often cleaner than keeping an HTTP request open while pretending the work is still interactive.
go Blocksgo blocks are for parking channel operations, not arbitrary blocking I/O. If you place JDBC calls, remote HTTP calls, or file reads directly inside them, you can create confusing bottlenecks fast.
Async systems often fail through overload rather than obvious crashes. A service that accepts work faster than downstream systems can consume it needs explicit queueing, shedding, timeouts, or concurrency limits.
Once the control flow becomes asynchronous, exception paths and logging boundaries become easier to duplicate or lose. Decide clearly which layer owns:
Use async request handling when the system is mostly waiting, not when it is mostly computing. Keep blocking code out of the most constrained execution contexts. Make timeout budgets explicit, and be honest about when work should move into a background job instead of staying in the HTTP lifecycle.