A Clojure/ClojureScript library for scoped values. On JDK 25+, uses Java's ScopedValue API for efficient context
propagation with virtual threads. On older JDKs, falls back to ThreadLocal. Full API support in ClojureScript.
Clojure:
ThreadLocal)ScopedValue)On JDK 25+, the library uses Java's ScopedValue API for maximum performance. On older JDKs, it
automatically falls back to a ThreadLocal-based implementation with identical semantics.
ClojureScript:
;; deps.edn
co.multiply/scoped {:mvn/version "0.1.15"}
This library emerged while working on async code where it became clear that extracting and setting thread bindings
accounted for the vast majority of the overhead involved. While I can't make broad claims about the efficiency of
ScopedValue (introduced in Java 21, GA in Java 25), it made a significant difference for my own code. Switching to
scoped values cut about 95% of the overhead: from ~20μs to ~1μs per async operation.
This library provides a way to use ScopedValue when running on JDK 25, while providing a semantically identical
fallback to ThreadLocal when running on older versions of the JDK. Full API support is available in ClojureScript,
enabling scope capture and restoration patterns in async contexts.
scoping (CLJ + CLJS)Establish scoped bindings, similar to binding:
(require '[co.multiply.scoped :refer [scoping ask]])
(def ^:dynamic *user-id* nil)
(def ^:dynamic *request-id* nil)
(scoping [*user-id* 123
*request-id* "abc"]
(ask *user-id*))
;; => 123
Scopes nest naturally; inner bindings shadow outer ones:
(scoping [*user-id* 1]
(scoping [*user-id* 2]
(ask *user-id*)))
;; => 2
ask (CLJ + CLJS)Access a scoped value. Falls back to the var's root binding if not in scope:
(require '[co.multiply.scoped :refer [ask scoping]])
(def ^:dynamic *user-id* :default)
(ask *user-id*) ; => :default
(scoping [*user-id* 123]
(ask *user-id*))
;; => 123
Throws IllegalStateException if the var is unbound and not in scope.
The two-arity form returns a default value instead of throwing:
(def ^:dynamic *user*)
(ask *user* :anonymous) ; => :anonymous (var is unbound)
(scoping [*user* 123]
(ask *user* :anonymous)) ; => 123 (default not used)
This is useful for optional context that should be a no-op when not established.
Note: In CLJS, a var with value
nilis indistinguishable from an unbound var when not in scope. However, explicitly scoping tonilworks correctly and returnsnil(not the default).
Gotcha: It can be easy to forget ask and reference the var directly. With a default value, this fails silently:
(def ^:dynamic *user-id* :default)
(scoping [*user-id* 123]
(str "User: " *user-id*)) ; Oops, forgot `ask`
;; => "User: :default" (wrong!)
Prefer unbound vars. They're more likely to fail when used, making the mistake obvious:
(def ^:dynamic *user-id*)
(scoping [*user-id* 123]
(+ *user-id* 1)) ; Forgot `ask`
;; => ClassCastException: Var$Unbound cannot be cast to Number
current-scope (CLJ + CLJS)Capture the current scope map for later restoration:
(require '[co.multiply.scoped :refer [current-scope scoping]])
(def ^:dynamic *user-id*)
(scoping [*user-id* 123]
(current-scope))
;; => {#'*user-id* 123}
assoc-scope (CLJ + CLJS)Extend a captured scope with additional bindings without creating another lambda:
(require '[co.multiply.scoped :refer [assoc-scope current-scope scoping]])
(def ^:dynamic *user-id*)
(def ^:dynamic *request-id*)
(def captured
(scoping [*user-id* 123]
(current-scope)))
(assoc-scope captured *request-id* "abc")
;; => {#'*user-id* 123, #'*request-id* "abc"}
This is useful when you have a captured scope and want to add bindings before restoring it, avoiding the overhead of
nesting scoping inside with-scope.
with-scope (CLJ + CLJS)Restore a previously captured scope:
(require '[co.multiply.scoped :refer [ask current-scope scoping with-scope]])
(def ^:dynamic *user-id*)
(def captured
(scoping [*user-id* 123]
(current-scope)))
(with-scope captured
(ask *user-id*))
;; => 123
Combined with assoc-scope:
(with-scope (assoc-scope captured *request-id* "abc")
(ask *request-id*))
;; => "abc"
Capture and restore scope across virtual thread boundaries:
(require '[co.multiply.scoped :refer [ask current-scope scoping with-scope]])
(defmacro vt
[& body]
`(let [scope# (current-scope)]
(Thread/startVirtualThread
(fn run# [] (with-scope scope# ~@body)))))
(def ^:dynamic *user-id*)
(scoping [*user-id* 123]
(vt (println "User:" (ask *user-id*))))
;; Prints: "User: 123"
(scoping [*user-id* 123]
;; Nested virtual threads
(vt (vt (println "User:" (ask *user-id*)))))
;; Prints: "User: 123"
In JavaScript, async callbacks (setTimeout, Promises, event handlers) run after the current scope exits.
You must capture and restore the scope explicitly:
(require '[co.multiply.scoped :refer [ask current-scope scoping with-scope]])
(def ^:dynamic *user-id*)
;; This WON'T work - scope exits before callback runs:
(scoping [*user-id* 123]
(js/setTimeout
#(println "User:" (ask *user-id*)) ; Runs with empty scope!
100))
;; This WILL work - capture and restore:
(scoping [*user-id* 123]
(let [scope (current-scope)]
(js/setTimeout
#(with-scope scope
(println "User:" (ask *user-id*))) ; Prints: "User: 123"
100)))
This pattern applies to all async boundaries: setTimeout, js/Promise, core.async channels, etc.
Eclipse Public License 2.0. Copyright (c) 2025 Multiply. See LICENSE.
Authored by @eneroth
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |