caching
Four LRU caches, automatic invalidation via the admin API, manual invalidation, and tuning for high-traffic apps.
Four caches
The engine maintains four LRU caches:
| Cache | Key | Stores | Purpose |
|---|---|---|---|
| Policy cache | 'all' | All ABAC policies | Avoid re-fetching policies on every check |
| Role cache | 'all' | All role definitions | Avoid re-fetching roles on every check |
| RBAC policy cache | 'rbac' | The synthetic RBAC policy | Avoid recomputing role-to-policy conversion |
| Subject cache | subject ID | Resolved subjects | Avoid re-resolving the same user repeatedly |
Default: cacheTTL: 60 seconds, maxCacheSize: 1000 subjects.
Why this shape?
Policies and roles are loaded as whole collections, so one cached entry per cache is enough. The engine reloads everything if any policy changes - this is fine because the cache key is just 'all' and overwrites on save.
Subjects are resolved individually, so the engine keeps a separate cache entry per subject ID. This is what lets a hot user (e.g. a service account hitting 1000s of endpoints) avoid repeated DB hits.
Invalidation methods
// Clear everything
engine.cache.invalidate()
// Clear a specific user's cached data (after role change, attribute update)
engine.cache.invalidateSubject('user-1')
// Clear cached policies (after adding/removing/editing policies)
engine.cache.invalidatePolicies()
// Clear cached roles and all subjects (subjects depend on roles)
engine.cache.invalidateRoles()
invalidateRoles() clears the subject cache too because resolved subjects include role names from the role definitions - if a role is renamed/deleted, every cached subject is potentially stale.
Automatic invalidation
The admin API methods automatically invalidate the relevant caches:
| Admin call | Invalidates |
|---|---|
admin.savePolicy() / admin.deletePolicy() | Policy cache + RBAC policy cache |
admin.saveRole() / admin.deleteRole() | Role cache + RBAC policy + all subjects |
admin.assignRole() / admin.revokeRole() | Specific subject only |
admin.setAttributes() | Specific subject only |
You only need to call invalidation methods manually if you modify data outside the admin API (e.g., direct database writes by another service).
Multi-instance deployments
The LRU cache is per-process. In a multi-node deployment, when one node mutates via engine.admin.*, the other nodes don't know - they keep serving stale decisions until their TTL expires.
Solutions:
Short TTL
Set cacheTTL: 5 (or less) so stale data has a small window. Trade-off: more adapter hits.
Pub/sub invalidation
Wire IConfig.invalidator and the engine handles the broadcast for you: every local engine.admin.* mutation publishes; every cross-instance event applies locally (with a built-in self-echo filter so a node never re-applies its own event).
import { IamEngine } from '@gentleduck/iam'
import { createIamRedisInvalidator } from '@gentleduck/iam/invalidators/redis'
const engine = new IamEngine({
adapter,
invalidator: createIamRedisInvalidator({
client: redisPubSub,
channel: 'iam:invalidate',
}),
})
// On shutdown - release the subscription
process.on('SIGTERM', () => engine.dispose())
The Redis helper expects a minimal pub/sub shape (publish, subscribe, optional unsubscribe) - both ioredis and node-redis v4+ satisfy it directly. Default channel is 'duck-iam:invalidate'; override with channel for multi-tenant deployments.
Custom invalidator
Want NATS / Kafka / AMQP / your in-process bus? Implement the IamEngineTypes.IInvalidator contract:
import type { IamEngineTypes } from '@gentleduck/iam/core'
const invalidator: IamEngineTypes.IInvalidator = {
publish(event) {
// event: { kind: 'all' | 'policies' | 'roles' | 'subject', roleId?, subjectId? }
nats.publish('iam.invalidate', JSON.stringify(event))
},
subscribe(handler) {
const sub = nats.subscribe('iam.invalidate', (msg) => handler(JSON.parse(msg.data)))
return () => sub.unsubscribe()
},
}
const engine = new IamEngine({ adapter, invalidator })
Delivery is at-least-once: engine.cache.invalidate*() methods are idempotent so re-applying the same event is safe.
Versioned policies
Tag policies with a version number. Increment on writes. On read, compare versions and refetch if stale. duck-iam doesn't ship this - implement at the adapter layer if you need strong consistency.
Tuning the cache
For high-traffic applications:
const engine = new IamEngine({
adapter,
cacheTTL: 300, // 5 minutes - policies/roles change infrequently
maxCacheSize: 10000, // 10k subjects in memory
})
For real-time permission changes:
const engine = new IamEngine({
adapter,
cacheTTL: 5, // 5 seconds - near real-time
maxCacheSize: 500,
})
For development:
const engine = new IamEngine({
adapter,
cacheTTL: 0, // No caching - always hit the adapter
})
cacheTTL: 0 means entries expire immediately - useful in tests where you want every check to see fresh adapter state without manual invalidation.
Memory footprint
A rough rule:
- Subject cache - ~1 KB per cached subject (depends on attribute size)
- Role cache - ~500 B per role (depends on permissions count)
- Policy cache - ~2 KB per policy (depends on rules)
- RBAC policy cache - single entry, sized to NxM (roles x permissions)
For 10,000 subjects + 100 roles + 50 policies, expect ~12 MB of cache memory. The fast-path index for evaluatePolicyFast adds another ~5 MB depending on rule count.
If memory is tight, reduce maxCacheSize and rely on the LRU eviction. Hot subjects stay cached; cold ones get evicted.
When to invalidate manually
You only need manual invalidation when:
- Another service writes to your shared adapter database directly (not through
engine.admin.*) - You're testing role logic and want to force a fresh load between cases
- You receive an external webhook that signals "permissions changed" (e.g. SCIM, IdP sync)
- You're implementing pub/sub invalidation across nodes
In single-node apps that always go through engine.admin.*, you should never need manual invalidation.