Skip to content

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.

Ownership condition
A USER may view a document they own: one role-gated rule with an attribute condition. ops/s, log scale — higher is better.
In-process
CASLability built once per user
14.9M
@rbac/rbaccan(role, operation, params)
3.76M
CASLability built per request
1.87M
AccessControltryCan(…).do(…)
955k
Kerberos.jsisAllowed · checkResources
810k
easy-rbaccan(roles, operation, params)
693k
OPARego compiled to WebAssembly, in-process
428k
casbinenforce · batchEnforce
297k
Sidecar — a network round-trip per call
OPA serverREST Data API, sidecar on localhost
2,159
Cerbos PDPgRPC SDK, sidecar on localhost
1,399
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
LibraryRole 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,000810,000820,00012,600
CASL
ability built once per user · v7.0.1
40,400,00014,900,00039,500,000103,000
CASL
ability built per request · v7.0.1
4,350,0001,870,0009,65093,500
casbin
enforce · batchEnforce · v5.51.1
383,000297,0005492,910
AccessControl
tryCan(…).do(…) · v3.1.1
1,170,000955,0001,160,00010,100
easy-rbac
can(roles, operation, params) · v4.0.0
770,000693,000762,0009,390
@rbac/rbac
can(role, operation, params) · v2.2.2
24,500,0003,760,00025,500,00029,800
OPA
Rego compiled to WebAssembly, in-process · v1.10.0
468,000428,00042,00014,900
OPA server
REST Data API, sidecar on localhost · 1.21.1
2,0502,1602,180903
Cerbos PDP
gRPC SDK, sidecar on localhost · 0.55.0
1,5001,4001,530286

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.1 and ghcr.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/embedded is 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.

Cold start
Load the library, build the ownership policy, first decision — fresh process, median of 20 runs. Lower is better.
easy-rbaccan(roles, operation, params)
1.9 ms
CASLability built once per user
5.3 ms
@rbac/rbaccan(role, operation, params)
8.5 ms
Kerberos.jsisAllowed · checkResources
16 ms
casbinenforce · batchEnforce
17 ms
AccessControltryCan(…).do(…)
17 ms
OPARego compiled to WebAssembly, in-process
24 ms
Apple M3 Max · 16 cores · Node v24.14.1 · Docker 27.4.0
LibraryLoadBuild policyFirst decisionTotal
easy-rbac
can(roles, operation, params) · v4.0.0
1.5 ms0.2 ms0.2 ms1.9 ms
CASL
ability built once per user · v7.0.1
4.6 ms0.3 ms0.4 ms5.3 ms
@rbac/rbac
can(role, operation, params) · v2.2.2
7.5 ms0.7 ms0.3 ms8.5 ms
Kerberos.js
isAllowed · checkResources · v4.3.0
13.7 ms1.1 ms1.3 ms16.1 ms
casbin
enforce · batchEnforce · v5.51.1
13.5 ms2.6 ms1.0 ms17.1 ms
AccessControl
tryCan(…).do(…) · v3.1.1
15.3 ms0.8 ms1.0 ms17.1 ms
OPA
Rego compiled to WebAssembly, in-process · v1.10.0
6.7 ms17.2 ms0.3 ms24.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.

Bundle size
Main entry bundled with esbuild, min+gzip. Lower is better.
easy-rbac
1.8 KB
CASL@casl/ability
6.6 KB
@rbac/rbac
18.3 KB
AccessControlaccesscontrol
29.0 KB
casbinNode-only, does not bundle for the browser
33.9 KB
Kerberos.js@alexify/kerberos
38.3 KB
OPA29.1 KB SDK + 51.7 KB compiled policy (.wasm)
80.8 KB
Libraryminmin+gzip
@alexify/kerberos (main entry)134.3 KB38.3 KB
@casl/ability18.3 KB6.6 KB
casbin125.3 KB33.9 KBdoes not bundle for the browser (Node builtins) — measured as a Node bundle
accesscontrol92.5 KB29.0 KB
easy-rbac4.8 KB1.8 KB
@rbac/rbac77.2 KB18.3 KB
@open-policy-agent/opa-wasm92.6 KB29.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.

Kerberos.js 4.3.0 hot paths
ops/s, linear scale — higher is better.
isAllowed
simple role match
796k
derived roles + variables + condition
617k
role policy + 2-level parentRoles chain
402k
3-segment scoped request (chain walk)
594k
simple role match + Zod validation
450k
simple role match + 1 sync decision listener
701k
simple role match + request-level hooks
393k
simple role match + per-resource hooks
337k
cache-backed dynamic policy ($expr)
271k
relation-backed derived role (deep walk)
70.8k
checkResources
10 resources × 3 actions
59.4k
10 resources, includeMeta
48.7k
10 resources × 3 actions + decision listener
53.9k
50 resources, cache-backed
8,283
planResources
$expr policy (variables + deny rule)
63k
relations.check
direct tuple (flat)
731k
deep walk (3 arrows + nested groups)
107k
Apple M3 Max · 16 cores · Node v24.14.1
Scenarioops/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).

4.2.0 → 4.3.0: how decision cost scales with policy shape
Speed-up of each scaling sweep, same machine and harness. Higher is better.
1 000 rules × distinct roles, match last23.8k → 721k per second
×30
1 000 rules × distinct actions, match last33.1k → 740k per second
×22
1 000 rules × distinct actions, match first35.9k → 749k per second
×21
100 concurrent relations.check, shared memo, depth 16378 → 3,223 per second
×8.5
checkResources 100 docs, relation depth 16330 → 1,673 per second
×5.1
64 principal roles, no role policies39k → 134k per second
×3.4
32 derived-role definitions, 1 referenced198k → 632k per second
×3.2
scope depth 8, decided at the most specific scope180k → 282k per second
×1.6
Apple M3 Max · 16 cores · Node v24.14.1
Scenario4.2.04.3.0Speed-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 ​

bash
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 README

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

Released under the MIT License.