Why blocking I/O inside go blocks and other async paths quietly destroys throughput in Clojure systems.
Blocking operations in asynchronous code are a Clojure anti-pattern because they smuggle thread-holding work into code that is supposed to stay responsive. The most common version is putting file I/O, HTTP calls, database queries, or Thread/sleep inside core.async go blocks and expecting the async design to save you anyway.
It usually does not. The result is reduced throughput, scheduler starvation, and systems that look asynchronous on paper but behave like a pile of stalled worker threads in production.
In core.async, go blocks are designed for parking operations such as <! and >!, not for arbitrary blocking work.
That distinction is essential:
go block yields control efficientlyWeak:
1(go
2 (Thread/sleep 1000)
3 (>! out :done))
This looks harmless, but it blocks a thread in the pool used to run go work.
Better:
1(thread
2 (Thread/sleep 1000)
3 (>!! out :done))
Or better still, avoid Thread/sleep as a coordination strategy unless the design truly calls for it.
The anti-pattern is forgetting that go is not a general-purpose “make this asynchronous” wrapper.
goThis is the most common failure mode:
1(go
2 (let [body (slurp "/tmp/orders.edn")]
3 (>! out body)))
or:
1(go
2 (let [response @(http/get "https://api.example.com/orders")]
3 (>! out response)))
These examples look compact, but the actual work is blocking. The go block does not magically turn it into non-blocking I/O.
The right alternatives are usually:
thread, future, or a dedicated executorgo for channel coordination rather than the blocking work itselfA healthy async architecture separates:
When those all collapse into the same async wrapper, it becomes hard to reason about which parts are cheap and which parts consume scarce resources.
For example:
1(go
2 (let [raw (slurp path)
3 data (parse-edn raw)
4 saved (jdbc/execute! db ...)]
5 (>! out saved)))
Now the go block hides:
That is too much responsibility for one async surface. If something stalls, the architecture gives you no clean boundary for reasoning about why.
A system can use channels, callbacks, or promises and still choke because the expensive part is blocking somewhere behind the scenes.
Warning signs:
go usage looks “correct”The anti-pattern is mistaking async structure for actual non-blocking execution.
Blocking work inside async code is especially harmful when queueing already exists. The system now pays twice:
This is why channel pipelines and worker pools need explicit boundaries:
Without those answers, async code can become a slower, harder-to-debug version of synchronous code.
go for Anything “That Feels Concurrent”Sometimes go is used because it feels like the idiomatic concurrency wrapper, even when the job is just a background blocking task. That leads to code that is superficially idiomatic and operationally wrong.
The better mental model is:
go for channel-oriented workflows built around parking operationsthread, dedicated executors, or other thread-based tools for blocking work
flowchart TD
A["Incoming work"] --> B["Async coordination / channel logic"]
B --> C{"Blocking I/O needed?"}
C -->|Yes| D["Dedicated thread or non-blocking client"]
C -->|No| E["Stay in go-based coordination path"]
D --> F["Return result to async pipeline"]
E --> F
This makes the resource model explicit instead of pretending every async-looking block has the same execution semantics.
go blocks focused on parking channel operationsthread, future, or explicit executorsgo blockThread/sleep in coordination paths that should stay responsivego is for parking operations, not arbitrary blocking work.