Skip to content
AgileLogics
7 min read

Performance

Prerequisites: Java interop, Transducers

Idiomatic Clojure is fast enough for most work — it compiles to JVM bytecode and benefits from the JIT. When a hot path needs more, these are the tools, roughly in order of impact.

1. Avoid reflection

(set! *warn-on-reflection* true)

(defn slow-length [s] (.length s))
(defn fast-length [^String s] (.length s))

(println (slow-length "reflection") (fast-length "hinted"))
Reflection warning, C:\demo\main.clj:3:23 - reference to field length can't be resolved.
10 6

Without a type hint, the compiler can't know which .length to call, so it looks it up by reflection on every call — often 10–100× slower. Turn on *warn-on-reflection* (projects usually set it for all namespaces) and add ^String, ^long, or class hints where warnings appear.

2. Use the right data structure and algorithm

  • Look up by key in maps and sets, not by scanning vectors.
  • Use transducers or reduce instead of long chains of lazy sequences in hot loops.
  • frequencies, group-by, and into are already optimized — prefer them to hand-written loops.

3. Transients for building collections

(defn squares-persistent [n]
  (loop [i 0 v []]
    (if (< i n) (recur (inc i) (conj v (* i i))) v)))

(defn squares-transient [n]
  (loop [i 0 v (transient [])]
    (if (< i n) (recur (inc i) (conj! v (* i i))) (persistent! v))))

(println (= (squares-persistent 100000) (squares-transient 100000)))
(println (count (squares-transient 1000000)))
true
1000000

A transient is a temporarily mutable version of a collection, for use inside one function: conj!, assoc!, then persistent! to get an ordinary immutable value back. into and frequencies use them internally.

4. Primitive math

(defn sum-to ^long [^long n]
  (loop [i 0 acc 0]
    (if (< i n) (recur (inc i) (+ acc i)) acc)))

(println (sum-to 10000000))
(println (unchecked-add Long/MAX_VALUE 1))
49999995000000
-9223372036854775808

^long and ^double hints on function arguments and return values let loops run on primitives without boxing. unchecked-add and friends skip overflow checks (and wrap around, as the second line shows) — use only when you're sure.

Measuring

;; illustrative — timings vary
(require '[criterium.core :refer [quick-bench]])
(quick-bench (squares-transient 100000))
Evaluation count : 1242 in 6 samples of 207 calls.
             Execution time mean : 487.312 µs
  • Criterium benchmarks correctly (JIT warm-up, statistics); plain (time ...) is fine for a rough idea.
  • clj-async-profiler produces flame graphs from a running REPL.
  • JVM options matter: heap size (-Xmx), GC choice, and -Dclojure.compiler.direct-linking=true for production builds.

Start-up time

A JVM Clojure program takes around a second (or more, with many libraries) to start. Ahead-of-time compilation (AOT, done by uberjar builds) helps; for scripts, use Babashka; for instant-start binaries, compile with GraalVM native-image.