
Part 1 of the caching series — how we built a pluggable cache layer for TCTF with DynamoDB TTL caching, in-memory caching for Lambda warm starts, cache monitoring with health checks, and the interface that makes storage backends swappable.
Caching in serverless does not work the way it does in traditional applications. There is no long-lived process holding state between requests. Lambda functions are ephemeral — they start, handle a request, and may never be reused. That means any cache you build must live outside the function. It must clean up after itself. And it must be fast enough that caching does not cost more than the operation it replaces. This article is Part 1 of a two-part series. It covers the caching architecture we built for TCTF: a pluggable CacheService backed by DynamoDB TTL caching (the default), an in-memory cache for Lambda warm starts, and a monitoring layer for resilience. Part 2 covers ElastiCache Redis for sub-millisecond caching on hot paths.

In traditional applications, caching is simple. You run a Redis instance, your application server connects to it, and cached data stays available as long as the process runs.
Serverless removes that guarantee. A Lambda function handles a request, and the next request may land on a completely different instance. In-memory state is gone. Connection pools are gone. Any data stored in the process disappears with it.
This creates three hard requirements for a serverless cache:
1. The cache must be external — stored in a service accessible from any Lambda instance. 2. The cache must handle TTL automatically — expired entries must be cleaned up without a background worker. 3. The cache must be fast enough that the caching overhead does not exceed the cost of the original operation.
For TCTF, there was a fourth requirement: the cache must be pluggable. We run 34 services. Some need durability (geolocation data that costs real money to fetch). Some need speed (session lookups on every request). Some need simplicity (configuration data that changes once a week). No single cache backend satisfies all of them.
🎯Four requirements: external storage, automatic TTL, low overhead, and pluggable backends. No single cache backend satisfies all 34 services.
The CacheService is the single entry point for all caching operations. It exposes a consistent API that works identically regardless of which storage backend is active.
Here is what the API surface looks like in practice:
import { CacheService } from '@tctf/cache';
const cache = CacheService.getInstance();
// Single item operations
await cache.set('user:123:profile', userData, 3600); // TTL in seconds
const profile = await cache.get<UserProfile>('user:123:profile');
// Batch operations — useful when you need multiple keys in one handler
const keys = ['config:feature-flags', 'config:rate-limits', 'config:thresholds'];
const configs = await cache.getMany<AppConfig>(keys);
await cache.setMany([
{ key: 'geo:8.8.8.8', value: geoResult, ttl: 86400 },
{ key: 'geo:1.1.1.1', value: geoResult2, ttl: 86400 },
]);
// TTL management
await cache.expire('session:abc', 1800); // extend session by 30 min
const remaining = await cache.getTtl('session:abc');
// Monitoring
const stats = await cache.getStats(); // { hits, misses, keys }A service caching geolocation data in DynamoDB uses the exact same methods as a service caching sessions in Redis. The caller does not know — and does not need to know — which backend handles the operation.
The CacheService is a singleton with lazy initialization. The first call to getInstance() creates the service and resolves the storage backend from the CACHE_STORAGE_TYPE environment variable. After that, it is reused across invocations within a warm Lambda instance.
You can also swap the backend at runtime via setCacheStorage(). This is useful for testing (swap to in-memory) and for graceful degradation (fall back to DynamoDB if Redis becomes unreachable).

