Performance
Bonsai parses and checks an expression once, compiles it to a tree of closures, and evaluates that tree directly. There is no interpreter loop over the syntax tree and no eval. A typical rule evaluates in well under a microsecond.
Recommended usage
| Practice | Why |
|---|---|
| Create one environment and reuse it | The environment owns the program cache. Creating one per request throws the cache away. |
compile() expressions that run repeatedly | Parsing and checking happen once. Keep the Program next to the data it evaluates. |
Prefer evaluateSync() | The synchronous path avoids promise overhead. Use evaluate() only when an expression calls an async host function. |
| Size the cache for your workload | env.evaluateSync(source, ...) caches up to cacheSize programs (default 256) by source text. Raise it if you evaluate thousands of distinct sources; set 0 to disable caching. Sources longer than 16K characters are not cached, and the cache holds at most 256K characters of source in total. |
| Keep compiled programs | Checking and compiling cost more than evaluating: a 10-term rule compiles in about 10 microseconds and then evaluates in about a quarter of a microsecond. Compile once and keep the Program, or let the cache do it. |
import { bonsai, t } from 'bonsai-js'
const env = bonsai({
variables: { order: t.object({ total: t.number(), country: t.string() }) },
})
// Compile once...
const rule = env.compile('order.total >= 100 && order.country == "GB"', { expect: t.boolean() })
// ...evaluate many times.
const orders = [
{ total: 120, country: 'GB' },
{ total: 80, country: 'GB' },
]
orders.filter((order) => rule.evaluateSync({ order })).length // => 1Why it is fast
- Checked once, compiled once. The static checker resolves overloads ahead of time when argument types are known, so the compiled program calls the right implementation directly instead of dispatching on every evaluation.
- Closures, not a tree walk. Each syntax node becomes a small closure; a program is a function call away from its result.
- Reused evaluation state. A program reuses its evaluation state between synchronous runs instead of allocating a new one each time.
- Cheap limits. The step budget is a counter, and the timeout reads the clock only periodically, so the safety limits cost little on the hot path.
- No dependencies. Nothing to initialize or ship besides the library itself.
Benchmarks
The repository includes a comparison against @marcbachmann/cel-js, another safe expression language, on the same workloads. On Node, a typical rule evaluates about 10 million times per second, 1.1x to 2.8x faster than cel-js depending on the workload (measured on Apple silicon; results vary by machine). Reproduce it from a checkout with:
bun run benchTreat benchmark numbers as point-in-time guidance for your hardware, not as part of the API contract.
Things that cost more
- Large data. Work proportional to data (lambdas over long lists, deep equality on big values, long templates) is linear in the data, like the JavaScript equivalent. The step budget bounds it.
- Async evaluation. An expression that calls an async host function runs on a separate async path with an
awaitper async call. Expressions that do not call async functions run on the synchronous path even throughevaluate(). - Async host calls run one at a time.
orders.map(fxRate(.currency) * .amount)waits for each call before starting the next, so twenty calls at 10 ms each take about 200 ms. When a function reaches a network or database, batch in the host: prefetch the data into the context, or cache inside your function. - Context validation.
validateContext: truechecks the whole context before every evaluation, which can cost more than the expression itself: over a list of 1,000 records it makes a filter-and-map expression roughly nine times slower. Enable it where the context comes from outside your code, not for data your own typed code builds. - Timeouts. A
timeoutmakes each evaluation read the clock, and adds a timer around each async host call. The cost is small for synchronous expressions and noticeable for expressions that make many async calls. - Regular expressions.
matchesruns a linear-time engine, which is predictable but slower than JavaScript's nativeRegExpon simple patterns. PreferstartsWith,endsWith, andincludeswhere they express the test. - Cache misses. Every distinct source text passed to
env.evaluate*()is parsed, checked, and compiled the first time it is seen. If your sources are generated with values embedded in them, pass the values through the context instead so the text stays the same. - Runtime
expectchecks. When the checker cannot prove a result matchesexpect(the result type is partlyany), the result is checked on every evaluation, at about 7 steps per record for a list of small maps. Declaring variable types lets the checker prove it instead.
What fits in the default budget
The step budget (maxSteps, default 1,000,000) is charged in proportion to the work each operation does, so it bounds how much data one evaluation can process. Approximate costs and the most records one evaluation can handle at the default budget:
| Operation | Steps | Most at the default budget |
|---|---|---|
filter over records | about 2 per record | the list limit (100,000) |
map to a number | about 6 per record | the list limit |
filter, map, and sum together | about 11 per record | about 94,000 records |
sortBy | about 12 per record | about 56,000 records |
unique over 10-field records | about 15 per record | about 66,000 records |
| A template per record | about 10 per record | about 99,000 records |
formatNumber, formatCurrency | about 20 per call | about 50,000 calls |
| A host function call | its cost (default 32), plus the call around it | about 31,000 bare calls; about 28,000 inside map over records |
matches | about 2 to 12 steps per character searched (alternations cost the most; a pattern anchored with ^ stops early) | roughly 80,000 to 500,000 characters of text |
Calendar functions in a time zone (startOfDay(t, zone)) | about 20 per call over nearby instants | about 50,000 calls |
| A built-in call with a string argument | 1, plus 1 per 32 characters of every string argument (the receiver included), plus 1 per 32 characters of text it returns | about 32 calls on a 1,000,000-character string |
Large strings
Built-in functions are charged by the length of their string arguments whether or not they read all of them, so the budget stays a bound on the work without per-function cost rules. Calls that only look at the start of a string cost as much as ones that scan it. Measured on strings of 100 and 64,000 characters:
| Expression | 100 characters | 64,000 characters |
|---|---|---|
s.length, s == "x" | 1 | 1 |
s.at(0), s.startsWith("a") | 4 | 2,001 |
s.slice(0, 50) | 5 | 2,002 |
s.includes("zz") | 7 | 3,252 |
s.toUpperCase().length | 7 | 4,001 |
classify(s), a host function | its cost (default 32) | its cost |
When expressions run on large payloads (a webhook body, a document), size maxSteps for about length / 32 steps per string built-in call on the payload, on top of the expression's other work. A per-tenant budget of 2,000 steps, for example, runs out on a single body.startsWith("x") once the body reaches 64,000 characters. If only a prefix or a field matters, pass that in the context instead of the whole payload, or expose a host function with a small cost: .length and == are not charged by length.
If a legitimate workload needs more, raise maxSteps for that environment or that evaluation; the budget then bounds proportionally more time. Lower a pure helper function's cost so its calls do not dominate.
The compiled regular expressions, number formats, and time zone data that these functions use are cached process-wide, with a fixed bound (at most 256 compiled patterns, about 100,000 compiled instructions in total), and shared by every environment. Step charges do not depend on whether something was already cached, so the same evaluation uses the same steps every time.