Faster Clojure REPL startup
Published Last modified
tl;dr
To make your Clojure REPL start fast (in about 1.5 seconds), in decreasing order of impact:
- Avoid requiring namespaces at the top level of your
user.clj
file; userequiring-resolve
to defer loading instead - Compile your dependencies
- Use the Clojure CLI instead of Leiningen
- Use a socket server instead of nREPL
Off to the races
In this article, I’ll discuss some of the things you can do to make sure your Clojure REPL starts as fast as possible. I’ll also mention how to load all the libs your app requires faster.
To whet your appetite, here’s a before-and-after: in one of my work projects (clocking in at about 15,000 lines of Clojure), applying these changes decreased the REPL startup time from 23 seconds to 1.6 seconds. Additionally, it decreased the time it takes to load (require
) all of the libs the app needs from 12 seconds to 5.4 seconds.
In this article, I’ll discuss, in decreasing order of impact, some of the factors that affect your REPL startup time. I’ll begin by discussing the effect your user.clj
file has on the startup time. Then, I’ll briefly discuss avoiding recompiling your dependencies every time you load them. Next, I’ll investigate the impact of choosing between Clojure CLI and Leiningen. I’ll also examine whether the socket server built into Clojure starts any faster than nREPL, the most popular Clojure network REPL.
Kind of a weird artifact
When Clojure starts, it loads the first file named user.clj
it finds in the classpath of your program. Even though it’s not very well documented (as of writing this article), most Clojure developers know this.
Because Clojure automatically loads whatever you put in user.clj
, many Clojure developers use it as a repository for development-time helper functions. Here’s an example of a user.clj
file that will look familiar to many Clojure developers. It is adapted from the README of Component, a Clojure library for managing application state.
In this example, my.app
is the entry point namespace of the application. A medium-size Clojure project can have hundreds of loaded libs.
When you require my.app
in user.clj
, whenever you run Clojure (via clj
or lein
or whatever), Clojure dutifully loads every lib your application needs to run. That can take a pretty long time. Here’s me loading the entry point namespace of my work project:
That’s 12 seconds to load ~300 libs.
The takeaway here is that if you require the entry point namespace for your application in your user.clj
, you pay a hefty penalty every time you start a REPL. The same is true if you require any (transitively) large namespace. clojure.core.async, in particular, is a usual suspect: requiring core.async alone takes 3.5 seconds on my machine.
Now that we know what the (biggest) factor affecting REPL startup time is, let’s look at how to alleviate it.
Deferring code loading
To avoid the startup time penalty requiring namespaces in user.clj
incurs, we’ll use a clojure.core function called requiring-resolve
. You can think of requiring-resolve
as a function that lazily requires vars from namespaces that might or might not already be loaded. In user.clj
, we can use it to avoid having Clojure load any libs when it starts.
Here’s the Component example from earlier rewritten using requiring-resolve
:
Notice how there’s no :require
in the ns
form. This might not be what you’d call pretty, but it does make a pretty big difference. Let’s use hyperfine
to find out just how big. Here’s how long it takes to start a REPL for my work project before refactoring user.clj
to use requiring-resolve
:
λ hyperfine --warmup 3 "echo ':repl/quit' | clj -J-Dclojure.server.repl='{:port 5555 :accept clojure.core.server/repl}' -M:dev -r"
Benchmark 1: echo ':repl/quit' | clj -J-Dclojure.server.repl='{:port 5555 :accept clojure.core.server/repl}' -M:dev -r
Time (mean ± σ): 23.142 s ± 2.761 s [User: 54.177 s, System: 3.629 s]
Range (min … max): 19.347 s … 27.199 s 10 runs
23 seconds. Here’s the result of running the same benchmark after using requiring-resolve
to remove all :require
s from user.clj
:
λ hyperfine --warmup 3 "echo ':repl/quit' | clj -J-Dclojure.server.repl='{:port 5555 :accept clojure.core.server/repl}' -M:dev -r"
Benchmark 1: echo ':repl/quit' | clj -J-Dclojure.server.repl='{:port 5555 :accept clojure.core.server/repl}' -M:dev -r
Time (mean ± σ): 1.611 s ± 0.042 s [User: 2.296 s, System: 0.274 s]
Range (min … max): 1.559 s … 1.703 s 10 runs
And so, we’re down to 1.5 seconds.
Of course, you have to pay the cost of requiring all those libs eventually. By using requiring-resolve
, you’re simply deferring the payment: you’ll pay it when you actually call one of the functions that calls requiring-resolve
(like start
, for example). It’s up to you to decide whether the tradeoff is worth it. To me, it is: just because I want to run a REPL doesn’t always mean I want to run the whole app, too.
Besides using requiring-resolve
to avoid loading anything on startup, there’s something else we can do to load libs faster: compile our dependencies. Let’s see how.
Avoiding dependency recompilation
Most Clojure libraries are distributed as packages of Clojure source files. That means that every time you load a lib, Clojure must first compile it to bytecode. Compilation accounts for maybe half of the time Clojure spends on loading libs.
To load libs faster, you can compile them beforehand, as proposed in the official Improving Development Startup Time guide. As a reminder, before making the changes proposed in the guide, it took 12 seconds to require the entry point namespace for my work project. Here’s how long it takes after following the instructions in the guide:
Less than half of the original duration. Not too shabby.
Avoiding lib-loading and compilation on startup have the largest impact on REPL startup time by far, so it’s totally reasonable to stop here. If you want to go as fast as possible, though, you might also want to consider using the Clojure CLI instead of Leiningen. That’s what we’ll look into next.
Clojure CLI vs. Leiningen
First, let’s use the Clojure command-line tool (henceforth clj
) to start (and immediately quit) a Clojure REPL. To make it a REPL you can connect your editor to, we’ll use the clojure.server
Java system property to also launch a socket server.
# We use --warmup 3 to run the benchmark on a warm disk cache.
λ hyperfine --warmup 3 "echo ':repl/quit' | clj -J-Dclojure.server.repl='{:port 5555 :accept clojure.core.server/repl}' -M -r"
Benchmark 1: echo ':repl/quit' | clj -J-Dclojure.server.repl='{:port 5555 :accept clojure.core.server/repl}' -M -r
Time (mean ± σ): 1.376 s ± 0.137 s [User: 1.631 s, System: 0.212 s]
Range (min … max): 1.194 s … 1.606 s 10 runs
Under 1.5 seconds. Let’s see how long it takes to do the same thing using Leiningen.
λ hyperfine --warmup 3 'echo "(exit)" | lein repl'
Benchmark 1: echo "(exit)" | lein repl
Time (mean ± σ): 3.477 s ± 0.045 s [User: 4.039 s, System: 0.551 s]
Range (min … max): 3.427 s … 3.560 s 10 runs
Over twice as long. That’s understandable, because comparing clj
to Leiningen is not exactly apples to apples. Leiningen not only loads more code than clj
, it also starts an nREPL server and connects to it. Still, there’s no way (that I know of) to go any faster than that using Leiningen, so I suppose the comparison isn’t completely unfair.
To be thorough, let’s eliminate Leinigen from the equation and run an nREPL server using clj
:
λ hyperfine --warmup 3 "echo '(exit)' | clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version \"0.9.0\"}}}' -M -m nrepl.cmdline --interactive"
Benchmark 1: echo '(exit)' | clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "0.9.0"}}}' -M -m nrepl.cmdline --interactive
Time (mean ± σ): 2.722 s ± 0.180 s [User: 4.716 s, System: 0.383 s]
Range (min … max): 2.584 s … 3.204 s 10 runs
Over half a second faster. That’s still twice as long as clj
and a socket server, but it’s not too bad.
The upshot is that if you want to minimize your REPL startup time, use clj
instead of Leiningen. The penalty nREPL carries isn’t huge, but if you want to go as fast as possible, use the socket server to run a clojure.main
REPL (or prepl, if you prefer, although editor support for that is slim) instead. Opting for clj
and socket server shaves off around two seconds of your REPL startup time.
Just a spoonful of sugar
If you like the idea of using requiring-resolve
to avoid top-level requires but find too verbose, here’s a little bit of sugar you might consider sprinkling on your user.clj
:
Armed with that, init
, for example, becomes:
Not a huge difference in readability here, granted, but you might find it useful if if you have many helper functions in your user.clj
.
Wrap up
Using one of my work projects as a test subject, using requiring-resolve
to avoid requiring anything at the top level of my user.clj
brought down the REPL startup time for that project from ~23 seconds to 1.6 seconds. After precompiling dependencies, Clojure loads all the libs the app needs in 5.4 seconds, instead of the 12 seconds sans precompilation.
Those two changes have the biggest impact on REPL startup time. If you want to go further, though, consider using clj
instead of Leiningen to shave off a couple of more seconds. Finally, using a socket server instead of nREPL might win you an additional second or so.
Why, though?
You might think that running a REPL with only clojure.core (and a few auxiliary libs, like clojure.repl) loaded isn’t particularly useful. To me, it is: that’s all I need to start evaluating things from my editor. Once I have clojure.core loaded, to start working with a specific part of my app, I can evaluate individual function definitions or namespaces and start using them. I find it’s not necessary to always have the entire app loaded into your runtime. When I need that, though, precompiling my dependencies helps me do that much faster, too.
Prior art
A lot of virtual ink has been spilled on this topic, so this is by no means an exhaustive list, but here’s some more reading on the topic of Clojure startup times.
- Improving Clojure Start Time by Alex Miller
- Why Clojure starts up slowly — is it really the JVM by Eric Normand
- Clojure’s slow start — what’s inside by Alexander Yakushev
- Why is Clojure bootstrapping so slow? by Nicholas Kariniemi
Tech specs
Here’s the Java version and hardware specs I ran these tests on:
λ java -version
openjdk version "18" 2022-03-22
OpenJDK Runtime Environment (build 18+36-2087)
OpenJDK 64-Bit Server VM (build 18+36-2087, mixed mode, sharing)
λ system_profiler SPHardwareDataType
…
Model Name: MacBook Pro
Model Identifier: MacBookPro16,2
Processor Name: Quad-Core Intel Core i5
Processor Speed: 2 GHz
Number of Processors: 1
Total Number of Cores: 4
Memory: 16 GB
…
For what it’s worth, while running the benchmarks, I noticed that compared to Java 11, Java 18 (and presumably 17, which is the latest long-term support release) shaves something like half a second off the startup time. So that’s another thing you might want to consider if you’re looking to make your REPL start as fast as possible. I didn’t really have time to do a lot of science on that yet, though.
⁂