Choosing Between core.async Channels, Agents, and Futures for Concurrent State in Clojure
A decision guide comparing Clojure's core.async channels, agents, and futures for concurrent state, with a concrete agent‑based metrics example and validation steps.
05 Dec 2025, 16:42 UTC

Decision and Constraints
When building a Clojure service that needs to share mutable state across threads, you must pick a concurrency primitive that matches your latency, reasoning, and integration requirements. The decision hinges on three constraints:
- Low latency – the mechanism should add minimal overhead to the critical path.
- Ease of reasoning – updates should be predictable and avoid subtle race conditions.
- Compatibility – the primitive should work smoothly with existing code, libraries, and tooling.
Options Comparison
| Primitive | Typical Latency | Back‑pressure | Error Handling | Blocking vs Non‑blocking | Common Use‑case |
|---|---|---|---|---|---|
| core.async go‑blocks with channels | Low‑moderate (depends on buffer size) | Yes – via buffer limits and <!!/>!! | Exceptions propagate to the go‑block; must be caught manually | Non‑blocking inside go‑block; blocking ops possible with <!!/>!! | Composable pipelines, staged processing, event streams |
Agents (send/send-off) | Low (thread‑pool dispatch) | No inherent back‑pressure; agents queue work | Errors are captured and can be inspected via agent-error | Non‑blocking for the caller; work runs on a shared pool | Serializable state updates, background aggregation, simple async work |
Futures (future/promise) | Low (one‑off task) | No back‑pressure; fire‑and‑forget | Exceptions stored in the future; deref throws | Blocking on deref until completion | One‑time asynchronous computation, lazy initialization |
Trade‑offs
Agents give you a single, automatically managed thread pool and guarantee that updates are applied serially, which simplifies reasoning about state. The downside is that each agent holds only one value, and any blocking operation inside send stalls the shared pool, potentially affecting other agents.
core.async shines when you need fine‑grained flow control: you can compose multiple channels, apply back‑pressure, and build pipelines that resemble Unix pipes. The cost is higher conceptual overhead; you must manage go‑block lifecycles, avoid leaking channels, and handle errors inside the blocks.
Futures are the simplest way to fire off a one‑off computation and later deref its result. They provide no coordination primitives, so if you need to coordinate multiple futures or share state between them, you must layer additional constructs (e.g., atoms, promises, or channels).
Concrete Implementation: Using an Agent to Accumulate Metrics
The following example shows how to use an agent to collect request‑level metrics in a web service. The agent stores a map of metric names to counts, and a watch logs each update.
(defonce metrics-agent
(agent {}))
(defn update-metrics [m key]
(update m key (fnil inc 0)))
;; Handler that could be called from an HTTP route
(defn record! [metric-name]
(send metrics-agent update-metrics metric-name))
;; Watch that prints the new state whenever it changes
(add-watch metrics-agent
::metrics-log
(fn [_ _ old-state new-state]
(when (not= old-state new-state)
(println "Metrics updated:" new-state))))
;; Example usage
(record! :request-count)
(record! :error-count)
;; Later, read the current state
@metrics-agent
;; => {:request-count 1, :error-count 1}
This pattern keeps state updates serializable, avoids explicit locking, and leverages the agent’s built‑in error handling. If the update function were potentially blocking (e.g., a network call), you would replace send with send-off to use the separate pool reserved for blocking work.
Validation with clojure.test
A minimal test confirms that the agent accumulates values as expected. The test sends two updates, waits for the agent to finish processing, and asserts the final map.
(ns metrics-agent-test
(:require [clojure.test :refer :all]
[metrics-agent :refer [metrics-agent record!]]))
(deftest agent-accumulates-metrics
(testing "agent updates are applied serially"
(reset! metrics-agent {}) ;; start clean
(record! :a)
(record! :b)
(await metrics-agent) ;; block until all queued actions complete
(is (= {:a 1 :b 1} @metrics-agent))))
;; Run with: clojure -X:test
The test uses await to block the calling thread until the agent’s queue is drained, ensuring deterministic assertions.
Practical Checks and Limitations
To verify that the agent is using the default thread pool, you can inspect the agent system:
(agents)
;; => #agent{:state {} :queue [] ...}
The output shows the agent’s state and its internal queue; a non‑empty queue indicates pending work.
Limitations to keep in mind:
- Agents are not suitable for high‑frequency, low‑latency updates that require sub‑millisecond response times because each update incurs a thread‑pool handoff.
- If you need to coordinate multiple independent state changes (e.g., a transfer between two accounts), agents alone cannot guarantee atomicity across agents; you would need a ref or a transaction.
- core.async channels can leak resources if a go‑block exits without closing the channel; always wrap channel operations in a
try/finallyor use the<!/>!operators inside a go block that guarantees cleanup.
By matching the primitive to your service’s latency, reasoning, and integration needs, you can build concurrent Clojure code that is both performant and maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.