Correct patterns for using core.async to coordinate asynchronous I/O without blocking go blocks.
core.async is excellent for coordinating asynchronous work, but it is easy to misuse if you treat go blocks as a magic way to make blocking I/O disappear. The current mental model is simpler and safer: use channels to model handoffs, timeouts, fan-in, and fan-out, and keep truly blocking network or file operations out of the go pool.
That distinction matters because the official core.async reference is explicit: go blocks park on channel operations, but they should not run blocking I/O directly. If you block the shared go thread pool on network calls, disk reads, or slurp, the rest of the system can stall behind what looks like “asynchronous” code.
Use core.async when the hard part is orchestration:
That is different from saying “core.async performs non-blocking I/O for me.” Usually it does not. The underlying library still determines whether the I/O is callback-driven, streaming, or blocking.
goThe safest rule is:
go, use parking channel operations like <!, >!, and alts!go, do blocking work directly or on a worker threadthread, thread-call, or another worker poolThis is the kind of example that looks elegant but is operationally wrong:
1(require '[clojure.core.async :as a])
2(require '[clj-http.client :as http])
3
4(a/go
5 ;; Wrong: this blocks a go thread while the HTTP request runs.
6 (http/get "https://example.com/health"))
The rewrite is to move the transport work to an async callback or a true worker thread, then hand the result back through a channel.
Many Clojure networking libraries already expose async callbacks. In that case, core.async becomes a clean coordination layer around the callback boundary instead of a replacement for it.
1(ns myapp.async-http
2 (:require [clojure.core.async :as a]
3 [org.httpkit.client :as http]))
4
5(defn get-json [url]
6 (let [out (a/promise-chan)]
7 (http/get url
8 {:timeout 3000
9 :headers {"accept" "application/json"}}
10 (fn [{:keys [status body error]}]
11 (a/put! out
12 (if error
13 {:ok? false
14 :kind :transport-error
15 :message (.getMessage error)}
16 {:ok? true
17 :status status
18 :body body}))))
19 out))
20
21(defn fetch-both [user-url billing-url]
22 (let [user-ch (get-json user-url)
23 billing-ch (get-json billing-url)]
24 (a/go
25 {:user (a/<! user-ch)
26 :billing (a/<! billing-ch)})))
The key design choice here is not the callback syntax. It is the normalized result shape. The rest of the system can reason about {:ok? ...} and :kind without knowing the raw client library contract.
thread for Blocking I/OFile reads, JDBC calls, and blocking SDK clients usually belong on worker threads, not in go.
1(ns myapp.async-file
2 (:require [clojure.core.async :as a]))
3
4(defn read-config [path]
5 (a/thread
6 (try
7 {:ok? true
8 :body (slurp path)}
9 (catch Exception ex
10 {:ok? false
11 :kind :file-read-failed
12 :message (.getMessage ex)}))))
thread returns a channel containing the result, so callers can still use go and <! on the coordination side without blocking the go pool on the actual disk read.
One of the real strengths of core.async is how naturally it models “first response wins” or “wait until timeout.”
flowchart LR
A["Blocking I/O on worker thread"] --> C["Result channel"]
B["Callback-based client"] --> C
C --> D["go block orchestration"]
D --> E["Timeouts with alts!"]
D --> F["Fan-in or fan-out"]
D --> G["State update or response"]
The important boundary is that the blocking or callback-driven transport lives on the left, and channel-based coordination lives on the right. That separation keeps your concurrency model honest.
1(defn fetch-with-timeout [url]
2 (let [result-ch (get-json url)
3 timeout-ch (a/timeout 2000)]
4 (a/go
5 (a/alt!
6 result-ch ([result] result)
7 timeout-ch {:ok? false
8 :kind :timeout}))))
If you are processing many inputs, the pipeline family matters:
pipeline for non-blocking computational transformspipeline-blocking when each item may blockpipeline-async when the work completes through a callback or another asynchronous boundaryChoosing the wrong variant is a common source of hidden throughput problems.
During development, enable core.async go checking:
1-Dclojure.core.async.go-checking=true
It only catches some invalid cases, but it helps surface the very bug this page is trying to prevent: blocking behavior hidden inside go.
core.async is a coordination tool first, not a generic non-blocking I/O wrapper.go.put!.thread or another worker pool for truly blocking operations.