DEV Community

Cover image for Building Scalable E-commerce Backends with Node.js
Waqar Habib
Waqar Habib Subscriber

Posted on • Originally published at waqarhabib.com

Building Scalable E-commerce Backends with Node.js

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
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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)