Avoiding Deadlocks and Race Conditions in Clojure Concurrency

Learn how Clojure reduces race conditions, where deadlocks can still appear, and how to choose atoms, refs, agents, and interop boundaries safely.

Race condition: A bug where the outcome depends on timing or interleaving.
Deadlock: A system state where progress stops because competing operations wait on each other indefinitely.

Clojure makes concurrency safer than many mainstream languages, but it does not make concurrency automatic. Immutable values eliminate a large class of shared-mutation bugs, yet timing problems still appear when you choose the wrong reference type, mix in blocking interop, or spread one invariant across several coordination models.

The most practical way to avoid deadlocks and race conditions in Clojure is to reduce the amount of mutable coordination in the design, then pick the smallest correct coordination tool for what remains.

Race Conditions in Real Clojure Code

Race conditions usually come from one of these mistakes:

  • updating several atoms that should really be one transaction
  • putting side effects inside swap! functions that may retry
  • reading state, computing outside the coordination boundary, then writing stale assumptions back
  • mixing JVM locks, futures, and Clojure references without a clear ownership model

This is safe for one independent value:

1(def requests-served (atom 0))
2
3(swap! requests-served inc)

This is unsafe if two values must remain consistent together:

1;; Usually a design smell when these must move together.
2(def seats (atom 10))
3(def reservations (atom 0))

That pair wants refs and dosync, not two unrelated atoms.

Where Deadlocks Still Come From

Clojure’s STM avoids many classic lock-ordering deadlocks because it coordinates with transactions rather than manual locks. But deadlocks can still happen when:

  • Java locks or synchronized sections are introduced through interop
  • one thread blocks waiting for work produced by another blocked thread
  • a bounded pipeline is designed with circular waits
  • thread pools are exhausted by tasks waiting on tasks from the same pool

The lesson is simple: Clojure lowers deadlock risk, but it does not remove it when you build blocking dependency cycles.

Choose the Right Coordination Tool

Most concurrency bugs start with the wrong primitive:

  • atom for one independent value
  • ref for coordinated synchronous updates across values
  • agent for queued asynchronous updates to one value
  • core.async channels for explicit asynchronous handoff and backpressure

When one design mixes all four without a clear boundary, complexity rises quickly. Prefer one dominant coordination model per subsystem.

Design Techniques That Prevent Trouble

The safest concurrency patterns are mostly architectural:

  • keep mutable coordination boundaries small
  • keep blocking I/O out of go blocks and out of STM transactions
  • keep swap! functions pure because retries are normal
  • keep transaction bodies short
  • use bounded queues or channels so overload becomes visible
  • use consistent lock ordering when Java interop forces manual locking

If you must use Java locking APIs, document lock order explicitly. Clojure’s safer defaults do not protect code that descends into arbitrary low-level lock graphs.

A Safer Coordinated Update

1(def available-seats (ref 10))
2(def reservations    (ref 0))
3
4(defn reserve-seat! []
5  (dosync
6    (when (zero? @available-seats)
7      (throw (ex-info "Sold out" {})))
8    (alter available-seats dec)
9    (alter reservations inc)))

This version keeps the invariant inside one transaction. The risk is no longer “two threads update two atoms out of sync.” The remaining concerns are now clearer: contention, retries, and business rules.

Diagnostics and Testing

When concurrency code feels unreliable, test invariants rather than exact thread schedules:

  • total balances remain conserved
  • queue depth never becomes negative
  • no item is processed twice
  • sold seats plus free seats always equal capacity

That style catches real bugs better than tests that assert one lucky interleaving.

Practical Heuristics

You rarely “debug your way out” of concurrency problems permanently. The durable fix is usually a narrower coordination boundary, a better primitive, or a clearer ownership rule. In Clojure, that often means moving complexity away from locks and toward immutable values plus a small, explicit state model.

Ready to Test Your Knowledge?

Loading quiz…
Revised on Thursday, April 23, 2026