Implementing Event Loops in Clojure: Mastering Asynchronous Programming

Learn how to build event loops in Clojure with channels, dispatch maps, and blocking boundaries that keep the loop responsive under load.

Event loop: A long-running process that waits for incoming events, dispatches each one to the right handler, and stays responsive by keeping slow work out of the loop itself.

An event loop is mainly about ownership. One process owns the incoming stream, chooses how each event is classified, and hands work to the correct next stage. That makes the system easier to reason about because there is one visible place where work enters and gets routed.

Core Shape

In Clojure, an event loop often starts as a channel plus a dispatch map:

1(require '[clojure.core.async :as async])
2
3(defn start-loop [in handlers]
4  (async/go-loop []
5    (when-some [event (async/<! in)]
6      (when-let [handle! (get handlers (:type event))]
7        (handle! event))
8      (recur))))

This is deliberately small. The value is not sophistication. The value is that routing is explicit.

Keep the Loop Narrow

The event loop should:

  • accept input
  • decode or classify it
  • dispatch quickly

It should not:

  • perform long blocking I/O
  • run expensive CPU work inline
  • accumulate unrelated business logic

Once the loop becomes the place where everything happens, it stops being a clean event loop and turns into a bottleneck.

Dispatch Maps Beat Long Conditionals

Most event loops improve when event type to handler mapping is data-driven:

1(def handlers
2  {:connected handle-connected
3   :message   handle-message
4   :timeout   handle-timeout})

That keeps the loop focused on routing rather than branching noise. It also makes ownership clearer when handlers evolve independently.

Handling Blocking Work

If a handler needs blocking I/O, hand it off:

  • to thread
  • to a worker pool
  • to pipeline-blocking
  • to a separate stage

This is one of the most important Clojure async rules. A go-loop is for parking channel operations, not for arbitrary blocking tasks.

Lifecycle Matters

Event loops also need a shutdown story:

  • who closes the input channel
  • whether in-flight events are drained or dropped
  • how downstream workers are told to stop

Without that lifecycle design, event loops are easy to start and awkward to stop, which shows up quickly in tests, REPL-driven development, and graceful service shutdown.

One Ingress Point Helps Debugging

An event loop becomes easier to operate when there is one obvious ingress channel or one clearly documented set of ingress channels. That makes it possible to answer basic runtime questions quickly:

  • where did this event enter?
  • which code is allowed to enqueue it?
  • how can it be sampled or traced?
  • what path should be paused during incident response

When events can appear from too many hidden entry points, the loop may still function, but diagnosing overload or misrouting becomes much harder.

Overload Policy Belongs Here Too

Every event loop needs an answer to overload:

  • bounded buffer and backpressure
  • dropping low-value events
  • load shedding
  • rerouting to a retry path

If you do not choose the policy explicitly, the policy is usually “degrade unpredictably.”

Instrument the Loop

Useful event-loop metrics include:

  • buffer pressure
  • event rate by type
  • dispatch latency
  • dropped or retried event count

These metrics reveal whether the loop is still acting like a dispatcher or has started absorbing too much work itself.

Event Loop vs Reactor

These overlap but are not identical:

  • an event loop is the control structure
  • a reactor is a design centered on fast dispatch around event readiness

Many reactors contain an event loop, but not every event loop is best described as a reactor.

Practical Rule

Build event loops as small dispatch centers with explicit handoff boundaries. Their job is to keep the system moving, not to do all the work themselves.

Revised on Thursday, April 23, 2026