The phrase "Redis for everything" sounds reckless, so I decided to actually try it as an exercise: how much of a commerce system's day-to-day traffic can Redis carry if you lean on it past the obvious cache-in-front-of-Postgres role?
I want to be upfront that this isn't how I'd wire a real checkout flow at a company that actually ships to customers — there's no relational database in this story, no reconciliation job, no separate disaster-recovery pipeline outside Redis itself. It's a sandbox for poking at the corners of Redis most people never touch: Lua scripting for atomic inventory math, sorted sets for ranking, RediSearch for full-text queries, streams for queue-like coordination. Most people know Redis as "the cache" or "the session store." Fewer have used it as a search engine, a leaderboard, and an atomic-transaction engine in the same afternoon, which is what made this fun to build.
The setup
Picture a toy commerce platform: product browsing and search, carts that update on every click, a flash sale with limited inventory, a trending-items feed, and a checkout flow that has to avoid overselling when two people click "buy" on the last unit at the same instant. Nothing here talks to a relational database — Redis is the only backend, on purpose, because the point is to see where that constraint actually pinches.
Redis earns a spot in this story because its data structures line up with these workloads more precisely than "just a cache" gives it credit for.
| Problem | Redis structure | Why it fits |
|---|---|---|
| Product pages | HASH or RedisJSON | Fast denormalized reads |
| Cart state | HASH or RedisJSON | Tiny mutable state with TTL |
| Search | RediSearch | Full-text and faceted queries |
| Inventory reservations | Lua script + keys | Atomic check-and-set |
| Trending items | ZSET | Score-based ranking |
| Sessions | String keys with TTL | Expiring auth state |
| Rate limiting | Counters with TTL | Simple per-user throttles |
| Events and jobs | Streams | Durable append-only work queue |
High-level shape
flowchart LR U[Customer] --> G[API Gateway] G --> C[Catalog API] G --> K[Cart API] G --> O[Checkout API] G --> S[Search API] G --> R[Recommendation Service] C --> REDIS[(Redis)] K --> REDIS O --> REDIS S --> REDIS R --> REDIS O --> PAY[Payments Provider] O --> SHIP[Shipping Service] REDIS --> STR[Streams / Workers]
Notice there's no relational database in that diagram. In a real system I'd draw one in immediately, with Redis sitting in front of it as the fast path and a worker keeping the two in sync. Leaving it out here is the whole experiment — it forces every one of these services to lean on a Redis structure that's actually suited to the job, rather than falling back on "just hit Postgres" the moment something gets awkward. (More on what that omission does and doesn't cost you at the end.)
Product catalog reads
The product page is the easiest place to start. A catalog item is naturally denormalized — title, price, an inventory snapshot, tags, rating, flags, marketing copy — so a HASH maps onto it almost without translation.
import { createClient } from "redis";
const redis = createClient();
type Product = {
id: string;
title: string;
price: string;
currency: string;
inventory: string;
rating: string;
};
export async function getProduct(id: string): Promise<Product | null> {
const key = `product:${id}`;
const cached = await redis.hGetAll(key);
if (Object.keys(cached).length > 0) {
return cached as unknown as Product;
}
return null;
}
In a system with a real database behind it, a miss here would fall through to Postgres and repopulate the hash with a TTL. In this exercise there's nothing to fall through to, so the hash is the product record, full stop — which is a good way to feel how much of "the catalog" is actually just key-value reads dressed up as a database table.
export async function cacheProduct(product: Product) {
await redis.hSet(`product:${product.id}`, product);
await redis.expire(`product:${product.id}`, 300);
}
Search that actually feels fast
For commerce, search isn't a luxury feature — it's the storefront. This is also the part of the exercise that surprised me most, because RediSearch is not something most engineers reach for even when they already run Redis for everything else.
RediSearch indexes the same denormalized catalog data and lets you query it with text, filters, and ranking, all without standing up a separate Elasticsearch cluster just to power the search box.
// illustrative example using Redis OM / RediSearch style indexing
const query = "@category:{shoes} @price:[50 150] sneaker";
const results = await redis.ft.search("idx:products", query, {
LIMIT: {
from: 0,
size: 12,
},
SORTBY: {
BY: "score",
DIRECTION: "DESC",
},
});
What's interesting isn't that RediSearch can do faceted queries — of course a purpose-built search engine can. It's that the index lives right next to the cart and checkout data, in the same tool, with the same operational model. That's the "lesser-known capability" I actually came away impressed by.
Cart state
Cart data is the least controversial fit in the whole exercise. It's small, mutable, session-like, and usually short-lived — a HASH with a TTL handles it without ceremony.
type CartItem = {
sku: string;
qty: number;
price: number;
};
export async function addToCart(userId: string, item: CartItem) {
const key = `cart:${userId}`;
await redis.hSet(key, item.sku, JSON.stringify(item));
await redis.expire(key, 60 * 60 * 24 * 7);
}
Carts expire on their own, updates are cheap, and a read right after a write comes back correct without you thinking about it — the whole category of "did the cache catch up yet" bugs just doesn't exist here.
Inventory reservations
This is where Redis stops being a cache and starts acting like a coordination layer, and it's the part of the exercise I found the most fun to get right.
The hard part of flash-sale inventory is atomicity: you need to check stock and decrement it without letting two concurrent requests both believe they got the last unit. A naive GET then DECR has a race condition baked into the gap between the two calls. A Lua script closes that gap by running both as one atomic step on the server.
const reserveStockScript = `
local key = KEYS[1]
local requested = tonumber(ARGV[1])
local current = tonumber(redis.call("GET", key) or "0")
if current < requested then
return 0
end
redis.call("DECRBY", key, requested)
return 1
`;
export async function reserveInventory(sku: string, quantity: number) {
const result = await redis.eval(reserveStockScript, {
keys: [`inventory:${sku}`],
arguments: [String(quantity)],
});
return result === 1;
}
That's maybe eight lines of Lua, and it's doing the work you'd otherwise reach for a database transaction or a distributed lock to get right. This is the capability I'd bet most people using Redis have never touched — scripting is right there in the client library, and it turns "check-then-act" from a footgun into a primitive.
sequenceDiagram participant U as Customer participant C as Cart Service participant I as Inventory Key participant P as Payment Service participant O as Order Log U->>C: submit checkout C->>I: reserve stock atomically I-->>C: reservation granted C->>P: charge card P-->>C: payment success C->>O: append order event C-->>U: order confirmed
Trending and recommendations
Sorted sets are one of the nicest Redis structures because they turn "ranking" into a primitive instead of a query you write against a table.
export async function recordProductView(productId: string) {
await redis.zIncrBy("trending:24h", 1, productId);
}
export async function getTrending(limit = 10) {
return redis.zRange("trending:24h", 0, limit - 1, {
REV: true,
WITHSCORES: true,
});
}
Swap the key name and you get the same trick for "most viewed in the last hour," category popularity, or a seller leaderboard — the increment-and-read pattern doesn't change, only what you're counting. Because the read is O(log n) regardless of how often you write, you can afford to keep these views live all the time instead of batching them.
Sessions and rate limits
Sessions are the least surprising part of the exercise, and that's fine — you want quick lookups, clean expiry, and no drama.
export async function saveSession(sessionId: string, userId: string) {
await redis.set(`session:${sessionId}`, userId, {
EX: 60 * 60 * 24,
});
}
Rate limiting is just a counter with a TTL, which is gloriously boring in the best way — one key per user per hour, and the expiry does the cleanup for you.
export async function allowRequest(userId: string) {
const key = `rate:${userId}:${new Date().toISOString().slice(0, 13)}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 3600);
}
return count <= 100;
}
What the exercise actually shows
I went in expecting Redis to feel stretched thin somewhere around the search or ranking use cases, and it didn't — the data structures really do line up with the workload, and RediSearch and Lua scripting are more capable than their "niche feature" reputation suggests.
I'd figured Redis being in-memory meant this whole platform was one power outage away from amnesia. It isn't. Redis can write itself to disk two ways — a periodic snapshot of the whole dataset, and a running log of every write that it can flush to disk as often as after each individual command. Run both together and Redis's own docs go as far as claiming durability "comparable to what PostgreSQL can provide." I'd take that with a grain of salt — but it's still worth knowing the knob exists.
So the honest gap isn't durability, it's the relational modeling around it. Postgres gives you multi-table transactions that actually roll back on error, foreign key constraints that reject bad writes at the boundary, and a query planner you can point at ad-hoc reporting questions you didn't anticipate when you designed the keys. Redis's MULTI/EXEC queues commands and runs them atomically, but it won't roll back a command that fails partway through, and there's no equivalent of JOIN order_items ON orders.id across arbitrary keys — you get back exactly the access pattern you designed the key for, and nothing else. That's the real trade this exercise surfaces: reach for Redis's less obvious structures — sorted sets, Lua scripts, RediSearch, streams, and yes, AOF — before assuming you need a separate service for ranking, search, or locking. Just don't expect it to replace the kind of ad-hoc relational querying a warehouse or Postgres gives you almost for free.