Managing Shared State with Atoms in Clojure

Learn when atoms fit, how swap! retries work, and how to use validators, watches, and compare-and-set! safely in concurrent Clojure code.

Atom: A synchronous reference type for one independently changing value. Atoms use compare-and-swap under the hood, so each update is atomic, but they do not coordinate with other references.

Atoms are the simplest shared-state tool in Clojure, and they are often the right one. If you have one value that changes over time, and that value does not need to move in lockstep with other values, an atom is usually the cleanest choice. Counters, caches, in-memory indexes, feature flags, and runtime configuration snapshots often fit this model well.

The key constraint is independence. An atom is excellent when one value can change on its own. It is the wrong choice when several pieces of state must remain consistent together, because atoms give you atomic updates to one reference at a time, not cross-reference transactions.

When Atoms Fit

Use an atom when all of these are true:

  • the state can be represented as one evolving value
  • updates are synchronous and should be visible immediately
  • each update can be retried safely
  • there is no requirement to coordinate with other references

That last point matters most. If you have :inventory and :balance that must change together, two separate atoms are a trap. If you have one cache map or one metrics snapshot that changes independently, an atom is a good fit.

1(def app-state
2  (atom {:feature-flags #{:new-search}
3         :request-count 0
4         :last-reload nil}))
5
6(swap! app-state update :request-count inc)

Updating Atoms Safely

The workhorse operation is swap!. It reads the current value, applies your function, and attempts to install the new value atomically. If another thread wins the race first, Clojure retries your function with the newer value.

That retry behavior is why the update function passed to swap! must be free of accidental side effects. Logging, HTTP calls, database writes, and random value generation inside the update function can happen more than once under contention.

1(def counter (atom 0))
2
3(swap! counter inc)
4(swap! counter + 10)

For structured state, prefer small pure transformation functions:

1(defn mark-user-online [state user-id]
2  (assoc-in state [:users user-id :status] :online))
3
4(swap! app-state mark-user-online 42)

Use reset! only when you truly want to replace the current value without deriving it from the old one:

1(reset! app-state {:feature-flags #{}
2                   :request-count 0
3                   :last-reload (java.time.Instant/now)})

Compare-and-Set, Validators, and Watches

compare-and-set! is useful when you want an explicit optimistic update that should succeed only from one known state:

1(def lifecycle (atom :starting))
2
3(compare-and-set! lifecycle :starting :running)
4;; => true or false

Validators let you reject invalid states before they are installed:

1(def port
2  (atom 8080
3        :validator #(<= 1 % 65535)))

Watches are useful for observation and integration boundaries, but they should stay lightweight. A watch is a notification hook, not a business-logic pipeline.

1(add-watch app-state :audit
2  (fn [_ _ old-state new-state]
3    (when (not= (:feature-flags old-state)
4                (:feature-flags new-state))
5      (println "Feature flags changed"))))

Common Mistakes with Atoms

The most common atom mistakes are architectural, not syntactic:

  • stuffing unrelated state into many loosely coordinated atoms
  • putting side effects inside swap! functions
  • using an atom where a ref transaction is required
  • treating a watch as a reliable event bus

If your update rule depends on several references staying consistent, move up a level to refs and STM. If your updates are naturally asynchronous and should be serialized in the background, agents are a better fit.

Practical Heuristics

If you are deciding between Clojure’s main state tools, this is the shortest useful rule set:

  • use an atom for one independent value
  • use a ref for coordinated synchronous changes across values
  • use an agent for queued asynchronous state changes

That distinction is more important than memorizing API details. Most concurrency bugs in Clojure come from choosing the wrong coordination model, not from forgetting the name of a function.

Ready to Test Your Knowledge?

Loading quiz…
Revised on Thursday, April 23, 2026