clj-pulse: a fast Clojure language server
November 11, 2026I've always wanted a faster language server for Clojure, one where navigation works right after I open the editor. Lately it matters even more: the way I work is changing, and I read much more code than I write by hand. When I open a project to check something, I want to jump between definitions right away, not wait for the analysis to finish.
That is why I started building clj-pulse, a language server for Clojure. I also wanted built-in Integrant support, but that is more of a convenience. I now use it every day, so I'd like to share what it is and how it works.
TL;DR
clj-pulse makes a large project (1,400+ files) navigable less than three seconds after you open it, and its dependencies a couple of seconds later, using about ~530 MiB of memory. A regular-sized project is indexed almost instantly, and navigation is available right away. It works with any editor that has an LSP client. It is a native binary, and it indexes in stages, so you can navigate before the work is done. It does not do everything clojure-lsp does yet, and the comparison is below.
The idea: answer first, finish later
The usual approach is to analyze the whole classpath before answering anything. clojure-lsp does it through clj-kondo. The analysis is thorough, but you pay for it up front.
clj-pulse is a native binary. It does the work in stages, and navigation is available after each one:
- Project sources. Every file under the project's
:pathsis parsed with tree-sitter, in parallel, and its definitions and usages go into an in-memory index. After this step you can jump around your own code. - Dependencies from the cache. The classpath is read from
.cpcache, which the Clojure CLI has already written, so no JVM starts. Each JAR is indexed once and the result is cached on disk, keyed by the JAR's modification time. After this step you can jump into libraries. - Classpath command.
clojure -Spath(orlein classpath) runs in the background, in case the cached classpath is stale. If it fails, the result of step 2 stays. - clj-kondo, last. If clj-kondo is installed, clj-pulse warms its dependency cache and hands linting over to it. Until then, five built-in lints give diagnostics. Nothing waits for it.
The rule I try to keep is: never a JVM on the hot path. Startup, keystrokes, and requests do not touch one. The classpath command in step 3 may start a JVM, but it runs in the background, and nothing waits for it either.
The numbers
Here is clj-pulse 0.5.7 next to clojure-lsp 2026.10.15 on a large real project, Metabase (1,400+ files). Both run at their defaults on a MacBook Pro M1 Pro with 16 GB, with clj-kondo installed. Cold is the first open of a fresh checkout, a single run. Warm is the next open, the median of three runs.
| Metric | clj-pulse cold | clj-pulse warm | clojure-lsp cold | clojure-lsp warm |
|---|---|---|---|---|
| First navigation | 2.4 s | 1.9 s | 91 s | 26 s |
| All dependencies navigable | 4.1 s | 2.1 s | 91 s | 26 s |
| clj-kondo linting available | 24 s | 16 s | 91 s | 26 s |
| Memory once settled | 528 MiB | 524 MiB | 2,461 MiB | 1,733 MiB |
| Definition (median of 20) | < 1 ms | < 1 ms | 5 ms | 3 ms |
| Keystroke to diagnostics, 251 KiB file | 259 ms | 258 ms | 323 ms | 350 ms |
Each startup row is the time until a real textDocument/definition request returns the right location. The two servers do different work in that time. clojure-lsp analyzes the whole classpath before it answers anything, so its three startup rows are one moment, and by then it knows more about your code. clj-pulse makes each stage available as soon as it is done. Linting with clj-kondo is not something you wait for: navigation, completion, and the built-in lints work the whole time. The keystroke row includes the wait each server takes after an edit before linting it: 50 ms for clj-pulse, 75 ms for clojure-lsp.
On a medium project, clj-kondo itself (400 files), the picture is the same at a smaller scale: clj-pulse navigates after 757 ms cold and 215 ms warm, against 8.1 s and 1.2 s, and settles at 91 MiB of memory, against 300 MiB. The full tables for both projects, the method, and how to reproduce them with bb bench are in the performance doc.
These numbers describe two projects on one machine. Yours will differ.
What you get
The everyday set is there: go to definition into project code and library JARs, fuzzy completion with auto-require, hover, signature help, references, rename, symbol search, and namespace quickfixes.
A few things I added because I needed them:
- Integrant navigation. Jump from a component key in
config.edn, or from an#ig/ref, to itsig/init-key. References and rename work on qualified keywords across sources and configs. - Monorepos. Every
deps.edn,project.clj, orlgx.ednproject in the workspace is found and indexed, with classpath settings per project. - let-go.
.lgfiles and lgx dependencies get the same navigation.
The full list is in the feature reference.
How it compares to clojure-lsp
clojure-lsp is mature and does more. Here is where clj-pulse stands against it today:
- Everyday navigation and editing are on par, and faster.
- Most refactorings are missing. Extract function, inline symbol, introduce let, and the rest are not there yet. Neither are go to implementation, code lens, or updating the
nsform when you rename a file; those three are planned next. Paredit-style editing is left to the editor. - Java support is partial. JDK classes, static members, and constructors work. Instance methods, library classes, and decompilation do not yet.
- The server does not format documents yet. It indents on Enter, and the editor does the rest: Clojure Pulse bundles cljfmt, and Calva has its own formatter.
- It reads syntax, it does not analyze. clojure-lsp builds on clj-kondo's analysis. clj-pulse parses the code and knows what
defn,let, and destructuring bind because it implements those rules itself. A macro that defines things is recognized through a built-in table, your clj-kondo:lint-asconfig, or its name (mu/defndefines whatdefndefines). An unusual macro it has never seen is just a call.
To find where the syntactic approach goes wrong, I check it against clj-kondo's own analysis. On the clj-kondo codebase, I ask the server 5,032 definition, references, and rename questions whose answers the analysis already knows, and 4,754 of them (about 94%) agree today. The rest is a list I'm working through.
The feature-by-feature list is in the parity matrix.
Try it
Install the server on macOS or Linux with Homebrew:
brew install abogoyavlensky/tap/clj-pulse
Or with mise:
mise use -g github:abogoyavlensky/clj-pulse
It speaks plain LSP over stdio, so any editor with an LSP client can run it. The ones I test on every change:
VS Code with Calva. Keep everything you have and change one setting:
{ "calva.clojureLspPath": "/path/to/clj-pulse" }VS Code with Clojure Pulse. My own extension, with the server bundled. It deserves a separate post.
Neovim 0.11+ with the built-in LSP client, no plugin needed.
Zed works for project navigation, but not for library JARs yet. The config for each editor is in the editor setup guide.
For the full linter set, install clj-kondo. clj-pulse finds it on its own. Create a .clj-kondo directory in each project to enable clj-kondo's cross-file cache.
Wrapping up
clj-pulse is at 0.5.x. It is my daily driver, and it is tested end to end through real editors on every change, but expect gaps. If you open a real project with it and something is wrong, slow, or missing, please open an issue. Hope you enjoy using it!