Sequential Async UI Logic in ClojureScript with core.async
Use core.async go blocks to turn callback-heavy ClojureScript UI code into sequential logic without blocking the browser event loop.
24 Mar 2026, 03:21 UTC

A button click that turns into callback spaghetti
Imagine a "Load more" button in a browser app. The click handler must disable the button, show a spinner, wait for a network response, hide the spinner, append rows, and re-enable the button. Written with callbacks or raw JavaScript promises, that sequence spreads across closures and error branches. Adding a retry or a timeout makes it harder to read.
The useful takeaway: ClojureScript's core.async library lets you write that same sequence as straight-line code inside a go block. Channel operations such as <! suspend the block without freezing the browser tab.
Channels, go blocks, and suspension
A channel is a mailbox that passes values between parts of a program. A go block is a macro that expands into a state machine. Inside it, <! reads from a channel and >! writes to one. When a go block hits <! on an empty channel, it pauses. The JavaScript event loop keeps running. When a value arrives, the state machine resumes where it left off.
Two rules matter: <! and >! are legal only inside go blocks. Calling them elsewhere throws at runtime. Also, core.async is cooperative, so CPU-heavy work inside a block stalls the tab until the code reaches a channel operation.
Worked example: a sequential load button
This example assumes a ClojureScript project using shadow-cljs or Figwheel Main, with org.clojure/core.async added to deps.edn. Replace VERSION with the current release you have verified against your ClojureScript compiler.
{:deps {org.clojure/core.async {:mvn/version "VERSION"}}}
Run the browser REPL from your project directory. No special permissions are required.
npx shadow-cljs cljs-repl app
In the REPL, define a shared channel and a loop that handles clicks sequentially:
(ns app.ui
(:require [cljs.core.async :as async
:refer [chan go <! >! timeout]]))
(def load-ch (chan 1))
(defn init-load-button []
(let [button (js/document.getElementById "load-btn")
status (js/document.getElementById "load-status")]
(go
(loop []
(let [msg (<! load-ch)]
(when (= :load msg)
(set! (.-disabled button) true)
(set! (.-textContent status) "Loading…")
(<! (timeout 1500))
(set! (.-textContent status) "Loaded")
(set! (.-disabled button) false))
(recur))))
(set! (.-onclick button)
(fn [e]
(>! load-ch :load)))))
Where to run: evaluate this in the browser REPL after the page has loaded, then call (init-load-button). Expected check: clicking the button disables it, shows "Loading…", and after roughly 1.5 seconds shows "Loaded" and re-enables the button. During the wait, the page should remain responsive; you can click other elements or move the mouse without the tab freezing.
Risk: the <! (timeout 1500) expression suspends the state machine, but it does not block JavaScript. If you replace the timeout with a tight computation loop, the tab will stall because core.async yields only at channel operations.
Limitations and how to verify
<!and>!outsidegoblocks raise a runtime exception. Keep them inside.- Channels are not automatically garbage collected while referenced. Use bounded channels and drop references when a component unmounts to avoid memory leaks.
- core.async does not wrap JavaScript promises automatically. You still need interop or a small helper to await
fetchinside agoblock.
Practical verification: in the browser REPL, evaluate:
(go
(js/console.log "before")
(<! (timeout 100))
(js/console.log "after"))
You should see "after" appear about 100 milliseconds after "before," and the browser should remain interactive in between. If "after" never appears, or the tab freezes, check that the code is running inside a go block and that no CPU-heavy work precedes the channel operation.
When to use it
Use core.async when a UI flow has multiple ordered asynchronous steps—click, wait, update, wait, update—and you want to avoid nesting callbacks. For a single HTTP request, a plain promise may be simpler. Keep go blocks short, offload heavy computation to web workers, and prefer bounded channels.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.