Apr 17, 20265 min read

How to Add Caching to Your TypeScript API

Redis-backed caching with automatic provisioning and type safety

Adding caching to a TypeScript API typically involves running Redis locally with Docker, managing connection strings, handling serialization, and provisioning ElastiCache or Memorystore for production. This guide shows a faster approach where the cache is provisioned automatically and the API is type-safe from the start.

We'll use Encore, which provides a built-in caching primitive that provisions Redis automatically during local development and ElastiCache (AWS) or Memorystore (GCP) in production.

What You'll Build

  • A cache cluster that provisions automatically
  • Type-safe keyspaces for different data types
  • Cache-aside pattern for database queries
  • Cache invalidation on writes
  • TTL management

Declaring a Cache

Start by declaring a cache cluster. This is all the configuration needed.

// cache/cache.ts import { CacheCluster } from "encore.dev/storage/cache"; // Becomes ElastiCache (Redis) on AWS or Memorystore (Redis) on GCP. export const cluster = new CacheCluster("app-cache", { evictionPolicy: "allkeys-lru", });

Running encore run starts a local Redis instance automatically. Deploying provisions ElastiCache or Memorystore in your cloud account with sensible defaults.

Type-Safe Keyspaces

Keyspaces define what you store and how keys are structured. Each keyspace is fully typed.

// cache/keyspaces.ts import { StructKeyspace, StringKeyspace, IntKeyspace, expireIn } from "encore.dev/storage/cache"; import { cluster } from "./cache"; interface UserProfile { id: number; email: string; name: string; plan: string; } // Cache user profiles by ID export const userProfiles = new StructKeyspace<{ userId: number }, UserProfile>(cluster, { keyPattern: "user/:userId", defaultExpiry: expireIn(15 * 60 * 1000), // 15 minutes }); // Cache session tokens export const sessions = new StringKeyspace<{ sessionId: string }>(cluster, { keyPattern: "session/:sessionId", defaultExpiry: expireIn(3600 * 1000), // 1 hour }); // Rate limit counters export const rateLimits = new IntKeyspace<{ ip: string; window: string }>(cluster, { keyPattern: "ratelimit/:ip/:window", defaultExpiry: expireIn(60 * 1000), // 1 minute });

The key pattern uses path-like syntax with typed parameters. TypeScript ensures you pass the right key shape and get the right value type back.

Cache-Aside Pattern

The most common caching pattern: check the cache first, fall back to the database, and populate the cache on miss.

// users/users.ts import { api } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { userProfiles } from "../cache/keyspaces"; // Provisions RDS on AWS or Cloud SQL on GCP with sensible defaults (uses Docker Postgres locally). const db = new SQLDatabase("users", { migrations: "./migrations" }); interface User { id: number; email: string; name: string; plan: string; } export const getUser = api( { method: "GET", path: "/users/:id", expose: true }, async ({ id }: { id: number }): Promise<User> => { // Check cache first const cached = await userProfiles.get({ userId: id }); if (cached) return cached; // Cache miss — query database const user = await db.queryRow<User>` SELECT id, email, name, plan FROM users WHERE id = ${id} `; // Populate cache for next time if (user) { await userProfiles.set({ userId: id }, user); } return user!; } );

The get call returns the typed UserProfile or undefined on a cache miss. The set call stores the value with the default TTL (15 minutes in this case).

Cache Invalidation

When data changes, invalidate the cache so stale data isn't served.

export const updateUser = api( { method: "PUT", path: "/users/:id", expose: true }, async (req: { id: number; name: string; plan: string }): Promise<User> => { const user = await db.queryRow<User>` UPDATE users SET name = ${req.name}, plan = ${req.plan} WHERE id = ${req.id} RETURNING id, email, name, plan `; // Invalidate the cached version await userProfiles.delete({ userId: req.id }); return user!; } );

For more granular control, you can replace instead of delete, which updates the cache immediately:

// Update cache immediately instead of deleting if (user) { await userProfiles.set({ userId: req.id }, user); }

Rate Limiting with IntKeyspace

The IntKeyspace supports atomic increment and decrement operations, which makes it useful for rate limiting, and it pairs well with API middleware.

// middleware/ratelimit.ts import { rateLimits } from "../cache/keyspaces"; export async function checkRateLimit(ip: string): Promise<boolean> { const window = new Date().toISOString().slice(0, 16); // per-minute window const count = await rateLimits.increment({ ip, window }, 1); return count <= 100; // 100 requests per minute }

The counter auto-expires after 1 minute (the TTL defined in the keyspace), so you don't need to clean up old rate limit windows.

Running Locally

encore run

Encore starts your app with a local Redis instance, PostgreSQL database, and distributed tracing. The cache operations appear in traces alongside database queries and API calls, so you can see the cache hit/miss ratio and latency in the local development dashboard.

Deploying to Production

git push encore

The Encore platform provisions ElastiCache (AWS) or Memorystore (GCP) in your cloud account alongside your other infrastructure. The cache cluster gets production-appropriate defaults for networking, encryption, and backup that you can adjust per environment.

What You've Built

  • A type-safe cache with structured keyspaces and automatic provisioning
  • Cache-aside pattern for database queries with TTL management
  • Cache invalidation on writes
  • Rate limiting with atomic counters
  • Local development with real Redis, no Docker setup
  • Production deployment to ElastiCache or Memorystore in your cloud account

Next Steps

Install Encore
brew install encoredev/tap/encore &&
encore app create --example=ts/url-shortener
Copy

Ship without waiting on Terraform

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 4.8 · Encore MCP connected~/orders
Infra from codereading the code…
SQLDatabaseorders · postgres
Topicorders · pub/sub
Bucketdeclared in code
not running yet