Skip to content

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.

PracticeWhy
Create one environment and reuse itThe environment owns the program cache. Creating one per request throws the cache away.
compile() expressions that run repeatedlyParsing 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 workloadenv.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 programsChecking 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.
ts
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 // => 1

Why 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:

bash
bun run bench

Treat 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 await per async call. Expressions that do not call async functions run on the synchronous path even through evaluate().
  • 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: true checks 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 timeout makes 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. matches runs a linear-time engine, which is predictable but slower than JavaScript's native RegExp on simple patterns. Prefer startsWith, endsWith, and includes where 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 expect checks. When the checker cannot prove a result matches expect (the result type is partly any), 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:

OperationStepsMost at the default budget
filter over recordsabout 2 per recordthe list limit (100,000)
map to a numberabout 6 per recordthe list limit
filter, map, and sum togetherabout 11 per recordabout 94,000 records
sortByabout 12 per recordabout 56,000 records
unique over 10-field recordsabout 15 per recordabout 66,000 records
A template per recordabout 10 per recordabout 99,000 records
formatNumber, formatCurrencyabout 20 per callabout 50,000 calls
A host function callits cost (default 32), plus the call around itabout 31,000 bare calls; about 28,000 inside map over records
matchesabout 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 instantsabout 50,000 calls
A built-in call with a string argument1, plus 1 per 32 characters of every string argument (the receiver included), plus 1 per 32 characters of text it returnsabout 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:

Expression100 characters64,000 characters
s.length, s == "x"11
s.at(0), s.startsWith("a")42,001
s.slice(0, 50)52,002
s.includes("zz")73,252
s.toUpperCase().length74,001
classify(s), a host functionits 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.

Released under the MIT License.