Skip to content

This site describes solve-engine as it is on main: 2.43.0, which npm does not have yet. npm installs 2.40.0, so a page may show an answer that version does not give yet.

Performance

The engine is built to run on every keystroke, which sets the performance bar.

Throughput is how much text the engine gets through each second, counting the whole journey from raw characters to computed results: lexing, normalising, parsing, compiling to bytecode and executing it. It runs over a document in a single pass, so the figure that matters for an editor is how many lines of a real document it clears per second.

DocumentCold, first parseWarm, re-parse
100 lines29,212 lines/sec396,980 lines/sec
1,000 lines120,142 lines/sec390,069 lines/sec
10,000 lines119,071 lines/sec261,755 lines/sec
50,000 lines115,416 lines/sec204,748 lines/sec

Cold is a fresh engine meeting the document for the first time; warm is the same engine re-parsing it with its bytecode already cached. Within a single expression the pipeline divides as lex 11.5%, normalise 33.2%, parse and compile 30.8%, and execute 24.6%.

Measured on AMD EPYC 9V45 96-Core Processor, Node v22.23.2 on Linux. Throughput depends on the hardware and on what else the machine is doing at the time, so treat these as indicative; reproduce them with npm run bench.

The per-line rate rises from the small document to the medium one, because the fixed cost of standing an engine up is spread across more lines. Every line of the benchmark document evaluates: each one that reads a variable reads one assigned above it, so the figure is the rate of a document that works, not of one that fails on an undefined name.

The number to take from the table is its size: an engine meant to run on every keystroke keeps pace with a document far larger than anyone types into one, and re-parsing an unchanged document is quicker still, since its bytecode is already compiled. The sections below are why.

Expressions compile to a compact bytecode and run on a stack machine. Compiling once and executing many times is much faster than re-walking a syntax tree, and it makes the execution step easy to bound.

Compiled bytecode is cached by expression text, so an unchanged line skips lexing, normalising, parsing and compiling entirely, on every entry point. Line results are cached separately, but only by a long-lived incremental evaluator (see Incremental re-evaluation below): there an unchanged line off screen skips execution too, while parseDocument executes every line on every call.

A line that does not parse is remembered as well. A line being typed does not parse for most of its life, so its next evaluation skips the lexing, the normalising and the throw, and re-runs only the forms that read other lines’ values. The memory is cleared with the compiled caches whenever a package or a user-defined unit changes what a line means.

The compiled caches are bounded and, when full, drop the entry that has gone longest without being used. The bound is performance.defaultCacheSize (2,000 entries by default) plus the line count of the open document, so a whole document always fits: a pass visits its lines in the same order every time, and a cache smaller than the document would drop each line just before the next pass wanted it, so that a warm pass over 10,000 distinct lines compiled every one of them again. The lines a document re-evaluates on every keystroke stay compiled while one-off expressions cycle out of the room on top. The cost is a compiled program per distinct line while the document is open (about 3.4 MB more at 10,000 lines than the old fixed bound held); clear() gives it back, and with no document open the bound is defaultCacheSize alone.

  1. Lex
  2. Normalise
  3. Parse
  4. Compile
  5. Execute

Every stage runs. This is the full cost, and it is paid once per distinct expression text.

Three layers

Which lines run after an edit depends on the entry point:

  • parseDocument runs every line on each call, and skips only the compile stages for text it has seen before.
  • evaluateDocument is a one-off pass: it builds a fresh evaluator for the call, runs every line, and discards the evaluator.
  • A long-lived ThreeTierEvaluator, told about each edit and given the range of lines on screen (the viewport), runs the edited line, the lines above the viewport that transitively depend on it through the dependency graph, and the visible lines. An unchanged line above the viewport is not run at all, and the lines below it wait until they are scrolled into view.

Measured over a 1,000-line note in which every line calls a counting function, after editing only its last line:

