Asynchronous I/O with core.async

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.

What core.async Is Actually For

Use core.async when the hard part is orchestration:

  • combine multiple downstream responses
  • wait on whichever result arrives first
  • fan work out to several consumers
  • buffer, drop, or slide messages intentionally
  • separate transport callbacks from the rest of your program
  • model timeouts and cancellation as channel events

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.

Do Not Block Inside go

The safest rule is:

  • inside go, use parking channel operations like <!, >!, and alts!
  • outside go, do blocking work directly or on a worker thread
  • if a library already exposes async callbacks, bridge the callback into a channel
  • if the work is blocking, use thread, thread-call, or another worker pool

This 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.

Bridge Callback-Based I/O into Channels

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.

Use thread for Blocking I/O

File 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.

Model Timeouts and Selection Explicitly

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}))))

Pipeline Choice Matters Too

If you are processing many inputs, the pipeline family matters:

  • pipeline for non-blocking computational transforms
  • pipeline-blocking when each item may block
  • pipeline-async when the work completes through a callback or another asynchronous boundary

Choosing the wrong variant is a common source of hidden throughput problems.

Development Guardrails

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.

Key Takeaways

  • core.async is a coordination tool first, not a generic non-blocking I/O wrapper.
  • Never run blocking network or file calls directly inside go.
  • Bridge callback-based libraries into channels with put!.
  • Use thread or another worker pool for truly blocking operations.
  • Model timeouts, fan-in, and cancellation explicitly through channels.

References and Further Reading

Ready to Test Your Knowledge?

Loading quiz…
Revised on Thursday, April 23, 2026