Skip to main content

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)
})
ModePathOutputUse for
'development' (default)evaluate()Full AccessControl.IDecision with timing, rule/policy refs, reasonLocal dev, explain(), debugging
'production'evaluateFast()Plain boolean - no allocationsDeployed services

What changes

development mode

  • engine.authorize() returns AccessControl.IDecision (object with allowed, effect, rule, policy, reason, duration, timestamp)
  • engine.check() returns AccessControl.IDecision
  • engine.explain() works (full evaluation trace available)
  • afterEvaluate and onDeny hooks receive the IDecision object
  • Timing is recorded - duration is the actual evaluation milliseconds
  • All allocations happen - matching rule arrays, intermediate effect tags, etc.

production mode

  • engine.authorize() returns plain boolean
  • engine.check() returns plain boolean (same as can())
  • engine.explain() throws (Error: explain() is not available in production mode)
  • afterEvaluate and onDeny hooks never fire (the engine takes the fast path that skips IDecision construction)
  • onMetrics, onError, and onPolicyError still fire - see hooks
  • No duration timing recorded on the path itself; use onMetrics for 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.


Performance difference

Benchmark numbers from bun run benchmark:

OperationdevelopmentproductionRatio
engine.can() simple RBAC~5 us~2 us2.5x faster
engine.authorize() full~6 us~1.5 us4x faster
engine.permissions() 20 checks~21 us~12 us1.7x faster
evaluatePolicyFast() (one policy)~1 us~0.5 us2x 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 onMetrics for 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.


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.