Benchmarks
Real performance measurements and honest comparisons: @gentleduck/iam benchmarked against every major JS authorization library.
Benchmarked against 7 libraries: @casl/ability, casbin, accesscontrol, role-acl, @rbac/rbac, and easy-rbac. Numbers from vitest bench on identical authorization scenarios. Sizes verified via bundlephobia on 2026-03-30.
Run bun run bench in packages/duck-iam to reproduce the numbers here. Three bench suites ship in-tree:
test/benchmark.bench.ts- head-to-head against the six competitor libraries.src/core/evaluate/__tests__/evaluate.bench.ts- micro-benchmarks forevaluatePolicyFast,indexPolicycold/warm, and cross-policy combine modes.src/core/resolve/__tests__/resolve.bench.ts- path resolution and pattern-matching hot paths.
The honest verdict
CASL is faster than us on most micro-benches. Pre-compiled hash-map dispatch at build() time beats a runtime policy engine on raw ops/sec:
- Simple RBAC check: CASL ~2.1x faster than
evaluateFastraw, ~14x faster end-to-end on cold start. evaluateFastraw: ~7.2M ops/s.engine.can()cache-warm: ~140k ops/s. The ~54x wrapper overhead is real work the raw path doesn't do - subject resolution, LRU lookup, hook dispatch, IDecision construction in dev mode.
duck-iam wins on architecture, not raw speed. What you get for the overhead:
- XACML-grade semantics - NotApplicable policies are skipped (not folded as default-deny), three cross-policy modes, four in-policy combiners, deterministic priority resolution.
- Type-safe adapter contract -
IamAdapter.IAdapterwithIReadOptions.signalfor cancellation, generic over actions / resources / roles / scopes. - Property-based oracle - 1000 random iterations per
(combine, defaultEffect)pair assertevaluateandevaluateFastcannot silently disagree. - Snapshot export / import -
engine.admin.export()produces a schema-versioned config snapshot for GitOps or staging->prod promotion.
duck-iam is faster than everyone except CASL. In production mode, beats easy-rbac, @rbac/rbac, accesscontrol, casbin, and role-acl on most paths.
The "duck-iam 41 KB" headline is misleading. Read on.
CASL ships at ~6 KB because its surface is small - one defineAbility builder + a condition evaluator. duck-iam's headline "41 KB" is the result of import * from '@gentleduck/iam' and pulling every adapter, every server middleware, every client wrapper, the builder, the explain tracer, and the validator. Nobody imports it this way in real code.
Realistic deployments (subpath imports + tree-shaking) end up at 15-25 KB, depending on what you wire. See the Real-world bundle profiles section below for measured numbers per use case.
2.1.0 measured (security cycle + bundle slim)
Numbers from scripts/benchmark.ts (microsecond-per-op). Bundle sizes from gzip -c | wc -c on dist/ chunks resolved via BFS through the import graph. Reproduce via bun run benchmark in packages/duck-iam/. Machine-relative; absolute numbers will vary, the deltas are the story.
Core paths
| Path | 2.0.0 | 2.1.0 | delta |
|---|---|---|---|
evaluatePolicy (simple rule) | 0.68 us | 0.76 us | +12% |
evaluatePolicy (conditions) | 1.33 us | 1.00 us | -25% |
evaluatePolicy (target match) | 0.47 us | 0.68 us | +45% |
evaluatePolicy (target skip) | 0.24 us | 0.24 us | 0% |
evaluate (2 policies) | 0.65 us | 1.24 us | +91% |
evaluate (deny path) | 0.58 us | 0.62 us | +7% |
Engine paths (cache-warm)
| Path | 2.0.0 | 2.1.0 | delta |
|---|---|---|---|
engine.can() | 4.86 us | 5.18 us | +7% |
engine.check() | 4.60 us | 4.60 us | 0% |
engine.permissions() (20 checks) | 20.06 us | 48.08 us | +140% |
engine.explain() | n/a | 8.02 us | new |
Bundle (gzipped, post-slim)
Measured against a clean 2.0.1 git worktree baseline (not eyeballed).
Net 2.0.1 -> 2.2.0 delta is +2.9 KB (+7.5%) - earlier docs cited a
~21 KB pre-cycle number that was estimated from a partial dist build.
| Snapshot | Bundle (gzipped) |
|---|---|
| 2.0.1 baseline (clean build) | 38.4 KB |
| 2.1.0 post-security-cycle | 44.8 KB (+17%) |
| 2.2.0 post-slim | 41.3 KB (+7.5% net vs 2.0.1) |
| Module | Size |
|---|---|
| Headline ("import * from") | 41.3 KB |
Core barrel (@gentleduck/iam/core) | ~15 KB realistic |
core/validate (admin only, lazy) | 12 KB chunk |
core/builder (config-time) | 9 KB chunk |
core/explain (dev-mode) | separate chunk |
core/schema (JSON schema export) | 1.3 KB |
| Memory adapter | 1.7 KB |
| Prisma adapter | 1.9 KB |
| Drizzle adapter | 3.0 KB |
| HTTP adapter | 6.0 KB |
| Redis adapter | 4.5 KB |
| Express server | 2.4 KB |
| Hono server | 2.4 KB |
| Next.js server | 3.1 KB |
| NestJS server | 2.9 KB |
| Generic server | 3.7 KB |
| React client | 1.3 KB |
| Vue client | 1.2 KB |
| Vanilla client | 2.0 KB |
Honest take
engine.permissions() more than doubled. Each batch check now fires onMetrics + builds IEvalSignals + threads onPolicyError and the catch arm wraps via _safeHookCall. 4 extra allocations per check. The 2-3x cost bought silent-fail-open elimination in batch UI gates - previously a real bug, now structurally impossible.
Core bundle headline +7.5% net (38.4 -> 41.3 KB) if you import the everything-barrel. The 2.1.0 security cycle added ~6 KB raw (44.8 KB peak); the 2.2.0 bundle slim cycle recovered ~3 KB by (1) dropping adapter re-exports from the barrel, (2) lazy-loading the validator chunk, (3) splitting builder / explain / validate into separate subpath entries. Realistic deployments measure 15-25 KB - see below.
engine.can() ~7% slower. 0.32 us added per cache-warm check. You'd need 1 million checks per second sustained before this is measurable.
evaluatePolicy(conditions) 25% faster. Per-Engine cache threading de-bounces redundant resolution work in the hot path.
Raw evaluateFast() ops/sec from vitest bench (separate suite): still ~7M/s on the production fast path.
Real-world bundle profiles
The 41 KB headline is the worst case. Here is what actual deployments measure (gzipped, subpath imports + standard ESM tree-shaking).
| Profile | Imports | Effective bundle |
|---|---|---|
| Edge function, RBAC-only | @gentleduck/iam/core + @gentleduck/iam/adapters/memory | ~17 KB |
| Express + Redis backend | @gentleduck/iam/server/express + @gentleduck/iam/adapters/redis | ~22 KB |
| Hono + memory | @gentleduck/iam/server/hono + @gentleduck/iam/adapters/memory | ~19 KB |
| Next.js + Drizzle | @gentleduck/iam/server/next + @gentleduck/iam/adapters/drizzle | ~21 KB |
| NestJS + Prisma | @gentleduck/iam/server/nest + @gentleduck/iam/adapters/prisma | ~20 KB |
| Admin dashboard (adds builder + validate) | + @gentleduck/iam/core/builder + @gentleduck/iam/core/validate (lazy) | +21 KB on admin route only |
| React UI gate (browser) | @gentleduck/iam/client/react | ~1.3 KB |
| Vue UI gate (browser) | @gentleduck/iam/client/vue | ~1.2 KB |
| Vanilla browser gate | @gentleduck/iam/client/vanilla | ~2.0 KB |
Notes:
- The validator (12 KB) is lazy-loaded on first call to
engine.admin.savePolicy/saveRole/import. Read-only services never pay for it. - The builder (9 KB) is only pulled if you import
@gentleduck/iam/core/builderdirectly. Apps that store policies as JSON skip it entirely. - The explain tracer is dev-mode only and a separate chunk; production builds tree-shake it away.
- Browser-side UI gates ship ~1-2 KB because they wire
useStateto a permission map the server fed them; the engine never enters the browser bundle.
How to keep your bundle tight
// yes Tight: import only what you use
import { IamEngine } from '@gentleduck/iam/core'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { adminRouter } from '@gentleduck/iam/server/express'
// no Pulls everything (41 KB)
import { IamEngine, IamMemoryAdapter } from '@gentleduck/iam'
Run bun run bench for the head-to-head against @casl/ability, casbin, accesscontrol, role-acl, @rbac/rbac, easy-rbac. Run bun run benchmark to emit fresh JSON for the docs site.
Library Overview
| @gentleduck/iam | @casl/ability | casbin | accesscontrol | role-acl | @rbac/rbac | easy-rbac | |
|---|---|---|---|---|---|---|---|
| Model | Policy engine | Ability-based | PERM DSL | Fluent grants | Role + conditions | Hierarchical | Hierarchical |
| ABAC | Yes (18 ops) | Yes | Yes | No | Yes | No | No |
| RBAC | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Runtime deps | 0 | 0 | 5 | 1 | 3 | 0 | 0 |
| TypeScript | Full generics | Full | String-based | Partial | Partial | Yes | No |
| Maintained | Active | Active | Active | No (2020) | Active | Active | No (2021) |
| Bundle, "import everything" | 41 KB | 6 KB | 30 KB | 8.2 KB | n/a | n/a | n/a |
| Bundle, realistic backend | 15-22 KB | ~6 KB | ~30 KB | ~8 KB | n/a | n/a | n/a |
| Bundle, browser UI gate | 1-2 KB | ~6 KB | (server only) | (server only) | (server only) | (server only) | (server only) |
Runtime Performance
All numbers are ops/sec (higher is faster). Each library solves the same authorization problem. CASL condition checks use subject() so conditions run (bare string checks skip them). duck-iam has two modes: [DEV] returns rich Decision objects with timing and reasons, [PROD] returns plain booleans with zero overhead.
Simple RBAC: "can viewer read post?"
| # | Library | ops/sec | vs CASL |
|---|---|---|---|
| 1 | @casl/ability | 15,200,000 | -- |
| 2 | @gentleduck/iam evaluatePolicyFast() [PROD] | 7,650,000 | 2x slower |
| 3 | @gentleduck/iam evaluateFast() [PROD] | 7,200,000 | 2.1x slower |
| 4 | easy-rbac | 5,003,000 | 3.0x slower |
| 5 | @rbac/rbac | 2,884,000 | 5.3x slower |
| 6 | @gentleduck/iam evaluatePolicy() [DEV] | 1,355,000 | 11.2x slower |
| 7 | @gentleduck/iam evaluate() [DEV] | 1,049,000 | 14.5x slower |
| 8 | @gentleduck/iam engine.can() [PROD, cache-warm] | 140,000 | 108x slower (54x wrapper overhead vs raw) |
| 9 | accesscontrol | 674,000 | 22.6x slower |
| 10 | casbin | 143,000 | 106x slower |
| 11 | role-acl | 140,000 | 108x slower |
ABAC condition check: "can owner update own draft?"
Libraries with ABAC condition support. CASL uses subject() so conditions run.
| # | Library | ops/sec | vs CASL |
|---|---|---|---|
| 1 | @casl/ability (with subject()) | 3,910,000 | -- |
| 2 | @gentleduck/iam evaluateFast() [PROD] | 1,177,000 | 3.3x slower |
| 3 | @gentleduck/iam evaluate() [DEV] | 648,000 | 6x slower |
Others excluded: no attribute-based condition support.
Role + condition: "can admin delete post?"
| # | Library | ops/sec | vs CASL |
|---|---|---|---|
| 1 | @casl/ability (with subject()) | 5,677,000 | -- |
| 2 | easy-rbac | 4,504,000 | 1.3x slower |
| 3 | @rbac/rbac | 2,780,000 | 2x slower |
| 4 | @gentleduck/iam [DEV] | 786,000 | 7.2x slower |
| 5 | accesscontrol | 388,000 | 14.6x slower |
| 6 | casbin | 55,000 | 103x slower |
| 7 | role-acl | 55,000 | 103x slower |
Deny path: "viewer cannot delete"
| # | Library | ops/sec | vs fastest |
|---|---|---|---|
| 1 | easy-rbac | 3,114,000 | -- |
| 2 | @casl/ability | 1,664,000 | 1.9x slower |
| 3 | @gentleduck/iam [DEV] | 803,000 | 3.9x slower |
| 4 | role-acl | 141,000 | 22x slower |
| 5 | @rbac/rbac | 68,000 | 46x slower |
| 6 | casbin | 51,000 | 61x slower |
Batch: 20 permission checks
| # | Library | ops/sec | vs CASL |
|---|---|---|---|
| 1 | @casl/ability | 3,481,000 | -- |
| 2 | easy-rbac | 497,000 | 7x slower |
| 3 | @gentleduck/iam evaluateFast() [PROD] | 462,000 | 7.5x slower |
| 4 | @gentleduck/iam evaluate() [DEV] | 137,000 | 25.4x slower |
| 5 | accesscontrol | 68,000 | 51x slower |
| 6 | role-acl | 22,000 | 158x slower |
| 7 | @rbac/rbac | 14,200 | 245x slower |
| 8 | casbin | 9,800 | 354x slower |
Cold start: build everything + first check
| # | Library | ops/sec | vs CASL |
|---|---|---|---|
| 1 | @casl/ability | 3,284,000 | -- |
| 2 | easy-rbac | 3,118,000 | 1.1x slower |
| 3 | accesscontrol | 830,000 | 4x slower |
| 4 | @gentleduck/iam | 234,000 | 14x slower |
| 5 | role-acl | 306,000 | 10.7x slower |
| 6 | @rbac/rbac | 183,000 | 17.9x slower |
| 7 | casbin | 62,000 | 53x slower |
The cold-start gap reflects the cost of building the policy engine, RBAC-to-ABAC index, condition operator table, and LRU caches at first call - one-time work the rest of the runtime amortises.
Why CASL is faster, and why it rarely matters
The architectural difference
CASL and duck-iam solve authorization at different engine levels:
CASL: pre-compiled lookup table. build() iterates every rule once and produces an index keyed by [action, subjectType]. Every can() call is a single hash-map lookup - O(1), ~0.012 us. Rules are frozen after build() and can't change at runtime.
duck-iam: dynamic policy engine. Policies load from databases, update at runtime through adapters, and invalidate via the LRU cache. Each evaluation does: WeakMap index lookup, Map.get by action:resource, condition evaluation, combining algorithm. Even with rule indexing, each check costs ~0.12 us - about 2x a single hash lookup.
Where the ~2x gap comes from (profiled)
Profiled operations in the production fast path:
| Operation | Cost | What it does |
|---|---|---|
| WeakMap index lookup | ~0.004 us | Retrieve cached rule index for the policy |
| String key concat | ~0.001 us | Build "read\0post" lookup key |
| Map.get | ~0.014 us | Find rules matching this action+resource |
| for loop (1 rule) | ~0.003 us | Iterate matched rules |
| Condition check | ~0.003 us | Skip (empty conditions) or evaluate |
| policyApplies | ~0.003 us | Check policy targets |
| Precomputed cache hit | ~0.080 us | Two nested Map.get calls (action -> resource) |
| Total | ~0.120 us | |
| CASL total | ~0.060 us | Hash lookup + return |
The gap is not one big bottleneck. It's the sum of small costs a policy engine requires. CASL sidesteps them by freezing rules at build time.
What we optimized (and what we can't)
Every optimization that keeps the dynamic policy model is applied:
- Rule indexing: pre-built
Map<action:resource, Rule[]>per policy, cached via WeakMap. Removes the linear scan over all rules. - Unconditional rule flag: rules with empty conditions skip
evalConditionGroup(). - Inlined combiners:
deny-overridesandallow-overridesinline into the evaluation loop - no array allocation, no function calls. - Path cache: condition field paths like
subject.attributes.rolesplit once and cache forever. - Production mode: no
performance.now(), noDate.now(), no Decision allocation, no reason strings.
Closing the last ~2x gap means dropping dynamic policies and pre-compiling at init like CASL. That breaks adapters, runtime policy updates, and the LRU cache - the features that make duck-iam a policy engine instead of a lookup table.
Why it doesn't matter in practice
Authorization isn't the bottleneck. A typical API request:
| Step | Time |
|---|---|
| Network round trip | 5,000--50,000 us |
| Database query | 500--5,000 us |
| JSON serialization | 50--500 us |
| duck-iam check (prod) | 0.12 us |
| CASL check | 0.06 us |
The gap is 60 nanoseconds. At 100 checks per request, that's 6 us - 0.00012% of a 50 ms request.
Dev vs Prod Mode
duck-iam has two execution modes. They change runtime behavior and return types:
// Development (default) -- rich AccessControl.IDecision with timing, reasons, rule refs
const engine = new IamEngine({ adapter, mode: 'development' })
const decision = await engine.check('user-1', 'read', post)
// decision: AccessControl.IDecision { allowed: true, effect: 'allow', reason: '...', duration: 0.5, timestamp: ... }
// engine.explain() is available
// Hooks (afterEvaluate, onDeny, onError) fire on every check
// Production -- plain boolean, maximum throughput
const prodEngine = new IamEngine({ adapter, mode: 'production' })
const allowed = await prodEngine.check('user-1', 'read', post)
// allowed: true (boolean)
// No performance.now(), no Date.now(), no object allocation, no reason strings
// engine.explain() throws -- not available in production
// Hooks (afterEvaluate, onDeny, onError) are skipped for maximum speed
// onMetrics still fires in production (primitive-only event, zero overhead when unwired)
engine.can() always returns boolean in both modes (for middleware compatibility).
Does production mode reduce bundle size?
The mode flag alone does not reduce bundle size. It's a runtime check. Import patterns and subpath entries do. The package is tree-shakeable AND ships per-module entries.
// yes Smallest production bundle - import only the fast evaluator
// Tree-shakes away: IamEngine, explain, builder, config, validate, dev evaluate
import { evaluateFast } from '@gentleduck/iam/core'
// yes Typical backend - pulls engine + adapter + server middleware only
import { IamEngine } from '@gentleduck/iam/core'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { adminRouter } from '@gentleduck/iam/server/express'
// yes Admin-write path - validator is lazy-loaded on first call
import { IamEngine } from '@gentleduck/iam/core'
await engine.admin.savePolicy(p) // pulls validate chunk on first call
// no Pulls everything (41 KB) - avoid the barrel import
import { IamEngine, IamMemoryAdapter } from '@gentleduck/iam'
The validator (12 KB), builder (9 KB), explain tracer, and JSON schema are all behind separate subpath entries - they only ship if you import them directly. The HTTP, Redis, Drizzle, Prisma, and file adapters are subpath-only; the barrel no longer re-exports them.
Why is duck-iam ~41 KB when CASL is ~6 KB?
It is not. 41 KB is the headline if you do import * from '@gentleduck/iam'. Almost nobody does. Real deployments are 15-25 KB - see Real-world bundle profiles.
duck-iam is bigger than CASL because it ships more: validator, fluent builder, explain tracer, multi-adapter contract, hook safety wrappers, per-Engine caches, server middleware for 5 frameworks, client wrappers for 3 frameworks. CASL is one builder + one evaluator. Different products. Use CASL if you need the smallest possible bundle and don't need adapters / explain / lazy validate.
Internal Performance
Pure evaluation timing, average of 2,000 iterations after 200 warmup rounds.
| Operation | Time |
|---|---|
evaluatePolicyFast() - simple rule | ~0.87 us |
evaluatePolicyFast() - with conditions | ~1.61 us |
evaluatePolicy() [DEV] - target match | ~0.59 us |
evaluatePolicy() - target skip | ~0.37 us |
evaluate() - 2 policies | ~0.70 us |
evaluate() - deny path | ~0.96 us |
Engine Performance (with LRU caching)
| Operation | Time | ops/s |
|---|---|---|
engine.can() [PROD] - cache-warm | ~7.1 us | ~140,000 |
engine.check() [DEV] - cache-warm | ~4.2 us | ~238,000 |
engine.permissions() - 20 checks | ~21 us | ~47,000 batches |
engine.explain() - full trace | ~5.7 us | ~175,000 |
The gap between raw evaluateFast() (~7.2M ops/s) and engine.can() cache-warm (~140k ops/s) is wrapper overhead: subject resolution from the LRU, hook dispatch, mode-conditional Decision construction. The wrapper does work the raw evaluator skips.
Times vary by machine. Run bun run benchmark for your hardware.
Bundle Size
| Library | Size (gzip) | Runtime deps | Tree-shakeable |
|---|---|---|---|
| easy-rbac | ~2 KB | 0 | No |
| @rbac/rbac | ~4 KB | 0 | No |
| @casl/ability | ~6 KB | 0 | Yes |
| accesscontrol | ~8.2 KB | 1 | No |
| role-acl | ~12 KB | 3 | No |
| @gentleduck/iam (full) | ~21 KB | 0 | Yes |
| casbin (node-casbin) | ~30 KB | 5 | No |
We are not the smallest. At ~21 KB, duck-iam is 3.5x larger than CASL. The full package bundles: evaluation engine, RBAC-to-ABAC converter, conditions engine (18 operators), explain/debug tracer, type-safe builder, config validator, and LRU cache. CASL ships none of that.
The package is tree-shakeable. Import only evaluateFast and skip the engine, explain, and builder for a much smaller bundle. Each adapter and server middleware adds ~0.8-1.7 KB.
Module Sizes
| Module | Size (gzip) |
|---|---|
| Core (full entry) | 21.9 KB |
| Adapter: Memory | 1.1 KB |
| Adapter: Prisma | 1.4 KB |
| Adapter: Drizzle | 1.7 KB |
| Adapter: HTTP | 1.2 KB |
| Adapter: Redis | 1.4 KB |
| Server: Express | 1.1 KB |
| Server: Next.js | 1.0 KB |
| Server: Hono | 0.9 KB |
| Server: NestJS | 1.3 KB |
| Server: Generic | 0.8 KB |
| Client: React | 1.1 KB |
| Client: Vue | 1.0 KB |
| Client: Vanilla | 1.4 KB |
Feature Comparison
| Feature | gentleduck | CASL | Casbin | accesscontrol | role-acl | @rbac/rbac | easy-rbac |
|---|---|---|---|---|---|---|---|
| RBAC | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| ABAC (conditions) | 18 operators | Yes | Yes | No | Yes | No | No |
| Policy engine | Yes | No | Yes | No | No | No | No |
| Dev/Prod mode | Yes | No | No | No | No | No | No |
| Deny-overrides | Yes | No | Yes | No | No | No | No |
| Combining algorithms | 4 | 1 | Custom | 1 | 1 | 1 | 1 |
| Scoped roles | Yes | No | No | No | No | No | No |
| Explain / debug | Yes | No | No | No | No | No | No |
| Lifecycle hooks | Yes | No | No | No | No | No | No |
| LRU caching | Built-in | No | No | No | No | No | No |
| Rule indexing | Yes | Yes | No | No | No | No | No |
| DB adapters | 5 | 3 | 20+ | 0 | 0 | 3 | 0 |
| Server middleware | 5 | 0 | 2 | 0 | 0 | 3 | 0 |
| React integration | Yes | Yes | No | No | No | No | No |
| Vue integration | Yes | Yes | No | No | No | No | No |
| Type-safe config | Yes | Yes | No | Yes | No | Yes | No |
| Zero runtime deps | Yes | Yes | No | No | No | Yes | Yes |
| Batch permissions | Yes | No | No | No | No | No | No |
Where each library wins
@gentleduck/iam wins on
- XACML-grade semantics: NotApplicable policies skipped (not folded as default-deny), three cross-policy combine modes (
and,allow-overrides,first-applicable), four in-policy combiners with deterministic priority. - Type-safe adapter contract:
IamAdapter.IAdaptergeneric over actions / resources / roles / scopes;IReadOptions.signalforAbortController-driven cancellation. - Property-based oracle: trace and fast paths can't silently drift - 1000 random iterations per combine/default-effect pair, six historical drifts caught.
- Snapshot export / import:
engine.admin.export()-> schema-versioned snapshot for GitOps, environment promotion, backup. - Feature density: scoped roles, explain/debug, lifecycle hooks (
onPolicyError,onMetrics), batch permissions, 18 condition operators, dev/prod mode in one package. - Faster than casbin, role-acl, accesscontrol in production mode.
- Operability surface:
preload(),healthCheck(),stats(),dispose(), cross-instanceIInvalidator, adapter timeouts, fail-open opt-in.
@casl/ability wins on
- Raw speed: 2x faster than duck-iam in production mode from the pre-compiled ability index
- Bundle size: ~6 KB, 3.5x smaller
- Maturity: production since 2017
- Ecosystem: ~900K downloads/week, extensive docs and community
- Isomorphic: proven frontend + backend sharing pattern
easy-rbac wins on
- Fastest deny path: 2x faster than CASL on deny checks
- Tiny bundle: ~2 KB, the smallest
- Zero config: hierarchical RBAC, nothing to set up
casbin wins on
- Adapter ecosystem: 20+ database adapters across 15+ languages
- Admin UI: web-based policy management panel
- Academic backing: formal PERM metamodel
@rbac/rbac wins on
- Fast simple checks: 2.5M ops/sec for basic RBAC
- Built-in middleware: Express, NestJS, Fastify
- Runtime role updates: add or change roles without restart
Smallest possible bundle
defineIam() sets up the whole authorization system in one call, but it pulls in the full config system, validator, and builder. If all you need is policy evaluation, skip the config layer and import the building blocks directly.
Build a typed policy and evaluate it without defineIam:
import type { AccessControl, IamRequest } from '@gentleduck/iam'
import { evaluatePolicyFast } from '@gentleduck/iam'
// Define your action/resource types for type safety
type Action = 'read' | 'update' | 'delete'
type Resource = 'post' | 'comment'
const policy: AccessControl.IPolicy<Action, Resource> = {
id: 'blog-policy',
algorithm: 'deny-overrides',
rules: [
{ id: 'allow-read', effect: 'allow', actions: ['read'], resources: ['post', 'comment'], conditions: {}, priority: 0 },
],
}
const request: IamRequest.IAccessRequest<Action, Resource> = {
subject: { id: 'user-1', roles: ['viewer'] },
action: 'read',
resource: { type: 'post', id: 'post-1' },
}
const allowed = evaluatePolicyFast(policy, request) // boolean
The package is fully tree-shakeable. Anything you don't import drops out: IamEngine, explain, builder, config, validate, adapters. From the module sizes above, evaluateFast alone is tiny next to the 21.9 KB core entry: pay only for what you use.
Other low-level pieces to import directly: PolicyBuilder, RuleBuilder, evaluateFast, evaluatePolicy, and the condition operators. Mix and match for the exact surface area you need.
Methodology
- @gentleduck/iam: bundle sizes from
dist/viagzip -c | wc -c. Performance viavitest benchwith N=3 inner loops. Production mode usesevaluateFast()with rule indexing (WeakMap-cached per policy, Map lookup byaction:resource). - @casl/ability: condition benchmarks use
subject()for condition evaluation. Bare string checks (can('read', 'Post')) skip conditions and would give misleading numbers - we don't do that. - casbin: real RBAC model (
newModel()+StringAdapter) with role inheritance via grouping rules. - accesscontrol, @rbac/rbac, easy-rbac: excluded from ABAC benchmarks (no condition support).
- Competitor sizes from bundlephobia.com, verified 2026-03-30.
- Sizes are minified + gzipped.
- All benchmarks run on the same machine in the same vitest session.
Reproduce:
cd packages/duck-iam
bun run bench # vitest bench -- competitive comparison + micro-benchmarks
bun run benchmark # JSON data output + console summary
Property-based regression guard
The benchmarks above measure speed. A second suite measures correctness drift between the trace path (evaluate) and the production fast path (evaluateFast).
src/core/evaluate/__tests__/oracle.test.ts
1000 deterministic-random iterations per (combine, defaultEffect) pair. Each iteration generates a policy set with mixed exact / wildcard / colon-prefix / dot-hierarchy / parent-prefix resource patterns plus randomized conditions, scoped roles, and target dimensions. Then asserts:
evaluate(policies, request).allowed === evaluateFast(policies, request)
Why it matters: across thirteen audit rounds the two paths drifted six times (first-match priority order, colon-prefix index, parent-prefix lookup, NotApplicable handling, etc.). Each drift was caught by a regression test added after the bug shipped. The oracle is the generative guarantee that the two paths can't silently disagree on inputs the regression suite didn't pick.
Failures surface with the exact seed + policy set + request that diverged - full reproducer in the test error message.