REPL vs nREPL — Why Clojure's Live Programming Is a Superpower
What Is a REPL?
REPL stands for Read-Eval-Print Loop. It is the simplest possible interactive programming interface: an infinite loop that reads an expression, evaluates it, prints the result, and repeats.
The naive implementation is about twenty lines:
(defn repl []
(loop []
(print "=> ") (flush)
(let [input (read-line)
expr (clojure.edn/read-string input)
val (eval expr)]
(println val)
(recur))))
Every top-level Clojure process is a REPL at heart. When you run
clojure -M, you get a JVM process that reads from stdin,
evaluates forms against a global namespace, and prints to stdout. The
same is true for python3, node, and
bb.
The Terminal REPL Is Table Stakes
Every dynamic language has one. It is useful for debugging one-liners and testing library imports. It is not useful for building systems.
$ python3 -c "print(2 + 2)"
4
$ node -e "console.log(2 + 2)"
4
$ bb -e '(+ 2 2)'
4
These are all REPLs. They share a fundamental limitation: each invocation is a fresh process. Any state you built up — loaded namespaces, database connections, atom values — is gone when the process exits. A terminal REPL is a calculator, not a development environment.
What Is nREPL?
nREPL is a network REPL protocol. It is not a tool or a library in the traditional sense. It is a specification for how a client and server communicate over TCP, using bencode as the wire format and a message-passing model built on requests and responses.
The Wire Protocol
nREPL messages are serialized in bencode (BitTorrent encoding). A simple eval request looks like this on the wire:
;; Clojure representation before bencode encoding
{:op "eval"
:code "(+ 1 2 3)"
:id "a1b2c3d4"
:session "ses-001"}
Encoded to bencode:
d2:op4:eval4:code8:(+ 1 2 3)2:id8:a1b2c3d47:session7:ses-001e
Decoded back, the server responds:
{:ns "user"
:value "6"
:id "a1b2c3d4"
:session "ses-001"}
Every message has an :id that pairs request to response,
and a :session that multiplexes multiple logical sessions
over one TCP connection. This means you can have five editor buffers
each with their own namespace state, all sharing one nREPL
connection.
Operational Model
nREPL defines a set of standard ops (operations):
| Op | Purpose | Example |
|---|---|---|
eval |
Evaluate a code form | (+ 1 2) |
load-file |
Load a file by path | (load-file "core.clj") |
describe |
List server capabilities | Middleware versions |
clone |
Create a new session | Isolated namespace |
close |
Close a session | Free resources |
interrupt |
Cancel a running eval | Stop infinite loop |
completions |
Tab-completion candidates | (str/ → suggestions |
lookup |
Var metadata lookup | Docstring, arglists |
Each op is handled by a middleware stack. Middleware is just a chain of functions that process the message map before passing it along. This is the same pattern as Ring (Clojure’s HTTP server) — every middleware function takes a message and returns a response.
;; Conceptual middleware chain
(defn wrap-eval [handler]
(fn [msg]
(if (= (:op msg) "eval")
(let [result (eval (:code msg))]
{:value (pr-str result)
:ns (str *ns*)})
(handler msg))))
This middleware architecture is why nREPL is extensible. Anyone can write middleware that adds new ops — completion, lookup, linting, formatting — without touching the core protocol.
Why Clojure’s Architecture Makes This Possible
Clojure runs on hosts that support dynamic evaluation at the language runtime level. This is not a feature Clojure adds — it is a feature Clojure inherits from its host platforms.
Clojure’s eval function at its core calls
.getRuntime().exec(...) on a compiled form. But the key
insight is the Reader — Clojure’s reader parses text
into plain Clojure data structures (lists, vectors, maps, keywords,
symbols). Macros operate on these data structures before
compilation. This is “homoiconicity” —
code is data.
Because code is data, you can construct arbitrary programs at runtime
as data structures and pass them to eval:
;; Construct a function at runtime as data
(def expr `(fn [~'x] (+ ~'x 10)))
;; => (fn [x] (+ x 10))
;; Evaluate it
(eval expr)
;; => #function[...]
No other mainstream language can do this without string interpolation and a secondary parsing step. In Clojure, the reader and the data layer are the same thing.
Comparison: Python
Python has a REPL. It even has importlib.reload for
reloading modules.
$ python3
>>> import my_module
>>> my_module.my_function(5)
10
>>> import importlib
>>> importlib.reload(my_module)
<module 'my_module' from 'my_module.py'>
But importlib.reload is a paper-thin abstraction:
No wire protocol. Python has no standard network REPL. Tools like
ptpythonrun in-terminal only. Remote debugging requirespdboverpdb-attachor proprietary IDE protocols.Module reload breaks references. If any live object holds a direct reference to a class or function from the old module, reload leaves a dangling reference. The old code still runs in that reference.
# main.py from my_module import my_function # After importlib.reload(my_module): # my_function still points to the OLD functionNo session multiplexing. Python has no concept of multiple isolated evaluation contexts sharing one connection.
No middleware ecosystem. There is no Python equivalent of nREPL middleware — no standard way to add completion, lookup, or linting ops to a REPL protocol.
The Technical Reason
Python compiles source to bytecode (pyc files) and the
VM has no standard mechanism to replace a function’s code object at
runtime. Third-party libraries like dill and
cloudpickle can serialize code, but the runtime itself has
no replace_function primitive.
Comparison: JavaScript / TypeScript
Node.js has a REPL. You type node and get a prompt.
$ node
> JSON.parse('{"a": 1}')
{ a: 1 }
Hot-reload in JS/TS is handled by build tooling: Webpack’s HMR,
Vite’s HMR, ts-node-dev. These work by intercepting module
imports and swapping them at the bundler level.
No runtime REPL protocol. Node has no nREPL equivalent. There is
node --inspectfor the Chrome DevTools protocol, which gives you a debugger — not a REPL. You can evaluate expressions in the debugger, but there is no programmatic, extensible protocol for it.HMR is a bundler trick, not a runtime feature. HMR works by wrapping every module in a proxy. When a file changes, the bundler sends a WebSocket message to the client saying “re-evaluate module X.” The client deletes the module from its cache and re-
requires it.// Vite's HMR in pseudo-code: if (import.meta.hot) { import.meta.hot.accept(['./my-module'], ([mod]) => { // mod is the new version, but old references still exist }) }TypeScript has no REPL at all.
ts-nodeandbungive you a TypeScript- aware eval, but it is still “eval a string” — no wire protocol, no middleware, no session isolation.State loss on every hot-reload. When a module is swapped, all its internal state is lost. React components lose their local state. Redux stores survive only because they are externalized. Clojure’s
defonceand atom-based state survive re-evaluation by design.
The Technical Reason
JavaScript’s module system (ESM and CJS) is based on immutable
binding. Once a module is loaded, its exported bindings are live but
read-only. Hot-swapping requires deleting the module from the runtime
cache (delete require.cache[...]) and re-loading — which is
unsupported in ESM entirely.
Comparison: Java
Java has javac compile to .class bytecode,
loaded by a JVM ClassLoader. The JVM supports hot-swap via
the JPDA (Java Platform Debugger Architecture) —
specifically the redefineClasses command in the Java Debug
Wire Protocol (JDWP).
HotSwap is limited to method body changes. You cannot add or remove methods, change class hierarchies, or add/remove fields. The JVM’s
ClassLoaderprevents redefining a class that has already been loaded.// This fails with UnsupportedOperationException: // Adding a new method is not a hot-swappable change. public class MyService { public String greet(String name) { return "Hello, " + name; // ← body change only } // public String farewell(String name) { ... } ← would fail }JDWP is a debugger protocol, not a REPL. You can evaluate expressions in the debugger, but there is no standard “eval in namespace” operation. No middleware. No session isolation. No programmatic client library outside of debugging tools.
No interactive namespace model. Java has no equivalent of
in-ns,require, orrefer. You cannot “switch to a namespace” and evaluate code in its context. Every eval is string-based and context-less.Build-deploy loop is baked into the culture. A Java developer expects to edit → compile → package → deploy → restart. The JVM ecosystem never developed a live programming culture because the architecture actively fights it.
The Technical Reason
Java’s ClassLoader model is append-only. Once a class is
loaded by a ClassLoader, that ClassLoader
cannot unload it. The only way to redefine a class is to create a
new ClassLoader and load the new version — but
objects created by the old loader still reference the old class. This is
the “permgen leak” problem that plagued Java hot-reload tools for a
decade.
This is why Java hot-reload frameworks (JRebel, DCEVM) work by
instrumenting the JVM itself — they bypass ClassLoader
entirely by patching the internal JVM data structures. This is fragile,
JVM-version specific, and not available in standard Java.
Summary
| Feature | Clojure | Python | JS/TS | Java |
|---|---|---|---|---|
| Wire protocol | nREPL (bencode, TCP) | None | Debugger only (Chrome DevTools) | JDWP (debugger only) |
| Live eval | Yes, native | importlib.reload (fragile) |
Bundler HMR (state loss) | DCEVM/JRebel (fragile) |
| Session isolation | Yes, via :session |
None | None | None |
| Middleware/ops | Extensible chain | None | None | None |
| State persistence | Atoms survive re-eval | Module refs break | Lost on HMR | Objects pinned to ClassLoader |
| Homoiconicity | Yes (code = data) | No | No | No |
| Production hotfix | Common practice | Rare | Not done | JRebel (enterprise) |
Built with Babashka and nREPL — single binary, ~2ms startup, wire protocol on port 1667. The D2 diagrams in this post compile to SVG statically; no JavaScript required.