Caching

Improve performance with built-in in-memory and persistent caching. Reduce network calls, speed up your UI, and control cache invalidation.

Caller provides a layered caching system that works out of the box with in-memory storage and can be extended to persistent stores like Redis.


How Caching Works

When you call .stale(milliseconds) on a request, Caller:

  1. Checks if a cached response exists and is within the stale window
  2. If found and fresh → returns cached data immediately (no network call)
  3. If not found or expired → makes the network request → caches the result
// First call — network request, cached for 30s
const { data: fresh } = await api.get('/users')
  .stale(30_000)
  .execute();

// Second call (within 30s) — instant cached response
const { data: cached } = await api.get('/users')
  .stale(30_000)
  .execute();
// No network request made!

Basic Caching

The .stale() method accepts milliseconds. Simple and predictable:

// Cache for 30 seconds
await api.get('/users').stale(30_000).execute();

// Cache for 5 minutes
await api.get('/config').stale(300_000).execute();

// Cache for 1 hour
await api.get('/constants').stale(3_600_000).execute();

Cache Keys

By default, Caller generates cache keys from the request URL. Use .cache() to set a custom key:

// Custom cache key for manual invalidation
await api.get('/me')
  .cache('default', 'current-user')
  .stale(60_000)
  .execute();

// Same data from a different endpoint can share the cache key
await api.get('/users/current')
  .cache('default', 'current-user')
  .stale(60_000)
  .execute();

Fetch Cache Strategy

The first parameter to .cache() is the native RequestCache strategy:

StrategyBehavior
'default'Browser default cache behavior
'no-store'Never cache (bypasses both browser and Caller cache)
'reload'Fetch from network, update cache
'force-cache'Use cache even if stale
'only-if-cached'Only return cached data, error otherwise
// Force network fetch, bypassing browser cache
await api.get('/real-time-data')
  .cache('no-store')
  .execute();

// Prefer cache for static data
await api.get('/static-content')
  .cache('force-cache')
  .stale(600_000)
  .execute();

Cache Invalidation

Invalidate cached entries when data changes (e.g., after a mutation):

Programmatic Invalidation

Use the static IgniterCallerManager methods:

import { IgniterCallerManager } from '@igniter-js/caller';

// After creating a user, invalidate the users list
const { error } = await api.post('/users').body(newUser).execute();

if (!error) {
  IgniterCallerManager.on('response', (result, context) => {
    // React to specific responses
    if (context.url.includes('/users') && context.method === 'POST') {
      // Clear cache for this resource
      console.log('User created — cache should be refreshed');
    }
  });
}

React Hook Invalidation

In React, use the .invalidate() method from hooks:

function CreateUserForm() {
  const main = useCaller('main');

  const handleSubmit = async (data: CreateUserInput) => {
    const { error } = await main.post('/users').body(data).execute();

    if (!error) {
      // Invalidate all cached queries for /users
      main.invalidate('/users');
    }
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

Persistent Caching (Redis / Store Adapter)

For server-side applications, use a persistent store to share cache across instances:

import { IgniterCaller } from '@igniter-js/caller';

// Implement the StoreAdapter interface for your backend
class RedisStoreAdapter {
  readonly client: RedisClient;

  constructor(redisUrl: string) {
    this.client = createRedisClient(redisUrl);
  }

  async get<T>(key: string): Promise<T | null> {
    const raw = await this.client.get(key);
    return raw ? JSON.parse(raw) : null;
  }

  async set(key: string, value: any, options?: { ttl?: number }): Promise<void> {
    const serialized = JSON.stringify(value);
    if (options?.ttl) {
      await this.client.setex(key, options.ttl, serialized);
    } else {
      await this.client.set(key, serialized);
    }
  }

  async delete(key: string): Promise<void> {
    await this.client.del(key);
  }

  async has(key: string): Promise<boolean> {
    return (await this.client.exists(key)) === 1;
  }
}

// Use it
const redisStore = new RedisStoreAdapter(process.env.REDIS_URL!);

const api = IgniterCaller.create()
  .withBaseUrl('https://api.example.com')
  .withStore(redisStore, {
    ttl: 3600,                // 1 hour default TTL
    keyPrefix: 'api-cache:',  // Redis key prefix
    fallbackToFetch: true,    // Fall back to in-memory if Redis is down
  })
  .build();

Store Options

OptionDefaultDescription
ttl3600Default TTL in seconds
keyPrefix'igniter:caller:'Prefix for all cache keys
fallbackToFetchtrueFall back to in-memory when store is unavailable

Testing with MockStoreAdapter

Use the built-in mock adapter for testing:

import { MockCallerStoreAdapter } from '@igniter-js/caller/adapters';
import { IgniterCaller } from '@igniter-js/caller';

describe('Cache behavior', () => {
  it('uses store for caching', async () => {
    const store = MockCallerStoreAdapter.create();

    const api = IgniterCaller.create()
      .withStore(store)
      .build();

    // First call: cache miss
    await api.get('/data').stale(30_000).execute();

    // Second call: should hit cache
    await api.get('/data').stale(30_000).execute();

    // Assert
    expect(store.calls.set).toBe(1);
    expect(store.calls.get).toBeGreaterThanOrEqual(1);
  });
});

Caching with Telemetry

Monitor cache performance with telemetry events:

import { IgniterCallerTelemetryEvents } from '@igniter-js/caller/telemetry';

// Every cache hit emits: igniter.caller.cache.read.hit
// Every successful request emits: igniter.caller.request.execute.success (with ctx.cache.hit)

Best Practices

Caching Guidelines

  • Cache GET requests — mutations (POST/PUT/PATCH/DELETE) always bypass cache
  • Use reasonable stale times — 5-30s for dynamic data, 5-60min for static data
  • Invalidate on mutation — clear related caches when data changes
  • Use custom keys for data that doesn't map 1:1 to URLs
  • Monitor hit rates with telemetry to tune stale times

Common Mistakes

  • Don't cache user-specific data with shared keys — use user-specific cache keys
  • Don't set extremely long stale times for rapidly changing data
  • Don't forget that cache is in-memory by default — restarts clear it
  • Don't use .stale() without a store adapter if you need cross-instance caching

Next Steps