Skip to main content

Advanced

@cache() Decorator vs Inline Router Config

Both approaches are equivalent at runtime. Choose based on your routing style:

@cache() decoratorInline router config
Used withController classesserver.router.*()
Type-safe include picks❌ (no schema access)✅ (inferred from schema)
Tag support
fromRequest

Decorator:

import { controller, get, cache } from 'balda';

@controller('/api/users')
class UserController {
@get('/:id')
@cache({ ttl: 60, tags: ['users'] })
async getUser(req, res) { /* ... */ }
}

Inline:

server.router.get(
'/api/users/:id',
{ cache: { ttl: 60, tags: ['users'] } },
(req, res) => { /* ... */ },
);

Response Compression

For large responses, Balda can gzip-compress the serialized JSON before storing it in the cache.

Enable it per-route:

@cache({ ttl: 300, useCompression: true })

Compression is only applied when the serialized response exceeds compressionThreshold (default: 1024 bytes). Smaller responses are stored as-is regardless of the useCompression flag.

Configure the threshold when initializing the cache service:

import { initCacheService, RedisCacheProvider, DEFAULT_CACHE_OPTIONS } from 'balda';

initCacheService(new RedisCacheProvider({ host: 'localhost' }), {
...DEFAULT_CACHE_OPTIONS,
compressionThreshold: 2048, // compress responses > 2 KB
});

Thundering Herd Protection

When many concurrent requests hit a cold cache entry at the same time, only the first request should execute the handler — the rest should wait for the result. Balda handles this with a distributed lock.

How it works

  1. The first request acquires a lock for the cache key.
  2. Subsequent requests detect the lock and enter a wait loop (polling every 50 ms).
  3. Once the first request stores the result and releases the lock, waiting requests serve the cached response.
  4. If the lock expires before the cache is populated, waiting requests fall through to execute the handler themselves.

lockBehavior

Control what happens when a lock cannot be acquired via lockBehavior (global default) or per-route:

ValueBehaviour
'wait'Poll until the cache is populated or lockTimeout expires (default)
'bypass'Execute the handler immediately without waiting
'fail'Return 503 Service Unavailable immediately
// Global default
import { initCacheService, RedisCacheProvider, DEFAULT_CACHE_OPTIONS } from 'balda';

initCacheService(new RedisCacheProvider({ host: 'localhost' }), {
...DEFAULT_CACHE_OPTIONS,
lockTimeout: 5000, // ms before lock expires
lockBehavior: 'bypass', // fall through instead of waiting
});

// Per-route override
@cache({ ttl: 60, lockBehavior: 'fail' })

Statistics

When enableStats: true (the default), Balda tracks cache activity and exposes it via getCacheService()?.getStats():

import { getCacheService } from 'balda';

const stats = getCacheService()?.getStats();
// {
// hits: 142,
// misses: 38,
// hitRate: 0.789,
// invalidations: 5,
// }

console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
FieldTypeDescription
hitsnumberTotal cache hits since server start
missesnumberTotal cache misses since server start
hitRatenumberhits / (hits + misses) (0.0 – 1.0)
invalidationsnumberTotal keys deleted via any invalidation method

Disable stats tracking to save a small amount of overhead:

import { initCacheService, MemoryCacheProvider, DEFAULT_CACHE_OPTIONS } from 'balda';

initCacheService(new MemoryCacheProvider(), {
...DEFAULT_CACHE_OPTIONS,
enableStats: false,
});