development vs production mode
The mode option - full AccessControl.IDecision objects with timing in dev, plain booleans with zero allocations in prod. 2x performance trade-off.
Two modes
The mode config option chooses the evaluation path:
const engine = new IamEngine({
adapter,
mode: 'production', // or 'development' (default)
})
| Mode | Path | Output | Use for |
|---|---|---|---|
'development' (default) | evaluate() | Full AccessControl.IDecision with timing, rule/policy refs, reason | Local dev, explain(), debugging |
'production' | evaluateFast() | Plain boolean - no allocations | Deployed services |
What changes
development mode
engine.authorize()returnsAccessControl.IDecision(object withallowed,effect,rule,policy,reason,duration,timestamp)engine.check()returnsAccessControl.IDecisionengine.explain()works (full evaluation trace available)afterEvaluateandonDenyhooks receive theIDecisionobject- Timing is recorded -
durationis the actual evaluation milliseconds - All allocations happen - matching rule arrays, intermediate effect tags, etc.
production mode
engine.authorize()returns plainbooleanengine.check()returns plainboolean(same ascan())engine.explain()throws (Error: explain() is not available in production mode)afterEvaluateandonDenyhooks never fire (the engine takes the fast path that skipsIDecisionconstruction)onMetrics,onError, andonPolicyErrorstill fire - see hooks- No
durationtiming recorded on the path itself; useonMetricsfor telemetry - Zero-allocation hot path - combined action+resource index, pre-computed unconditional rules
engine.can() always returns boolean regardless of mode - it's the simple-API method that works the same way in both modes.
The IamEngine constructor refuses two combinations when mode: 'production' is set:
policyCombine: 'first-applicable'- the production fast path returns a plain boolean and cannot distinguish "rule fired" from "default applied", so first-applicable is rejected outright.defaultEffect: 'allow'withoutallowFailOpen: true- production never falls back to allow-by-default unless you opt in explicitly.
Both cases throw at construction time so misconfigurations fail loudly before serving traffic.
Performance difference
Benchmark numbers from bun run benchmark:
| Operation | development | production | Ratio |
|---|---|---|---|
engine.can() simple RBAC | ~5 us | ~2 us | 2.5x faster |
engine.authorize() full | ~6 us | ~1.5 us | 4x faster |
engine.permissions() 20 checks | ~21 us | ~12 us | 1.7x faster |
evaluatePolicyFast() (one policy) | ~1 us | ~0.5 us | 2x faster |
Production mode is ~2x faster than development on simple checks. The gap widens for batch operations because the per-check IDecision allocation cost compounds.
For raw single-policy lookups, duck-iam in production mode is ~2x slower than CASL (8.2M ops/sec vs CASL's 16.8M). CASL pre-compiles rules at build time; duck-iam supports runtime-updatable policies, which costs an extra Map lookup per check.
When to switch modes
Default to development mode in:
- Local dev
- Test suites (you want
explain()for failing tests) - Staging environments
- CI
Switch to production mode in:
- Production deployments serving live traffic
- High-throughput batch jobs
- Edge runtimes where every microsecond matters
You can mix - different engines for different routes if you really care:
const debugEngine = new IamEngine({ adapter, mode: 'development' })
const prodEngine = new IamEngine({ adapter, mode: 'production' })
app.use('/api/debug', accessMiddleware(debugEngine))
app.use('/api', accessMiddleware(prodEngine))
But this is rare. Default to one mode per process.
Detecting mode at runtime
The engine exposes its mode via the type system:
const engine = new IamEngine<MyAction, MyResource, MyRole, MyScope, 'production'>({
adapter,
mode: 'production',
})
// `engine.authorize()` returns boolean (typed)
// `engine.explain()` is unavailable at the type level (and throws at runtime)
The mode is a type parameter of IamEngine - calling explain() on a 'production'-mode engine is a compile-time error if you've declared the mode in the generic. At runtime, it throws if called regardless of the static type.
Caveat: development hooks vs production fast path
afterEvaluate and onDeny receive the IDecision object - only constructed in development mode. In production mode these two hooks don't fire because the fast path skips IDecision construction entirely.
onMetrics is the production-safe telemetry hook: it gets primitive-only events (subjectId, action, resource, allowed, durationMs, mode) on every terminal path in both modes. Wire it for latency, hit-rate, and outcome metrics. onError and onPolicyError also fire in both modes.
If you need full-decision auditing in production:
- Either run development mode (and pay the ~2x cost)
- Or wire
onMetricsfor the boolean outcome plus timing and emit your own audit record from there
For batched audit logging, build it into your route handlers or middleware around engine.can() instead of relying on engine hooks.
Recommended setup
For most apps, development mode is the right default. The 2x performance gap doesn't matter unless you're doing 100k+ checks per second.
// Most apps:
const engine = new IamEngine({ adapter })
// Only if you're chasing microseconds:
const engine = new IamEngine({ adapter, mode: 'production' })
When in doubt, benchmark your actual workload. Adapter latency (DB round-trips, Redis network hops) usually dominates engine evaluation time - and that doesn't change between modes.