Introduction
When thinking of caching, Redis is usually the default answer. However, in a recent high-traffic project involving insurance policy data, infrastructure complexity and cost management were top priorities. I needed a solution that was serverless, practically infinite in scale, and required zero maintenance.
Enter Amazon DynamoDB as a distributed cache.
Why DynamoDB?
Redis is powerful, but managing a cluster (or paying for a managed one) adds overhead. For our use case—caching API responses and complex query results—DynamoDB offered specific advantages:
- Serverless: No instances to provision or patch.
- TTL (Time To Live): Native support for automatic item expiry.
- Millisecond Latency: Consistent performance at any scale.
The Implementation
I designed a generic CacheService within our NestJS backend that abstracted the underlying storage mechanism. This allowed us to easily swap storage providers, but the primary implementation used DynamoDB.
1. Table Design for Caching
The table design was simple but effective:
- Partition Key (
PK): The cache key (e.g.,user:123:profileorquery:products:active). - Attribute (
value): The serialized JSON data. - Attribute (
expiresAt): A UNIX timestamp used by DynamoDB’s TTL feature.
sequenceDiagram
participant Client
participant API
participant Cache as DynamoDB Cache
participant DB as Primary DB
Client->>API: Request data
API->>Cache: Check cache (getItem)
alt Cache Hit & Not Expired
Cache-->>API: Return cached data
API-->>Client: Response (fast)
else Cache Miss or Expired
Cache-->>API: No data / expired
API->>DB: Fetch fresh data
DB-->>API: Return data
API->>Cache: Update cache (async)
API-->>Client: Response
end
Note over Cache: TTL auto-deletes expired items
2. The Abstract Service
I built the service to handle the “stale-while-revalidate” pattern and standard lookups.
// Simplified logic of the Cache Service
async getOrSet<T>(key: string, ttlSeconds: number, fetcher: () => Promise<T>): Promise<T> {
const cachedItem = await this.dynamoDbClient.getItem({ Key: { pk: key } });
if (cachedItem && cachedItem.expiresAt > Date.now()) {
return JSON.parse(cachedItem.value);
}
// Cache miss or expired: Fetch fresh data
const freshData = await fetcher();
// Asynchronously update cache to not block response
this.set(key, freshData, ttlSeconds);
return freshData;
}
Solving the “Zero Downtime” Migration
One of the more complex aspects of this project was synchronizing data between the transactional document store (MongoDB/DocumentDB) and this caching layer. I implemented specific event triggers that would invalidate or update specific cache keys whenever the master data changed, ensuring users never saw stale data for critical operations.
graph LR
subgraph "Data Flow"
App["Application"]
Primary[("Primary DB<br/>MongoDB")]
Events["Change Events"]
Lambda["Cache Invalidator<br/>Lambda"]
Cache[("DynamoDB Cache")]
end
App -->|write| Primary
Primary -.->|trigger| Events
Events -->|invoke| Lambda
Lambda -->|invalidate/update| Cache
App -->|read| Cache
style Primary fill:#51cf66,stroke:#2f9e44
style Cache fill:#74c0fc,stroke:#1c7ed6
style Lambda fill:#ffa94d,stroke:#d9480f
Results
By offloading repetitive read operations to DynamoDB:
- Latency Drop: Average response times for complex dashboard endpoints dropped from ~400ms to <50ms.
- Database Load: Read throughput on the primary PostgreSQL database decreased by 40%.
- Cost: significantly lower operational cost compared to a provisioned Redis cluster for our specific traffic pattern.
Conclusion
Redis isn’t always the only tool for the job. By leveraging DynamoDB’s TTL features, I built a robust, maintenance-free caching layer that scaled perfectly with the serverless architecture.
Tags