DynamoDB is the default cache backend for TCTF services. The reason is operational simplicity: it requires no additional infrastructure. It uses the service's existing DynamoDB table with a key prefix to isolate cache entries from business data.
Each cache entry is stored as a DynamoDB item with four attributes:
PK — the prefixed cache keyvalue — the JSON-serialized cached datattl — a Unix timestamp for automatic expirationcreatedAt — for monitoring and debuggingDynamoDB's built-in TTL feature deletes items automatically when their timestamp passes. No cleanup jobs. No scheduled Lambda functions. No manual purging.
Here is what a cache write and read look like under the hood:
import { DynamoDBCacheStorage } from '@tctf/cache';
const storage = new DynamoDBCacheStorage({
tableName: process.env.CACHE_TABLE!,
prefix: 'CACHE#geo:',
});
// Writing a cache entry with 24-hour TTL
await storage.set('8.8.8.8', {
country: 'US',
region: 'California',
city: 'Mountain View',
}, 86400);
// Reading — returns null if expired or missing
const location = await storage.get<GeoLocation>('8.8.8.8');Batch operations use DynamoDB's BatchGetItem and BatchWriteItem for efficiency. The has operation uses a projection expression to check existence without reading the full item.
Key prefixing prevents collisions. Every cache key is prefixed with a configurable string (e.g., CACHE#geo:, CACHE#config:). Cache entries coexist safely with business data in the same table. The prefix is stripped on retrieval, so callers work with clean keys.
The DynamoDB cache is durable. Cold starts do not clear it. Service restarts do not clear it. Data only leaves through explicit deletion or TTL expiration. This makes it the right choice for data that is expensive to recompute — geolocation lookups, external API responses, and computed aggregations.
📦DynamoDB caching requires zero extra infrastructure. It piggybacks on your existing table. TTL handles expiration. The cache survives cold starts and restarts.
The in-memory cache backend uses a JavaScript Map with TTL tracking. It is the fastest option — no network calls, no serialization, sub-microsecond access. The tradeoff: it resets on every Lambda cold start.
When does that tradeoff make sense?
Initialization is straightforward:
import { InMemoryCacheStorage } from '@tctf/cache';
const memoryCache = new InMemoryCacheStorage({
maxSize: 500, // max entries before FIFO eviction
cleanupInterval: 30000, // remove expired entries every 30s
prefix: 'MEM#',
});
CacheService.getInstance().setCacheStorage(memoryCache);The maxSize configuration caps the number of entries. When the cache is full, the oldest entry is evicted (FIFO). This prevents unbounded memory growth, which matters in Lambda where memory is a billed resource.
The in-memory cache implements the same ICacheStorage interface as DynamoDB and Redis. TTL, batch operations, stats tracking, key prefixing — all consistent across backends. A cleanup interval runs periodically to remove expired entries and prevent memory creep in long-running instances with provisioned concurrency.
⚡In-memory cache: sub-microsecond access, zero network calls. But volatile — resets on cold start. Best for testing, per-invocation data, and warm-start optimization.
In serverless, you cannot run background workers to sweep stale entries. TTL solves this by making expiration a property of each entry rather than a system-wide process.
Every cache entry has a TTL — the number of seconds until it expires. When you call set(key, value, ttl), the backend stores the expiration timestamp. On get(key), the backend checks whether the entry has expired. If it has, the caller gets a cache miss. The stale data is never returned.
DynamoDB handles TTL natively. Items with a ttl attribute are deleted automatically by a background process — typically within 48 hours of expiration, though the item becomes invisible to reads the moment the timestamp passes.
The in-memory cache checks TTL on every get and runs a periodic cleanup to free memory proactively.
TTL values should reflect how the data changes:
The CacheService also provides explicit TTL management. The expire method updates the TTL on an existing entry without re-writing the value. The getTtl method returns seconds remaining. These are useful for sliding expiration windows — extending a session each time the user makes a request, for example.
A cache that fails silently is worse than no cache at all. If the DynamoDB backend starts returning errors, the service should degrade gracefully rather than throw unhandled exceptions. The CacheMonitor provides this resilience layer.
Health checks run on a configurable interval. The monitor writes a test key, reads it back, verifies the value, and deletes it. If the check fails after a configured number of retries, it emits a CloudWatch metric and logs an error. The operations team sees the failure in the dashboard before users notice anything.
The CacheMonitor integrates with a circuit breaker. If the cache backend fails repeatedly, the circuit opens and cache operations are skipped entirely. The service operates without caching, hitting the source of truth directly. When the cache recovers, the circuit closes and caching resumes.
The getStats method on each backend returns hit count, miss count, and total keys. These metrics feed into CloudWatch dashboards showing cache hit rates per service. A dropping hit rate is an early signal — it could mean TTL values are too aggressive, the cache is too small, or access patterns have shifted.
Graceful shutdown ensures connections are closed cleanly. For DynamoDB, this is a no-op (the client manages connection pooling). For Redis (Part 2), this closes the connection to prevent leaks. For in-memory, it clears the cleanup interval.
📊Health checks catch cache failures before users notice. The circuit breaker provides automatic fallback. CloudWatch hit rate metrics show cache effectiveness per service.

The pluggable architecture means every service picks the backend that fits its workload.
DynamoDB is the default and the right choice for most services. No extra infrastructure, durable across cold starts, automatic TTL, single-digit millisecond latency. Use it for: - Geolocation data - Configuration caching - Circuit breaker state - Any data where durability matters more than sub-millisecond speed
In-memory is for ephemeral data and testing. Use it for: - Parsed configurations within a single invocation - Validated schemas - Any data that is cheap to recompute on cold start
Do not use it as a primary cache for data that must persist across invocations.
Redis (Part 2) is for hot-path operations where sub-millisecond latency is measurably important — session validation, rate limit counters, API response caching. It requires Lambda to run inside a VPC, which adds operational complexity. Use it only when profiling shows DynamoDB latency is a bottleneck.
The decision is not permanent. Start with DynamoDB. Monitor hit rates and latency. If a service needs faster access, switch to Redis by changing one environment variable. The code stays the same. The API stays the same. Only the performance profile changes.
In Part 2, we cover ElastiCache Redis — VPC configuration for Lambda, the Redis cache storage implementation, cluster mode, and the specific use cases where Redis outperforms DynamoDB caching.
🎯Start with DynamoDB (zero infra, durable, sufficient for most services). Add Redis only when profiling shows the need. The pluggable interface makes switching painless.
Caching in serverless is about choosing the right tool for each service — balancing speed, durability, complexity, and cost. The CacheService gives every TCTF service that choice through a single interface. DynamoDB for durability. In-memory for speed. Redis for the hot path. In Part 2, we dive into ElastiCache Redis: VPC configuration, cluster mode, and the workloads where sub-millisecond latency makes a real difference.
Never miss an edition
Subscribe to get TCTF newsletters delivered to your inbox.