PathLines executed
parseDocument, same engine as the first pass1,000
evaluateDocument1,000
ThreeTierEvaluator, evaluating lines 1 to 1,0001,000
ThreeTierEvaluator, evaluating the viewport, lines 981 to 1,00020

The boundary: an evaluator asked for the whole document runs the whole document. A variable’s value depends on where in the note it is read, so the lines are run in order from the top, and the only lines that can be left alone are the ones nobody is looking at. Making parseDocument skip unchanged lines is not proposed, for the same reason.

An evaluator hears about an edit in one of two ways. DocumentModel.editLine replaces the text of one line. ThreeTierEvaluator.applyTransaction takes a list of changes, each a first line, a number of lines to delete and the lines to insert in their place, which is how most editors describe what happened (CodeMirror’s change sets, for one).

A change that deletes as many lines as it inserts moves no line: a keystroke inside a line arrives as “delete this line, insert its new text”. The evaluator applies such a transaction line by line, in place, exactly as editLine would: each line keeps its id and its compiled program, and only the edited lines and whatever reads them run again. The result lists the ids of the lines whose text changed in edited, with inserted and removed empty. Any change that alters the line count (an insertion, a deletion, a paste of a different number of lines) makes the whole transaction structural: the lines below move, and each is followed to its new position.

// A keystroke on line 3: an edit in place, no line moves.
evaluator.applyTransaction([{ startLine: 3, deleteCount: 1, insertLines: [":v2 = 8"] }]);
// Enter pressed at the end of line 3: a structural edit, every line below moves down one.
evaluator.applyTransaction([{ startLine: 4, deleteCount: 0, insertLines: [""] }]);
evaluator.evaluate({ startLine: 1, endLine: 40 });

