User-Configurable Cache TTL: A Pattern Stolen from Magento for Multi-Tenant SaaS
How I gave each workspace control over its own cache lifetime, with auto-flush on TTL decrease, cross-entity invalidation, and E2E tests that prove it works.
How I gave each workspace control over its own cache lifetime, with auto-flush on TTL decrease, cross-entity invalidation, and E2E tests that prove it works.

I spent years working with Magento before building AI platforms. Say what you will about its architecture, but Magento got one thing right: it gave merchants full control over their own cache.
System > Tools > Cache Management. Selective refresh per cache type. A configurable TTL for public content. Automatic invalidation on config changes. The merchant never had to ask a developer to clear a cache.
When I built the CMS layer of my platform, I stole that pattern wholesale. Every workspace controls its own cache TTL. When the TTL decreases, stale caches flush automatically. And there's a big red button for when you just want everything gone.
The initial implementation cached everything at fixed intervals: 120 seconds for lists, 300 seconds for individual entities. This is the default for most SaaS platforms. It's also wrong for most SaaS platforms.
| Workspace Type | Ideal TTL | Why |
|---|---|---|
| News publisher | 60s | Content changes constantly |
| Documentation site | 600s | Pages rarely update |
| Staging | 0s | Debugging needs fresh data |
Hardcoding the TTL means optimizing for nobody. The alternative — different cache durations per plan tier — is still a guess. The workspace admin knows their update frequency better than any pricing page.
The workspace's CmsSettings table stores cache_time_minutes as an integer with a default of 30:
DEFINE FIELD OVERWRITE cache_time_minutes ON cms_settings TYPE int DEFAULT 30;
Every handler that caches a response reads the workspace's TTL:
pub async fn get_cache_ttl(state: &AppState, workspace_id: &str) -> u64 {
let cache_key = cms_settings_cache_key(workspace_id);
let minutes = if let Ok(Some(cached)) = state.redis.get::<CmsSettings>(&cache_key).await {
cached.cache_time_minutes
} else if let Ok(settings) = state.dal.cms_settings.get_or_create(workspace_id).await {
let _ = state.redis.set(&cache_key, &settings, Some(60)).await;
settings.cache_time_minutes
} else {
30
};
u64::from(minutes) * 60
}
The call site is one line: let ttl = get_cache_ttl(&state, &workspace.workspace_id).await;. Used across agents, categories, pages, components, actions, and tags.
Notice the settings cache uses a fixed 60-second TTL (Some(60)). This is the Magento lesson. You can't look up the TTL from the config that tells you the TTL — that's infinite recursion.
The settings cache has its own short, hardcoded lifetime. When settings change, the settings cache is explicitly flushed as part of the workspace-wide flush.
When an admin decreases the cache TTL from 300 to 60, every cached response from the old 300-second window is now potentially stale. Waiting for natural expiry would serve stale data for up to 4 more minutes.
if updated.cache_time_minutes < existing.cache_time_minutes {
let redis = state.redis.clone();
let ws_id = workspace.workspace_id.clone();
tokio::spawn(async move {
flush_workspace_caches(&redis, &ws_id).await;
});
}
Only on decrease. Increasing the TTL doesn't create staleness — existing entries just expire sooner than the new maximum, which is fine. Magento invalidates the config cache on any settings change, but it doesn't distinguish direction. I wanted something more precise: only flush when the window actually shrinks.
The flush deletes all seven workspace-scoped list caches in a single Redis pipeline:
pub async fn flush_workspace_caches(redis: &RedisClient, workspace_id: &str) {
let keys = [
format!("cache:agents:ws:{workspace_id}"),
format!("cache:categories:ws:{workspace_id}"),
format!("cache:tags:ws:{workspace_id}"),
format!("cache:components:ws:{workspace_id}"),
format!("cache:pages:ws:{workspace_id}"),
format!("cache:actions:ws:{workspace_id}"),
cms_settings_cache_key(workspace_id),
];
if let Err(e) = redis.del_many(&keys).await {
tracing::warn!("Cache flush failed: {e}");
}
}
del_many pipelines the deletions into a single Redis round-trip. Individual entity caches (cache:agent:{id}) expire naturally — there could be thousands of them, and flushing them all would be expensive for marginal benefit.
The harder problem is not TTL-based expiry. It's knowing which caches to bust when an entity changes.
| Mutation | Also Invalidates | Why |
|---|---|---|
| Action update/delete | Agent list cache | Agents reference action_ids |
| Category delete | Agent list cache | Agents reference category_ids |
| Component update/delete | Page + category list caches | Pages embed component content |
| Agent delete | Category list cache | Categories track agent_order |
| Media update | Agent + category list caches | Entities embed resolved media |
Each handler explicitly lists which caches to bust:
// Bust agent list cache — agents reference action_ids
let agent_cache_key = format!("cache:agents:ws:{}", workspace.workspace_id);
let _ = state.redis.del(&agent_cache_key).await;
This is manual and explicit. Not an automated dependency graph. Not a pub/sub event system. Just a comment and a del call in the right place. Magento uses the same approach with cache tags — developers explicitly declare which tags to invalidate.
It's boring and it works. The risk is missing an invalidation path. I found two missing ones during this work: component updates weren't busting page caches, and component deletes weren't busting category caches. Both were surfaced by E2E tests. I cover production safety patterns in more detail in an earlier post.
Magento's Admin > System > Cache Management has a "Flush Cache Storage" button. I added the same:
pub async fn flush_cache(
State(state): State<AppState>,
ctx: Ctx,
) -> AppResult<Json<ApiResponse<serde_json::Value>>> {
// ...
tokio::spawn(async move {
flush_workspace_caches(&redis, &ws_id).await;
});
Ok(Json(ApiResponse::new(json!({ "status": "flush_initiated" }))))
}
Fire-and-forget via tokio::spawn. The response returns immediately. The frontend shows a toast.
This exists because sometimes the answer is "I don't know why the cache is stale, just clear it." Giving the admin that power prevents a support ticket.
Cache invalidation is notoriously hard to test because the bug is invisible — you get a 200 with correct-shaped but stale data. The E2E test pattern:
// Step 2: GET returns "Original Name" ✓
// Step 3: Update name to "Updated Name"
// Step 4: GET returns "Updated Name" ✓ (cache was invalidated)
// Step 5: Delete the entity
// Step 6: GET returns empty list ✓ (delete invalidated cache)
If the invalidation is missing, step 4 returns the stale "Original Name" and the test fails. This pattern caught the two missing cross-entity invalidation paths I mentioned earlier.
| Concern | Solution |
|---|---|
| Configurable TTL | cache_time_minutes in settings table, read by every handler |
| Settings cache recursion | Fixed 60s TTL for settings themselves |
| TTL decrease staleness | Auto-flush in tokio::spawn background task |
| Cross-entity invalidation | Explicit del calls per mutation, manual dependency mapping |
| Emergency flush | Admin button → POST /flush-cache → pipeline DEL of 7 keys |
| Multi-key deletion | Redis pipeline, single round-trip |
| Testing | E2E: create → cache → mutate → assert fresh data |
Magento taught me that cache management is a user feature, not an infrastructure detail. If your tenants can't control their own cache, you'll end up controlling it for them — one support ticket at a time.