Browse Learn Clojure Foundations as a Java Developer

Build a Thread-Safe Counter with an Atom

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.

From Java Counter To Clojure Counter

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.

Simulate Contention

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.

Keep The Update Function Pure

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.

Snapshot Reads

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.

When Not To Use This Design

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.

Knowledge Check

### Why is an atom a good fit for the counter map in this example? - [x] The whole counter map is one independent state value - [ ] Each key needs its own Java lock - [ ] Atoms make database writes durable - [ ] Atoms run updates asynchronously > **Explanation:** Atoms are best for one independent state value. The map can be replaced atomically while readers keep immutable snapshots. ### Why should the function passed to `swap!` avoid logging or database writes? - [x] It may be retried under contention - [ ] It always runs on a UI thread - [ ] It cannot return a map - [ ] It prevents dereferencing the atom > **Explanation:** `swap!` can rerun the update function if another thread changes the atom first. One-time effects can repeat even though only one final state is installed. ### What does `@counts` return to a reader? - [x] The current immutable map value held by the atom - [ ] A lock that must be released - [ ] A mutable copy of every counter - [ ] A future that completes later > **Explanation:** Dereferencing an atom returns its current value. Standard Clojure maps are immutable, so the reader can work from a stable snapshot.
Revised on Saturday, May 23, 2026