Choosing atom, ref, or agent for concurrent state in Clojure
Pick atom for independent synchronous state, ref for coordinated transactional state across multiple values, and agent for asynchronous decoupled work. A compact comparison and practical Clojure examples show when each primitive fits and what to watch for.
30 Dec 2025, 21:42 UTC

The decision
You need mutable state that is safe under concurrent access in Clojure. The practical choice is between atom for independent synchronous state, ref for coordinated transactional state, and agent for asynchronous independent state. Pick atom when you want low-latency single-value updates, ref when you need atomic changes across multiple values, and agent when you want to decouple work from the caller.
Constraints that drive the choice are latency versus eventual consistency, whether atomicity must span more than one value, and how much coordination you can tolerate between threads.
Options at a glance
| Primitive | Sync/Async | Coordinated? | Typical use |
|---|---|---|---|
| Atom | Synchronous | No | Independent counters, flags, single-value state |
| Ref | Synchronous via dosync | Yes | Bank transfers, multi-var invariants |
| Agent | Asynchronous | No | Background logging, queue processing |
Atom uses compare-and-swap retry. It is not part of STM and cannot coordinate with other atoms.
Ref lives inside Software Transactional Memory. Changes are made with alter inside a dosync block and commit atomically.
Agent holds state and processes actions on a separate thread pool via send. Reads are synchronous with deref, writes are asynchronous.
Trade-offs
Atom
Fastest read and write for a single value. No cross-var guarantees. swap! retries on contention but does not block other threads. Good for independent counters and flags.
Ref
Provides ACID-like transactions across multiple refs. dosync gives atomicity and isolation, but adds overhead and can cause contention if transactions are long or frequent. Avoid holding locks across I/O inside dosync.
Agent
Decouples caller from work, improving responsiveness. Actions are queued and applied in order. Lag is introduced between send and visible state. Exceptions in actions are swallowed unless you set a custom error-handler with set-error-handler!, which can hide failures.
Concrete examples
Run the following in a REPL with read access to the namespace. No special permissions are required.
(def counter (atom 0))
(swap! counter inc)
@counterswap! applies a function to the current value and retries on conflict. Deref @ gives the current value synchronously.
(def accounts (ref {:a 100 :b 100}))
(dosync
(alter accounts update :a -50)
(alter accounts update :b +50))
@accountsalter schedules a change to a ref inside the transaction. dosync commits both updates together or aborts on exception.
(def logger (agent []))
(send logger conj "msg")
@loggersend enqueues an action for the agent. The deref reflects the state after actions have been applied. The exact timing depends on the agent thread pool.
How to check the result
- Define each var, perform the operations, and deref to observe the state changes. For atom and ref the change is visible immediately after the call. For agent, the change becomes visible after the action runs.
- Use clojure.test to assert that a dosync transaction either fully commits or aborts on exception, confirming atomicity across refs.
- Monitor contention by keeping dosync bodies short and avoiding I/O inside them.
Limitations
Atoms do not coordinate. Using multiple atoms for a single invariant can lead to race conditions.
Refs can suffer from retry storms under high contention. Profile transaction length and keep transactions small.
Agents introduce eventual consistency. Do not use an agent when the caller needs an immediate result. Always configure an error-handler for production agents to avoid silent failures.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.