The difference is the cost, and what survives below the viewport. On a 20,000-line note, a keystroke followed by evaluating the first 40 lines took 33.75 ms as a structural edit and 2.24 ms in place (#713), and a line far below, which did not read the edited one, kept its answer instead of losing it until it was scrolled to again. A host that already sends one-for-one replacements gets this without changing anything.

Two other costs of a long-lived evaluator are linear in the note since 2.42. A repeat pass over bare assignments (price = 4, with no colon) used to cost the square of the note, 5,975 ms for a second pass over 10,000 of them on the machine these figures come from, and costs 212 ms now (#712). A line reading a name nothing defines, evaluated from a timer callback or an event handler rather than after an await, cost about 22 microseconds against under one for 2 + 5, and costs about 7 now (#714).

What an evaluator keeps between passes is linear in the note too. It records which lines read which, so an edit or a live value reaches every line that depends on it, and a running total used to record one entry for every line it read: a ledger with total above after each entry held the square of its length, and 3,000 such lines through evaluateDocument kept over 500 MB. A span is one entry now, a tag total one entry on its tag and a section total one on its block, and the same note keeps under 10 MB (#733). total by tag is worked out once for all the breakdown lines that ask it of the same figures, so 500 of them cost about what one does (#734).

The caches keep a re-parse cheaper than a first parse. They do not move the first parse of a large document off the thread that asked for it, and on every keystroke that first parse, or a paste, is where jank comes from.

solve-engine/worker wraps the core evaluate methods behind a postMessage boundary, so a host can run them on a Web Worker or a Node worker_threads thread without hand-rolling the protocol. Results cross as a serialisable DTO, never as a live Value, which carries BigInt, matrix objects and exact-decimal sidecars that structured cloning cannot reproduce.

import { createWorkerEngine, eventTargetTransport } from "solve-engine/worker";
const worker = new Worker(new URL("./engine.worker.ts", import.meta.url), { type: "module" });
const engine = await createWorkerEngine({ transport: eventTargetTransport(worker) });
const result = await engine.parseDocument(text);
for (const line of result.lines) {
if (line.result) render(line.lineNumber, line.result.text);
}

The worker entry is two lines: adapt the worker’s own global onto a transport and start the runtime.

engine.worker.ts
import { startWorkerRuntime, eventTargetTransport } from "solve-engine/worker";
startWorkerRuntime(eventTargetTransport(self));

eventTargetTransport takes anything with the EventTargetLike shape: a DOM Worker, a worker’s self and a MessagePort all fit it under strict, and the type is exported for a host adapting something else. The Node equivalent is messagePortTransport, for a worker_threads Worker or parentPort (MessagePortLike).

A result that is an error carries its code in errorCode as well as its message in text, so a host tells 5 kg in m (INCOMPATIBLE_UNITS) from any other failure without reading the words:

const value = await engine.evaluateExpression("5 kg in m");
value.errorCode; // "INCOMPATIBLE_UNITS"
value.text; // "a mass cannot be converted to a length"

A value that is not an error has no errorCode key at all.

There is a second, narrower use of a worker inside the engine itself. While you read a document, the evaluator compiles the lines just past the viewport ahead of time, so scrolling does not pay for it. That compilation moves to a worker when there is one.

Running lines stays on the main thread. When live data lands and more than fifty lines depend on it, the engine used to send their compiled bytecode to the same workers, which hold none of the document’s variables or packages, so a line such as price * qty came back as “Undefined variable: price”. A re-run now happens on the main thread however many lines it touches, with the same answers as a host that registers no factory.

There is no way for a library to make a worker on its own: the file a worker runs has to be a URL the host’s bundler produced, and every bundler spells that differently. So the engine asks. Register a factory once and the compile path uses it.

import { setEngineWorkerFactory } from "solve-engine";
setEngineWorkerFactory(() =>
new Worker(new URL("solve-engine/engine-worker", import.meta.url), { type: "module" }),
);

solve-engine/engine-worker is a bundle of its own, and it carries every built-in package. That matters more than it sounds: a compile worker with no vocabulary refuses every line a package gives meaning to, which is most lines, and each of those falls back to the main thread having gained nothing. Being a separate bundle is also what keeps it out of your own: a host that registers no factory ships neither the worker nor the vocabulary it registers.

Registering nothing is a supported state, not a degraded one. Every published build behaved this way until this option existed, and the fallbacks are the paths that were always taken: compilation happens on the main thread when the line is next evaluated, which is where it happened anyway.

This does not replace solve-engine/worker above, and the two are unrelated. That entry moves your calls off the thread; this one lets the engine get ahead of itself on work you never asked for directly.

An AbortSignal on a call rejects the promise and aborts the work on the other side, so a superseded keystroke does not race a stale result home.

const result = await engine.parseDocument(text, { signal });

Packages cross as names, not objects, because a package carries functions that postMessage cannot clone. createWorkerEngine({ packages: ["solve-arithmetic", "solve-uom"] }) selects among the built-ins the worker already bundles; a custom package is baked into the worker entry via startWorkerRuntime(transport, { packages }) and selected by name from the main side.

A currency, weather or historical-rate line comes back pending: the value is not in hand when parseDocument returns. It resolves inside the worker some time later, and onResolved is how that value reaches the main thread. It is a subscription, not a per-call promise, because a resolution belongs to whichever document is current when the value lands, not to one request.

const stop = engine.onResolved((lines) => {
for (const { lineNumber, value } of lines) render(lineNumber, value.text);
});
const result = await engine.parseDocument(text);
// result.lines[n].result may be a pending DTO; onResolved delivers the live
// value once it settles. `lines` is a batch, since resolutions that land in one
// tick arrive together. Call stop() to unsubscribe.

A failed resolution arrives through onAsyncError with the same structured EngineError an in-process failure would surface. Both subscriptions track the current document: parsing a new document supersedes the old one, and a value still resolving for the superseded document is dropped rather than delivered against the new one.

engine.onAsyncError(({ queryKey, packageId, error }) => {
console.warn(`${packageId} could not resolve ${queryKey}: ${error.message}`);
});

The worker client is not limited to evaluating. parseDocument is the batch pass, which cannot re-run a line and so refuses goal seek; a host that moved its evaluation to a worker used to lose goal seek, and kept a second engine on the main thread for everything else. The client now proxies the calls a document’s features rely on, each answering a clone-safe object (a DTO, data with no functions or class instances in it, so it survives postMessage and JSON):

CallMirrorsAnswers
evaluateDocument(text)the evaluateDocument helperthe document through the incremental pass, goal seek included
whatIf(text, overrides)engine.whatIfthe document re-run with inputs changed
explainLine(expression)engine.explainLinethe steps and the result
traceLine(text, line, { maxDepth, maxLines })engine.traceLinethe line’s inputs, each traced the same way
settle({ timeoutMs })engine.settleonce no live value is in flight
getSemanticTokens, getCompletionsLanguageServicehighlighting and completions
findReferences, getDefinition, rename, shiftLineReferencesLanguageServicepositions and edits, refusals included
const doc = await engine.evaluateDocument(
":deposit = 100000\n:rate = 4%\nmonthly repayment on deposit over 25 years at rate\nsolve line 3 for deposit = 900",
);
doc.lines[3].result?.text; // "= 170,507.23"

Through the worker, evaluateDocument agrees with the main thread’s value for value, and parseDocument still refuses goal seek with GOAL_SEEK_NO_DOCUMENT, as the batch pass does in process.

A what-if’s overrides cross as a finite number or as text the worker evaluates on its own ({ price: "$120" }); a Value cannot keep its type across postMessage, so send its text. An argument postMessage cannot copy at all (a function, a symbol) is refused before anything is sent, with WORKER_ARGUMENT_NOT_CLONEABLE, rather than surfacing as a raw DataCloneError. A trace reads the document given; when it is the text the worker evaluated last, it reads those answers rather than evaluating again.

The boundary: an AbortSignal rejects the caller’s promise at once, but a goal seek already running in the worker runs to the end, since the worker handles one message at a time; its answer is then dropped. Packages still cross as names, and the worker’s language service reads the worker’s own engine, so a package the worker entry does not bake in is not highlighted there either.

Nothing about the synchronous API changes. solve-engine/worker is a separate, side-effect-free entry point, so a host that never imports it pays nothing.

Load-time performance is parse time, and parse time is bytes: before the first line runs, a browser has to download the engine’s JavaScript and parse it. The engine ships minified with sideEffects: false, and it registers only the packages you give it. Together those are meant to let a bundler tree-shake the build, which means leaving out the code nothing in your program refers to, such as the built-in packages you never import.

Two measured figures bound what an import costs. Both come from bundling the engine for real on every commit with size-limit, which bundles with rolldown and compresses the result with brotli, as a web server would send it, and both are read here from the same data the home page quotes rather than typed in:

ImportMinified and compressed
import { ExpressionEngine }, the core with no package registered179 kB
the whole root entry, close to what createEngine() pulls in278 kB

An engine constructed with a handful of packages, such as new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE] }), lands between the two. Each built-in package is built into files of its own, so a bundler can leave out every package nothing imports. That matters most under esbuild, which Obsidian plugins build with: esbuild leaves out whole files and keeps everything inside a file it keeps, so while the built-ins shared one file, an arithmetic-only engine bundled to nearly the size of createEngine(). The same two programs bundled with esbuild, minified and compressed with brotli, measured on every commit alongside the figures above:

Bundled with esbuildMinified and compressed
createEngine()277 kB
new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE] })181 kB

The boundary: the shared core stays shared. The lexer, the parser, the VM and the unit table are in every engine, so a slim engine is never much smaller than the core with no package registered. createEngine stays the plain choice when bundle size is not the concern. Each syntax page names the package its feature needs, so a slim build registers exactly what it uses. See choosing packages.

Constructing a new engine per evaluation throws away every cache and re-registers every package. Create one engine and call clear() between documents.

Enabling diagnostics collects a large amount of per-stage detail. It is intended for a devtool and should be off in production.