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.

Architecture Diagram

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.

Architecture Diagram

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.

Architecture Diagram

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:

  1. No wire protocol. Python has no standard network REPL. Tools like ptpython run in-terminal only. Remote debugging requires pdb over pdb-attach or proprietary IDE protocols.

  2. 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 function
  3. No session multiplexing. Python has no concept of multiple isolated evaluation contexts sharing one connection.

  4. 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.

Architecture Diagram

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.

  1. No runtime REPL protocol. Node has no nREPL equivalent. There is node --inspect for 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.

  2. 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
      })
    }
  3. TypeScript has no REPL at all. ts-node and bun give you a TypeScript- aware eval, but it is still “eval a string” — no wire protocol, no middleware, no session isolation.

  4. 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 defonce and 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.

Architecture Diagram

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).

  1. HotSwap is limited to method body changes. You cannot add or remove methods, change class hierarchies, or add/remove fields. The JVM’s ClassLoader prevents 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
    }
  2. 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.

  3. No interactive namespace model. Java has no equivalent of in-ns, require, or refer. You cannot “switch to a namespace” and evaluate code in its context. Every eval is string-based and context-less.

  4. 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.

Architecture Diagram

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

Architecture Diagram
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.