Implement a shared counter with a Clojure atom, compare it with Java AtomicInteger and synchronized counters, and learn the retry-safe rules that keep atom updates correct under contention.
A shared counter is the smallest useful example of Clojure state. Java engineers have probably built this with synchronized, AtomicInteger, LongAdder, or a ConcurrentHashMap of counters. In Clojure, the starting point is usually an atom that owns one immutable value.
Atom: A Clojure reference type for one independent state value that changes synchronously through atomic update functions.
Use an atom when the whole counter state can be replaced as one value. Do not use one atom per field unless those fields are truly independent.
Java has several valid counter designs:
| Java approach | Good fit | Clojure lesson |
|---|---|---|
synchronized field update |
Simple protected field | The lock protects one mutable location. |
AtomicInteger |
Hot numeric counter | Atomic read-modify-write matters. |
LongAdder |
Very high contention metrics | Throughput can matter more than exact immediate reads. |
ConcurrentHashMap plus counters |
Many named counters | The map and each counter need ownership rules. |
The Clojure version can start with a map inside one atom:
1(ns examples.counter)
2
3(def counts (atom {}))
4
5(defn record! [event-type]
6 (swap! counts update event-type (fnil inc 0)))
7
8(defn count-for [event-type]
9 (get @counts event-type 0))
10
11(defn snapshot []
12 @counts)
The atom is mutable. The map inside it is not. Each successful swap! installs a new map value, usually sharing most of its structure with the old one.
Use futures to create concurrent updates from several JVM threads:
1(defn run-simulation! []
2 (reset! counts {})
3 (let [tasks (doall
4 (for [_ (range 1000)]
5 (future
6 (record! :request)
7 (record! :db-call))))]
8 (doseq [task tasks]
9 @task)
10 (snapshot)))
11
12(run-simulation!)
13;; => {:request 1000, :db-call 1000}
If this were a plain mutable map, concurrent read-modify-write operations could lose increments. With swap!, Clojure retries the update function when another thread wins the race first.
The function passed to swap! may run more than once. That is the most important rule for Java engineers moving from lock-protected critical sections.
Put inside swap! |
Keep outside swap! |
|---|---|
| Pure calculations | Logging |
assoc, update, conj, dissoc |
Database writes |
| Validation that only reads the current value | HTTP calls |
| Construction of the next immutable state | Random IDs or timestamps that must happen once |
This is safe:
1(defn record-status! [status]
2 (swap! counts update-in [:status status] (fnil inc 0)))
This is risky because the log can repeat on a retry:
1(defn record-with-bad-side-effect! [event-type]
2 (swap! counts
3 (fn [current]
4 (println "recording" event-type)
5 (update current event-type (fnil inc 0)))))
Compute the new state inside swap!; perform one-time side effects before or after the atomic update.
Dereferencing an atom gives a stable immutable value:
1(defn top-events [n]
2 (->> @counts
3 (sort-by val >)
4 (take n)))
top-events does not need to lock the atom while sorting. It works from the value it read. If another request updates the atom during the sort, that update belongs to a later snapshot.
An atom is not the best fit for every counter.
| Situation | Better choice |
|---|---|
| Extremely hot metrics path with heavy contention | Java LongAdder behind a small metrics boundary |
| Several independent values must change together | One combined atom or refs with dosync |
| Counter update triggers background work | Atom for state, agent/queue/executor for work |
| Counter must be durable | Database or event log as source of truth |
The atom design is excellent for moderate shared application state, feature counters, request summaries, and REPL-visible diagnostics. For low-level telemetry, keep specialized Java counters at the edge and return ordinary Clojure data to the rest of the program.