Learn how to shape atoms, refs, agents, batches, and persistent collections so state changes stay small, explicit, and efficient under concurrent JVM load.
State tuning in Clojure is not about making immutable data mutable again. It is about doing as much work as possible with local immutable values, then publishing the smallest necessary change through the right coordination primitive.
Java engineers often start with a shared object and add synchronization. In Clojure, start with pure transformations, then decide which state boundary must exist.
| Tuning move | Why it helps |
|---|---|
Keep computation outside swap! and dosync |
Less time is spent in retryable coordination. |
| Batch many events into one state update | Fewer atom CAS attempts or STM transactions. |
| Split unrelated state into separate atoms | Independent updates stop fighting over one root value. |
| Use refs only for coordinated invariants | STM is valuable when consistency spans multiple places. |
| Use agents for ordered background work | A single owner can serialize work without caller-side locking. |
The goal is not to use the cleverest primitive. The goal is to make the ownership model obvious.
This version updates shared state once per event:
1(def order-counts (atom {}))
2
3(defn record-event!
4 [event]
5 (swap! order-counts update (:type event) (fnil inc 0)))
That is acceptable for low volume. Under load, each event competes for the same atom. A better first version summarizes locally, then publishes once:
1(defn summarize-events
2 [events]
3 (reduce (fn [summary {:keys [type]}]
4 (update summary type (fnil inc 0)))
5 {}
6 events))
7
8(def order-counts (atom {}))
9
10(defn record-events!
11 [events]
12 (let [summary (summarize-events events)]
13 (swap! order-counts merge-with + summary)))
The pure function is easy to test and can run on any thread. The atom update is short enough that retries are less expensive.
One large atom can be convenient, but it can also serialize unrelated work.
1(def app-state
2 (atom {:sessions {}
3 :metrics {}
4 :feature-flags {}}))
If sessions change constantly, metrics arrive in bursts, and feature flags change rarely, one atom forces unrelated updates through the same compare-and-set path. Separate atoms can make the real ownership clearer:
1(def sessions (atom {}))
2(def metrics (atom {}))
3(def feature-flags (atom {}))
Do not split state mechanically. Split when update frequency, ownership, or consistency rules differ.
Use refs when a change must preserve an invariant across multiple identities.
1(def checking-balance (ref 500M))
2(def savings-balance (ref 2000M))
3
4(defn transfer-to-savings!
5 [amount]
6 (dosync
7 (when (<= amount @checking-balance)
8 (alter checking-balance - amount)
9 (alter savings-balance + amount)
10 true)))
This is a good STM shape because the invariant is cross-ref: money must not disappear between checking and savings. A metrics counter, cache refresh time, or independent request total usually does not need STM.
Persistent collections are usually efficient enough. If profiling proves that a local collection-building loop is hot, use transients inside the pure function and return an ordinary persistent value.
1(defn index-by-id
2 [orders]
3 (persistent!
4 (reduce (fn [idx order]
5 (assoc! idx (:id order) order))
6 (transient {})
7 orders)))
The transient never escapes the function. Shared state still receives an immutable result.
| Review question | Good answer |
|---|---|
| Can this computation run before touching shared state? | Yes, make it a pure function and publish the result. |
| Do these fields really need one atom? | Only if they share update timing or consistency rules. |
| Does this operation require a cross-value invariant? | If yes, refs may fit; if no, prefer simpler state. |
| Is the hot path building many intermediate collections? | Profile first, then consider reducers, transients, or chunked processing. |
| Is an agent action blocking? | Move blocking work to an appropriate executor, queue, or durable boundary. |
| Java habit | Clojure tuning habit |
|---|---|
Add synchronized to protect a mutable object. |
Make the data immutable and coordinate only the reference change. |
| Use one service object as the mutable center. | Separate pure domain functions from state-owning namespaces. |
| Update counters one event at a time. | Aggregate locally, then publish fewer changes. |
| Share a mutable map for convenience. | Share values freely, but control where new values are installed. |
| Optimize by changing collections first. | Profile first, then tune allocation or state shape. |