Stage 7 · Master
Caching & Rate Limiting with Redis
What to Cache & Invalidation Strategy
Fieldwork only caches hot, stable tenant reads, and it invalidates them like write paths matter because they do.
Start with the Miss
The easiest way to get caching wrong is to start with Redis instead of the read path. In Fieldwork, the question was never where can we put a cache? It was which queries are hot enough and stable enough that a miss is acceptable, but a stale hit is still safe? That immediately ruled out half the API. A multi-tenant task system has a lot of writes: task status changes, project renames, membership updates, reassignment, due date edits. If every write forces us to play whack-a-mole with ten unrelated cache keys, the cache is no longer buying latency; it is buying correctness risk.
So we chose cache-aside for a narrow slice of reads instead of broad response caching. The stable, repeatedly-read objects were tenant metadata, workspace summaries, membership lookups used by authorization, and project snapshots used on the landing screens. We rejected full-page or full-handler caching because the handler boundary in Gin is the wrong abstraction level here. A handler usually joins authorization context, workspace membership, and a data read. Those pieces age differently. Caching the whole response means one membership change invalidates everything, even when the project data itself has not changed.
| Question | Cache it | Do not cache it |
|---|---|---|
| Is the read hot across many requests? | Tenant settings fetched on every request | A rarely-opened audit export |
| Can we define a small invalidation surface? | Workspace summary tied to one workspace version | Cross-tenant search results with many contributing rows |
| Is a short stale window acceptable? | Project name or archived flag | Permission changes used for an access check |
| Can the miss path stay the source of truth? | A pgx query wrapped by cache-aside | Derived handler output assembled from many services |
We are not trying to make Redis a second database. On a miss, we run the normal pgx query, map the result, then populate the cache. That keeps the write path simple and lets us add or remove caching per read without redesigning the data model.
The Keys We Actually Cache
The shape of the key matters as much as the value. Fieldwork is tenant-scoped first, so every cache key starts with the tenant boundary before any entity identifier. That sounds obvious, but it prevents the quietest multi-tenant bug in this module: accidentally reusing a workspace ID or project ID across tenants in a shared cache namespace. We also cache normalized DTOs, not raw rows and not arbitrary JSON responses. The same project summary can back an API response, a worker lookup, and a permission helper if the representation is stable.
- Cache tenant-scoped summaries that are read far more often than they are written.
- Cache DTOs that can be reused by more than one caller, not whole Gin responses.
- Give every key an explicit tenant prefix, entity prefix, and version segment.
- Prefer short TTLs as a safety valve, but do not pretend TTL is your invalidation strategy.
Versioned Keys Beat Blind Deletes
The rejected design here was tag-style invalidation by pattern delete: update a workspace, then delete every key that might mention that workspace. It sounds flexible, but it makes writes expensive and incomplete at the same time. You either enumerate too few keys and serve stale data, or you enumerate too many and recreate cache stampedes after every edit. Fieldwork uses per-entity version counters instead. Writes bump a version in Redis, and reads compose the current version into the cache key. The old keys expire naturally. We stop caring about finding every historical key because the new read will never ask for them again.
That choice is especially useful in a system where one write changes multiple read models. Renaming a project affects the project detail view, workspace summary counts, search indexes, and maybe a dashboard widget. We do not want the handler performing four cache deletes inline with the transaction. The write path commits Postgres first, then bumps the small set of version counters tied to entities whose representations changed. Readers automatically move to the next namespace.
| Approach | Why it looks attractive | Why Fieldwork rejected or chose it |
|---|---|---|
| Pattern delete | Feels simple: delete everything matching project:* | Dangerous in shared cache namespaces and incomplete once one write affects many read models |
| TTL only | No explicit invalidation logic | Staleness window becomes arbitrary and permission-sensitive data can stay wrong for too long |
| Write-through cache | Cache updated immediately on write | Write path becomes coupled to every derived representation, which is exactly what we wanted to avoid |
| Versioned cache-aside | Slightly more bookkeeping on reads | Chosen because it bounds invalidation work and keeps Postgres authoritative |
That is the real win. We are not trying to mutate every cached value in place. We are deciding which namespace the next reader should trust. That scales much better than pretending writes know every consumer ahead of time.
What Fieldwork Deliberately Does Not Cache
There are three categories we intentionally leave on the database path. First: authorization-critical membership reads. We may cache a low-risk membership summary used for UI decoration, but the access-control decision itself still checks current membership state from Postgres when the consequence is admitting or denying a write. Second: task lists with highly dynamic filters. Once a query varies by assignee, status, sort order, due date range, and pagination cursor, key cardinality explodes and invalidation becomes fiction. Third: counters used for financial or compliance-style reporting. The course domain is not billing, but if a value must be exact, Redis should never be the thing deciding it.
- Do not cache permission decisions that can outlive a membership revocation.
- Do not cache high-cardinality list queries whose keys grow faster than their hit rate.
- Do not cache values whose correctness requirement is exact rather than fast.
- Do not cache writes themselves; cache the read models they justify.
In a multi-tenant system, a fast stale authorization read is worse than a slower correct one. Fieldwork is willing to pay a few milliseconds to keep tenant boundaries and role changes trustworthy.
The Decision
Fieldwork caches only hot, reusable tenant-scoped reads behind cache-aside helpers. Every cache key includes tenant identity and a read-model version. Writes commit to Postgres first, then advance the small number of version counters affected by that change. We rejected broad handler caching, pattern-delete invalidation, and TTL-as-strategy because they make the system feel fast while quietly eroding trust in the data. The decision here is intentionally narrow: fewer cached things, clearer ownership, lower blast radius when the data model changes later.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.