Architecture
Three-Layer Caching for a Multi-Tenant CRM
By Vivek Jalondhara · 6 min read
Published Feb 2026 · Updated
In-memory, distributed, and HTTP caching each solve a different problem. Layer them deliberately.
My CRM work includes a three-layer caching strategy. This walkthrough explains how to reason about in-memory, Redis, and HTTP caching using an illustrative tenant-scoped record request. Each layer has a different lifetime and a different correctness boundary.
The hard part isn't reading from cache — it's invalidation. I key entries carefully, prefer short TTLs with explicit busting on writes, and treat cache as an optimization that the system must remain correct without.
Measure before and after. A cache that improves p50 but wrecks p99 because of stampedes is a net loss. Single-flight and jittered TTLs are your friends.
Start with a read path, not a cache
Consider a CRM record page that loads a record, its layout, and the current user's permissions. This is an illustrative workload related to my CRM work. Those pieces have different lifetimes and different access rules. Treating the complete response as one reusable object makes it difficult to invalidate safely, especially when the same record looks different to different users.
Before changing the architecture, trace a slow request through authorization, database queries, serialization, and network delivery. A missing index or repeated query might explain most of the wait. Caching that work can hide the cause while adding another system to maintain. Write down which part is expensive, which part repeats, and how stale each answer is allowed to be.
Give each layer a narrow responsibility
Request-local memory is useful when several handlers need the same data during one request. Its lifetime ends with that request. A process-wide cache is different: it survives requests and needs a size limit, expiration, and a plan for invalidation. It also disappears when the process restarts and is not automatically shared with other application instances.
Redis can hold reusable data across instances. HTTP caching applies to responses and needs its own policy. These layers do not have to form one mandatory pipeline. An authenticated record response might bypass HTTP storage while its public static assets are cached aggressively. A small request-local lookup may never need Redis. Choose layers independently for the data they can safely reuse.
Keys describe the data and its access boundary
A record ID alone is not a sufficient key in a multi-tenant application. Include tenant identity, and distinguish representations when the serialized shape changes. If the result depends on filters, sorting, locale, or field selection, those inputs also affect identity. A key for an unfiltered collection cannot safely stand in for every search result derived from it.
For the worked example, cache a tenant-scoped record representation, authorize each request, then apply any user-specific field visibility before producing the response. Do not treat possession of a cache key as permission to read it. If permission-filtered responses are cached instead, changes in permission state become part of the invalidation problem as well.
Follow a write through every cached representation
Imagine a user changing a company's name. The record page may be cached, but so might search results and lists that include that name. Deleting one record key does not refresh all of those views. Document the dependency set before adding cached collections. Sometimes keeping the record cache and leaving the search query uncached is the simpler correct choice.
Commit the database write before removing the stale entry. Even that sequence has a race: an earlier reader can finish afterward and refill an old value. Expiration limits how long an entry can survive, but does not guarantee immediate consistency. Stronger requirements may call for version-aware fills or bypassing the cache on sensitive reads. An invalidation failure also needs an observable recovery path.
Design the cache-miss path for failure
When a popular entry expires, several requests may ask the database for it at once. Sharing one in-flight read within a process reduces duplicate work there; it does not coordinate every server. Staggered expiration can reduce synchronized misses. The appropriate approach depends on whether recomputing an item is cheap or can overwhelm the underlying service.
A Redis outage must have a bounded effect. Set short connection and command timeouts and decide whether the endpoint can fall back to the database. Unrestricted fallback can overwhelm a database sized for normal cache misses, so concurrency limits and load shedding may be necessary. Never turn an infrastructure timeout into a successful empty record that clients might interpret as real data.
Keep HTTP policy explicit
Public caching belongs on content that may be shared between users. Personalized CRM responses should not enter a shared cache. A private directive permits browser storage while excluding shared caches; no-store requests that the response not be stored. No-cache has a different meaning: storage is allowed, but reuse requires validation. Check the deployed CDN configuration as well as application headers.
Test with two separate users and two tenants. Inspect actual response headers and repeat the request after changing access rights. A fast response is not a success if it contains another user's fields. Browser navigation, reloads, and sign-out are useful parts of this check because cached responses can outlive the screen that first requested them.
Measure correctness alongside latency
Compare the same workload before and after the change: cold reads, warm reads, concurrent writes, and cache outages. Track database request volume, tail latency, errors, memory use, and observed staleness. A high hit rate alone can hide incorrect keys or stale data. Use bounded-cardinality labels for metrics; tenant or record identifiers rarely belong in every time-series label.
For a small service, a well-indexed query plus a request-local lookup may be enough. Add Redis when repeated cross-request work justifies it, then add HTTP caching only where responses permit reuse. The goal is a read path whose behavior you can explain during an update or an outage, with a measured benefit that pays for the additional complexity.
Scope cached CRM records to a tenant
A shared cache must distinguish the same record identifier in different tenants. The key below illustrates that boundary; authorization still happens on the request before cached data is returned.
Illustrative example · TypeScript · Simplified for explanation
function recordKey(tenantId: string, recordId: string) {
return JSON.stringify(["crm-record", tenantId, recordId]);
}
// After committing a record update:
await cache.delete(recordKey(tenantId, recordId));- Authorize request
- Tenant-scoped cache lookup
- Database on miss
- Invalidate after writes
Conceptual flow for the example above.
Tradeoff to keep in mind
Redis can share results between instances, but invalidation and failure handling add complexity. Keep personalized responses out of public HTTP caches and measure before adding another layer.