Asynchronous Programming Patterns in Clojure: Mastering Concurrency with Core.Async and More

Learn how to choose among futures, promises, agents, channels, and callback handoff patterns in Clojure based on the real interaction shape.

Asynchronous pattern: A repeatable way to let work proceed over time without forcing the caller to wait immediately for every step to finish.

The most useful question in Clojure async design is not “How do I make this non-blocking?” It is “What shape does this interaction actually have?” Different shapes want different tools:

  • one result later
  • many values over time
  • coordinated background state change
  • explicit request and handoff between stages

Once that shape is clear, the Clojure choice usually becomes straightforward.

Pattern 1: One-Shot Result

If one computation runs in the background and eventually yields one value, start with future:

1(def user-report
2  (future
3    (build-report! user-id)))
4
5@user-report

This is the right shape when:

  • the work is independent
  • the caller only needs one eventual result
  • there is no broader communication topology

Do not force channels into this case just because they are available. A one-shot background computation is often clearest as a future.

Pattern 2: Single Assignment Handoff

If a value is supplied once, possibly from another thread or callback edge, promise or promise-chan is often the right abstraction.

This fits:

  • startup gates
  • deferred initialization
  • callback bridging
  • “whoever finishes first writes the answer” coordination

The value of this pattern is not speed. It is that the ownership model is obvious: one result, one handoff, many readers if needed.

Pattern 3: Stream of Messages

If values arrive over time and multiple steps consume them, use channels.

1(require '[clojure.core.async :as async])
2
3(def inbound (async/chan 64))
4
5(async/go-loop []
6  (when-some [msg (async/<! inbound)]
7    (handle-message msg)
8    (recur)))

Channels fit when:

  • messages form a stream
  • handoff boundaries should be explicit
  • backpressure matters
  • producers and consumers should stay loosely coupled

This is where core.async is strongest. It gives the communication shape a concrete, reviewable form.

Pattern 4: Background State Change

If the real problem is “accept actions and apply them asynchronously to one evolving value,” use an agent rather than inventing your own message loop.

Agents are a good fit when:

  • one logical state value changes over time
  • updates should be serialized
  • callers should not block on every change

That is not the same as a general event bus or reactor. It is asynchronous state evolution around one state owner.

Pattern 5: Callback Boundary to Channel Boundary

Many real systems use libraries that expose callbacks rather than channels. In Clojure, a useful bridging pattern is:

  1. accept the callback
  2. put the callback result onto a channel
  3. continue normal processing from the channel side

That keeps the callback edge thin and moves the real logic into the main async model the rest of the system uses.

Blocking vs Parking Still Matters

The most common async mistake in Clojure is using go as a general “run later” construct. It is not. go is for parking channel operations. Blocking I/O belongs in:

  • thread
  • future
  • an executor-backed task
  • pipeline-blocking

If you blur that distinction, the program may look asynchronous while quietly starving the fixed go pool.

Choosing by Interaction Shape

Use this decision rule:

  • one result later -> future
  • single-assignment handoff -> promise or promise-chan
  • stream of values -> channels
  • queued async state updates -> agent
  • staged processing -> channel pipeline

Asynchronous design gets much easier once you stop asking one tool to model every interaction.

Asynchronous Edges Need Explicit Ownership

Asynchronous code gets easier to trust when every handoff makes ownership obvious. Someone should own the request, someone should own the result channel or callback, and someone should own timeout, cancellation, and cleanup.

That usually means deciding:

  • who is responsible for closing a channel or completing a promise
  • where timeout policy lives
  • whether the caller or callee owns retries
  • how abandoned work is detected during shutdown or user cancellation

Without those answers, asynchronous patterns look clean in examples but become hard to reason about during incidents.

Practical Rule

Clojure has several async tools because asynchronous work is not one problem. Choose the tool that matches the lifetime and ownership of the interaction. If the communication shape is explicit, the rest of the design is usually easier to keep honest.

Revised on Thursday, April 23, 2026