Last Updated: October 6, 2026
This guide covers whether to cache something at all, which instance to put it on, which pattern to use, and how to plan for a launch. If you're looking for how to wire up a namespace in redis.yml, see the How-to companion guide instead.
Companion guides: Redis-Namespace How-to, Rate Limiting Guide
Which Redis instance should my cache use?
vets-api has two general-purpose Redis instances behind Common::RedisStore and Rails.cache, plus a third (Sidekiq's) that's off-limits for application caching. They fail differently, so the choice is a decision about what happens when the instance fills up.
|
|
App-data instance |
Rails cache |
|---|---|---|
|
Eviction policy |
|
|
|
What happens when full |
Rejects new writes with an error. Reads keep working. |
Quietly evicts the least-recently-used key that has a TTL to make room. |
|
Blast radius |
All of vets-api: it also holds active sessions ( |
Just your cache. Evicted entries mean more cache misses and more downstream calls, not errors. |
|
Recommended for |
Data that must exist until its TTL expires and can't tolerate silent loss: sessions, rate-limit counters, circuit-breaker state. In practice, this list is already complete; new application caches almost never belong here. |
Everything else. This is the default for new caches. |
|
Where to jump in |
|
config/environments/production.rb, then any |
Why app-data is noeviction: It was switched in software/va.gov-team#137413 to stop silent data loss for TTL-bound data and Sidekiq queues, trading that for loud write failures instead.
Rule of thumb: if you're not certain your cache needs to be on the app-data instance, it doesn't. Use Rails.cache. This matches the general selection criteria for these two policies: volatile-lru when a cache mixes permanent reference data with temporary cache data (it protects the permanent keys while still freeing memory), and noeviction only when data loss is strictly unacceptable, per this analysis.
What can be cached
-
PII/PHI is allowed, but must be encrypted at rest. Use Lockbox to encrypt the cached payload before writing it to
Rails.cache, and decrypt on read. Don't cache PII/PHI in plaintext. -
Compress before you encrypt.
Rails.cachecompresses entries over 1 KB, but encrypted data doesn't compress, so an encrypted entry is stored at roughly its full plaintext size. Compress the serialized payload first, then encrypt it; on read, decrypt, then decompress. -
Measure entry size before ramping. Per-user response caches grow with every user who hits them. Record the size of what you write (a StatsD histogram of the payload's bytesize) and estimate entries × bytes at full rollout before increasing a flag percentage.
-
Per-user data needs per-user key isolation. Key the cache entry to the user (
user.uuidor equivalent), not to a shared or predictable key. Mobile'sRedisCachingconcern (modules/mobile/app/models/mobile/v0/concerns/redis_caching.rb) is a working example: it keys byuser.uuidand adds a version suffix to the namespace. -
Every cache entry needs a TTL. Indefinite caching isn't a pattern used for cached data in vets-api today (the one entry written without an expiry is a small control key for the search results cache), and it should stay that way. Untracked data volume growth on a shared instance is how you end up filling it. If you think you need an indefinite cache, ask Platform.
-
Feature-flag new caches that hold sensitive data, the way DMC debts and VBS copays do (
:debts_cache_dmc_full_response,:medical_copays_cache_vbs_full_response). It gives you an instant kill switch if something's wrong with what's being cached, without a deploy.
Choosing a caching pattern
Lazy loading (cache-aside) is the default. Read from cache; on a miss, fetch from the source, cache the result, return it. Use it unless you have a specific reason not to. On Rails.cache, Rails.cache.fetch(key, expires_in: ...) { ... } implements it. Common::CacheAside implements it for the app-data instance only (it must be mixed into a Common::RedisStore); it requires the cached response to implement #cache?, called before a write happens; use this to gate caching on response validity (don't cache errors or incomplete data).
Write-through and write-behind are rare in vets-api today and add real complexity (the cache and the source of truth can drift). Only reach for them if lazy loading's stale-read window is unacceptable for your use case. Most vets-api caching tolerates staleness measured in minutes to a day.
Avoid synchronized expiry: it causes real, measurable thundering-herd bursts
This is the pattern to actively avoid: two existing caches (DMC debts, VBS copays) expire on a shared daily wall-clock boundary rather than a TTL relative to when they were written. Every active user's cache goes cold at the same moment each day (DMC at ~6:15 AM Central, VBS at 5:00 AM UTC). Neither has any request-coalescing or locking to deduplicate the resulting simultaneous re-fetches. BGS Payment History doesn't have this problem, because its TTL (expires_at: 30.minutes.from_now) is relative to each write, so it's naturally staggered across users instead of synchronized.
If your cache needs a daily refresh, don't peg it to a fixed wall-clock time. Options, roughly in order of preference:
-
Relative TTL from write time (what BGS Payment History already does): simplest, naturally staggered, no extra code.
-
Jittered fixed-time TTL: if you genuinely need a daily-boundary refresh (e.g., to match an upstream system's own daily batch cycle), add random jitter per user or per key so the boundary isn't identical for everyone. AWS's "Full Jitter" formula (
sleep = random(0, min(cap, base * 2^attempt)), written for retry backoff) is the same bounded-randomization idea adapted to a TTL:ttl = base_ttl + random(0, jitter_window), for example a 15 to 30 minute window around the daily boundary. -
Stale-while-revalidate: serve the just-expired cached value immediately while refreshing it in the background, so no request blocks on a full rebuild. Formalized as an HTTP
Cache-Controlextension in RFC 5861. vets-api's caches sit inRails.cache/app-data, not an HTTP cache layer, so the header doesn't apply.Rails.cache.fetchhas a close built-in equivalent,race_condition_ttl:, which lets the first request to find an expired entry regenerate it while others keep using the old value; vets-api already uses it for shared tokens (lib/unified_health_data/token_provider.rb,modules/check_in/app/services/travel_claim/redis_client.rb). It protects a single key that many requests share, not many per-user keys expiring at the same moment (the DMC and VBS case; use options 1 or 2 for that). A true background refresh means implementing it in application code (serve the stale value, kick off an async refresh). -
Request coalescing: if many requests can legitimately miss cache at once, ensure only one of them calls the downstream service and the rest wait on that result.
race_condition_ttlgives you this for a shared key that already has a stale value. For a cold key there is nothing in vets-api today; it would need to be added (for example a distributed lock keyed to the cache key).
Downstream-contract considerations
Caching doesn't replace (literally) talking to the downstream service. It reduces call volume but it doesn't change what that service can actually sustain, and it doesn't tell you anything about its SLA or how it communicates outages. Before you cache something to reduce load on a downstream service, do the same check the Rate Limiting Guide's "Step 3: Check Upstream Service Constraints" describes: contact the upstream team, understand their actual rate limits and SLA, and treat caching as one mitigation among several.
If a downstream call is cacheable, ask:
-
What's the actual staleness tolerance? (Drives your TTL.)
-
What happens to your feature if the downstream service is down and your cache is cold? (Should degrade gracefully, not error the whole page.)
-
Does the downstream team know you're caching their data, and for how long?
Pre-launch caching process
Before a feature that adds new caching goes live, especially ahead of a concentrated-traffic launch date:
-
Map the user flows that will hit the new cache: where in the product does this data get requested, and how often per session?
-
Estimate downstream call volume at expected launch traffic, not just current traffic. If you don't have a volume estimate, get one before launch.
-
Form a caching hypothesis: which instance, what TTL, what pattern, and why. Write it down (an Architecture Decision Record (ADR) or a line in your Architecture Intent checklist is enough; it doesn't need to be elaborate).
-
Test it before go-live. Coverband (https://api.va.gov/coverband) shows real usage data if you want to validate an existing cache's actual hit pattern before changing it. Request Coverband access using this template.
Client-side vs. backend caching
Not everything needs a backend cache. If the data is:
-
Static or rarely-changing per session (e.g., form metadata, reference lists): consider caching it in the frontend (browser storage, in-memory state) instead of adding backend cache infrastructure.
-
User-specific and changes based on backend state (debts, claims status, appointments): backend caching is usually the right call, since the frontend can't validate staleness on its own.
-
Needed by multiple clients or services (web, mobile, another internal service): backend caching avoids duplicating the caching logic in every client.
When in doubt, prefer client-side caching for anything that doesn't need a backend cache. It's one less thing occupying a shared Redis instance.
Help and feedback
-
Get help from the Platform Support Team in Slack.
-
Submit a feature idea to the Platform.