Upstash Redis
Our serverless Redis, with @upstash/ratelimit on top of it.
Why Upstash and not a normal Redis: it speaks HTTP, so it works from Vercel edge
and serverless functions where a persistent TCP connection is not available. A regular
Redis client opens a connection per invocation and exhausts the connection pool under
load — that is the failure ioredis has to manage on a long-lived Railway process.
Environment
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN= # SECRET
(UPSTASH_REDIS_REST_KV_REST_API_URL/_TOKEN also turn up — the names Vercel's KV
integration injects. Same values; prefer the plain pair.)
Client
import { Redis } from "@upstash/redis";
export const redis = Redis.fromEnv(); // reads the two vars above
await redis.set("price:eth", 3200, { ex: 60 }); // TTL in seconds
const price = await redis.get<number>("price:eth");
Always set a TTL. Upstash bills per command and per stored byte; keys without expiry accumulate silently and turn into a monthly line item nobody can attribute.
Rate limiting
import { Ratelimit } from "@upstash/ratelimit";
const limiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "60 s"),
analytics: true,
prefix: "gt",
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anon";
const { success, reset } = await limiter.limit(ip);
if (!success) {
return new Response("Demasiadas solicitudes", {
status: 429,
headers: { "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)) },
});
}
// …
}
Rate-limit by authenticated user id where you have one, falling back to IP. IP alone is both too coarse (shared NAT in a school or office) and too easy to rotate.
Every route that costs money — an Anthropic call, a Pinata pin, an onchain write, an email — should be behind a limiter. Those are the ones an attacker targets precisely because they cost us more than they cost them.
Caching pattern
async function cached<T>(key: string, ttl: number, fn: () => Promise<T>): Promise<T> {
const hit = await redis.get<T>(key);
if (hit !== null) return hit;
const value = await fn();
await redis.set(key, value, { ex: ttl });
return value;
}
Good fits: token prices, Shopify catalog reads, Meta insights (which lag anyway), RPC reads that change per block rather than per request.
Gotchas
- No TTL → unbounded storage and cost.
- Serialisation. The SDK JSON-encodes automatically; do not
JSON.stringifyfirst or you get double-encoded strings back. bigintis not JSON-serialisable. Store as string.- Rate limiting by IP only is both too coarse and too weak.
- Every command is billed, including misses — a hot loop of cache lookups is not free.
- Not a database. Anything that must survive is Supabase's job.