Skip to content

Stale-While-Revalidate (SWR)

Return cached data immediately, refresh in background.

When to Use

Use CaseSWR?Reason
User profileYesStale OK for seconds
Product catalogYesChanges rarely
Dashboard statsYesApproximate OK
Shopping cartNoMust be current
Inventory countNoMust be accurate
Auth tokensNoSecurity critical

How It Works

SWR extends the cache lifetime with a stale window. During this window, cached data is returned immediately while a background revalidation fetches fresh data.

|<------ Fresh (TTL) ------>|<-- Stale (staleTime) -->|<-- Expired -->|
0s                         300s                      600s
                             |                          |
                       Return stale data          Must wait for
                       + revalidate async          fresh load

SWR entry metadata (stored in L2/Redis):

FieldDescription
valueThe cached data
cachedAtTimestamp when value was cached (ms)
staleAtcachedAt + TTL — when value becomes stale
expiresAtstaleAt + staleTime — when value expires completely

Revalidation process:

  1. getOrSet() reads SWR entry from L2 (Redis)
  2. If fresh (now < staleAt) — return immediately
  3. If stale (staleAt < now < expiresAt) — return stale data, schedule background revalidation
  4. If expired (now > expiresAt) — wait for fresh load (same as cache miss)
  5. Background revalidation runs via setImmediate() (non-blocking, next event loop tick)
  6. Only one revalidation per key at a time (deduplication via shouldRevalidate())
  7. On success — both L1 and L2 updated with fresh data
  8. On failure — error logged, stale data preserved until expiry

SWR is L2-only

SWR metadata (staleAt, expiresAt) is stored only in L2 (Redis). L1 (memory) is updated when revalidation succeeds. This means SWR requires L2 to be enabled.

Configuration

typescript
new CachePlugin({
  swr: {
    enabled: true,            // Enable globally (default: false)
    defaultStaleTime: 60,     // Default stale window in seconds (default: 60)
  },
})
OptionDefaultDescription
enabledfalseEnable SWR globally. When enabled, getOrSet() uses SWR flow by default.
defaultStaleTime60Default stale window in seconds. Can be overridden per call.

Service API Usage

SWR works through getOrSet() — this is the only method that supports the full SWR flow (read stale + background revalidation).

typescript
const user = await this.cache.getOrSet<User>(
  'user:123',
  () => this.repository.findOne('123'),
  {
    ttl: 300,                                // Fresh for 5 minutes
    swr: { enabled: true, staleTime: 300 },  // Stale for another 5 minutes
  }
);

Disable SWR per call

typescript
// Override: disable SWR for this specific call
const user = await this.cache.getOrSet<User>(
  'user:123',
  () => this.repository.findOne('123'),
  {
    ttl: 300,
    swr: { enabled: false },  // No stale window for this call
  }
);

Decorator Usage

@Cached SWR limitation

@Cached uses separate get() + set() calls internally. When SWR is enabled on @Cached, it stores SWR metadata via getOrSet() on write (cache miss), but read uses plain get() which does not check staleAt/expiresAt. For full SWR behavior (return stale + background revalidation), use getOrSet() in your service directly.

typescript
import { Injectable } from '@nestjs/common';
import { CacheService } from '@nestjs-redisx/cache';
import { User, UserRepository } from './types';

// Full SWR with getOrSet (recommended)
@Injectable()
export class UserService {
  constructor(
    private readonly cache: CacheService,
    private readonly repository: UserRepository,
  ) {}

  async getUser(id: string): Promise<User> {
    return this.cache.getOrSet<User>(
      `user:${id}`,
      () => this.repository.findOne(id),
      {
        ttl: 300,
        tags: ['users'],
        swr: { enabled: true, staleTime: 300 },
      }
    );
  }
}

Cache States

StateConditionBehavior
Freshnow < staleAtReturn immediately
StalestaleAt < now < expiresAtReturn stale + revalidate in background
Expirednow > expiresAtWait for fresh load (cache miss)

Error Handling

ScenarioBehavior
Background revalidation failsError logged. Stale data is preserved until expiry — not invalidated.
Revalidation already in progressDuplicate skipped (shouldRevalidate() returns false).
Redis unavailable during SWR readFalls back to regular getOrSet() flow (cache miss → load).
Loader throws during fresh loadError propagates to caller. No SWR entry created.

Best Practices

Good TTL + staleTime Combos

Data TypeTTLstaleTimeTotal Window
User profile5m5m10m
Product info1h30m1.5h
Config24h1h25h
Search results5m2m7m

Tips

  • Start conservative — short staleTime first, increase based on monitoring
  • SWR + stampede — both work together: getOrSet() uses stampede protection for fresh loads, SWR for background revalidation
  • Don't use SWR for security-critical data — tokens, permissions, auth state must always be fresh

Next Steps

Released under the MIT License.