A Clojure/ClojureScript library for scoped values. On the JVM, uses Java 25's ScopedValue API for efficient context
propagation with virtual threads. In ClojureScript, falls back to binding.
Clojure:
- JDK 25+ (recommended, LTS)
- Clojure 1.12+
ScopedValue was a preview feature in JDK 21-24 and became stable in JDK 25. It may work on earlier
versions with --enable-preview, but this is untested and the API may have changed between versions.
ClojureScript:
- Any supported ClojureScript version
;; deps.edn
co.multiply/scoped {:mvn/version "0.1.x"}Clojure's binding uses thread-local storage, which doesn't propagate to child threads. This is problematic with
virtual threads where work is frequently dispatched across threads.
ScopedValue (introduced in Java 21, GA in Java 25) provides immutable bindings that are significantly more efficient
than thread-locals, making them well-suited for both virtual threads and platform threads. Unlike thread-locals, they
have minimal overhead per binding and per-thread access.
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. Switching to scoped values cut about 95% of the overhead: from ~20μs to ~1μs per async operation.
To make it easier to use in Clojure/ClojureScript cross-compilation contexts, a ClojureScript code-path is included
which falls back on standard binding, as ScopedValue has no JavaScript equivalent.
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*))
;; => 123Scopes nest naturally; inner bindings shadow outer ones:
(scoping [*user-id* 1]
(scoping [*user-id* 2]
(ask *user-id*)))
;; => 2Access 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*))
;; => 123Throws IllegalStateException if the var is unbound and not in scope.
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 NumberCapture 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}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*))
;; => 123Capture 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"MIT License. Copyright (c) 2025 Multiply. See LICENSE.
Authored by @eneroth