Build fast, scalable shopping carts with Redis. Perfect for high-traffic e-commerce.
Store cart items as hash fields:
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); interface CartItem { productId: string; name: string; price: number; quantity: number; image?: string; } class ShoppingCart { private ttl = 60 * 60 * 24 * 7; // 7 days private key(cartId: string) { return `cart:${cartId}`; } async addItem(cartId: string, item: CartItem) { const key = this.key(cartId); const existing = await redis.hget(key, item.productId); if (existing) { const current = JSON.parse(existing) as CartItem; item.quantity += current.quantity; } await redis.hset(key, item.productId, JSON.stringify(item)); await redis.expire(key, this.ttl); return this.getCart(cartId); } async updateQuantity(cartId: string, productId: string, quantity: number) { const key = this.key(cartId); if (quantity <= 0) { await redis.hdel(key, productId); } else { const existing = await redis.hget(key, productId); if (existing) { const item = JSON.parse(existing) as CartItem; item.quantity = quantity; await redis.hset(key, productId, JSON.stringify(item)); } } await redis.expire(key, this.ttl); return this.getCart(cartId); } async removeItem(cartId: string, productId: string) { await redis.hdel(this.key(cartId), productId); return this.getCart(cartId); } async getCart(cartId: string) { const data = await redis.hgetall(this.key(cartId)); const items = Object.values(data).map(v => JSON.parse(v) as CartItem); const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0 ); return { id: cartId, items, itemCount: items.reduce((sum, item) => sum + item.quantity, 0), subtotal, }; } async clearCart(cartId: string) { await redis.del(this.key(cartId)); } } // Usage const cart = new ShoppingCart(); await cart.addItem('user_123', { productId: 'prod_abc', name: 'Wireless Headphones', price: 79.99, quantity: 1, image: '/images/headphones.jpg', }); await cart.updateQuantity('user_123', 'prod_abc', 2); const myCart = await cart.getCart('user_123'); // { id: 'user_123', items: [...], itemCount: 2, subtotal: 159.98 }
Merge anonymous cart when user logs in:
class CartWithMigration extends ShoppingCart { async migrateGuestCart(guestCartId: string, userId: string) { const guestKey = `cart:${guestCartId}`; const userKey = `cart:user:${userId}`; // Get both carts const [guestItems, userItems] = await Promise.all([ redis.hgetall(guestKey), redis.hgetall(userKey), ]); // Merge: guest items override user items for same product const merged = { ...userItems, ...guestItems }; if (Object.keys(merged).length > 0) { // Write merged cart await redis.del(userKey); await redis.hset(userKey, merged); await redis.expire(userKey, this.ttl); } // Delete guest cart await redis.del(guestKey); return this.getCart(`user:${userId}`); } } // On login app.post('/login', async (req, res) => { const user = await authenticateUser(req.body); const guestCartId = req.cookies.cart_id; if (guestCartId) { await cart.migrateGuestCart(guestCartId, user.id); res.clearCookie('cart_id'); } res.json({ user, cartId: `user:${user.id}` }); });
Reserve inventory when adding to cart:
class CartWithReservation { private reservationTtl = 60 * 15; // 15 minutes async addWithReservation( cartId: string, productId: string, quantity: number ): Promise<{ success: boolean; error?: string }> { const inventoryKey = `inventory:${productId}`; const reservationKey = `reservation:${cartId}:${productId}`; // Lua script for atomic check-and-reserve const script = ` local inventory = tonumber(redis.call('GET', KEYS[1]) or '0') local currentReservation = tonumber(redis.call('GET', KEYS[2]) or '0') local requested = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) -- Calculate additional needed local additional = requested - currentReservation if additional <= 0 then -- Already have enough reserved return 1 end if inventory < additional then -- Not enough inventory return 0 end -- Reserve inventory redis.call('DECRBY', KEYS[1], additional) redis.call('SET', KEYS[2], requested, 'EX', ttl) return 1 `; const result = await redis.eval( script, 2, inventoryKey, reservationKey, quantity, this.reservationTtl ); if (result === 0) { return { success: false, error: 'Not enough inventory' }; } // Add to cart await this.addItem(cartId, { productId, quantity } as CartItem); return { success: true }; } async releaseReservation(cartId: string, productId: string) { const reservationKey = `reservation:${cartId}:${productId}`; const inventoryKey = `inventory:${productId}`; const reserved = await redis.get(reservationKey); if (reserved) { await redis.incrby(inventoryKey, parseInt(reserved)); await redis.del(reservationKey); } } async checkout(cartId: string) { const cart = await this.getCart(cartId); // Reservations become permanent (delete reservation keys) for (const item of cart.items) { await redis.del(`reservation:${cartId}:${item.productId}`); } // Clear cart await this.clearCart(cartId); return { success: true, orderId: generateOrderId() }; } }
Sync cart across browser tabs using Pub/Sub:
// Backend: Publish cart updates class RealtimeCart extends ShoppingCart { async addItem(cartId: string, item: CartItem) { const result = await super.addItem(cartId, item); // Notify all connected clients await redis.publish(`cart:${cartId}`, JSON.stringify({ action: 'update', cart: result, })); return result; } } // Frontend: Subscribe via WebSocket/SSE // server.ts import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', (ws, req) => { const cartId = new URL(req.url!, 'http://localhost').searchParams.get('cartId'); if (!cartId) return ws.close(); // Subscribe to cart updates const subscriber = new Redis(process.env.ARCTICKEY_URL); subscriber.subscribe(`cart:${cartId}`); subscriber.on('message', (channel, message) => { ws.send(message); }); ws.on('close', () => { subscriber.unsubscribe(); subscriber.quit(); }); }); // client.ts const ws = new WebSocket(`wss://api.example.com/cart?cartId=${cartId}`); ws.onmessage = (event) => { const { action, cart } = JSON.parse(event.data); if (action === 'update') { updateCartUI(cart); // React state update, etc. } };
Track cart metrics for insights:
class CartAnalytics { async trackAddToCart(productId: string, price: number) { const today = new Date().toISOString().split('T')[0]; await redis.pipeline() // Count adds per product .hincrby('analytics:cart:adds', productId, 1) // Daily revenue potential .hincrbyfloat(`analytics:cart:value:${today}`, 'total', price) // Hourly distribution .hincrby( `analytics:cart:hourly:${today}`, new Date().getHours().toString(), 1 ) .exec(); } async trackAbandoned(cartId: string, items: CartItem[]) { const value = items.reduce((sum, i) => sum + i.price * i.quantity, 0); await redis.pipeline() .hincrby('analytics:cart:abandoned', 'count', 1) .hincrbyfloat('analytics:cart:abandoned', 'value', value) .lpush('analytics:cart:abandoned:recent', JSON.stringify({ cartId, items: items.map(i => i.productId), value, timestamp: Date.now(), })) .ltrim('analytics:cart:abandoned:recent', 0, 999) // Keep last 1000 .exec(); } async getAbandonmentRate(): Promise<number> { const [created, completed] = await Promise.all([ redis.get('analytics:cart:created'), redis.get('analytics:cart:completed'), ]); const createdNum = parseInt(created || '0'); const completedNum = parseInt(completed || '0'); if (createdNum === 0) return 0; return ((createdNum - completedNum) / createdNum) * 100; } }
import express from 'express'; const app = express(); const cart = new ShoppingCart(); // Get cart ID from cookie or generate new app.use((req, res, next) => { req.cartId = req.user?.id ? `user:${req.user.id}` : req.cookies.cart_id || `guest:${crypto.randomUUID()}`; if (!req.cookies.cart_id && !req.user) { res.cookie('cart_id', req.cartId, { maxAge: 7 * 24 * 60 * 60 * 1000, httpOnly: true, }); } next(); }); app.get('/api/cart', async (req, res) => { const data = await cart.getCart(req.cartId); res.json(data); }); app.post('/api/cart/items', async (req, res) => { const { productId, name, price, quantity, image } = req.body; const data = await cart.addItem(req.cartId, { productId, name, price, quantity, image, }); res.json(data); }); app.patch('/api/cart/items/:productId', async (req, res) => { const { quantity } = req.body; const data = await cart.updateQuantity( req.cartId, req.params.productId, quantity ); res.json(data); }); app.delete('/api/cart/items/:productId', async (req, res) => { const data = await cart.removeItem(req.cartId, req.params.productId); res.json(data); });
Shopping Carts
Build fast, scalable shopping carts with Redis. Perfect for high-traffic e-commerce.
Why Redis for Shopping Carts?#
Basic Cart with Hashes#
Store cart items as hash fields:
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); interface CartItem { productId: string; name: string; price: number; quantity: number; image?: string; } class ShoppingCart { private ttl = 60 * 60 * 24 * 7; // 7 days private key(cartId: string) { return `cart:${cartId}`; } async addItem(cartId: string, item: CartItem) { const key = this.key(cartId); const existing = await redis.hget(key, item.productId); if (existing) { const current = JSON.parse(existing) as CartItem; item.quantity += current.quantity; } await redis.hset(key, item.productId, JSON.stringify(item)); await redis.expire(key, this.ttl); return this.getCart(cartId); } async updateQuantity(cartId: string, productId: string, quantity: number) { const key = this.key(cartId); if (quantity <= 0) { await redis.hdel(key, productId); } else { const existing = await redis.hget(key, productId); if (existing) { const item = JSON.parse(existing) as CartItem; item.quantity = quantity; await redis.hset(key, productId, JSON.stringify(item)); } } await redis.expire(key, this.ttl); return this.getCart(cartId); } async removeItem(cartId: string, productId: string) { await redis.hdel(this.key(cartId), productId); return this.getCart(cartId); } async getCart(cartId: string) { const data = await redis.hgetall(this.key(cartId)); const items = Object.values(data).map(v => JSON.parse(v) as CartItem); const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0 ); return { id: cartId, items, itemCount: items.reduce((sum, item) => sum + item.quantity, 0), subtotal, }; } async clearCart(cartId: string) { await redis.del(this.key(cartId)); } } // Usage const cart = new ShoppingCart(); await cart.addItem('user_123', { productId: 'prod_abc', name: 'Wireless Headphones', price: 79.99, quantity: 1, image: '/images/headphones.jpg', }); await cart.updateQuantity('user_123', 'prod_abc', 2); const myCart = await cart.getCart('user_123'); // { id: 'user_123', items: [...], itemCount: 2, subtotal: 159.98 }Guest Cart → User Cart Migration#
Merge anonymous cart when user logs in:
class CartWithMigration extends ShoppingCart { async migrateGuestCart(guestCartId: string, userId: string) { const guestKey = `cart:${guestCartId}`; const userKey = `cart:user:${userId}`; // Get both carts const [guestItems, userItems] = await Promise.all([ redis.hgetall(guestKey), redis.hgetall(userKey), ]); // Merge: guest items override user items for same product const merged = { ...userItems, ...guestItems }; if (Object.keys(merged).length > 0) { // Write merged cart await redis.del(userKey); await redis.hset(userKey, merged); await redis.expire(userKey, this.ttl); } // Delete guest cart await redis.del(guestKey); return this.getCart(`user:${userId}`); } } // On login app.post('/login', async (req, res) => { const user = await authenticateUser(req.body); const guestCartId = req.cookies.cart_id; if (guestCartId) { await cart.migrateGuestCart(guestCartId, user.id); res.clearCookie('cart_id'); } res.json({ user, cartId: `user:${user.id}` }); });Inventory Reservation#
Reserve inventory when adding to cart:
class CartWithReservation { private reservationTtl = 60 * 15; // 15 minutes async addWithReservation( cartId: string, productId: string, quantity: number ): Promise<{ success: boolean; error?: string }> { const inventoryKey = `inventory:${productId}`; const reservationKey = `reservation:${cartId}:${productId}`; // Lua script for atomic check-and-reserve const script = ` local inventory = tonumber(redis.call('GET', KEYS[1]) or '0') local currentReservation = tonumber(redis.call('GET', KEYS[2]) or '0') local requested = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) -- Calculate additional needed local additional = requested - currentReservation if additional <= 0 then -- Already have enough reserved return 1 end if inventory < additional then -- Not enough inventory return 0 end -- Reserve inventory redis.call('DECRBY', KEYS[1], additional) redis.call('SET', KEYS[2], requested, 'EX', ttl) return 1 `; const result = await redis.eval( script, 2, inventoryKey, reservationKey, quantity, this.reservationTtl ); if (result === 0) { return { success: false, error: 'Not enough inventory' }; } // Add to cart await this.addItem(cartId, { productId, quantity } as CartItem); return { success: true }; } async releaseReservation(cartId: string, productId: string) { const reservationKey = `reservation:${cartId}:${productId}`; const inventoryKey = `inventory:${productId}`; const reserved = await redis.get(reservationKey); if (reserved) { await redis.incrby(inventoryKey, parseInt(reserved)); await redis.del(reservationKey); } } async checkout(cartId: string) { const cart = await this.getCart(cartId); // Reservations become permanent (delete reservation keys) for (const item of cart.items) { await redis.del(`reservation:${cartId}:${item.productId}`); } // Clear cart await this.clearCart(cartId); return { success: true, orderId: generateOrderId() }; } }Real-Time Cart Sync (Multi-Tab)#
Sync cart across browser tabs using Pub/Sub:
// Backend: Publish cart updates class RealtimeCart extends ShoppingCart { async addItem(cartId: string, item: CartItem) { const result = await super.addItem(cartId, item); // Notify all connected clients await redis.publish(`cart:${cartId}`, JSON.stringify({ action: 'update', cart: result, })); return result; } } // Frontend: Subscribe via WebSocket/SSE // server.ts import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', (ws, req) => { const cartId = new URL(req.url!, 'http://localhost').searchParams.get('cartId'); if (!cartId) return ws.close(); // Subscribe to cart updates const subscriber = new Redis(process.env.ARCTICKEY_URL); subscriber.subscribe(`cart:${cartId}`); subscriber.on('message', (channel, message) => { ws.send(message); }); ws.on('close', () => { subscriber.unsubscribe(); subscriber.quit(); }); }); // client.ts const ws = new WebSocket(`wss://api.example.com/cart?cartId=${cartId}`); ws.onmessage = (event) => { const { action, cart } = JSON.parse(event.data); if (action === 'update') { updateCartUI(cart); // React state update, etc. } };Cart Analytics#
Track cart metrics for insights:
class CartAnalytics { async trackAddToCart(productId: string, price: number) { const today = new Date().toISOString().split('T')[0]; await redis.pipeline() // Count adds per product .hincrby('analytics:cart:adds', productId, 1) // Daily revenue potential .hincrbyfloat(`analytics:cart:value:${today}`, 'total', price) // Hourly distribution .hincrby( `analytics:cart:hourly:${today}`, new Date().getHours().toString(), 1 ) .exec(); } async trackAbandoned(cartId: string, items: CartItem[]) { const value = items.reduce((sum, i) => sum + i.price * i.quantity, 0); await redis.pipeline() .hincrby('analytics:cart:abandoned', 'count', 1) .hincrbyfloat('analytics:cart:abandoned', 'value', value) .lpush('analytics:cart:abandoned:recent', JSON.stringify({ cartId, items: items.map(i => i.productId), value, timestamp: Date.now(), })) .ltrim('analytics:cart:abandoned:recent', 0, 999) // Keep last 1000 .exec(); } async getAbandonmentRate(): Promise<number> { const [created, completed] = await Promise.all([ redis.get('analytics:cart:created'), redis.get('analytics:cart:completed'), ]); const createdNum = parseInt(created || '0'); const completedNum = parseInt(completed || '0'); if (createdNum === 0) return 0; return ((createdNum - completedNum) / createdNum) * 100; } }Express API#
import express from 'express'; const app = express(); const cart = new ShoppingCart(); // Get cart ID from cookie or generate new app.use((req, res, next) => { req.cartId = req.user?.id ? `user:${req.user.id}` : req.cookies.cart_id || `guest:${crypto.randomUUID()}`; if (!req.cookies.cart_id && !req.user) { res.cookie('cart_id', req.cartId, { maxAge: 7 * 24 * 60 * 60 * 1000, httpOnly: true, }); } next(); }); app.get('/api/cart', async (req, res) => { const data = await cart.getCart(req.cartId); res.json(data); }); app.post('/api/cart/items', async (req, res) => { const { productId, name, price, quantity, image } = req.body; const data = await cart.addItem(req.cartId, { productId, name, price, quantity, image, }); res.json(data); }); app.patch('/api/cart/items/:productId', async (req, res) => { const { quantity } = req.body; const data = await cart.updateQuantity( req.cartId, req.params.productId, quantity ); res.json(data); }); app.delete('/api/cart/items/:productId', async (req, res) => { const data = await cart.removeItem(req.cartId, req.params.productId); res.json(data); });Best Practices#