# Redis for Builders - full corpus > Modern Redis patterns, guides, production code examples and builds. This file contains every post on the site, concatenated. Each post is preceded by a YAML frontmatter block with metadata and a stable URL. Generated: 2026-10-12T14:38:30.107Z Post count: 8 --- --- url: https://www.xadd.dev/builds/caching-ai-sdk-v6-tool-results-with-redis title: "Caching AI SDK v6 tool results with Redis" date: 2026-05-20T12:06:01.064Z updated: 2026-05-20T20:16:00.088Z word_count: 1209 tags: ["AI SDK", "Redis", "Upstash", "Caching", "TypeScript"] --- # Caching AI SDK v6 tool results with Redis > Cache AI SDK v6 tool results in Redis by writing a simple, clean TypeScript wrapper. Working code with Upstash Redis and an example tool. For tools like web search, web fetching, weather maps, getting geographical data or many others, caching with Redis can reduce tool call times up to 25x. It's also much cheaper and takes 40 lines of TypeScript in the newest Vercel AI SDK version. ## The tool we're going to speed up Here is an example AI SDK v6 web search tool. It works, but every call uses another API credit and takes around 2 seconds. ```tsx id="code-yjhqa6wo" import { tool } from "ai"; import { z } from "zod"; const web_search = tool({ description: "Search the web for up-to-date information.", inputSchema: z.object({ query: z.string(), }), execute: async ({ query }) => { return await searchTheWeb(query); }, }); ``` In this article, we'll wrap the `execute` function with a small Redis cache so a repeated query returns from memory in a few milliseconds. ![Tool calling without caching](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/ScFbeASUvO38suqnptG9V.png) ![Tool calling with caching: The tool only needs to run once, we cache its result](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/zCAF2OXHAQ0tXS0k-llhe.png) ## Key takeaways - **Tool caching is \~25x faster on a repeat query.** This of course depends a lot on the tool itself and how long it takes. But just for testing, I wrapped a Firecrawl-backed web search tool with the cache below. It's uncached call takes 810 ms and a cache hit 32 ms (across 10 runs) - **About 40 lines of TypeScript.** Our cache is a single higher-order function around the tool's execute. The tool code stays the same. ## Why cache tool calls instead of LLM responses? The AI SDK ships a [caching middleware pattern](https://ai-sdk.dev/docs/advanced/caching) that wraps the model itself: ```tsx id="code-0z72g6fx" import { Redis } from "@upstash/redis"; import type { LanguageModelMiddleware } from "ai"; const redis = Redis.fromEnv(); const cache: LanguageModelMiddleware = { specificationVersion: "v3", wrapGenerate: async ({ doGenerate, params }) => { const key = JSON.stringify(params); // πŸ‘ˆ whole prompt, settings, history const cached = await redis.get(key); if (cached) return cached as Awaited>; const result = await doGenerate(); await redis.set(key, result, { ex: 60 * 60 }); return result; }, }; ``` The cache key is the entire `params` object: message history, model settings, system prompt, all of it. That works for batch jobs replaying the same prompt, but I found that conversational agents rarely repeat full prompts. Message history, timestamps, and user phrasing all change, so the cache hits almost never. Tool inputs, on the other hand, repeat a lot. A `web_search` tool gets called with `{ query: "typescript v6" }` fairly often when a user asks about TypeScript because models are quite deterministic in how they write their input queries. A `getUserProfile` tool gets called with the same user ID on every step of a multi-step loop. A `fetchPricing` tool gets called with the same product ID. Caching at the tool layer is useful even when the LLM cache misses. A web search API like [Tavily charges $0.008 per credit](https://docs.tavily.com/documentation/api-credits) (basic search = 1 credit) and [Exa charges $5 per 1,000 searches](https://exa.ai/pricing). An agent doing 10,000 searches/day on Tavily costs about $80/day just for that tool. If we assume a 25% cache hit rate, we're down to about $60/day, or roughly $600/month saved on a single tool. ## How do I write a Redis cache wrapper? Let's build the wrapper as a higher-order function: it takes a tool, returns a new tool with the same shape but a wrapped `execute`. On every call the wrapped function hashes the input into a Redis key, checks for a hit, runs the original on miss, and writes the result back with a TTL. ```tsx id="code-0eyuuvnt" // lib/cache.ts import { Redis } from "@upstash/redis"; import type { Tool } from "ai"; const redis = Redis.fromEnv(); // we sort object keys so {a:1,b:2} and {b:2,a:1} hash the same. function stableStringify(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; const entries = Object.entries(value as Record).sort( ([a], [b]) => a.localeCompare(b), ); return `{${entries .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`) .join(",")}}`; } export function cached( name: string, toolDef: T, options: { ttlSeconds?: number } = {}, ): T { const original = toolDef.execute; if (!original) return toolDef; const ttl = options.ttlSeconds ?? 60 * 60; // 1 hour default return { ...toolDef, execute: async (input: unknown, ctx: Parameters[1]) => { const key = `tool:${name}:${stableStringify(input)}`; const hit = await redis.get(key); if (hit !== null) return hit; const result = await original( input as Parameters[0], ctx, ); await redis.set(key, result, { ex: ttl }); return result; }, } as T; } ``` Quick note, the `ex` option on `set` is a single round-trip with TTL attached (the same as `SET key value EX 3600`), which is faster than `SET` + `EXPIRE`. ## How do I apply it to the web\_search tool? In just one line. We wrap the tool definition with `cached("web_search", ...)` and export the result. The agent code that consumes the tool does not change at all. ```tsx id="code-d0u67216" // tools/web-search.ts import { tool } from "ai"; import { z } from "zod"; import { cached } from "@/lib/cache"; async function searchTheWeb(query: string) { // any search provider works β€” firecrawl, tavily, exa, etc. const res = await fetch("https://api.firecrawl.dev/v2/search", { method: "POST", headers: { Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ query, limit: 5 }), }); if (!res.ok) throw new Error(`web search failed: ${res.status}`); return (await res.json()) as { success: boolean; data: { web: { url: string; title: string; description: string }[] }; }; } export const web_search = cached( "web_search", tool({ description: "Search the web for up-to-date information.", inputSchema: z.object({ query: z.string().describe("The search query."), }), execute: async ({ query }) => searchTheWeb(query), }), { ttlSeconds: 60 * 60 }, ); ``` Drop it into a `ToolLoopAgent` or `streamText` call the same way you would any other tool: ```tsx id="code-3rssaixc" import { streamText } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; import { web_search } from "@/tools/web-search"; const result = streamText({ model: anthropic("claude-sonnet-4-6"), messages, tools: { web_search }, }); ``` ## How do I test if it works? Let's loop 10 unique queries through the wrapped tool, timing both the cold call and an immediate repeat: ```tsx id="code-ojftmf9f" // bench.ts import { web_search } from "./tools/web-search"; const ctx = { toolCallId: "bench", messages: [] } as never; const misses: number[] = []; const hits: number[] = []; for (let i = 0; i < 10; i++) { const input = { query: `bench query ${Date.now()}-${i}` }; const t1 = performance.now(); await web_search.execute!(input, ctx); misses.push(performance.now() - t1); const t2 = performance.now(); await web_search.execute!(input, ctx); hits.push(performance.now() - t2); } const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]; console.log(`miss median: ${median(misses).toFixed(1)} ms`); console.log(`hit median: ${median(hits).toFixed(1)} ms`); ``` Running this against live Firecrawl + a fresh Upstash Redis database (not even same region): ```tsx id="code-haw35r7z" miss median: 809.6 ms hit median: 32.1 ms ``` A repeat call goes from \~810 ms (Firecrawl round-trip) to \~32 ms (Upstash REST GET). It's about 25x faster, and the cached call costs zero search credits. A same-region production deployment will see hits in the single-digit milliseconds; the 32 ms above includes \~25-30 ms of cross-region REST round-trip. In both cases, to the model, it looks like it got the exact same web search result. ## What TTL should I pick? I asked AI what it thinks and it said this: | Tool type | Suggested TTL | Why | | --- | --- | --- | | Web search (general / evergreen queries) | 1 hour to 24 hours | Top results for "what is X" do not change minute-to-minute. | | Web search (news, sports scores, prices) | 30–120 seconds | Freshness matters but a chat session is still cacheable. | | Public API lookups (weather, exchange rates) | 1–10 minutes | Data drifts but freshness within a chat is fine. | | User-scoped reads (profile, settings) | 30–120 seconds | Cheap, but invalidate on writes. | | Anything with a write side-effect | Do not cache | Caching `sendEmail` is how you ship bugs. | For the web search case, 1 hour is a good default. Long enough that "redis vs memcached" hits the cache across different conversations (or in one conversation if you go hard on context compression), and short enough that "openai latest model" stays roughly current. If you want freshness control per call, add a `maxAgeSeconds` field to the tool input. The model can ask for fresh data when the user explicitly wants it (news, today's weather), and the key naturally splits hot/fresh from regular requests. ## When should I not cache a tool? Three cases where the wrapper is a bad idea: - **Tools with side effects.** Anything that writes (create order, send email, post message) must run every time. Caching the response means the side effect happens once and the next call silently returns the cached confirmation without doing the work. - **Tools whose output depends on time or randomness.** A `getCurrentTime` tool, a `rollDice` tool, anything stochastic. The cache will pin the first answer forever (or until TTL). - **Tools where the input space is effectively unbounded.** If every call has a unique input (a freeform user message embedded as the key), you will fill Redis with garbage you never read. Track the hit ratio for a day before keeping the wrapper on. For everything else (documentation search, third-party reads, computed analytics), the wrapper adds one Redis round-trip per call (single-digit milliseconds when your function and Redis are in the same region) and saves the underlying latency on every hit. Track the hit ratio with `INCR` counters on hit and miss to see if it pays off. ## How is this different from the AI SDK's caching middleware? The [`LanguageModelMiddleware` cache](https://ai-sdk.dev/docs/advanced/caching) shown above wraps `doGenerate` and `doStream.` It caches the entire model response keyed by the full request params. That is the right layer when you have idempotent prompts (a daily summary job, a deterministic classification endpoint). With tool caching, we cache the work *inside* each step of the agent loop, while letting the LLM run normally and pick which tool to call. Both can coexist. For chat agents, tool caching is usually better because a single tool input (a search query, a user ID, a product ID) repeats far more often than a whole conversation prefix. If you want a packaged version of the same pattern with streaming-tool support and richer key generation, [`@ai-sdk-tools/cache`](https://ai-sdk-tools.dev/cache) implements the same idea with a `createCached({ cache: Redis.fromEnv() })` factory. --- url: https://www.xadd.dev/comparison/upstash-vs-redis-cloud-a-2026-comparison title: "Upstash vs Redis Cloud: A 2026 comparison" date: 2026-05-19T13:48:29.111Z updated: 2026-05-20T17:49:45.346Z word_count: 2635 tags: ["Upstash", "Redis Cloud", "Serverless", "Pricing", "Comparison"] --- # Upstash vs Redis Cloud: A 2026 comparison > Upstash vs Redis Cloud in 2026: Latency & performance benchmarks, pros & cons, use cases, pricing comparison **Pick Upstash if:** - You run serverless code or want scale-to-zero pricing ($0 idle) - You're using agents (Claude Code, Codex) to write code and want a better developer experience **Pick Redis Cloud if:** - You want dedicated VMs and don't mind the $200/mo Pro minimum - You need the full Redis Stack (TimeSeries, Bloom, etc.) or Active-Active multi-region writes ## Key takeaways - Redis Cloud's free tier is 30 MB with a 100 ops/sec cap and 30 concurrent connections (see [Essentials plan docs](https://redis.io/docs/latest/operate/rc/subscriptions/view-essentials-subscription/essentials-plan-details/)). Upstash's free tier covers 500K commands/month and 256 MB - Upstash's Fixed plans start at $10/month for 250 MB with no per-command billing and unlimited commands (with a 10,000 cmd/sec ceiling). The same plan costs $5/month on Redis Cloud Essentials but throttles at 1,000 ops/sec. - You can get a free Upstash Redis database without any sign-up by making a POST request to [https://upstash.com/start-redis](https://upstash.com/start-redis). It's valid for 72h and you can claim it. - Above \~10M commands/month at on pay-as-you-go, it's cheaper to go for a fixed plan on either side - Upstash exposes both standard Redis TCP and a [native HTTP API](https://upstash.com/docs/redis/features/restapi), so it works in serverless and normal server environments. Redis Cloud is TCP only and needs a TCP-capable runtime or proxy. ## What is Upstash Redis, and what is Redis Cloud? **Upstash Redis** is a Redis-compatible database operated by Upstash. You create a database, get a URL and a REST token, and pay per command or get a fixed instance with unlimited commands. Upstash speaks both the standard Redis protocol over TCP (so ioredis, node-redis, redis-py, and go-redis work unchanged) and HTTP/REST via the @upstash/redis client for serverless runtimes. **The free tier gives you 500K commands and 256 MB.** Pay-as-You-Go charges $0.20 per 100K commands plus $0.25/GB storage and free bandwidth up to 200 GB/month ([upstash.com/pricing](https://upstash.com/pricing)). ![Upstash Redis Pricing](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/ISx4r4Ii2cER_LdVmj1Cd.png) **Redis Cloud** is the managed Redis service from Redis Inc., the company that ships open-source Redis and Redis Stack. You pick a plan (Free, Essentials, or Pro), a cloud provider (AWS, GCP, Azure), and a memory size. Essentials runs on **shared infrastructure starting at $5/month for 250 MB**; Pro runs on dedicated VMs starting at $0.014/hr with a $200/month minimum ([redis.io/pricing](https://redis.io/pricing/)). Pro includes Active-Active replication, auto-tiering, and the full Redis Stack module set. ![Redis Cloud Pricing](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/VBz6GuyIsxF6YeaFTZkoc.png) ## How does Upstash pricing compare to Redis Cloud pricing in 2026? The two pricing models barely overlap, so the cheaper option depends on throughput. | Metric | Upstash | Redis Cloud | | --- | --- | --- | | Free tier | 256 MB, 500K commands/month | 30 MB, 100 ops/sec, 30 connections | | Cheapest paid plan | $0.20 per 100K commands (PAYG) | $5/month for 250 MB (Essentials) | | Storage | $0.25/GB on PAYG | Included in plan size | | Bandwidth | First 200 GB/month free, then $0.03/GB | Included, varies by plan | | Cheapest unlimited-commands plan | $10/month Fixed 250 MB (no per-command billing) | $5/month Essentials 250 MB (1,000 ops/sec cap) | | Fixed/dedicated entry | $10/month for 250 MB | $200/month minimum (Pro) | | Modules included | Upstash Search (Rust-based full-text + JSON) | RedisJSON, RediSearch, RedisTimeSeries, RedisBloom on Pro | | Max throughput | 16,000 ops/sec, unlimited on Enterprise | 16,000 ops/sec on largest Essentials, unlimited on Pro | | Ceiling/cap | $360/month per PAYG database or custom budget | None. You pay for what you provision | Upstash's [March 2025 pricing update](https://upstash.com/blog/redis-new-pricing) bumped the free tier from 10K commands/day to 500K/month and lifted the PAYG storage cap from 10 GB to 100 GB. At 1 GB and 1M commands/month, Upstash is roughly $2.25 vs Redis Cloud Essentials at $22. Upstash is **about 10Γ— cheaper for the most common use cases**. ## How does latency and performance compare? I benchmarked from EC2 in us-east-1. Both databases were paid plans with TLS enabled, reached over TCP using the same ioredis client. I ran three c7i.large hosts in parallel, one per availability zone (us-east-1a, us-east-1b, us-east-1c), at **2,000 sequential ops per workload** after 200 warmup commands. The full benchmark script and raw per-AZ results are in [this gist](https://gist.github.com/joschan21/c65266e17100311bd472be9ecbe2c167) if you want to reproduce or pick it apart. ### Preface: I did not like Redis Cloud UX On Redis Cloud, enabling TLS required me to upgrade to a paid plan (so I did), where TLS then shows up as a per-database toggle in the console (every new database defaults to plaintext). Once on, the client also needs the private CA bundle downloaded from the console: ```tsx id="code-rediscloud-tls" new Redis(url, { tls: { ca: fs.readFileSync("redis-ca.pem") } }); ``` Every Node/Python/Go service that talks to Redis Cloud has to ship that PEM file alongside the binary, so rotating the CA means redeploying. On Upstash, TLS is default on every database, also on the free tier. ### My Benchmark Methodology - **Host:** c7i.large EC2 (Intel Sapphire Rapids, 2 vCPU, 4 GiB, up to 12.5 Gbps), Amazon Linux 2023, Node 22.22.3, ioredis 5.7.0 - **Region:** us-east-1, three AZs run in parallel against the same two databases - **Network path:** public endpoints for both providers, default VPC - **Workload:** 200 warmup ops on the live connection, then 2,000 measured sequential ops on a single long-lived TLS connection - **Throughput:** after the sequential runs, 200 concurrent GET promises fired through the same client, which ioredis auto-pipelines onto one TCP connection ### p50 latency (ms, median across 3 AZs) | Workload | Upstash | Redis Cloud | | --- | --- | --- | | GET | 1.28 | **1.07** | | SET | 1.26 | **1.08** | | INCR | 1.21 | **1.04** | | Pipeline Γ—10 SETs | 1.28 | **1.10** | | Rate-limit (INCR + EXPIRE) | 1.26 | **1.06** | ![Redis Cloud vs. Upstash p50 latency](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/O6BWVfX7zqGvwc-K7yIYA.png) ### p99 latency (ms, median across 3 AZs) | Workload | Upstash | Redis Cloud | | --- | --- | --- | | GET | 2.06 | **1.85** | | SET | 1.83 | **1.80** | | INCR | 1.84 | **1.27** | | Pipeline Γ—10 SETs | 2.05 | **1.58** | | Rate-limit (INCR + EXPIRE) | 1.85 | **1.29** | ![Redis Cloud vs. Upstash p99 latency](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/23SH9eDNxiqfLaiZbK_rf.png) ### Concurrent throughput, 200 in-flight GETs through one client | Provider | us-east-1a | us-east-1b | us-east-1c | Median | | --- | --- | --- | --- | --- | | Upstash | 48,458 | 108,163 | 74,911 | 74,911 | | Redis Cloud | 89,949 | 132,652 | 134,349 | **132,652** | ![Redis Cloud vs. Upstash ops/sec](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/NBIs8UylL2yLI0kWIkQJo.png) ### Benchmark Results **Redis Cloud is about 1/5th of a millisecond faster on my bench.** p50 sits a consistent 0.15-0.20 ms ahead across all five tests. p99 is close on plain GET/SET (within 0.05-0.2 ms), and pulls further ahead on INCR, pipelined writes, and the two-op rate-limit pattern (0.5-0.6 ms). The largest gap is concurrent throughput from a single client: Redis Cloud lands \~77% more ops/sec at the median AZ (132K vs 75K). From what I can tell, both are well optimized, and Redis Cloud is even better micro-optimized for running on dedicated hardware. Some notes on this benchmark: - **The benchmark only tests TCP from a long-running EC2 host.** Upstash is exceptional in serverless runtimes, where Redis Cloud cannot connect at all. If a workload lives in Cloudflare Workers or Vercel, this table is irrelevant. The comparison there is "Upstash REST works" vs "Redis Cloud needs a TCP proxy". - **Latency changed a lot by AZ.** Both providers were sub-0.5 ms p50 from us-east-1b and 1.0-1.5 ms from us-east-1a/c, a 2-3Γ— swing depending on which AZ I provisioned my EC2 in. - Redis Cloud is in-memory: data lives in RAM, and AOF/snapshot persistence is a setting that's [off by default](https://redis.io/docs/latest/operate/rc/databases/configuration/data-persistence/) and only available for paid tiers. Upstash Redis is tiered: hot keys are in memory, colder keys are on disk, and the engine can switch between them. - This is a benchmark to get an idea of performance. It does not reflect a real production use case, in that case I recommend testing both yourself. ## What about features and Redis module support? This used to be Redis Cloud's biggest lead. Redis Cloud still has more modules, but Upstash closed the two most common ones in 2026 by shipping first-party JSON and Tantivy-backed full-text search inside the same Redis database. **Upstash now ships:** - **Upstash Search:** full-text search [built on Rust's Tantivy library](https://upstash.com/docs/redis/search/introduction) (the same engine behind Quickwit and ParadeDB). Writes auto-sync into indexed JSON, Hash, or String keys, and the JSON-based query syntax supports boolean operators, fuzzy matching, phrase queries, and regex. - **JSON:** the document API works directly against Redis keys, and indexed JSON fields flow into Search automatically. - **Vector:** vector search lives in a separate product, [Upstash Vector](https://upstash.com/docs/vector/overall/getstarted), with its own per-query pricing rather than the same keyspace. ## How do you connect to each from code? Upstash gives you both TCP and HTTP. For serverless runtimes, the Upstash Redis TypeScript client wraps the REST API and runs anywhere fetch works: ```tsx id="code-qyksx0o6" import { Redis } from "@upstash/redis"; // works inside Cloudflare Workers, Vercel Edge, browsers: // any runtime that can make an HTTPS request. const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, }); export async function rateLimit(ip: string, limit = 10, windowSec = 60) { const key = `rl:${ip}`; const count = await redis.incr(key); if (count === 1) await redis.expire(key, windowSec); return { allowed: count <= limit, count }; } ``` For Redis Cloud it's easiest to use ioredis or node-redis and a long-lived TCP connection with TLS: ```tsx id="code-2djsztg6" import Redis from "ioredis"; import fs from "node:fs"; // Standard Redis protocol over TCP. Needs a long-lived // connection and works in Node/containers, not edge runtimes. // Redis Cloud uses a private CA, so the CA bundle from the // console has to be loaded into the client. const redis = new Redis(process.env.REDIS_URL!, { tls: { ca: fs.readFileSync("redis-ca.pem") }, maxRetriesPerRequest: 3, }); export async function rateLimit(ip: string, limit = 10, windowSec = 60) { const key = `rl:${ip}`; const [count] = await redis .multi() .incr(key) .expire(key, windowSec, "NX") // only set TTL on first hit .exec() .then((res) => res!.map(([, v]) => v as number)); return { allowed: count <= limit, count }; } ``` For Upstash Redis Search: ```tsx id="code-n2sq1mw9" import { Redis, s } from "@upstash/redis"; const redis = Redis.fromEnv(); export async function setupAndQuery() { // create an index that auto-syncs from any JSON key matching product:* const index = await redis.search.createIndex({ name: "products", dataType: "json", prefix: "product:", schema: s.object({ name: s.string(), description: s.string(), category: s.string().noTokenize(), price: s.number(), }), }); // writing the JSON document is enough: Search indexes it automatically await redis.json.set("product:1", "$", { name: "Wireless Headphones", description: "Premium noise-cancelling, 30-hour battery", category: "electronics", price: 199.99, }); // query against the same Redis keyspace return index.query({ filter: { description: "wireless" }, }); } ``` The Redis Cloud equivalent uses RediSearch commands (FT.CREATE, FT.SEARCH) through the same ioredis connection. Both styles work; the Upstash one works better with serverless functions because it uses the same HTTPS connection as every other command. ## What are the pros and cons of Upstash and Redis Cloud? **Upstash wins:** - Scale-to-zero pricing. A database sitting idle costs $0 - Better UX across the board, easier to set-up, cleaner dashboard with a visual data browser - Easier for agents to use, type-check and understand than Redis Cloud - Works in serverless runtimes (Cloudflare Workers, Vercel, Fastly Compute) where Redis Cloud cannot connect - Fixed plans from $10/month give you unlimited commands (no per-command billing) up to the 16,000 cmd/sec ceiling ![Upstash Dashboard - Data Browser](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/IRvoIyX6OYBags1qXJVuV.png) **Redis Cloud wins:** - Full Redis Stack: search, JSON, time-series, Bloom, vector, all in one keyspace - Active-Active multi-master replication with CRDTs for multi-region writes - Predictable performance on dedicated VMs with up to 99.999% uptime SLA on Pro - Tracks Redis OSS versions closely ![Redis Cloud - Data Browser](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/lvW0otdJq03e89c9xZ__O.png) **Upstash negatives:** - No native RedisTimeSeries or RedisBloom; vector search lives in a sibling product (Upstash Vector) instead of the same keyspace. **Redis Cloud negatives:** - TLS is gated behind a paid Essentials plan, defaults to off on every new database, and requires shipping a private CA bundle with each client (see the TLS UX section). - Not usable from serverless runtimes. If you live on Cloudflare Workers, you'll need a TCP proxy or a different store. ## When should you pick Upstash vs Redis Cloud? **Pick Upstash if any of these are true:** - You're using an AI agent to write code for you - You're deploying to Vercel, Cloudflare Workers, Fastly Compute, Netlify Edge, or any runtime without persistent TCP - You want a free tier that's good for a production prototype (500K commands/month β‰ˆ 16K commands/day) **Pick Redis Cloud if any of these are true:** - You need RedisTimeSeries (downsampling, aggregation), RedisBloom (Bloom/Cuckoo/CMS/top-k), or vector + text search in one query against the same keyspace - You need Active-Active replication with CRDTs for multi-region writes - You're already self-hosting Redis OSS ## Can you migrate between them? Mostly yes. Both speak Redis 7.x command syntax for core data structures, so an RDB dump from one and a restore into the other covers caches, sessions, sorted sets, and streams. The breakages are predictable: - RedisJSON moves over directly. Upstash supports the same JSON commands against the same keys. - RediSearch indexes don't move as easily. Upstash Search uses a different (JSON-based) query syntax, so you re-create the index schema and rewrite query strings, but the data underneath stays. - RedisTimeSeries and RedisBloom have no Upstash equivalent in-keyspace. It's better to use an external time-series store. - If your app uses Redis admin commands (CLIENT LIST, CONFIG SET, etc.), Upstash restricts most of them by design. ## Which has better developer experience? To me, Upstash. **Upstash Redis setup is three steps:** create a database in the console, copy the REST URL and token, install the `@upstash/redis` client. No instance size, no AOF/RDB persistence choice, no replication topology to pick. The console shows commands/sec, data size, and a browser CLI. You can also POST toΒ [https://upstash.com/start-redis](https://upstash.com/start-redis) to get a free, agent-ready Redis database without any signup. It has a nicer dashboard, is easier for debugging and I feel more comfortable here. ![Upstash Redis Dashboard](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/ocqZHB2f3jn5hFpmm8Cwv.png) **Redis Cloud setup is heavier.** You walk through cloud + region + memory size + throughput tier + persistence policy (none, AOF every-write, AOF every-second, snapshot) + replication mode (none, in-zone, multi-AZ) + module enablement + connection limits, then it hands you a TLS endpoint for ioredis. The console adds RedisInsight (keyspace browser, slow log, profiler), ACLs, VPC peering, PrivateLink, and per-database metrics dashboards. It also logged me out every 5 minutes, which I found really annoying. ![Redis Cloud Dashboard](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/ND7hjOG6ccYjElPD66Vwa.png) If you want a Redis URL and start building, Upstash is faster. If you need to tune AOF flush policy, set up CIDR allow-lists per database, and run RedisInsight against a slow-log, Redis Cloud is more like what you'd configure on self-hosted Redis OSS. ## FAQ **Is Upstash running Redis?** Upstash runs a Redis-compatible engine written in Go. It uses the Redis protocol and supports the core commands and data structures, plus first-party JSON and a Tantivy-backed search module. Most Redis client libraries (ioredis, redis-py, go-redis, `@upstash/redis`) work with it unchanged. What's restricted: the cluster/admin command family (CLUSTER, CONFIG, DEBUG, MIGRATE, SHUTDOWN) and a few Redis Stack modules with no Upstash counterpart (time-series, Bloom filters). See the [Upstash compatibility doc](https://upstash.com/docs/redis/overall/rediscompatibility) for the current list. **Does Redis Cloud have a serverless option?** Redis Cloud Essentials is the closest thing: shared infrastructure, fixed monthly price per memory tier, no per-command billing. It's not "serverless" in the scale-to-zero sense: you pay $5/month even at zero traffic. **Which is cheaper for rate limiting?** Upstash. Rate limiting is the canonical serverless Redis workload: small payloads, intermittent traffic. The Upstash free tier (500K commands/month) covers many small apps entirely. The [@upstash/ratelimit](https://github.com/upstash/ratelimit) SDK ships sliding-window, fixed-window, and token-bucket primitives out of the box. **Can I use Redis Cloud from Cloudflare Workers?** Not directly. Workers don't allow raw TCP outbound. You can run a Hyperdrive connection or a TCP proxy, but the simpler answer is Upstash if you use Workers. **Which one supports vector search?** Both. Redis Cloud puts vector inside RediSearch on Pro (one query against the same keyspace as your text and JSON). Upstash splits it out into [Upstash Vector](https://upstash.com/docs/vector/overall/getstarted), a sibling product with its own URL, token, and per-query pricing, separate from Upstash Search and Upstash Redis. **Does Upstash have full-text search now?** Yes. [Upstash Redis Search](https://upstash.com/docs/redis/search/getting-started) is a first-party module shipped inside Upstash Redis, built on the Rust-based Tantivy engine. Writes auto-sync into the index, so you don't run a separate ETL pipeline. **Does Upstash have an SLA?** Yes, via the optional Prod Pack ($200/month per database) which adds the uptime SLA, SOC-2 reporting, PrivateLink, RBAC, and observability integrations on top of any plan. Redis Cloud Pro includes its 99.999% SLA in the base price. --- url: https://www.xadd.dev/builds/how-do-you-store-vercel-ai-sdk-chat-history-in-redis title: "How do you store Vercel AI SDK chat history in Redis?" date: 2026-05-19T10:34:04.896Z updated: 2026-05-20T20:10:11.325Z word_count: 2020 tags: ["Redis", "Vercel AI SDK", "Upstash", "Next.js", "TypeScript"] --- # How do you store Vercel AI SDK chat history in Redis? > Persist Vercel AI SDK chat history in Redis with onFinish, saveChat, loadChat, and createIdGenerator. Working AI SDK 6 + Upstash Redis code. The Vercel AI SDK keeps every message in React state, so refreshing the page clears the chat. To make a chat survive reloads, we save the messages after each turn and load them when the page mounts. Redis is perfect for this: storing new messages takes 2-3 ms and AI SDK messages are already JSON anyway. ## Key takeaways - The onFinish callback hands us the full conversation when streaming ends, so **persistence is a single 2 ms RPUSH command** - It takes **6.7 ms to load a 1000-message history**. A single LRANGE pulls the whole list back which is way below the 200-2000 ms a model takes to first token - We use the modern approach with createIdGenerator - No lost messages when the user closes the tab: a single consumeStream call drains the model on the server so the reply still saves ## Why does Vercel AI SDK chat history disappear on refresh? The useChat hook holds messages in component state. There is no persistence layer in the SDK itself. The [official persistence guide](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence) shows a persistence pattern that writes each conversation to a JSON file on disk, which is fine for a demo but isn't great the moment we deploy to serverless. Each serverless function instance (Lambda, Vercel fluid compute, etc.) has its own filesystem, and there is no shared directory to write into. We need an external store, and we need writes that complete inside the request without blocking the streamed response to the user. Redis is great at this: a single network call per turn, no schema, and the AI SDK message shape is already JSON anyway. ## Why pick Redis over Postgres for AI chat messages? I'd say use Postgres when you need to query across messages (analytics, full-text search, joins to users and orgs). For the normal use case of "save this message, load this chat", Redis is a lot faster and easier to use. ![Our chat history in the Upstash Redis data browser](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/2PoDDQmSJRbvOHTWuEz5L.png) A chat message is a deeply nested JSON object with parts, metadata, tool calls, and provider-specific fields. You read it back in exactly the shape you wrote it. You almost never query inside a message. That is the workload Redis handles best and where SQL adds friction. Here is the head-to-head: | Concern | Redis (Upstash) | Postgres | | --- | --- | --- | | Write latency per turn | 2 ms over HTTP | 20-100 ms depending on region | | Schema for the UIMessage shape | None, store as JSON | jsonb column or one row per part | | Schema migrations when AI SDK adds fields | None | ALTER TABLE or a migration job | | Append a single message | RPUSH, O(1) | INSERT plus an index update | | Load full conversation | LRANGE 0 -1 | SELECT ... ORDER BY created\_at | ## How fast is the Redis round-trip in practice? I created a fresh Upstash Redis database and ran a small benchmark from a Node.js process: 30 warm pings, 50 single-message RPUSH calls, then LRANGE reads against history sizes of 10, 50, 200, and 1000 stored UIMessage objects. Each message had an id, a role, and one text part of roughly 200 bytes (about the size of a short chat sentence). Both the writes and the JSON serialization run through the Upstash Redis SDK. ![Redis latency for PING, RPUSH, and LRANGE commands](https://contentport.s3.amazonaws.com/chat/Sw016rSl6asUNtj0EpHf3DiBcD0ot3sQ/uYeCAdDF_-s1ugr7qAdEn.png) | Operation | n | avg | p50 | p95 | | --- | --- | --- | --- | --- | | PING (REST round-trip) | 30 | 2.29 ms | 2.11 ms | 3.35 ms | | RPUSH 1 message (per turn) | 50 | 2.21 ms | 2.19 ms | 2.68 ms | | RPUSH 2 messages (user + reply) | 30 | 2.33 ms | 2.19 ms | 3.95 ms | | LRANGE full list, history = 10 | 20 | 3.13 ms | 3.18 ms | 4.32 ms | | LRANGE full list, history = 50 | 20 | 2.76 ms | 2.73 ms | 3.76 ms | | LRANGE full list, history = 200 | 20 | 3.05 ms | 3.02 ms | 3.60 ms | | LRANGE full list, history = 1000 | 20 | 6.76 ms | 6.73 ms | 8.56 ms | A typical chat turn costs about 2 ms to persist and 3 ms to read back. Even at 1000 messages, a full history load is under 9 ms at p95, well under the 200-2000 ms a model takes to return its first token. The latency of course changes with the network distance between your function and the Redis region. ## How does the AI SDK 6 onFinish callback work? The streamText function returns a result object whose toUIMessageStreamResponse method takes an onFinish callback. That callback fires once the stream is fully drained, with a payload like this: ```tsx id="code-ibc35aii" onFinish: ({ messages }: { messages: UIMessage[] }) => { // messages = previous history + new user message + assistant reply // same shape useChat uses on the client, ready to JSON.stringify }; ``` We get the full conversation including the new assistant reply, in the exact shape the client already understands. The [persistence docs](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence) phrase it as "the complete messages including the new AI response." Two things changed in AI SDK 6 that bite you if you copy older code: 1. convertToModelMessages is now async ([migration guide](https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0#async-converttomodelmessages)). You have to await it, or tool-output handling breaks. 2. useChat in v6 no longer manages the input field's state. You hold the input value in your own useState hook. The route handler that ties everything together looks like this: ```tsx id="code-yi2hyuj9" // app/api/chat/route.ts import { openai } from "@ai-sdk/openai"; import { convertToModelMessages, createIdGenerator, streamText, type UIMessage, } from "ai"; import { appendMessages, loadChat } from "@/util/chat-store"; export async function POST(req: Request) { const { message, id } = (await req.json()) as { message: UIMessage; id: string; }; const previous = await loadChat(id); const messages = [...previous, message]; const result = streamText({ model: openai("gpt-4o"), messages: await convertToModelMessages(messages), }); // drain the stream even if the client disconnects πŸ‘‡ result.consumeStream(); return result.toUIMessageStreamResponse({ originalMessages: messages, generateMessageId: createIdGenerator({ prefix: "msg", size: 16 }), onFinish: async ({ messages: finalMessages }) => { const delta = finalMessages.slice(previous.length); await appendMessages(id, delta); }, }); } ``` onFinish runs once, after the model is done, with the full message list. We only persist the delta (the new user message plus the assistant reply) because earlier messages are already in Redis. ## What does the Redis chat store look like? One Redis list per chat, with keys shaped like chat:CHATID:messages where CHATID is the conversation's unique ID. RPUSH appends new turns at the tail, LRANGE reads the whole conversation back in order. No JSON merging, no read-modify-write cycle on long chats. ```tsx id="code-h3jbp62y" // util/chat-store.ts import { Redis } from "@upstash/redis"; import { generateId, type UIMessage } from "ai"; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, }); const key = (chatId: string) => `chat:${chatId}:messages`; export async function createChat(): Promise { // no write needed yet; an empty list means "no messages" return generateId(); } export async function loadChat(chatId: string): Promise { return redis.lrange(key(chatId), 0, -1); } export async function appendMessages( chatId: string, messages: UIMessage[], ): Promise { if (messages.length === 0) return; await redis.rpush(key(chatId), ...messages); } ``` The Upstash Redis SDK JSON-encodes any non-primitive on write and decodes on read, so we don't need to worry about serialization at any point. If you also store the chat owner, give yourself a second key like chat:CHATID:meta as a Redis hash with the user ID, the created-at timestamp, and a title you generate from the first user message. ## How do you send only the last message instead of the whole history? By default, the useChat hook posts the entire messages array on every request. After 50 turns that is a lot of redundant payload, and you are paying for tokens the server is going to discard anyway because it already has them in Redis. The DefaultChatTransport class accepts a prepareSendMessagesRequest hook that lets you trim the body down to just the new message: ```tsx id="code-tntzu363" // ui/chat.tsx "use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type UIMessage } from "ai"; import { useState } from "react"; interface ChatProps { id: string; initialMessages: UIMessage[]; } export function Chat({ id, initialMessages }: ChatProps) { const [input, setInput] = useState(""); const { sendMessage, messages } = useChat({ id, messages: initialMessages, transport: new DefaultChatTransport({ api: "/api/chat", prepareSendMessagesRequest({ messages, id }) { return { body: { message: messages[messages.length - 1], id } }; }, }), }); return (
{messages.map((m) => (
{m.role}:{" "} {m.parts.map((p, i) => p.type === "text" ? {p.text} : null, )}
))}
{ e.preventDefault(); if (!input.trim()) return; sendMessage({ text: input }); setInput(""); }} > setInput(e.target.value)} />
); } ``` The request body now contains only the latest message instead of the full conversation. The server pulls history from Redis and never trusts the client for older turns, which also stops a tampered client from rewriting earlier turns. ## How do you load the initial chat history on first paint? Because we trimmed the request to just the latest message, the client starts with whatever array you pass into useChat as initial messages. In Next.js App Router, the cleanest way is to read Redis in a server component and hand the array to the chat component: ```tsx id="code-oo9rves6" // app/chat/[id]/page.tsx import { loadChat } from "@/util/chat-store"; import { Chat } from "@/ui/chat"; export default async function Page(props: { params: Promise<{ id: string }>; }) { const { id } = await props.params; const initialMessages = await loadChat(id); return ; } ``` One Redis call, no client-side loading state, the chat renders with history already on screen. In a plain React or SPA setup, do the same call from a React Query or useEffect hook and feed the result into useChat. ## Why do we need server-side message IDs? By default, useChat generates message IDs on the client. That works for ephemeral chats but breaks the moment you persist: open the same chat in two tabs and you get colliding IDs, or refresh and the assistant message you saved comes back with a different ID than the one in state. The AI SDK docs are explicit: "for persistence, you need server-side generated IDs to ensure consistency across sessions and prevent ID conflicts when messages are stored and retrieved" ([source](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#client-side-vs-server-side-id-generation)). The supported helper is createIdGenerator. Configure it with a short prefix (we use "msg") and a 16-character size, then pass it as the generateMessageId option on toUIMessageStreamResponse. Every assistant message coming out of the stream now has an ID you control, and the IDs in Redis match the IDs useChat sees on the client. ## What happens if the user closes the tab mid-stream? By default, streamText applies backpressure: if the client stops reading, the model stops generating. That saves tokens, but it also means onFinish never fires and you lose the half-finished reply. The fix is to call consumeStream on the result, without awaiting it, before you return the response. It drains the stream on the server regardless of what the client is doing, so the model runs to completion and onFinish writes the full assistant reply to Redis even if the client has already closed the connection. From the [persistence guide](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#handling-client-disconnects): "consumeStream effectively removes the backpressure, meaning that the result is stored even when the client has already disconnected." This costs you tokens for replies nobody reads. If that matters, gate it: only drain the stream for paid users, or only when the conversation has more than N turns invested. For mid-stream resume (where the user comes back and sees the response continue from where it left off), look at [Chatbot Resume Streams](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams), which uses a separate stream ID stored in Redis. ## What about tools, metadata, and schema drift? The AI SDK ships a validateUIMessages helper that re-checks stored messages against your current tool and metadata schemas. Messages saved a month ago might reference a tool you renamed, or use a metadata field your Zod schema no longer accepts. If you feed those into the model conversion step unchecked, the model call throws partway in. Validate on read: ```tsx id="code-u2u67kke" import { validateUIMessages, TypeValidationError } from "ai"; const previous = await loadChat(id); let validated; try { validated = await validateUIMessages({ messages: [...previous, message], tools, metadataSchema, }); } catch (err) { if (err instanceof TypeValidationError) { // drop or migrate the offending messages, then retry validated = [message]; } else { throw err; } } ``` For tool-heavy agents, this is the most important guard between Redis and the model. A bad schema in storage should degrade to a fresh conversation, not a 500. ## When should you not use Redis for chat history? Pick a SQL database when any of these are true: - You need to search across all messages from all users (full-text or vector search on the message body itself). - You report on conversations (token usage by org, average turns per user, retention). - You have hard compliance requirements that need row-level audit trails. A common pattern: Redis as the hot store the chat reads and writes on every turn, plus an async job that copies finalized conversations into Postgres or a warehouse for analytics queries. The other failure mode is enormous individual messages (long tool outputs, base64-encoded images embedded in message parts). Redis charges for memory and for request size, so for that case, store the heavy payload in object storage and keep only a reference in the message. ## FAQ **Does this work outside Next.js?** Yes. The streamText and toUIMessageStreamResponse APIs and the Upstash Redis client are framework-agnostic. The route handler shown here is a plain Web request and response handler, so it drops into Hono, Remix, SvelteKit, or a raw Node server. **Can I use ioredis or node-redis instead of the @upstash/redis client?** Yes. Upstash speaks both protocols: the @upstash/redis client uses HTTP and works inside Vercel Edge Functions and Cloudflare Workers, where outbound TCP is restricted. In a regular Node.js deployment, ioredis or node-redis connect to the same Upstash database over TCP and perform identically. Pick the client your runtime supports. **How do I expire old chats?** Call EXPIRE on the key after each RPUSH, or run a daily job that deletes keys older than a cutoff. For per-user quotas, keep an index list per user (one Redis list per user, containing chat IDs) and trim it with LTRIM when it grows past your retention limit. **Will this handle 10 million messages?** A single Redis list scales to that, but the read cost is O(N). For very long single conversations, paginate the LRANGE call with start and stop indexes and only hydrate the last 50-100 turns on the client. The model only needs the recent window plus whatever summary you have already produced. --- url: https://www.xadd.dev/glossary/rate-limiting-algorithms-compared-fixed-window-sliding-window-token-bucket-leaky-bucket title: "Rate Limiting Algorithms Compared: Fixed Window, Sliding Window, Token Bucket, Leaky Bucket" date: 2026-05-19T09:44:16.469Z updated: 2026-05-19T09:47:41.377Z word_count: 2235 tags: ["Redis", "Rate Limiting", "Upstash", "Algorithms", "API"] --- # Rate Limiting Algorithms Compared: Fixed Window, Sliding Window, Token Bucket, Leaky Bucket > Fixed window, sliding window, token bucket, and leaky bucket compared with Redis Lua scripts, measured boundary-burst numbers, and when each algorithm wins. **TL;DR:** Rate limiting decides which requests get through and which get a 429. The five practical algorithms are fixed window, sliding window log, sliding window counter, token bucket, and leaky bucket. For most public APIs, pick sliding window counter (best fairness-to-cost ratio) or token bucket (when you want to allow short bursts). Avoid fixed window for anything user-facing because it lets a client send 2x the limit across a window boundary. ## Key takeaways - A fixed-window limiter set to 10 requests per second allowed 20 requests in a 1ms span in my sandbox test, while sliding window stayed at 10 (as it should!). - Sliding window counter is the same Redis cost as fixed window plus one extra `GET` per request, and it removes the boundary problem. - Token bucket is the only common algorithm that intentionally allows bursts above the average rate, which is usually what API users want. - Leaky bucket is the only one that enforces a strict output rate. It's great for limiting traffic to a downstream service rather than for fairness between users. - The [`@upstash/ratelimit`](https://github.com/upstash/ratelimit-js) library ships fixed window, sliding window, and token bucket as Lua scripts that work out of the box. ## The best rate limiting algorithms - an overview | Algorithm | Redis ops per request | Memory per identity | Allows bursts? | Boundary burst risk | Best use | | --- | --- | --- | --- | --- | --- | | Fixed window | 1 (`INCR` + `EXPIRE` on first) | 1 counter | No (but leaks at boundary) | High | Cheap internal limits | | Sliding window log | 3 (`ZREMRANGEBYSCORE`, `ZADD`, `ZCARD`) | One sorted-set entry per request | No | None | Strict fairness, low volume | | Sliding window counter | 2 `GET`s + `INCR` | 2 counters | No | None (weighted approximation) | Public APIs at scale | | Token bucket | 1 `HMGET` + `HSET` + `PEXPIRE` | 1 hash with 2 fields | Yes, up to `maxTokens` | None | Developer APIs, SaaS quotas | | Leaky bucket | 1 hash read/write | 1 hash with 2 fields | No (strict rate) | None | Outbound traffic shaping | ## What problem does a rate limiter solve? A rate limiter answers one question: given an identity (user ID, API key, IP) and a request, should I allow it now? The algorithm encodes three sub-decisions: how time is sliced, how usage is recorded, and what to do when the budget is gone. Those three choices drive every tradeoff in this article. The reason there are five algorithms instead of one is that "fairness" means different things. A login endpoint wants to reject a credential-stuffing attack at the second attempt. A public REST API wants to let a customer's batch job burst through 100 calls and then slow down. A queue producer wants to feed a downstream worker at exactly its drain rate. No single counter satisfies all three. For Redis-backed limiters specifically, there is a fourth concern: the algorithm has to be implementable as a single round trip. Two-step "read then conditionally write" logic races between processes. Every production implementation collapses the check into one Lua script, which is why the [Upstash ratelimit Lua scripts](https://github.com/upstash/ratelimit-js/blob/main/src/lua-scripts/single.ts) all return both the decision and the remaining budget in one call. ## How does the fixed window algorithm work, and why does it leak? Fixed window divides time into equal intervals (say, 1-second buckets). For each identity, you `INCR` a counter keyed by the current bucket. If the counter exceeds the limit, reject. When the next bucket starts, the counter resets. In Redis, for example, that whole algorithm fits in two commands per request: an `INCR` to bump the counter, and a `PEXPIRE` on the first hit so the key cleans itself up. As a Lua script: ```tsx id="code-bq6o5dxg" -- KEYS[1] = "rl:fixed:user-123:1700000000" -- ARGV[1] = limit, ARGV[2] = window_ms local count = redis.call("INCR", KEYS[1]) if count == 1 then redis.call("PEXPIRE", KEYS[1], ARGV[2]) end if count > tonumber(ARGV[1]) then return 0 end return 1 ``` This is the cheapest possible limiter. It is also the most exploitable. A client that knows the window boundary can fire `limit` requests at `T - 1ms` and another `limit` requests at `T`, and both windows accept them. In a sandbox test I ran with a 10 req/sec limit, this technique allowed all 20 requests through in about 1 millisecond: ```tsx id="code-xyaxj4n1" == Boundary burst: 10 req limit per 1s window == Fixed window allowed: 20 / 20 Sliding window counter allowed: 10 / 20 Sliding window log allowed: 10 / 20 ``` My numbers above came from a pure-JS reimplementation of the [Upstash sliding-window Lua script](https://github.com/upstash/ratelimit-js/blob/main/src/lua-scripts/single.ts). Use fixed window for internal jobs where the identity is trusted, or as a coarse safety net behind a more accurate primary limiter. Do not use it on a login endpoint. ## How does the sliding window log algorithm fix the boundary problem? Sliding window log stores a timestamp for every request and counts how many fall inside the last `window_ms`. In Redis, that is a sorted set keyed by the identity, with the timestamp as both score and member: ```tsx id="code-mqeazux3" -- KEYS[1] = "rl:log:user-123" -- ARGV[1] = now_ms, ARGV[2] = window_ms, ARGV[3] = limit redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, ARGV[1] - ARGV[2]) local count = redis.call("ZCARD", KEYS[1]) if count >= tonumber(ARGV[3]) then return 0 end redis.call("ZADD", KEYS[1], ARGV[1], ARGV[1]) redis.call("PEXPIRE", KEYS[1], ARGV[2]) return 1 ``` This is the most accurate algorithm. At every moment, you are evaluating the literal last N seconds of traffic. There is no approximation and no boundary effect. The cost is linear memory per identity per active second. A user firing 1,000 requests per second at a 60-second window holds a 60,000-entry sorted set in Redis. During an abuse spike, memory blows up exactly when you can least afford it. The Stripe engineering team [moved away from sliding log to GCRA](https://stripe.com/blog/rate-limiters) (a token-bucket variant) for this reason. Use sliding window log only when the limit is small (single-digit requests per minute) and fairness has to be exact, like password reset or 2FA endpoints. ## How does the sliding window counter algorithm work? Sliding window counter is the practical compromise. Instead of storing every timestamp, you keep two counters: one for the current fixed window and one for the previous fixed window. You then estimate the count over the last `window_ms` by weighting the previous window by how much of the sliding window overlaps it. The formula from the [Upstash sliding-window Lua script](https://github.com/upstash/ratelimit-js/blob/main/src/lua-scripts/single.ts) is: ```tsx id="code-156g9uvv" pct_in_current = (now % window_ms) / window_ms weighted_previous = floor((1 - pct_in_current) * requests_in_previous_window) total = weighted_previous + requests_in_current_window allow = total < limit ``` If you are 25% into the current window, you count 75% of the previous window plus 100% of the current. This assumes traffic in the previous window was uniform, so the result is an approximation rather than a strict measure. In exchange, you get O(1) memory and 2-3 Redis commands per request. In the boundary test above, sliding window counter caught exactly the same bursts that sliding window log did (10 of 20 allowed). The cases where it disagrees with the log are clustered-burst patterns inside the previous window, and the disagreement is bounded by the cluster size. For public APIs at any reasonable scale, this is the default. It is what [`Ratelimit.slidingWindow`](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms#sliding-window) ships in `@upstash/ratelimit`. One operational note: the [sliding window in a multi-region setup generates a large number of Redis commands](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms#usage-2). If you replicate across regions, fall back to fixed window for cross-region coordination. ## How does the token bucket algorithm allow bursts without breaking the average? Token bucket models capacity as tokens that refill over time. Each identity has three numbers: max capacity, refill rate, and current token count. A request consumes one token. If the bucket is empty, the request is rejected. Idle clients accumulate tokens up to the cap, so a customer that has been quiet for 10 seconds can burst through 10 seconds of saved capacity. The Redis representation is a hash with `refilledAt` and `tokens` fields. The [Upstash token-bucket Lua script](https://github.com/upstash/ratelimit-js/blob/main/src/lua-scripts/single.ts) lazy-refills on read: ```tsx id="code-orm95p9o" -- bucket = HMGET(key, "refilledAt", "tokens") if now >= refilledAt + interval then local refills = math.floor((now - refilledAt) / interval) tokens = math.min(maxTokens, tokens + refills * refillRate) refilledAt = refilledAt + refills * interval end if tokens == 0 then return reject end tokens = tokens - 1 -- HSET(key, "refilledAt", refilledAt, "tokens", tokens) ``` In the sandbox, a bucket with `maxTokens=10` and `refillRate=5` per second allowed exactly 10 of an initial 15-request burst, then 5 of the next 10 requests one second later, matching the spec: ```tsx id="code-6xtfxltt" == Token bucket: max 10, refill 5/sec == Initial burst at t=0: 10 / 15 allowed After 1s refill: 5 / 10 allowed ``` Token bucket is the right default for any API where users have unpredictable batch behavior: webhooks, AI inference, file uploads. The catch is that it is the most expensive algorithm computationally (a hash get plus a refill calculation plus a hash set), and it is [not supported by `MultiRegionRatelimit`](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms#usage-3) in `@upstash/ratelimit` as of this writing. ## How does the leaky bucket algorithm differ from token bucket? Leaky bucket is a queue with a fixed drain rate. Incoming requests enter the bucket, the bucket drains at a constant rate, and if the bucket is full when a request arrives, the request is rejected. There is no burst capacity, because the output rate is hard-capped at the drain rate. You can implement it as a token bucket where the bucket capacity equals the drain rate, but the conceptual model is reversed. Token bucket protects the limit by counting tokens left. Leaky bucket protects the *downstream* by counting requests in flight: ```tsx id="code-i413w4h5" -- bucket = HMGET(key, "level", "updatedAt") local drained = (now - updatedAt) * leak_per_ms level = math.max(0, level - drained) if level + 1 > capacity then return reject end level = level + 1 -- HSET(key, "level", level, "updatedAt", now) ``` In the sandbox, a leaky bucket with capacity 5 draining 1/sec allowed 5 of an initial 10-request burst, then 3 of 10 more after a 3-second pause (because 3 slots had drained): ```tsx id="code-uz1k6jia" == Leaky bucket: capacity 5, drain 1 req/sec == Burst of 10 at t=0: 5 allowed After 3s of drain: 3 / 10 allowed ``` Use leaky bucket when something downstream cannot tolerate bursts: a payment processor with strict TPS limits, an outbound email provider, a scraper that has to stay polite. Do not use it for per-user API fairness; it will punish a customer for sending two requests in the same millisecond even if their long-term rate is fine. ## What does this look like with @upstash/ratelimit in code? The library wraps all three production-grade algorithms behind one `limit()` call. The choice happens in the constructor: ```tsx id="code-ftq786au" import { Redis } from "@upstash/redis"; import { Ratelimit } from "@upstash/ratelimit"; const redis = Redis.fromEnv(); // 10 requests per 10 seconds, weighted sliding window export const slidingWindow = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, "10 s"), analytics: true, prefix: "rl:sliding", }); // 5 tokens refilled every 10s, max burst of 10 export const tokenBucket = new Ratelimit({ redis, limiter: Ratelimit.tokenBucket(5, "10 s", 10), analytics: true, prefix: "rl:tb", }); ``` In a Next.js middleware, the call site is identical for any algorithm: ```tsx id="code-8vb88khy" import { Redis } from "@upstash/redis"; import { Ratelimit } from "@upstash/ratelimit"; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(100, "1 m"), analytics: true, }); export async function middleware(req: Request) { const id = req.headers.get("x-forwarded-for") ?? "anon"; const { success, limit, remaining, reset } = await ratelimit.limit(id); if (!success) { return new Response( JSON.stringify({ error: "Too many requests", limit, remaining, reset }), { status: 429, headers: { "content-type": "application/json" } }, ); } return new Response(JSON.stringify({ ok: true, remaining })); } ``` The `reset` field is a Unix timestamp in milliseconds when the limit refreshes. For sliding window it points to the start of the next fixed window rather than an exact rolling reset. ## Which algorithm should I pick? Pick by the failure mode you most want to avoid. - **Sliding window counter.** Default for public REST APIs with a long-tail user base. It removes the boundary leak, costs about the same as fixed window, and the approximation error is invisible in practice. Pair it with per-identity keys (user ID or API key). - **Token bucket.** Default for paid APIs with burst-tolerant customers. AI inference endpoints, file upload endpoints, webhook fan-outs. Set `maxTokens` to 2-5x your steady rate to give honest customers headroom. - **Sliding window log.** Reach for it only when fairness has to be exact and request volumes are low: password reset, 2FA, OAuth code exchange. The memory cost makes it a liability anywhere else. - **Leaky bucket.** Use it outbound, not inbound. Protect a downstream provider, smooth a queue producer, throttle a scraper. - **Fixed window.** Cheap internal kill switch. Behind a CDN, behind another limiter, behind anything you trust to absorb the 2x boundary leak. The honest version of "which is best" is that you usually want two layers: a coarse limiter at the edge (fixed window per IP) and a fair limiter inside the app (sliding window counter or token bucket per user). Each catches a different abuse pattern. ## Why does Redis dominate rate limiting implementations? Three reasons. First, every algorithm above fits in a single Lua script of under 30 lines, and `EVAL` is atomic in Redis, which eliminates the read-then-write race. Second, `INCR`, `EXPIRE`, sorted sets, and hashes are exactly the primitives these algorithms need, with no extra indirection. Third, Redis sub-millisecond latency means the limiter adds noise-level overhead to a typical API request. The serverless variant adds a fourth: services like [Upstash Redis expose Redis over HTTP](https://upstash.com/docs/redis/features/restapi), so AWS Lambda, Vercel Edge, and Cloudflare Workers can rate-limit without holding a TCP connection pool. That is why [`@upstash/ratelimit`](https://github.com/upstash/ratelimit-js) is shipped as a connectionless library. ## FAQ **Can I combine multiple algorithms?** Yes, and you usually should. A common pattern is fixed window per IP at the edge (cheap, stops floods) plus sliding window counter per user inside the app (fair, stops abuse from a single account). They guard different threats. **What about GCRA?** Generic Cell Rate Algorithm is a clever single-timestamp variant of leaky/token bucket popularized by [Stripe's rate limiter post](https://stripe.com/blog/rate-limiters). It behaves roughly the same as token bucket, with a smaller memory footprint (one timestamp instead of a hash). If you are writing the limiter from scratch and want minimum state, GCRA is worth a look. If you are using `@upstash/ratelimit`, token bucket gets you the same behavior. **Does the sliding window counter ever disagree with the log?** Yes, when traffic in the previous window was bursty rather than uniform. The disagreement is bounded by how much of the previous window's burst landed in the still-counted portion. For most APIs the error is under a few percent and it errs on the side of allowing the request, which is the right default for paying customers. **How do I rate-limit across regions?** Cross-region rate limiting is mostly a consistency problem; the algorithm choice matters less than how state is shared. The Upstash docs are explicit that [sliding window across regions multiplies Redis commands](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms#usage-2) and that token bucket is unsupported in multi-region. For most multi-region setups, the right answer is to limit per region with a single shared limit value and accept that a coordinated attacker can briefly exceed the global limit. If you need strict global, route each identity to a single home region. **What HTTP status code should I return?** `429 Too Many Requests`, with a `Retry-After` header set to the seconds until reset. The `Ratelimit` response includes a `reset` timestamp you can convert. --- url: https://www.xadd.dev/builds/how-do-you-use-redis-as-a-session-store-2026-guide title: "Using Redis as a Session Store (2026 Guide)" date: 2026-05-18T12:45:08.219Z updated: 2026-05-19T13:58:22.261Z word_count: 2088 tags: ["Redis", "Sessions", "Authentication", "Upstash", "Next.js"] --- # Using Redis as a Session Store (2026 Guide) > How to use Redis as a session store: opaque cookie IDs, sliding TTLs, hash-vs-string layout, global revocation, and Redis 7.4 HEXPIRE, with verified @upstash/redis code. Every web app with logins needs to answer two questions on every request: "who is this user?" and "are they still allowed to be here?" Redis answers both in well under a millisecond, expires stale sessions on its own, and lets us invalidate sessions with a single command. In this article I cover the architectural layout, sliding plus absolute TTLs, global revocation, the new HEXPIRE option in Redis 7.4, and where JWTs make sense instead. **TL;DR.** We store each session as one key in Redis with a server-generated ID in a `HttpOnly; Secure; SameSite=Lax` cookie. We use `SET key json EX ` for simple payloads, or HSET plus EXPIRE when we need partial updates. We refresh the TTL on each request to get a sliding window, and track a `user::sids` set so we can revoke sessions at logout or password reset. ## Key takeaways - A Redis GET for a 200-byte session payload runs in roughly 0.1 ms on a local instance, about 85% faster than the same lookup against a Postgres unlogged table at 0.679 ms ([benchmark](https://medium.com/redis-with-raphael-de-lio/can-postgres-replace-redis-as-a-cache-f6cba13386dc)). - Use a 256-bit random session ID from a CSPRNG, hash it with SHA-256 before using it as the Redis key, and store the cookie value separately. A leaked RDB snapshot then doesn't hand attackers live session IDs. - One `SET ... EX` per write is one round trip; [SETEX has been deprecated since Redis 2.6.12](https://redis.io/docs/latest/commands/setex/) in favor of exactly that. HSET plus EXPIRE on the same key is also one round trip when pipelined. - Redis 7.4 added [HEXPIRE](https://redis.io/docs/latest/commands/hexpire/) for per-field TTLs on hashes, so you can finally expire individual session attributes without expiring the whole record. - JWTs are not a session store. If you need server-side revocation, you need a session store, and Redis is the cheapest place to put it. ## Why use Redis as a session store at all? Sessions are short-lived, frequently read, written on almost every request, and worthless after a few minutes of inactivity. That workload maps directly onto Redis: in-memory access, primary-key lookups by opaque ID, and a built-in EXPIRE that removes garbage without a cron job. The latency gap is what makes this a non-debate. On the [Postgres-vs-Redis caching benchmark by Raphael De Lio](https://medium.com/redis-with-raphael-de-lio/can-postgres-replace-redis-as-a-cache-f6cba13386dc), Redis hit 0.095 ms per read versus 0.679 ms for an unlogged Postgres table. Auth middleware runs on every protected request, so a 0.5 ms saving per page load compounds: a page that fans out to five backend services saves 2.5 ms before any real work happens. Postgres can absolutely store sessions. It just charges you a connection, a query plan, and a vacuum cycle for what should be a 200-byte hash lookup. The other reason teams pick Redis is operational: a session table on Postgres is a bloat magnet because rows die every few minutes. Redis evicts on its own. ## How should I structure the session key and payload? Pick one key per session and put the session ID in the key. Two patterns work; choose one and don't mix: | Pattern | Command | When to use | | --- | --- | --- | | String + JSON | `SET sess: EX 1800` | Default. Payload is small (under 1 KB), you always read the whole thing. | | Hash | `HSET sess: field val ...` + `EXPIRE sess: 1800` | You frequently update one field (e.g. `lastSeenAt`) without rewriting the rest. | The maintainer of `connect-redis`, TJ Holowaychuk, [closed a 2012 issue asking the same thing](https://github.com/tj/connect-redis/issues/57) with the same answer: hash-per-session works for partial updates, but in classic Redis you can't expire individual fields, so the whole session expires together. That changed in Redis 7.4 with [HEXPIRE](https://redis.io/docs/latest/commands/hexpire/), but most managed providers haven't surfaced it yet, and most apps don't need it. Salesforce reported [up to a 50% memory reduction by migrating a large cache from sets to hashes](https://engineering.salesforce.com/using-redis-hash-instead-of-set-to-reduce-cache-size-and-operating-costs-2a1f7b847ded/). That mostly matters if you have millions of small sessions, because the compact listpack encoding only kicks in below `hash-max-listpack-entries` (512 in upstream Redis, 128 on ElastiCache) and `hash-max-listpack-value` (64 bytes). Past those thresholds the hash promotes to a full hashtable and the memory win evaporates. Always hash the cookie value before using it as the key. If `sid` is `g7b...`, store under `sess:sha256(g7b...)`. A BGSAVE dump is then useless for session hijacking. ## What does the code look like? Here is the full pattern with Upstash Redis. The client serializes objects to JSON automatically and parses them back on read. ```tsx id="code-zlwnf8p7" import { Redis } from "@upstash/redis"; import { randomBytes, createHash } from "node:crypto"; export type SessionData = { userId: string; email: string; csrfToken: string; createdAt: number; }; const TTL_SECONDS = 60 * 60 * 24 * 14; // 14 days absolute const SLIDING_SECONDS = 60 * 30; // 30 min idle window const sidToKey = (sid: string) => `sess:${createHash("sha256").update(sid).digest("hex")}`; export class SessionStore { constructor(private redis: Redis) {} newSid(): string { // 32 bytes = 256 bits of entropy. base64url is cookie-safe. return randomBytes(32).toString("base64url"); } async create(sid: string, data: SessionData) { await this.redis.set(sidToKey(sid), data, { ex: TTL_SECONDS }); } async read(sid: string): Promise { const key = sidToKey(sid); // GET + bump the TTL in one pipelined round trip. const p = this.redis.pipeline(); p.get(key); p.expire(key, SLIDING_SECONDS); const [data] = await p.exec<[SessionData | null, number]>(); return data ?? null; } async destroy(sid: string) { await this.redis.del(sidToKey(sid)); } } ``` Two things worth highlighting. First, `SET ... EX` is atomic. There is no window where the key exists without a TTL, which is the classic foot-gun of doing SET followed by EXPIRE in separate calls. Second, the read path pipelines GET and EXPIRE so sliding renewal costs one network round trip, not two. Over Upstash's HTTP REST interface that matters more than with a TCP-pipelined client, because each HTTP request crosses the public internet. ## How do sliding and absolute TTLs work together? Sliding TTL alone is dangerous: an active attacker keeps the session alive forever. Absolute TTL alone is annoying: users get logged out mid-task. So let's combine them. We store two timestamps in the payload: `createdAt` (set once) and we treat the Redis TTL as the idle window. On every request, before calling EXPIRE, we check `Date.now() - createdAt < MAX_LIFETIME`. If the absolute lifetime has passed, we delete the session and force the user to login again. ```tsx id="code-huk9bseu" async read(sid: string): Promise { const key = sidToKey(sid); const data = await this.redis.get(key); if (!data) return null; const MAX_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; if (Date.now() - data.createdAt > MAX_LIFETIME_MS) { await this.redis.del(key); return null; } await this.redis.expire(key, 60 * 30); // sliding idle window return data; } ``` Redis's TTL precision is millisecond-level, and EXPIRE resets the timer on every call. The active expiration cycle runs 10 times per second by default and probes \~20 random keys per cycle, so cleanup pressure is bounded regardless of how many sessions exist. ## How do I revoke all sessions for a user? This is really useful for the "log me out everywhere" feature, like a password reset or account compromise. We track a `user::sids` set alongside each session: ```tsx id="code-b3h8yskh" async track(userId: string, sid: string) { await this.redis.sadd(`user:${userId}:sids`, sid); } async revokeAll(userId: string): Promise { const sids = await this.redis.smembers(`user:${userId}:sids`); if (sids.length === 0) return 0; const keys = sids.map(sidToKey); const [deleted] = await Promise.all([ this.redis.del(...keys), this.redis.del(`user:${userId}:sids`), ]); return deleted; } ``` The set entries are the raw session IDs (not the hashed keys), because the set itself isn't an attacker target. It lives next to the sessions it indexes. Give the user-sids set a TTL slightly longer than the longest possible session so abandoned sets don't accumulate. For password resets, also bump a `user::sessionEpoch` counter and embed the epoch into each session payload at creation time. On read, compare; if the session's epoch is older than the user's current epoch, treat it as revoked. This gives you O(1) global invalidation without scanning the set. ## Redis vs JWT for sessions: when does each win? | Concern | Redis session | JWT in cookie | | --- | --- | --- | | Read latency | \~0.1–1 ms (network bound) | 0 ms (in-process verify) | | Revocation | DEL one key | Requires a blocklist (you just reinvented sessions) | | Payload size on the wire | \~40 byte cookie | 500–2000 byte cookie on every request | | Rotating user roles/perms | Reflected next request | Stale until token expiry | | Cross-service auth without shared DB | Awkward | Strong fit | | Failure mode if store is down | All auth fails | Auth keeps working until tokens expire | JWTs are useful when you cannot share a session store: third-party API auth, service-to-service tokens, mobile clients hitting many independent backends. They're a bad fit for a web app's primary session cookie. The moment you need to [revoke one](https://www.linkedin.com/posts/sarah-nzeshi-bb39a4268_server-side-sessions-with-redis-when-control-activity-7413831647248392192-qXCH) (compromised account, terminated employee, role downgrade) you bolt a blocklist onto Redis and now you have both the JWT verification overhead and the Redis lookup. If you're shipping a Next.js or Express app today: server-side sessions in Redis, opaque cookie. If you have a microservices fleet without a shared store: short-lived JWTs (5–15 min) for service-to-service, Redis sessions for the user-facing edge. ## Does Redis 7.4's hash field expiration change the design? Yes, for one specific case. Before 7.4, the only way to expire `lastSeenAt` separately from `userId` was to put them in different keys. With [HEXPIRE](https://redis.io/docs/latest/commands/hexpire/) you can set per-field TTLs on a single hash. The implementation runs an active expiration cycle similar to keyspace EXPIRE; a [published benchmark for the feature](https://redis.io/blog/hash-field-expiration-architecture-and-benchmarks/) on an `m6i.large` AWS instance reported releasing 10 million expired fields in roughly 30 seconds, with throughput on a concurrent workload dropping \~18.8% during the burn-down. Per-field expiration metadata is around 20 bytes. For sessions specifically, HFE lets you do things like keep a long-lived `userId` field, a 30-minute `csrfToken` field that rotates without touching the rest, and a 5-minute `mfaChallenge` field that auto-clears. That's nicer than juggling three keys. Check whether your managed provider exposes HEXPIRE before designing around it; feature parity lags the open-source release. ## How do I plug this into Express or Next.js? For **Express**, we can use [`connect-redis`](https://github.com/tj/connect-redis) with `express-session`. The Upstash [Cloud Run sessions tutorial](https://upstash.com/docs/redis/tutorials/cloud_run_sessions) walks through the wiring end to end. Behind the scenes, `connect-redis` stores each session as a string keyed by `sess:` with `EX` set to the configured `cookie.maxAge`. The serialization is `JSON.stringify` of `req.session`, so anything you assign to `req.session` becomes part of the payload. For **Next.js App Router**, we have two good choices: 1. [`iron-session`](https://github.com/vvo/iron-session): stateless encrypted cookies. It's ok for small payloads and no revocation but not great if we want to later add a "log out everywhere." 2. A thin Redis wrapper like the `SessionStore` above, called from Route Handlers and Server Actions. Set the session cookie with `cookies().set("sid", sid, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 60*60*24*14 })`, read it on every protected request, look up Redis. A common mistake on Vercel and other serverless runtimes is to instantiate the Redis client per request. With the `@upstash/redis` HTTP client that's cheap, because each call is a stateless REST request. With a TCP client like `ioredis` (which also works with Upstash) every cold function pays for a fresh TLS handshake before the first command can run, so it's better to use the HTTP client on serverless and keep `ioredis` for long-running servers. Either way, we instantiate once at module scope, e.g. `Redis.fromEnv()`. ## What can go wrong in production? - **No TTL on the key.** Someone uses SET without an EX argument and you eventually fill memory with abandoned sessions. Always go through one helper that enforces it. - **Storing the cookie value as the key.** A snapshot leak (or a misconfigured replica) becomes a session hijacking event. Hash the cookie, store under the hash. - **Sliding TTL without a ceiling.** An attacker who steals a cookie keeps it alive indefinitely. Pair sliding with an absolute `createdAt` check. - **Hot single key.** Heavy admin accounts with a giant session payload hit the same key thousands of times per second. Split read-mostly data (permissions, tenant config) into a separate cache key with its own TTL. - **Eviction policy set to `allkeys-lru` on a shared cache+sessions instance.** Active users get logged out under memory pressure. Either run a dedicated instance for sessions or use `volatile-ttl` so sessions with explicit TTLs are evicted last. For Upstash specifically, sessions are a near-ideal workload: small payloads, predictable TTL, request-priced. On serverless runtimes the HTTP REST client removes connection-pooling that's not great for people running TCP clients on Lambda. On long-running servers a TCP client like `ioredis` works against the same Upstash database with the same performance you'd get from any managed Redis. ## When should I not use Redis for sessions? - You need session payloads larger than a few KB and durable across a full Redis outage. Use Postgres (with a partial index on `expires_at`) or DynamoDB with TTL. - You're building a fully offline-first mobile app where the device is the source of truth. Sessions are the wrong model; use signed tokens with refresh. - You have hard regulatory requirements that mandate session data persistence with point-in-time recovery beyond what your Redis provider offers. Upstash, ElastiCache, and Redis Enterprise all offer durability, but the bar varies, so check before committing. For most web apps with logins, sliding sessions, and a "log out everywhere" button, `SET sess: EX ` is all the architecture we need. Everything else in this article is either how to make that one command safer (hash the cookie, combine sliding plus absolute TTL) or how to extend it (per-user revocation set, hash-per-session with HEXPIRE). --- url: https://www.xadd.dev/builds/how-do-i-add-rate-limiting-to-a-nextjs-app-with-upstash-redis-2026 title: "How do I add rate limiting to a Next.js app with Upstash Redis (2026)?" date: 2026-05-18T11:39:15.801Z updated: 2026-05-18T11:46:09.752Z word_count: 2157 tags: ["Redis", "Upstash", "Next.js", "Rate Limiting", "Serverless"] --- # How do I add rate limiting to a Next.js app with Upstash Redis (2026)? > How to rate limit a Next.js 16 app with and Upstash Redis: sliding window vs fixed window, proxy.ts vs Route Handlers, deny lists, costs. **TL;DR:** We install the Upstash Ratelimit and Redis SDKs, create a sliding-window limiter at module scope, and call it from a `proxy.ts` file (Next.js 16's replacement for `middleware.ts`) or directly inside Route Handlers and Server Actions. We identify callers by user ID when authenticated and IP otherwise, set a 1-second timeout to fail open on Redis hiccups, and flush the SDK's pending analytics writes after the response is sent. ## Key takeaways - Upstash Ratelimit v2.0.8 (released January 2026) added [global dynamic limits](https://upstash.com/docs/redis/sdks/ratelimit-ts/features#dynamic-limits), so you can change a limit at runtime without redeploying. - Sliding window costs 4 Redis commands per hit in the steady state vs 2 for fixed window. Pick fixed window when you're rate-limited a lot or running multi-region. ([costs](https://upstash.com/docs/redis/sdks/ratelimit-ts/costs)) - The SDK's ephemeral cache option rejects already-blocked identifiers without calling Redis at all. This way, it takes zero Redis commands per rejected request on a warm function. - Turning on protection mode uses Upstash's [auto IP deny list](https://upstash.com/docs/redis/sdks/ratelimit-ts/traffic-protection#auto-ip-deny-list), which pulls from the [ipsum repo](https://github.com/stamparm/ipsum) aggregating 30+ open-source blocklists, refreshed daily at 2 AM UTC. ## Why Upstash Redis instead of in-memory or Vercel KV for Next.js rate limiting? Serverless Next.js deployments have no persistent memory between invocations. An in-memory counter works in local dev and breaks when Vercel spins up a second Lambda. [Vercel's Fluid Compute](https://vercel.com/docs/fluid-compute) (default for new projects since 2025) softens this a little because multiple invocations [share one physical instance and its global state](https://vercel.com/docs/fluid-compute#isolation-boundaries-and-global-state), so a module-scope counter does count across the requests landing on that instance. But Vercel still scales out under load by spawning more instances, each starting from zero, so we can't rely on it as the source of truth. We need a shared store with sub-10ms reads. Upstash Redis fits because we have two ways to talk to it. For long-running servers, we can connect over standard TCP with node-redis, ioredis, or any Redis client. For serverless, we can use the REST-based Upstash client. The Ratelimit SDK is [built on top of the REST client](https://github.com/upstash/ratelimit-js) for that serverless case. It has 2k GitHub stars, 18.8k dependents, and is the same library Vercel ships in its own [Ratelimit with Upstash template](https://vercel.com/templates/next.js/ratelimit-with-upstash-redis). ## Which rate limiting algorithm should I pick? The SDK ships three algorithms: | Algorithm | Redis commands per hit (intermediate state) | Boundary-burst issue | Multi-region friendly | Good for | | --- | --- | --- | --- | --- | | Fixed Window | 2 (EVAL, INCR) | Yes, a user can burst 2n at the window seam | Yes | High-volume APIs where cost matters | | Sliding Window | 4 (EVAL, GET, GET, INCR) | No, weighted across two windows | Avoid (high command volume) | Default for almost everything | | Token Bucket | 4 (EVAL, HMGET, HSET, PEXPIRE) | No, burst-friendly by design | Not supported | Paid-tier APIs that need burst tolerance | The "boundary burst" problem is what makes fixed window leaky. A fixed window with a limit of 100 per minute resets at the top of each minute, so if a user fires 100 requests at 12:00:59 and another 100 at 12:01:00, they've gotten 200 requests through in two seconds while technically staying within the limit. Sliding window fixes this by weighting the previous window into the count, and token bucket sidesteps it entirely because the bucket refills continuously instead of resetting on a clock. I pulled these numbers from the [Upstash costs reference](https://upstash.com/docs/redis/sdks/ratelimit-ts/costs#limit). The recommendation: we default to sliding window for accuracy, swap to fixed window only when we've measured the cost difference and care, and reach for token bucket on paid tiers where users genuinely need to absorb bursts (a batch upload endpoint, for example). ## Where should the limiter live: proxy.ts, Route Handler, or Server Action? For me, the default is `proxy.ts`. It's one file, runs before any handler, and is perfect for per-IP limits on every API route. (If you're still on Next.js 15, the same code goes in `middleware.ts`; the file was renamed in 16 but the logic is identical.) ```tsx id="code-i68dliba" // src/proxy.ts import { NextRequest, NextResponse, after } from "next/server"; import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(20, "10 s"), prefix: "rl:proxy", analytics: true, }); export const config = { matcher: ["/api/:path*"], }; export default async function proxy(req: NextRequest) { const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? "127.0.0.1"; const { success, limit, remaining, reset, pending } = await ratelimit.limit(ip); after(pending); const res = success ? NextResponse.next() : NextResponse.json({ error: "Too many requests" }, { status: 429 }); res.headers.set("X-RateLimit-Limit", limit.toString()); res.headers.set("X-RateLimit-Remaining", remaining.toString()); res.headers.set("X-RateLimit-Reset", reset.toString()); return res; } ``` We move the limiter into a specific Route Handler when the limit depends on the route's body (weighting by token count for an LLM endpoint, for example) or when we need a different identifier per route (like a user ID for an account endpoint or IP for signup). We move it into a Server Action when the action is the only thing being protected and we want the limiter to share scope with the action's auth check. The `after()` call is the Next.js 15.1+ API for [running work after the response is sent](https://nextjs.org/docs/app/api-reference/functions/after). The SDK returns a pending promise covering analytics writes and deny-list refreshes; handing it to `after()` keeps those off the response's critical path. ## How do I identify the caller behind Vercel's proxy? The identifier we pass to the limiter is the rate-limit key. This is important because if we get it wrong, we'll either bucket everyone together (one user burns the whole quota) or never enforce anything at all (each request gets a unique key). I recommend this, from best to worst identifiers: 1. **Authenticated user ID**, from the session cookie or JWT. It's stable across IPs and immune to NAT (where many users on the same network share a single public IP and would otherwise get bucketed together). 2. **API key** for B2B endpoints. We hash it before using it as the key so the raw key never lands in Redis. 3. **IP address** as a fallback for anonymous traffic. On Vercel, the client IP is in the `x-forwarded-for` header. We fall back to `x-real-ip` and finally to a localhost placeholder so the limiter never gets an undefined identifier. ```tsx id="code-zv09au0d" const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? "127.0.0.1"; ``` We never use the user agent or any other client-controllable value as the sole identifier. Attackers rotate them. We pass them to the deny list instead (see below). ## How do I run different limits for free vs paid tiers? We create one limiter instance per tier, each with its own Redis key prefix so the tiers don't collide: ```tsx id="code-x64asiqb" // src/lib/tiered.ts import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; const redis = Redis.fromEnv(); const cache = new Map(); type Tier = "anon" | "free" | "pro"; const limiters: Record = { anon: new Ratelimit({ redis, prefix: "rl:anon", limiter: Ratelimit.slidingWindow(20, "1 m"), ephemeralCache: cache, analytics: true, }), free: new Ratelimit({ redis, prefix: "rl:free", limiter: Ratelimit.slidingWindow(100, "1 m"), ephemeralCache: cache, analytics: true, }), pro: new Ratelimit({ redis, prefix: "rl:pro", // 200 tokens/min steady, burst up to 600 limiter: Ratelimit.tokenBucket(200, "1 m", 600), ephemeralCache: cache, analytics: true, }), }; export async function checkQuota(tier: Tier, id: string, weight = 1) { return limiters[tier].limit(id, { rate: weight }); } ``` The `rate` parameter is the SDK's [custom-rate feature](https://upstash.com/docs/redis/sdks/ratelimit-ts/features#custom-rates). Passing the request's token count as the rate makes one expensive LLM call worth 4,000 tokens consume 4,000 from the bucket instead of 1. This is the right primitive for LLM gateways and batch APIs. ## How do I keep the app running when Redis is slow or down? Two settings together: a network timeout, and an in-memory cache for already-blocked identifiers. ```tsx id="code-7743iers" const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, "10 s"), timeout: 1000, // fail open after 1s ephemeralCache: new Map(), analytics: true, }); ``` The timeout defaults to 5 seconds, which is too long. A [Stack Overflow report](https://stackoverflow.com/questions/77066455/upstashs-rate-limiter-takes-5s-to-complete-api-call) shows users hitting the full 5-second wait when Redis is unreachable. We drop it to 1000ms. When the timeout fires, the SDK returns success with a timeout reason attached, our endpoint stays up, and we've traded rate-limit accuracy for availability during an outage. We log on that timeout reason so we notice when it fires in production. The ephemeral cache is a plain JavaScript Map that lives on the warm function instance. Once an identifier is blocked, the SDK caches the reset timestamp locally and rejects further requests from that identifier without calling Redis at all. We use zero commands per blocked request and get a response in microseconds. Critical under DoS-like load. The Map has to be declared at module scope, not inside the handler, or it gets re-created on every invocation. ## When should I use MultiRegionRatelimit? Rarely. The single-region setup is the right default even for global apps because the Upstash Redis REST API resolves close to the edge and the latency of one HTTP round-trip is dominated by the limit logic, not the geography. We reach for the multi-region limiter when both of these are true: we have measurable user populations on two or more continents, and we've measured p99 latency to a single Upstash region and it exceeds our budget. Otherwise the cost is real. Multi-region replicates state asynchronously between databases via [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type), and the docs warn that [sliding window in multi-region produces a large number of Redis commands](https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms#sliding-window). Stick to fixed window if we go this route: ```tsx id="code-njip9ns3" import { MultiRegionRatelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; export const ratelimit = new MultiRegionRatelimit({ redis: [ new Redis({ url: process.env.US_URL!, token: process.env.US_TOKEN! }), new Redis({ url: process.env.EU_URL!, token: process.env.EU_TOKEN! }), ], limiter: MultiRegionRatelimit.fixedWindow(100, "10 s"), prefix: "rl:multi", analytics: true, }); ``` CRDT replication means the configured limit can be exceeded by a small margin during the sync window. That's acceptable for abuse prevention. It's not acceptable for billing: never use rate limits as the source of truth for usage charges. ## How do I block abusive IPs automatically? We turn on protection in the limiter config and pass the request's IP, user agent, and country alongside the identifier: ```tsx id="code-7s0axzjf" const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, "10 s"), enableProtection: true, analytics: true, }); const { success, reason, deniedValue, pending } = await ratelimit.limit(userId, { ip: clientIp, userAgent: req.headers.get("user-agent") ?? undefined, country: req.headers.get("x-vercel-ip-country") ?? undefined, }); after(pending); if (!success && reason === "denyList") { console.warn("denied by deny list:", deniedValue); } ``` This turns on two things. First, a manual deny list you manage from the [Upstash Ratelimit dashboard](https://console.upstash.com/ratelimit), where you add IPs, user agents, countries, or arbitrary identifiers that are blocked without consulting the algorithm. Second, the auto IP deny list, which syncs daily at 2 AM UTC from the [ipsum aggregator](https://github.com/stamparm/ipsum) that pulls from 30+ open-source blocklists. The cost overhead is two extra Redis commands per call for the deny-list lookup, plus nine commands per day for the IP-list refresh. When a value is denied, the SDK caches it for 60 seconds so repeat attempts cost nothing. Pattern matching isn't supported, only exact matches. ## How much will this actually cost on Upstash? Upstash bills per Redis command. The [free tier](https://upstash.com/pricing) covers 500,000 commands per month, and pay-as-you-go is $0.20 per 100,000 commands after that. Let's say we use sliding window + analytics: - Allowed request, intermediate state: 4 commands (algorithm) + 1 (analytics) = **5 commands** - Rate-limited request, cache miss: 3 + 1 = **4 commands** - Rate-limited request, cache hit: 0 + 1 = **1 command** So 500K commands a month gets us roughly 100K legitimate requests with sliding window plus analytics, before we start paying. Switching to fixed window without analytics doubles that to around 250K. The ephemeral cache means bot traffic effectively costs nothing once an attacker is blocked. Past the free tier, the numbers stay tiny. A million allowed requests a month with sliding window plus analytics is 5M commands, which works out to about $9. For most side projects, this is a rounding error compared to the Vercel bill. ## When should I NOT use Upstash Ratelimit? I wouldn't recommend Upstash Ratelimit if: - **Per-second precision is required**, for financial or trading APIs. Sliding window is an approximation. Write a custom Lua script against Redis directly. - **You're already on a self-hosted Redis Cluster** with sub-millisecond latency from your app servers. In this case it's easier to use node-redis with a token bucket Lua script. - **For pure DDoS protection.** Application-layer rate limiting fires after the request has already reached your function. Pair it with Vercel's WAF or Cloudflare in front of your origin. ## FAQ ### Does Upstash Ratelimit work in serverless runtimes? Yes. The SDK is HTTP-based, so it runs in Edge, Node.js, Cloudflare Workers, Fastly Compute, and any other runtime. In Next.js 16, the proxy file defaults to Node.js, it works great there too. ### What happens when my Redis fills up with rate-limit keys? Every key gets a TTL set to the window duration on first hit, so a fixed-window or sliding-window key auto-deletes after the window passes with no further requests. You don't need to clean up manually. If you want to reset a specific identifier's state, the SDK exposes a reset method that takes the identifier. ### Can I share one Upstash Redis between rate limiting and caching? Yes, and you should. Upstash bills per command, not per database. Use the limiter's prefix option (something like `rl:api`, `cache:sessions`) to keep keyspaces separate. The default prefix is `@upstash/ratelimit`. ### How do I test rate limiting locally without hitting production Redis? Spin up a free Upstash dev database for local use and point your env at it. There's no in-memory mock that matches the SDK's behavior accurately: the Lua scripts the SDK uploads to Redis are part of the contract. If you must run tests offline, mock the limiter's return shape directly in your test setup. You can also get a free 72-hour Redis database for experiments by making a POST request to [https://upstash.com/start-redis](https://upstash.com/start-redis). ### How is this different from rate-limiter-flexible or express-rate-limit? Those libraries assume a persistent process with a connection pool, and they break on serverless cold starts because each Lambda invocation opens a fresh TCP connection. Upstash Ratelimit uses HTTP, so cold starts are no problem. If you're on a long-running Node server with one regional Redis, rate-limiter-flexible is fine, but takes more set-up. --- url: https://www.xadd.dev/builds/how-do-you-use-bloom-filters-with-upstash-redis title: "Building a high-speed Bloom Filter with Upstash Redis" date: 2026-05-18T09:36:34.941Z updated: 2026-05-18T09:45:57.490Z word_count: 2254 tags: ["Redis", "Upstash", "Bloom Filter", "BITFIELD", "TypeScript"] --- # Building a high-speed Bloom Filter with Upstash Redis > Build a Bloom filter on Upstash Redis in ~50 lines of TypeScript using BITFIELD and the Upstash Redis SDK. Sizing math, working code examples, and a measured 1.45% false-positive rate. TL;DR This article walks through building a Bloom filter on Upstash Redis in about 50 lines of TypeScript. The filter uses `BITFIELD` for both writes and reads, talks to Upstash through the `@upstash/redis` client, and fits a million items at 1% accuracy in roughly 1.2 MB, which is 30 to 100 times less memory than the same data in a normal Redis set. Each add or check takes one network round trip. ## Key takeaways - A Bloom filter is the right tool when you want to ask "have I seen this before?" cheaply and you can tolerate the occasional false "yes." - One million items at 1% accuracy fits in about 1.2 MB. The same data in a normal Redis set of 40-byte emails takes about 80 MB. - A 1% accuracy filter needs about 9.6 bits per item. A 0.01% one needs about 19.2 bits per item. Item length does not matter. - Adding and checking each take one network round trip when you batch the bit operations into a single `BITFIELD` command. - A Bloom filter will never tell you "no" when the answer is "yes." It cannot remove items, and it can occasionally answer "yes" when the real answer is "no." ## What is a Bloom filter? A Bloom filter is a small data structure that answers one question very efficiently: have I seen this thing before? It is allowed to be wrong sometimes, but only in one direction. If it says "no, I haven't seen it," that answer is always correct. If it says "yes, I think I've seen it," it might be lying about 1% of the time (or whatever accuracy you pick). Think of it as a long row of light switches, all off to start. Every time you add a new item (say, an email address), you run that email through a few hash functions, and each hash tells you a position in the row. You flip every one of those switches on. To check whether you've seen an email before, you compute the same positions and look at the switches. If any of them are still off, you've definitely never added that email. If they're all on, you've probably added it, though there is a small chance some unrelated emails happened to flip those exact switches. That tiny chance of a false "yes" is the only price we pay, and we get to pick how small it is up front. Here is what the standard sizing math (from the [RedisBloom `BF.RESERVE` reference](https://redis.io/docs/latest/commands/bf.reserve/)) costs us: | Accuracy target | Bits per item | Number of hash functions | | --- | --- | --- | | 1 in 10 wrong | 4.8 | 3 | | 1 in 100 wrong | 9.6 | 7 | | 1 in 1,000 wrong | 14.4 | 10 | | 1 in 10,000 wrong | 19.2 | 13 | Notice that the cost is per item, not per byte. A filter for one million 64-byte SHA-256 hashes uses the same memory as one for one million short integers. That is the whole point: a Bloom filter compresses any kind of input down to a constant number of bits. ## Which Redis commands do we need? Upstash supports the full set of bit-level Redis commands: `SETBIT` and `GETBIT` for one bit at a time, `BITCOUNT` for counting set bits, and `BITFIELD` for reading or writing several bits in a single round trip. The bitfield is the most important for this build, because the whole point of a Bloom filter is to touch several bits per operation. A quick note on what you would do if you were running [Redis Stack](https://redis.io/docs/latest/operate/oss_and_stack/) or another distribution with the RedisBloom module: there are dedicated commands like `BF.RESERVE`, `BF.ADD`, and `BF.EXISTS` that wrap all this for you. Upstash does not ship that module, so we use `BITFIELD` directly. We give up auto-resizing (which slows queries down anyway as the filter grows) and a built-in hash function (a few lines of code), and in return we get a filter that runs on any Redis-compatible store and behaves exactly the way we sized it. ## How do we size a Bloom filter? We give the filter two numbers up front: how many items we expect to add, and how often we're willing to be wrong. From those two numbers we derive the size of the bit array and the number of hash functions. Two helper functions: ```tsx id="code-cow6xlhy" export function optimalBits(n: number, p: number): number { return Math.ceil((-n * Math.log(p)) / Math.LN2 ** 2); } export function optimalHashes(m: number, n: number): number { return Math.max(1, Math.round((m / n) * Math.LN2)); } ``` Running this for 100,000 items would mean: ```tsx id="code-2aq6ldvr" p=0.01 m=958506 bits k=7 bits/item=9.585 p=0.001 m=1437759 bits k=10 bits/item=14.378 p=0.0001 m=1917012 bits k=13 bits/item=19.170 ``` A filter for one million items at 1% accuracy is about 1.2 MB and lives in a single Redis string. Upstash limits any single string to 100 MB on Free and Pay-as-you-go ([source](https://upstash.com/docs/redis/troubleshooting/max_record_size_exceeded)), which gives us space for roughly 83 million items in one filter before we'd need to shard across keys. ## How do we add an item? Adding an item means flipping seven (or however many) bits at once in a single Redis string. There are two ways to do this: send seven separate `SETBIT` commands in seven network round trips, or send one `BITFIELD` command with seven sub-operations (which is a lot more efficient). There's also a shortcut we can use for the hash functions. Instead of running seven different hash algorithms over the input, we compute one 128-bit hash, split it into two halves, and combine them to generate the seven positions. This is the standard [double-hashing trick from Kirsch and Mitzenmacher](https://www.eecs.harvard.edu/~michaelm/postscripts/rsa2008.pdf) and gives almost the same accuracy as seven independent hashes at a fraction of the cost. ```tsx id="code-jfsnanl5" import { Redis } from "@upstash/redis"; function fnv1a64(bytes: Uint8Array, offset: bigint): bigint { const PRIME = 1099511628211n; const MASK = 0xffffffffffffffffn; let h = offset; for (let i = 0; i < bytes.length; i++) { h ^= BigInt(bytes[i]); h = (h * PRIME) & MASK; } return h; } const enc = new TextEncoder(); const A = 0xcbf29ce484222325n; const B = 0x84222325cbf29ce4n; function bitPositions(item: string, m: number, k: number): number[] { const bytes = enc.encode(item); const h1 = fnv1a64(bytes, A); const h2 = fnv1a64(bytes, B); const mBig = BigInt(m); const out: number[] = []; for (let i = 0; i < k; i++) { out.push(Number((h1 + BigInt(i) * h2) % mBig)); } return out; } export async function bloomAdd( redis: Redis, key: string, bits: number, hashes: number, item: string, ): Promise { const positions = bitPositions(item, bits, hashes); let cmd = redis.bitfield(key); for (const pos of positions) cmd = cmd.set("u1", pos, 1); const old = await cmd.exec(); // returns the old value of each bit return old.some((b) => b === 0); // true if any bit flipped } ``` There's a small trick built into the return value. When we ask Redis to set a bit, it tells us what that bit was *before* we flipped it. If even one of the seven bits was zero before the call, the item was almost certainly new (we just set a fresh bit). If every bit was already one, the item might be a duplicate, or it might just be unlucky. One thing to watch out for with the `@upstash/redis` client: by default it batches commands together to save round trips, but that batching does not play nicely with the chainable `bitfield()` builder. The fix is to disable the auto-batcher when we create the client: ```tsx id="code-jblbznoj" const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, enableAutoPipelining: false, }); ``` ## How do we check if an item is in the filter? Checking if an item is present works the same way as adding, except we read the bits instead of writing them. We compute the same bit positions and use `BITFIELD` to read them all in one go. If every bit is 1, the item is probably in the filter. If even one bit is 0, the item is definitely not there. ```tsx id="code-j85ngn78" export async function bloomExists( redis: Redis, key: string, bits: number, hashes: number, item: string, ): Promise { const positions = bitPositions(item, bits, hashes); let cmd = redis.bitfield(key); for (const pos of positions) cmd = cmd.get("u1", pos); const result = await cmd.exec(); return result.every((b) => b === 1); } ``` To check that this actually behaves like a real Bloom filter, I ran it against a live Upstash Redis database: insert 5,000 distinct items into a filter sized for 5,000 at 1% accuracy, then check 2,000 different items that were never added. The filter answered "yes" 29 times, for a measured false-positive rate of 1.45%. That is slightly above the 1% target, which is expected when we derive the hashes from a single FNV-1a value instead of running seven independent hash functions. Swapping FNV for [xxhash3](https://github.com/Cyan4973/xxHash) brings the rate closer to the theoretical bound. The filter also produced zero wrong "no" answers, which is the one guarantee Bloom filters always make. Once a bit is set during an add, it stays set for the lifetime of the filter, so any item we added really did set its bits to 1 and will keep returning "yes" when we ask. ## How do you count how many items are in the filter? With our custom filter we don't store an exact count of items added, but we can estimate it from the fraction of bits that are set. Redis counts set bits with `BITCOUNT`, and a known formula (the Swamidass-Baldi estimator) converts that count back into an item-count estimate. ```tsx id="code-hjj6mrlv" export async function bloomCardinality( redis: Redis, key: string, bits: number, hashes: number, ): Promise { const setBits = await redis.bitcount(key, 0, -1); if (setBits === 0) return 0; if (setBits >= bits) return Infinity; return Math.round(-(bits / hashes) * Math.log(1 - setBits / bits)); } ``` The estimate is good while the filter is lightly to moderately filled. Past about 80% of bits set, the math becomes very sensitive to noise and the result gets unreliable. If we need an accurate count at high fill, we can run a [HyperLogLog](https://upstash.com/blog/redis-hyperloglog) alongside the filter. `PFADD` and `PFCOUNT` work on Upstash and give us about 0.81% accuracy in 12 KB no matter how many items we add. ## When should we use a Bloom filter vs a Redis Set or HyperLogLog? Three probabilistic options, three different jobs: | Structure | What it answers | Memory for 1M items | False positives | False negatives | Cardinality | | --- | --- | --- | --- | --- | --- | | Redis Set (`SADD`/`SISMEMBER`) | Exact membership + count | \~40-80 MB | None | None | Exact (`SCARD`) | | Bloom filter (`BITFIELD`) | Approximate membership | \~1.2 MB at 1% | Tunable | Never | Approximate (BITCOUNT + estimator) | | HyperLogLog (`PFADD`/`PFCOUNT`) | Approximate count only | \~12 KB | N/A | N/A | 0.81% std. error | Three good places to reach for a Bloom filter, where the memory savings over a normal Redis set are 30 to 100 times: - **Skipping pointless database queries.** Before hitting your database, ask the Bloom filter whether the key probably exists. A "no" lets you skip the query entirely. A "yes" means you still need to query, but you have already filtered out most of the misses. This is the original [Cassandra and HBase use case](https://cassandra.apache.org/doc/latest/cassandra/managing/operating/bloom_filters.html). - **Deduplicating URLs in a web crawler.** A filter for 100 million URLs at 1% accuracy fits in about 120 MB. Storing those same URLs in a normal Redis set, at 50 bytes each, would cost around 5 GB. - **Rejecting obviously duplicate usernames or coupon codes.** Run the cheap Bloom check first, then only query the real database for the items it flags as "maybe seen." Reach for a normal Redis set instead if you need to list the members, count them exactly, or delete some of them. Reach for HyperLogLog if you only need to know how many distinct items you have seen, not whether a specific one is in the set. ## What are the limitations of a Bloom filter on Upstash Redis? Four to keep in mind: - **You cannot delete items.** Clearing a bit could break the lookup for some other item that also happened to set that same bit. The standard workarounds are to use a [counting Bloom filter](https://en.wikipedia.org/wiki/Counting_Bloom_filter) (which stores small counters instead of single bits) or to rebuild the filter from scratch on a schedule. - **There is no perfectly atomic "add only if new."** The real `BF.ADD` was designed to give you that guarantee. The `BITFIELD`-based version runs as one atomic Redis command, but its "this is a new item" answer is probabilistic. If you need a hard guarantee, pair the filter with a regular `SET key value NX` for the small set of items the filter flags as possibly seen. - **An empty filter still costs full memory.** The bit array is allocated up front, so a 10 million bit filter is a 1.25 MB string in Redis whether you have added zero items or a million. Do not size much higher than you actually need. - **One Redis key per filter.** Upstash caps any single value at 100 MB, which means a single filter holds at most about 80 million items at 1% accuracy. Beyond that, shard: pick which filter an item belongs to by hashing it, and store N filters under N keys. ## What about expiring the filter? The filter lives in a normal Redis string, so all the usual key commands work on it. `EXPIRE bloom:emails 86400` will auto-delete it after a day. `PERSIST` removes the expiration. `RENAME` moves it. This is the easiest way to do rolling deduplication: name the filter after the hour or day ("have I seen this event in the last hour?"), let `EXPIRE` clean up old buckets, and you avoid any cleanup job entirely. ## Should I just use a Redis with the RedisBloom module? If you genuinely need RedisBloom's more advanced features (auto-resizing filters, restoring a filter from a dump file, or its specific atomicity guarantees), then sure, run a Redis that has the module loaded. But for the common case of "I want a fixed-size Bloom filter that I configure once when I deploy," the 50-line version we built here is great. It runs against Upstash Redis from any environment (`@upstash/redis` over HTTP for serverless, or ioredis/node-redis over TCP for traditional servers), and it hit 1.45% false positives against a 1% target in the test above. ## FAQ **Can I use this from Cloudflare Workers or Vercel Edge?** Yes. For serverless runtimes use `@upstash/redis`, which is HTTP-based and runs in any V8 isolate. For long-running Node.js or Bun servers, the same code works with ioredis or node-redis over Upstash's TCP endpoint. The `BigInt` arithmetic in the FNV-1a hash is supported in every modern runtime. **Does `BITFIELD` count as one command for billing?** Yes. A single `BITFIELD` call with k `SET u1` subcommands is one Upstash command, not k. That is the main reason to use `BITFIELD` over k individual `SETBIT` calls both for latency and for the per-command billing model. **How do I serialize a filter for offline analysis?** `GET bloom:key` returns the raw byte string; `SET bloom:key ` restores it. Pair with `STRLEN` to confirm the byte count matches `Math.ceil(m / 8)`. --- url: https://www.xadd.dev/data-structures/what-is-the-redis-string-data-type title: "What is the Redis String Data Type?" date: 2026-05-18T09:07:06.636Z updated: 2026-05-18T09:07:34.888Z word_count: 1983 tags: ["Redis", "Data Structures", "Upstash", "Strings", "SDS"] --- # What is the Redis String Data Type? > Deep dive into the Redis string data type: SDS internals, int/embstr/raw encodings, all 25 commands, atomic counters, and Upstash Redis SDK examples. TL;DR: A Redis string is a binary-safe byte sequence (0 to 512 MB) stored against a single key. It is the oldest and simplest of Redis's data types, but it backs three different internal encodings (`int`, `embstr`, `raw`) and powers patterns far beyond key-value caching: atomic counters, distributed locks, bitmaps, rate limiters, and HTTP fragment caches. Every other Redis type is built on the same Simple Dynamic Strings (SDS) library underneath. ## Key takeaways - A single Redis string can hold up to **512 MB** of arbitrary bytes: text, JPEGs, protobufs, or a serialized PyTorch tensor. - Strings have three encodings: `int` (for values that fit in a `long`), `embstr` (short strings, single allocation), and `raw` (longer strings, two allocations). - The `embstr` cutoff was a constant `44` bytes for years; [Redis 8.2 changed it to a 64-byte cache-line heuristic](https://github.com/redis/redis/issues/14438) that depends on both key and value length. - `INCR`, `INCRBY`, and `INCRBYFLOAT` operate on a 64-bit signed integer parsed from the string and are atomic: no race even with thousands of concurrent clients. - Over the [@upstash/redis](https://upstash.com/docs/redis/sdks/ts/commands/string) REST SDK, `SET`, `GET`, `INCR`, and `MGET` each cost a single HTTP round trip, which makes pipelining and `MGET`/`MSET` materially more important than on TCP clients. ## What is a Redis string under the hood? A Redis string is not a C string. Internally it is an [SDS (Simple Dynamic String)](https://redis.io/docs/latest/operate/oss_and_stack/reference/internals/internals-sds/), a header plus a flat byte buffer. The header carries the length, the allocated capacity, and a 1-byte type tag. Three properties fall out of that design: 1. **`STRLEN` is O(1).** The length is a field, not a `strlen` walk. 2. **Strings are binary-safe.** `\0` is just another byte, so you can store images or Protobuf payloads. 3. **`APPEND` is amortized O(1).** SDS doubles free capacity on reallocation, exactly like a C++ `std::vector`. On top of SDS, Redis picks one of three encodings per key. You can inspect it with `OBJECT ENCODING`: ```tsx id="code-u72feutv" > SET counter 42 OK > OBJECT ENCODING counter "int" > SET greeting "hi" OK > OBJECT ENCODING greeting "embstr" > SET blob "$(head -c 200 /dev/urandom | base64)" OK > OBJECT ENCODING blob "raw" ``` The `int` encoding skips SDS entirely. The value lives directly in the object's pointer slot as a `long`, which is why `INCR` can be O(1) and lock-free: there is no parsing on the hot path. ## When does Redis use embstr vs raw encoding? For most of Redis's history the rule was a hard constant: values up to `OBJ_ENCODING_EMBSTR_SIZE_LIMIT = 44` bytes used `embstr`, anything longer used `raw`. The difference matters because: - **`embstr`** allocates the `robj` header and the SDS buffer in a single contiguous block. One `malloc`, one cache line, faster reads. - **`raw`** does two allocations: the `robj` points at a separately allocated SDS. Slower, but supports in-place mutation. In [Redis 8.2 the rule changed](https://github.com/redis/redis/issues/14438). Keys and values were unified into a single `kvobj` struct, and the new heuristic is roughly "embed only if the total kvobj + key SDS + value SDS fits in one 64-byte cache line." That means a 40-byte value with a 4-byte key is `embstr`, but the same 40-byte value with a 40-byte key flips to `raw`. The 44-byte folklore is now incomplete; the threshold depends on key length too. Redis maintainers have already [floated relaxing it again](https://github.com/redis/redis/issues/14438#issuecomment-3436854191) to allow small cache-line spillover. Practical implication: if you care about memory at scale, keep frequently-touched keys short. A 1 KB key is not free; it can push the value out of `embstr` and double the allocator overhead. ## What commands operate on Redis strings? There are [25 string commands](https://redis.io/docs/latest/commands/?group=string) in the modern Redis 8 reference. The ones worth memorizing: | Command | Complexity | What it does | | --- | --- | --- | | `SET` / `GET` | O(1) | Assign or read a value, replacing any existing type | | `SETNX` / `SET ... NX` | O(1) | Write only if the key does not exist (the lock primitive) | | `GETEX` | O(1) | Read and refresh TTL atomically | | `GETDEL` | O(1) | Read and delete atomically (counter rotation) | | `INCR` / `INCRBY` / `DECR` | O(1) | Atomic 64-bit signed integer math | | `INCRBYFLOAT` | O(1) | Atomic IEEE-754 double math | | `APPEND` | O(1) amortized | Push bytes onto the end | | `STRLEN` | O(1) | Byte length | | `MGET` / `MSET` | O(N) keys | Batched read/write in one round trip | | `GETRANGE` / `SETRANGE` | O(N) | Substring read or in-place overwrite at an offset | A trap worth calling out: `GETRANGE`, `SETRANGE`, and `SUBSTR` are O(N) in the returned (or written) length. `STRLEN` on a 100 MB blob is instant; `GETRANGE` of that blob is not. Treat anything random-access on large strings as a slow command. ## How do you use Redis strings with the @upstash/redis SDK? The [Upstash Redis SDK](https://upstash.com/docs/redis/sdks/ts/commands/string) speaks REST/HTTP instead of RESP, which means every command is a round trip. The API itself mirrors the standard Redis command set: ```tsx id="code-rq6l4gf4" import { Redis } from "@upstash/redis"; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, }); // basic assignment await redis.set("user:42:name", "Ada"); const name = await redis.get("user:42:name"); // "Ada" // SET with TTL + NX, the canonical "acquire lock" call πŸ‘‡ const got = await redis.set("lock:order:99", "worker-7", { nx: true, px: 30_000 }); const acquired = got === "OK"; // false if already held // atomic counter, increments and returns the new value const views = await redis.incr("views:/pricing"); // atomically take the current value and reset to nil πŸ‘‡ const previous = (await redis.getdel("views:/pricing")) ?? 0; ``` Two SDK-specific notes: 1. `redis.mset({...})` accepts a plain object and JSON-encodes non-string values automatically. So `redis.mset({ "user:42": { name: "Ada" } })` stores a JSON string; `redis.get("user:42")` parses it back. The wire format is still a Redis string; the SDK does the serialization. 2. `redis.mget(...keys)` and `redis.mset({...})` [bill as one command](https://upstash.com/docs/redis/sdks/ts/commands/string#mget), not N, which makes batching free on top of being fast. The full set of typed snippets above (lock acquisition, counter rotation, MGET cart loading, `INCRBYFLOAT` balances, `GETEX` cache refresh, `SETRANGE` byte patching) type-checks against `@upstash/redis` v1 with `strict: true`. ## Why is INCR atomic and what does it do? `INCR` is the single most useful string command after `SET`/`GET`, and the atomicity guarantee is exact: even with thousands of concurrent clients hitting the same key, the final value is the sum of all increments, never lost, never doubled. Redis is single-threaded for command execution, so `INCR` is a read-parse-add-write sequence that runs to completion before the next command starts. What `INCR` does mechanically: 1. Look up the key. If missing, treat the value as `0`. 2. Parse the string as a signed 64-bit integer. If it does not parse, return an error (`ERR value is not an integer or out of range`). 3. Add 1 (or N, for `INCRBY`). If the result overflows `int64`, return an error. 4. Re-encode as a string and store. The encoding stays `int` so the next `INCR` skips parsing. The bounds are `-9_223_372_036_854_775_808` to `9_223_372_036_854_775_807`, roughly 9.2 quintillion. You will not hit them with page views, but you will hit them if you `INCRBY 1e18` twice. `INCRBYFLOAT` operates on doubles and has the usual IEEE-754 precision caveats; do not use it for currency that needs cent-exact rounding. Use `INCRBY` against cents instead. The same atomicity story is why `SET key value NX PX 30000` is the textbook distributed-lock primitive: NX makes the write conditional, PX bounds the hold time, and both are evaluated server-side without a CAS loop. ## When should you use a Redis string vs a hash or JSON? Strings are the right answer more often than people assume, but not always. The honest comparison: | Use case | Best fit | Why | | --- | --- | --- | | Single scalar value (counter, flag, session token) | **String** | One allocation, `INCR`/`GETEX` work directly | | Serialized object you always read whole | **String** with JSON or MessagePack | One `GET` round trip, no field-level overhead | | Object where you mutate individual fields | **Hash** | `HINCRBY`/`HSET` avoid round-tripping the full value | | Deeply nested JSON with path queries | **RedisJSON** | `JSON.SET $.user.address.city` server-side | | Bitmap or bitset (presence, feature flags) | **String** with `SETBIT`/`BITCOUNT` | Bitmaps are strings under the hood | | Many small related values | **Hash** | Hashes pack into `listpack` encoding while they stay under 512 entries and each value under 64 bytes (the Redis 7+ defaults), saving memory | The bitmap point is the one most people miss: there is no separate "bitmap" type in Redis. `SETBIT mykey 1000000 1` allocates a 125 KB string and flips bit 1,000,000. The bit-level commands (`SETBIT`, `GETBIT`, `BITCOUNT`, `BITOP`, `BITPOS`, `BITFIELD`) all operate on regular string keys. If you are storing a structured object and you JSON-encode it into a string, the cost is the full round trip on every field change. If your access pattern is "read one field, write one field", a Redis hash beats it. If your access pattern is "fetch the whole user record on every request", a string is faster and uses less memory. ## What are the performance limits of Redis strings? The hard limit is **512 MB per value**. Practical limits are tighter: - **Network.** A `GET` on a 100 MB string serializes 100 MB onto the wire. On Upstash's REST API that is a single HTTP response, and you will hit body-size limits before Redis does. - **Single-threaded blocking.** Any O(N) command on a giant string blocks the whole Redis instance for the duration. A `GETRANGE` returning 50 MB can stall every other client for tens of milliseconds. - **Allocation overhead.** Every `raw` string is two jemalloc allocations: the `robj` header and the separately allocated SDS buffer. Packing them into a hash does not avoid this if your values exceed [`hash-max-listpack-value`](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/) (default **64 bytes**); above that threshold the hash converts to `hashtable` encoding with its own per-field allocations. The "hashes save memory" trick is real, but it pays off for many small fields under 64 bytes, not for 1 KB–10 KB blobs. - **Counter contention.** `INCR` is atomic, but every increment is still a write that lands on a single shard. If you need millions of increments per second on one logical counter, shard it across N keys and sum on read. For the common case (config flags, session blobs under 4 KB, counters, idempotency tokens, rate-limit buckets), none of these matter. Redis strings comfortably do hundreds of thousands of ops/sec per shard at sub-millisecond latency. ## Frequently asked questions **Is a Redis string the same as a C string?** No. Redis strings are [SDS (Simple Dynamic Strings)](https://redis.io/docs/latest/operate/oss_and_stack/reference/internals/internals-sds/): a header (`len`, `alloc`, `flags`) followed by a byte buffer. They are binary-safe, length-prefixed, and store an `\0` terminator only as a convenience for C interop. **Can I store a JSON object in a Redis string?** Yes, and it is a perfectly reasonable pattern when you read the object whole. The Upstash SDK auto-serializes with `JSON.stringify` on `set` and `JSON.parse` on `get`, so `await redis.set("user:42", { name: "Ada" })` and `await redis.get("user:42")` round-trip cleanly. For per-field mutation, prefer a hash or RedisJSON. **Why does `SET` on an existing key destroy its TTL?** By design. `SET` is an unconditional assignment that overwrites both value and metadata. Pass the `KEEPTTL` flag (Redis 6.0+) to preserve expiration: `SET key newvalue KEEPTTL`. Most client libraries expose this as a `{ keepTtl: true }` option. **What is the difference between `SETNX` and `SET ... NX`?** `SETNX` is the original 1.0 command; `SET ... NX` (Redis 2.6.12+) is the modern variant that also accepts `EX`/`PX`/`EXAT` in the same atomic call. Always prefer `SET key value NX PX 30000` for locks because it is atomic. The two-call sequence `SETNX` then `EXPIRE` is not, and will leak locks if your client crashes between them. **How big can a Redis string actually get in practice?** The protocol limit is 512 MB but the practical ceiling is lower. Replication, RDB snapshots, and AOF rewrites all serialize the full value, so a 100 MB string lengthens your fsync tail and can starve other commands during BGSAVE. Most production deployments cap individual values at a few megabytes and put larger blobs in object storage with the key holding a reference.