Benchmarks
Every number on this page comes from scripts in the repository, recorded in bench/results/ and turned into these charts and tables by pnpm bench:report. They were measured on one machine (named under each chart). Treat them as relative guidance, not absolutes: the order and the orders of magnitude carry over to other hardware, the exact figures do not.
Compared with other libraries
Four scenarios, each implemented idiomatically in every library (bench/compare/adapters/): a plain role check, a role check with an ownership condition, a policy with 1,000 rules where the request matches the last one, and filtering a list of 100 documents with the ownership rule. Pick a scenario. Hover over or focus a bar to see the per-call time, the sample spread and the exact version.
| Library | Role check ops/s | Ownership condition ops/s | 1,000 rules ops/s | Filter 100 documents batches/s |
|---|---|---|---|---|
| Kerberos.js isAllowed · checkResources · v4.3.0 | 817,000 | 810,000 | 820,000 | 12,600 |
| CASL ability built once per user · v7.0.1 | 40,400,000 | 14,900,000 | 39,500,000 | 103,000 |
| CASL ability built per request · v7.0.1 | 4,350,000 | 1,870,000 | 9,650 | 93,500 |
| casbin enforce · batchEnforce · v5.51.1 | 383,000 | 297,000 | 549 | 2,910 |
| AccessControl tryCan(…).do(…) · v3.1.1 | 1,170,000 | 955,000 | 1,160,000 | 10,100 |
| easy-rbac can(roles, operation, params) · v4.0.0 | 770,000 | 693,000 | 762,000 | 9,390 |
| @rbac/rbac can(role, operation, params) · v2.2.2 | 24,500,000 | 3,760,000 | 25,500,000 | 29,800 |
| OPA Rego compiled to WebAssembly, in-process · v1.10.0 | 468,000 | 428,000 | 42,000 | 14,900 |
| OPA server REST Data API, sidecar on localhost · 1.21.1 | 2,050 | 2,160 | 2,180 | 903 |
| Cerbos PDP gRPC SDK, sidecar on localhost · 0.55.0 | 1,500 | 1,400 | 1,530 | 286 |
Apple M3 Max · 16 cores · Node v24.14.1 · Docker 27.4.0 · median of 5 × 1 s samples, each library in a fresh process · 2026-10-03. Medians, rounded to three significant figures.
How to read it
The libraries do different amounts of work per call, so read the chart as a price list, not a ranking:
- CASL, @rbac/rbac and AccessControl check an in-memory predicate or map: no policy documents, versions or scopes, no audit or telemetry path, no query planner. @rbac/rbac also memoizes role × operation lookups. Every timed call repeats the same request, so it is measured at its cache-hit speed.
- CASL builds an ability per user. The built once per user row times the check alone. The built per request row includes building the ability, which is what you pay unless you cache abilities between requests. With 1,000 rules that build dominates.
- A Kerberos.js call includes argument validation, the guarded audit, telemetry and hook seams, and the walk along the scope chain. Its rule index keeps the 1,000-rule policy as fast as the one-rule policy. casbin, OPA's WebAssembly build and per-request CASL all slow down as the policy grows.
- OPA server and Cerbos PDP run as sidecars. Every decision is a serialized request over localhost, so the transport dominates whatever the engine does. The numbers were taken on Docker Desktop, where the round-trip also crosses the VM's network bridge, so they are slower than on a Linux host. A network hop is never free, though, which is the cost Kerberos.js is built to avoid.
Method
- Every (library, scenario) pair runs in a fresh process, so no library's JIT feedback or heap state leaks into the next one.
- Before timing, each adapter must reproduce the scenario's expected allow and deny decisions (and the exact set of visible ids for the list). A mismatch aborts the run.
- After a one-second warmup, calls are timed in five one-second samples and the median is reported. Calls run in chunks, so reading the clock does not dominate sub-microsecond calls. A library whose check returns synchronously is never awaited, because awaiting would charge it a microtask its callers do not pay.
- OPA and Cerbos run from pinned images:
openpolicyagent/opa:1.21.1andghcr.io/cerbos/cerbos:0.55.0, the version the conformance suite runs against. OPA is measured twice. Once its Rego is compiled to WebAssembly and evaluated in-process with the official SDK. Once it runs as a server. Cerbos is called through its gRPC SDK, the faster of its two transports, and its batch call sends all 100 documents in one request.@cerbos/embeddedis absent: its policy bundles can only be built by Cerbos Hub.
Cold start
Load the library, build the ownership policy and make the first decision, in a fresh process. For OPA this includes compiling the WebAssembly module. The sidecars are left out: their start-up is a deployment concern, not a per-process cost.
| Library | Load | Build policy | First decision | Total |
|---|---|---|---|---|
| easy-rbac can(roles, operation, params) · v4.0.0 | 1.5 ms | 0.2 ms | 0.2 ms | 1.9 ms |
| CASL ability built once per user · v7.0.1 | 4.6 ms | 0.3 ms | 0.4 ms | 5.3 ms |
| @rbac/rbac can(role, operation, params) · v2.2.2 | 7.5 ms | 0.7 ms | 0.3 ms | 8.5 ms |
| Kerberos.js isAllowed · checkResources · v4.3.0 | 13.7 ms | 1.1 ms | 1.3 ms | 16.1 ms |
| casbin enforce · batchEnforce · v5.51.1 | 13.5 ms | 2.6 ms | 1.0 ms | 17.1 ms |
| AccessControl tryCan(…).do(…) · v3.1.1 | 15.3 ms | 0.8 ms | 1.0 ms | 17.1 ms |
| OPA Rego compiled to WebAssembly, in-process · v1.10.0 | 6.7 ms | 17.2 ms | 0.3 ms | 24.1 ms |
Bundle size
The browser bundle of each library's main entry (pnpm size:compare: esbuild, min+gzip, measured the same way as pnpm size). OPA's WebAssembly SDK also needs the compiled policy at runtime, so its bar includes the .wasm file for the ownership rule alone.
| Library | min | min+gzip | |
|---|---|---|---|
@alexify/kerberos (main entry) | 134.3 KB | 38.3 KB | |
@casl/ability | 18.3 KB | 6.6 KB | |
casbin | 125.3 KB | 33.9 KB | does not bundle for the browser (Node builtins) — measured as a Node bundle |
accesscontrol | 92.5 KB | 29.0 KB | |
easy-rbac | 4.8 KB | 1.8 KB | |
@rbac/rbac | 77.2 KB | 18.3 KB | |
@open-policy-agent/opa-wasm | 92.6 KB | 29.1 KB | + 51.7 KB gzip for the compiled policy (.wasm, ownership rule only) |
CASL and easy-rbac are small because they implement far less. casbin does not bundle for the browser at all.
Kerberos.js hot paths
The zero-dependency regression harness in bench/bench.js covers the engine's own paths: the single check, batches, metadata, role-policy chains, scopes, validation backends, hooks and listeners, cache-backed $expr policies, query plans and relations. Each scenario gets up to 0.5 s of warmup, then five one-second samples, and the median is reported.
| Scenario | ops/sec |
|---|---|
isAllowed — simple role match | ~796,000 |
isAllowed — derived roles + variables + condition | ~617,000 |
checkResources — 10 resources × 3 actions | ~59,400 |
checkResources — 10 resources, includeMeta | ~48,700 |
isAllowed — role policy + 2-level parentRoles chain | ~402,000 |
isAllowed — 3-segment scoped request (chain walk) | ~594,000 |
isAllowed — simple role match + Zod validation | ~450,000 |
isAllowed — simple role match + 1 sync decision listener | ~701,000 |
isAllowed — simple role match + request-level hooks | ~393,000 |
isAllowed — simple role match + per-resource hooks | ~337,000 |
checkResources — 10 resources × 3 actions + decision listener | ~53,900 |
isAllowed — cache-backed dynamic policy ($expr) | ~271,000 |
checkResources — 50 resources, cache-backed | ~8,280 |
planResources — $expr policy (variables + deny rule) | ~63,000 |
relations.check — direct tuple (flat) | ~731,000 |
relations.check — deep walk (3 arrows + nested groups) | ~107,000 |
isAllowed — relation-backed derived role (deep walk) | ~70,800 |
checkResources evaluates resources concurrently (Promise.allSettled). With a remote policy store, N resources cost one parallel wave of lookups instead of N sequential round-trips (about 8× faster with a 2 ms-latency cache and 10 resources). One failing resource never fails the batch: it fail-closes to EFFECT_DENY for its own actions only.
Release over release
The scaling sweeps measure how decision cost grows with the shape of a policy: rules per policy, principal roles, imported derived roles, scope depth and relation depth. Both releases were measured on the same machine with the same harness (node bench/bench.js scaling --src <checkout of the previous release>, see below).
| Scenario | 4.2.0 | 4.3.0 | Speed-up |
|---|---|---|---|
| 1 000 rules × distinct roles, match last | ~23,800 | ~721,000 | ×30.3 |
| 1 000 rules × distinct actions, match last | ~33,100 | ~740,000 | ×22.4 |
| 1 000 rules × distinct actions, match first | ~35,900 | ~749,000 | ×20.9 |
| 100 concurrent relations.check, shared memo, depth 16 | ~378 | ~3,220 | ×8.5 |
| checkResources 100 docs, relation depth 16 | ~330 | ~1,670 | ×5.1 |
| 64 principal roles, no role policies | ~39,000 | ~134,000 | ×3.4 |
| 32 derived-role definitions, 1 referenced | ~198,000 | ~632,000 | ×3.2 |
| scope depth 8, decided at the most specific scope | ~180,000 | ~282,000 | ×1.6 |
Reproducing the numbers
node bench/bench.js --json --samples 5 # hot paths → bench/results/engine.json
git worktree add ../kerberos-previous v4.2.0 # the previous release, measured with today's harness:
node bench/bench.js scaling --json --samples 5 --src ../kerberos-previous
pnpm bench:compare # libraries + cold start → bench/results/compare.json (needs Docker)
node scripts/size-compare.js --json # bundle sizes → bench/results/size.json
pnpm bench:report # charts (docs/public/benchmarks) + the tables on this page and in the READMEpnpm bench:compare --no-docker skips OPA's server and Cerbos. --only kerberos,casl and --scenarios abac,batch re-measure a subset and merge it into the existing results. pnpm bench:coldstart breaks Kerberos.js's own start-up into require, construction and the first decision.
Fuzzing
The security-sensitive surfaces — the $expr codec (parse + evaluate), the CEL translator's output contract, the YAML subset parser, and RelationResolver — are covered by a deterministic, seeded fuzz suite (test/Fuzz.test.js) that runs as part of pnpm test and can be cranked with FUZZ_ITERATIONS=100000. The properties asserted are the contracts: typed errors only, no leaked functions, no prototype pollution, and every expression the translator emits must compile.