Back to KB
Difficulty
Intermediate
Read Time
11 min

Reducing Order Read Latency from 450ms to 18ms: Production CQRS with PostgreSQL 17, Node.js 22, and Go 1.22

By Codcompass Team··11 min read

Current Situation Analysis

We hit the wall at 4,200 concurrent read requests. Our order history endpoint, a monolithic SELECT with six JOINs across orders, line_items, products, shipments, and payments, was pegging the CPU on our PostgreSQL 15 primary instance. The p99 latency had crept from 120ms to 450ms. The business impact was immediate: checkout abandonment correlated directly with history load times, costing us an estimated $14,000/month in lost conversion.

Most tutorials on CQRS (Command Query Responsibility Segregation) are dangerous. They show you how to split a CommandHandler and a QueryHandler into separate files within the same process, using the same database connection. This is not CQRS; this is just code organization. It solves nothing. When you separate the handlers but leave the data model coupled, you still suffer from lock contention, complex joins, and schema rigidity.

The failure mode I see repeatedly in mid-sized teams is the "Dual-Write Trap." Developers attempt to update the write database and the read database synchronously within the same HTTP request. This doubles the write latency and introduces catastrophic consistency bugs when one write succeeds and the other fails. We tried this once. It resulted in DeadlockFound errors in PostgreSQL 17 and phantom orders in our dashboard.

The Bad Approach:

// DO NOT DO THIS. This is the Dual-Write Trap.
async function createOrder(dto: CreateOrderDto) {
  // 1. Write to normalized DB
  const order = await writeDb.orders.create(dto);
  
  // 2. Synchronously update denormalized read model
  // Fails if read DB is slow or down. Increases latency.
  await readDb.orders.upsert({ ...order, calculatedTotal: ... });
  
  return order;
}

This approach blocks the user, couples infrastructure availability, and offers no scalability benefit.

The Setup: We needed a solution that decoupled the read path's performance requirements from the write path's integrity requirements. We needed to optimize the write for ACID compliance and the read for access patterns, with a robust, asynchronous bridge that guaranteed delivery without blocking the user.

WOW Moment

The paradigm shift is realizing that CQRS is not a pattern; it is a controlled trade-off. You pay operational complexity to buy independent scalability and latency reduction.

The "aha" moment came when we stopped treating the read model as a "copy" of the write model. The read model is a distinct data structure optimized for the UI. In our case, the UI needed a flattened JSON blob with pre-calculated totals and status flags. By materializing this in a separate schema and updating it asynchronously via an Idempotent Projection Service, we eliminated all joins from the read path.

Furthermore, we introduced the "Pending Command Consistency" pattern. Standard eventual consistency means a user might not see their order immediately. We bridged this gap by checking the command store for pending commands when the read model lagged, giving the illusion of strong consistency for the user while maintaining async projection under the hood.

Core Solution

Stack Versions:

  • Node.js 22.4.0 (API Layer)
  • TypeScript 5.5.2
  • PostgreSQL 17.0 (Write DB & Read DB)
  • Redis 7.4.0 (Command Bus / Stream)
  • Go 1.22.3 (Projection Worker)
  • pg 8.12.0 (Node Driver)
  • pgx 5.5.5 (Go Driver)

Architecture Overview

  1. Write Path: Node.js API writes to orders table and an outbox table in a single transaction.
  2. Bridge: A Go worker polls the outbox table, publishes events to a Redis Stream, and marks the outbox entry as processed.
  3. Read Path: A separate Go worker consumes the Redis Stream, applies transformations, and upserts into the order_read_model table.
  4. Read API: Node.js reads from order_read_model. If data is stale, it falls back to the outbox to patch the response.

Code Block 1: The Command Handler with Transactional Outbox

This is the critical component. We use the Transactional Outbox pattern. The command and the event are written in the same database transaction. This guarantees that if the command succeeds, the event is persisted. No message queues are touched during the transaction, eliminating network failures from the critical path.

// src/commands/order-command.service.ts
import { Pool, PoolClient } from 'pg';
import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';

const CreateOrderSchema = z.object({
  userId: z.string().uuid(),
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive(),
    price: z.number().positive()
  })).min(1),
  shippingAddressId: z.string().uuid()
});

export class OrderCommandService {
  constructor(private readonly pool: Pool) {}

  async createOrder(userId: string, payload: unknown): Promise<{ commandId: string; status: 'accepted' }> {
    const validated = CreateOrderSchema.parse(payload);
    const commandId = uuidv4();
    const client: PoolClient = await this.pool.connect();

    try {
      await client.query('BEGIN');

      // 1. Write to normalized Write Model
      // Optimized for integrity, constraints, and foreign keys
      const orderResult = await client.query(
        `INSERT INTO orders (id, user_id, status, created_at) 
     

🎉 Mid-Year Sale — Unlock Full Article

Base plan from just $4.99/mo or $49/yr

Sign in to read the full article and unlock all 635+ tutorials.

Sign In / Register — Start Free Trial

7-day free trial · Cancel anytime · 30-day money-back

Sources

  • ai-deep-generated