
The invalidation graph, cross-process staleness detection, distributed locking, and singleton patterns that keep our multi-tenant cache correct in production.
This is Part 2 of our caching deep dive. Part 1 covered the dual-layer foundation, scoped services, providers, type-safe accessors, and TTL strategy. This post covers the hard part — knowing when to throw cached data away, and keeping it correct across multiple processes.
The hardest problem in caching isn't storing data — it's knowing when to throw it away.
Our answer: a single file called invalidation-graph.ts that maps every domain event to every cache key it affects.
const INVALIDATION_GRAPH = {
'plan.changed': ['features', 'subscription', 'overages'],
'member.added': {
user: ['userMemberships'],
org: ['members', 'memberRoleMap', 'overages'],
},
'workflow.published': ['workflowApps'],
'customField.changed': ['customFields'],
'resource.changed': ['resources'],
// ... 40+ event mappings
}
Two mapping types. Org-only mappings are simple arrays — just org cache keys. Mixed mappings are objects with user, org, and build keys — they can invalidate across cache services in a single event.
Why a graph? All invalidation logic lives in one file. No scattered cache.delete() calls sprinkled across the codebase. Adding a new cache key means adding one line to the graph. You can read the graph and understand all cache dependencies at a glance. And it's testable — you can verify that every cache key has at least one invalidation path.
The public API for invalidation is onCacheEvent():
onCacheEvent('member.added', {
orgId: '123',
userId: '456',
broadcastUserKeys: true,
})
The context parameters control scope:
orgId — required for org/user mappingsuserId — target a specific user's cachebroadcastUserKeys — invalidate for ALL org members. Used when org settings change and every member's user cache needs refreshingdeveloperAccountId — invalidate all members of a developer account in the build portalOne critical timing rule: call onCacheEvent AFTER the database transaction commits. Never inside it. Two reasons:
Subtle but important. Getting this wrong creates intermittent bugs that are extremely hard to reproduce.
Cascading invalidation happens naturally through the graph. member.added invalidates the org cache (members list) AND the user cache (memberships). The graph makes these cascades explicit and auditable — you can trace exactly what gets invalidated when.
The hardest cache bug we hit: Process A invalidates a key, Process B still serves stale local data.
You can't directly message between Node.js processes without infrastructure — pub/sub, shared memory, something. But Redis is already there. So we use it as the coordination layer.
The trick: store a UUID hash alongside every cached value in Redis.
Write path:
data key + hash key, both with TTLRead path:
Invalidation:
data and hash keys from RedisThe hash check is one small Redis read vs. fetching the full cached payload. For a 500KB serialized member list, that's a massive savings on the common case where the data hasn't changed.
The promise memoizer from Part 1 handles concurrent misses within one process. But what about multiple processes?
OrganizationCacheService acquires a Redis lock before computing:
lock:prefix:orgId:keyNameCombined with the promise memoizer, this creates two layers of thundering herd protection:
Result: N processes × M concurrent requests = exactly 1 provider.compute() call.
Most code never touches cache services directly. Helper functions provide clean access:
// Org cache helpers
const members = await getCachedMembers(orgId)
const resource = await getCachedResource(orgId, resourceId)
const fields = await getCachedCustomFields(orgId, entityDefId)
const fieldMap = await getCachedFieldMap(orgId, entityDefId)
// App cache helpers
const app = await getCachedAppBySlug('shopify')
const appId = await resolveAppSlug('gmail')
const apps = await getCachedPublishedApps()
All of these delegate to singleton cache services. They return typed accessors — callers get autocomplete and type checking. The abstraction hides which cache service, which key, and which accessor is involved.
This one caught us off guard. Next.js with Turbopack can re-evaluate server modules in separate scopes. Module-level variables become isolated:
// This creates separate instances per module scope:
let cache = new OrganizationCacheService()
// This shares one instance across all scopes:
const globalForCache = globalThis as { _auxxOrgCache?: OrganizationCacheService }
Why it matters: Process A invalidates cache via its module scope's instance. Process A's other module scope still has stale data in its separate instance. You've invalidated, but the app is still serving stale data. From the same process.
globalThis ensures one cache instance per Node.js process. Combined with the Redis hash check, this handles both intra-process and cross-process staleness.
Initialization is lazy. initCaches() creates all 5 services and registers all 33 providers. Called on first getOrgCache() / getUserCache() / etc. Subsequent calls return the same instance.
Some operations need bulk invalidation across organizations.
invalidateOrgsByAppId(appId) finds all organizations with a given app installed and invalidates their installedApps cache. Used when an app is updated, unpublished, or deprecated.
invalidateOrgsByDeploymentId(deploymentId) is similar but finds orgs via deployment reference. Used when a specific deployment changes status.
flushOrganization(orgId) is the nuclear option. Invalidates ALL cache keys for an org. Iterates every registered key and invalidates. Used for org deletion, migration, or debugging when you just need a clean slate.
The workflow engine gets specialized query functions:
getCachedWorkflowApp(orgId, appSlug, blockOrTriggerId)
getCachedWorkflowAppsByTrigger(orgId, triggerType)
getCachedWorkflowAppsByAppTrigger(orgId, appSlug)
Workflow execution is latency-sensitive — every millisecond counts in automation pipelines. These functions compose multiple cache lookups (app → deployment → blocks/triggers) into single calls and return exactly the shape the workflow engine needs. No post-processing required.
Zooming out, here's how the whole system fits together:
Request arrives (org-scoped)
→ Helper function (getCachedMembers)
→ Singleton cache service (OrganizationCacheService)
→ Local cache check (100ms TTL, hash match?)
→ HIT: return cached accessor
→ MISS: Redis hash check
→ Hash matches local: return local data
→ Hash mismatch or miss: Redis data fetch
→ HIT: update local cache, return accessor
→ MISS: acquire distributed lock
→ Promise memoizer (dedupe concurrent callers)
→ Provider.compute() (single DB query)
→ Write to Redis (data + hash + tags)
→ Write to local cache
→ Return typed accessor
And invalidation:
Database mutation commits
→ onCacheEvent('member.added', { orgId, userId })
→ Invalidation graph lookup
→ Org keys: ['members', 'memberRoleMap', 'overages']
→ User keys: ['userMemberships']
→ Delete Redis data + hash keys
→ Clear local cache
→ Other processes: hash mismatch on next read → recompute
A few things we'd change if starting over:
The 100ms local TTL is probably too short for some keys. Org settings change once a month. We could bump those to 1-2 seconds without any correctness issues. We kept everything at 100ms for simplicity, but the Redis round-trips add up.
Tag-based invalidation is underused. We built it but mostly rely on the invalidation graph for precision. Tags would be useful for "invalidate everything for this org" without iterating all keys. We have flushOrganization() for that, but tags would be cleaner.
Provider testing could be better. Each provider is a pure function, which makes them easy to test in theory. In practice, we test them through integration tests that hit the full cache path. Unit tests on individual providers would catch regressions faster.
If you want to dig into the code, here's where to look:
| File | Purpose |
|---|---|
packages/lib/src/cache/base-cache-service.ts | Dual-layer cache foundation |
packages/lib/src/cache/org-cache-service.ts | Org-scoped multi-tier cache |
packages/lib/src/cache/local-cache.ts | In-process LRU with hash staleness |
packages/lib/src/cache/promise-memoizer.ts | Concurrent call deduplication |
packages/lib/src/cache/accessors.ts | Typed accessor implementations |
packages/lib/src/cache/invalidation-graph.ts | Domain event → cache key mappings |
packages/lib/src/cache/invalidate.ts | Event-driven invalidation API |
packages/lib/src/cache/singletons.ts | globalThis singleton management |
packages/lib/src/cache/register-providers.ts | All 33 provider registrations |
packages/lib/src/cache/org-cache-helpers.ts | Public helper functions |
Auxx.ai is open source. The full caching implementation is in the repository if you want to see how all the pieces connect.