E-commerce backends have a specific performance profile that generic web application architecture doesn't account for: extreme read-to-write ratios on product data, inventory operations that must be atomic, and traffic spikes during sales events that can be 50x normal load.
Here's how to build a Node.js e-commerce backend that handles US-scale traffic without collapsing under pressure.
Product Catalog Architecture
Product data is read thousands of times more than it's written. The architecture must reflect this.
Write path (low volume, latency tolerant):
Admin updates product → PostgreSQL (source of truth)
→ Cache invalidation event → Redis + CDN
Read path (high volume, latency sensitive):
Customer browses → Redis cache → (miss) → PostgreSQL → repopulate cache
CDN cached pages → (miss) → App server → Redis
// Product service with caching layer
const PRODUCT_CACHE_TTL = 3600; // 1 hour
async function getProduct(productId: string): Promise<Product> {
// Check cache first
const cached = await redis.get(`product:${productId}`);
if (cached) return JSON.parse(cached);
// Cache miss: hit database
const product = await db.query(
'SELECT * FROM products WHERE id = $1 AND active = true',
[productId]
);
if (!product) throw new NotFoundError('Product not found');
// Populate cache
await redis.setex(`product:${productId}`, PRODUCT_CACHE_TTL, JSON.stringify(product));
return product;
}
// On product update: invalidate immediately
async function updateProduct(productId: string, updates: Partial<Product>) {
await db.query('UPDATE products SET ... WHERE id = $1', [...]);
await redis.del(`product:${productId}`); // Invalidate cache
await redis.del(`category:${product.categoryId}`); // Invalidate category listing
}
Inventory Management, The Hard Part
Inventory is where e-commerce backends most commonly fail under load. Two customers can't buy the last item simultaneously, but at scale, preventing this without killing performance requires careful architecture.
The naive approach (don't use this):
-- This race condition will oversell your inventory
SELECT stock FROM products WHERE id = 1; -- returns 1
-- Meanwhile, another request also sees stock = 1
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- Both decrement, stock goes to -1
The correct approach, optimistic locking with atomic operations:
-- Single atomic statement: only succeeds if stock > 0
UPDATE products
SET stock = stock - 1
WHERE id = $1 AND stock > 0
RETURNING id, stock;
-- If this returns 0 rows, stock is exhausted, no overselling
async function reserveInventory(
productId: string,
quantity: number,
orderId: string
): Promise<boolean> {
const result = await db.query(`
UPDATE products
SET stock = stock - $2,
reserved = reserved + $2
WHERE id = $1
AND (stock - $2) >= 0 -- Prevent negative stock
RETURNING id, stock
`, [productId, quantity]);
if (result.rowCount === 0) {
return false; // Insufficient stock
}
// Record the reservation for rollback if order fails
await db.query(
'INSERT INTO inventory_reservations (product_id, quantity, order_id) VALUES ($1, $2, $3)',
[productId, quantity, orderId]
);
return true;
}
Order Processing as a State Machine
Orders have complex lifecycles and things go wrong at every stage, payment fails, fulfillment rejects, shipping loses the package. Model orders as explicit state machines:
const ORDER_STATES = {
PENDING: 'pending',
CONFIRMED: 'confirmed',
PROCESSING: 'processing',
SHIPPED: 'shipped',
DELIVERED: 'delivered',
CANCELLED: 'cancelled',
REFUNDED: 'refunded',
} as const;
const VALID_TRANSITIONS: Record<string, string[]> = {
[ORDER_STATES.PENDING]: [ORDER_STATES.CONFIRMED, ORDER_STATES.CANCELLED],
[ORDER_STATES.CONFIRMED]: [ORDER_STATES.PROCESSING, ORDER_STATES.CANCELLED],
[ORDER_STATES.PROCESSING]: [ORDER_STATES.SHIPPED, ORDER_STATES.CANCELLED],
[ORDER_STATES.SHIPPED]: [ORDER_STATES.DELIVERED],
[ORDER_STATES.DELIVERED]: [ORDER_STATES.REFUNDED],
[ORDER_STATES.CANCELLED]: [],
[ORDER_STATES.REFUNDED]: [],
};
async function transitionOrder(orderId: string, newState: string) {
const order = await getOrder(orderId);
const allowed = VALID_TRANSITIONS[order.status] || [];
if (!allowed.includes(newState)) {
throw new Error(`Invalid transition: ${order.status} → ${newState}`);
}
await db.query(
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2',
[newState, orderId]
);
// Emit event for downstream processing
await eventBus.emit(`order.${newState}`, { orderId, previousState: order.status });
}
Search That Doesn't Kill Your Database
Product search with PostgreSQL full-text search works up to ~100,000 products. Beyond that, you need Elasticsearch or OpenSearch.
// PostgreSQL full-text search (suitable for smaller catalogs)
async function searchProducts(query: string, filters: ProductFilters) {
return db.query(`
SELECT
p.*,
ts_rank(search_vector, plainto_tsquery('english', $1)) AS rank
FROM products p
WHERE
search_vector @@ plainto_tsquery('english', $1)
AND ($2::text IS NULL OR p.category = $2)
AND ($3::numeric IS NULL OR p.price >= $3)
AND ($4::numeric IS NULL OR p.price <= $4)
AND p.active = true
ORDER BY rank DESC
LIMIT 50
`, [query, filters.category, filters.minPrice, filters.maxPrice]);
}
For the search vector column, maintain it automatically with a trigger:
ALTER TABLE products ADD COLUMN search_vector tsvector;
CREATE INDEX products_search_idx ON products USING GIN(search_vector);
CREATE TRIGGER products_search_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_vector, 'pg_catalog.english', 'name', 'description', 'brand');
Flash Sale Traffic Handling
US e-commerce events (Black Friday, Cyber Monday, product launches) can spike traffic 50–100x in seconds. The architecture changes needed:
- Pre-warm your auto-scaling groups 30 minutes before a scheduled sale
- Move to a queue-based order system during peak: accept orders instantly, process them asynchronously
- Implement a virtual waiting room for extreme events using a token bucket pattern
- Cache your product pages aggressively at CloudFront, dynamic pages become a liability when your database is at capacity
- Read replicas take all read traffic during sales events; primary handles writes only
E-commerce backend architecture is a balance of correctness (inventory never oversells, orders never lose state) and performance (product pages load fast, checkout doesn't time out). Getting both right requires intentional design, not accumulated patches.
I build e-commerce platforms and custom backends for US businesses that need more than Shopify can provide. See my work at waqarhabib.com/industries/ecommerce-platforms.
Originally published at waqarhabib.com
Top comments (0)