Teaser Candidates: ClojureScript 1.12.145 (^:async / await)

Table of Contents

1. Overview

ClojureScript 1.12.145 introduced ^:async and await as first-class language constructs, bringing native JavaScript async/await semantics into ClojureScript. This creates a new category of brain teasers around async semantics that have no direct equivalent in JVM Clojure.

These teaser candidates target experienced Clojure developers who bring JVM-shaped intuitions to ClojureScript's single-threaded async model.

Repository: clojure-brain-teasers-review

2. T1. Serial vs parallel await in let

(defn ^:async fetch-two []
  (let [a (await (slow-promise 100))
        b (await (slow-promise 100))]
    [a b]))

Trick: Feels like parallel, runs serial (200ms). Parallel form needs ~js/Promise.all over a JS array of promises, then a single await. The let desugaring forces sequencing because each binding suspends the async frame before the next initializer evaluates.

Refutation: If the compiler ever rewrites independent await-bearing bindings into a Promise.all, this teaser dies. Current release notes don't suggest that, but worth a compiler-output check before shipping.

Status: SELECTED – primary teaser, implemented in repo.

3. T2. Plain function inside ^:async is not async

(defn ^:async outer []
  (let [f (fn [] (await (p)))]
    (f)))

The release example explicitly notes (fn [] 20) is "not async". So await inside a non-async inner fn is a compile error (or worse, a silent bare-identifier reference). Teaser: which? Verify before authoring. Add ^:async to the inner fn to fix.

Status: Needs compiler verification.

4. T3. await is not a function

(defn ^:async await-all [ps]
  (map await ps))

await is a special form. (map await ...) either fails to compile or treats await as a var lookup. Even if it compiled, map is lazy, so realization order is undefined relative to the async frame.

Fix: (await (js/Promise.all (clj->js ps)))

Status: SELECTED – implemented in repo.

5. T4. Async test that "passes" by accident

(deftest broken
  (-> (fetch)
      (.then #(is (= 42 %)))))

No ^:async, no await. Returns a promise the runner never waits on. is fires after the test reports success. Teaser asks: does this test pass, fail, or pass-but-lying? Answer: third.

Demonstrates why ^:async deftest matters.

Status: SELECTED – documented in repo source comments.

6. T5. binding does not survive await

(def ^:dynamic *ctx* nil)
(defn ^:async run []
  (binding [*ctx* :a]
    (await (p))
    *ctx*))

JVM Clojure has this exact gotcha with futures (frame conveyance). ClojureScript over native JS async will lose the dynamic binding across the microtask boundary because binding is a try/finally push-pop and the async desugar splits the body across event-loop ticks.

Expected return: nil, not :a.

Refutation: If the compiler emits binding-conveyance shims for async fns, this is wrong. Not verified against 1.12.145 sources yet – flagged for compiler-output check.

Status: SELECTED – implemented in repo.

7. T6. swap! across await

(def counter (atom 0))
(defn ^:async bump []
  (let [v (await (read-remote))]
    (swap! counter + v)))

JS is single-threaded but the event loop interleaves. Two concurrent (bump) calls can both read @counter before either swap! (if you read before await). The example above is actually safe because the read of counter is inside swap! after await. The teaser is the version that reads first:

(let [c @counter, v (await (read-remote))]
  (reset! counter (+ c v)))  ;; race

Key insight: Single-threaded-therefore-safe is the wrong intuition once you introduce suspension points.

Status: Strong candidate, not yet implemented.

8. T7. Throwing across await with try/catch :default

(defn ^:async safe []
  (try (await (rejecting-promise))
       (catch :default e :caught)))

Confirms rejection -> exception in catch. Pair with a version where try is outside the async fn and the caller does (.catch (safe) ...) without await – different semantics, different catch site.

Status: Good secondary candidate.

9. T8. Lazy seq holding an await result

(defn ^:async items []
  (map inc (await (fetch-coll))))

Returns a lazy seq whose head is fine but whose realization escapes the async frame. If fetch-coll resolved to a chunked seq of promises rather than values, realization at the call site is synchronous and won't await them.

Status: Niche – depends on specific return-type confusion.

10. T9. refer-global shadowing

(refer-global :only '[Promise])
(require '[my.lib :refer [Promise]])

Which wins? Order-dependent. A brain teaser if the book hasn't covered refer-global yet (introduced ahead of this release).

Status: Needs more investigation on precedence rules.

11. Selection Summary

Teaser Title Status Strength
T1 Serial vs parallel await in let Implemented Strong
T2 Non-async inner fn Needs check Medium
T3 await is not a function Implemented Strong
T4 Test passes by accident Documented Strong
T5 binding lost across await Implemented Strong
T6 swap! across await race Candidate Strong
T7 try/catch across await boundary Candidate Medium
T8 Lazy seq escaping async frame Candidate Niche
T9 refer-global shadowing Needs check Niche

12. Next Steps

  1. Verify T2, T5, T9 against ClojureScript 1.12.145 compiler output
  2. Add shadow-cljs build config to test repo for full compilation
  3. Measure actual timing for T1 serial vs parallel (CI benchmark)
  4. Write up final versions of top 3-4 teasers for publication

Author: J Walsh

jwalsh@nexus

Last Updated: 2026-05-11 07:53:08

build: 2026-05-11 07:53 | sha: 125deff