Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 023 / 100

Preventing overselling: a practical guide for developers

Two customers, one item left.

Expona Admin8 min read

Imagine it's Black Friday: your inventory dashboard shows one item left in stock, but two customers are both completing checkout at exactly the same time. Without the right safeguards, you'll ship nothing to one of them—or worse, create a backorder nightmare you didn't plan for. Preventing overselling: a practical guide for developers means treating inventory as a critical state machine, not a simple counter. We'll walk through the battle-tested patterns that keep your stock accurate and your customers happy.

Why overselling happens (and why it matters)

Most developers think inventory is straightforward: check the count, subtract one, done. But the moment you introduce concurrency—multiple requests hitting your checkout simultaneously—that logic breaks down. A race condition between two read-check-write cycles can both see the same remaining count and both proceed.

In e-commerce, overselling doesn't just frustrate customers; it erodes trust and multiplies support costs. In ride-hailing or SaaS, it means double-booking resources. In travel, it creates legal compliance issues. The financial cost is real: a single oversold item might spawn refunds, expedited shipping, vendor penalties, or reputational damage.

The remedy isn't a simple band-aid; it's a layered strategy combining reservation logic, atomic operations, and honest communication with the customer.

Reserve stock at checkout with an expiry

The first line of defense is a reservation system. Rather than immediately decrementing inventory when a customer clicks "Buy," we create a temporary hold. This hold exists for a limited time—typically 10–30 minutes—and gives the customer a window to complete payment without losing the item.

Consider an e-commerce scenario: a customer adds a limited-edition sneaker to their cart. We create a reservation record that says "Customer ABC holds 1 unit until 14:45 UTC." If they don't pay by then, we release it. If they do pay, we convert the reservation into a final sale.

Here's a typical schema:

typescript
interface StockReservation {
  id: string;
  productId: string;
  customerId: string;
  quantity: number;
  reservedAt: Date;
  expiresAt: Date;  // e.g., reservedAt + 15 minutes
  status: 'active' | 'confirmed' | 'released';
  orderId?: string; // populated on conversion
}

In PostgreSQL, this might look like:

typescript
CREATE TABLE stock_reservations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id UUID NOT NULL,
  customer_id UUID NOT NULL,
  quantity INT NOT NULL CHECK (quantity > 0),
  reserved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  order_id UUID,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  FOREIGN KEY (product_id) REFERENCES products(id),
  FOREIGN KEY (customer_id) REFERENCES customers(id)
);

CREATE INDEX idx_reservations_expiry ON stock_reservations(expires_at)
WHERE status = 'active';

When creating a reservation, check available stock (accounting for existing active reservations), then insert the reservation record. On the application side, you'll run a background job every few minutes to clean up expired reservations and release those holds back to available inventory.

Use atomic decrements or row locks

Once payment is confirmed and a reservation is about to be converted, you must decrement the actual inventory count in a way that is atomic—that is, indivisible and collision-proof. Two concurrent requests must not both succeed if only one unit remains.

The most reliable approach is an atomic decrement operation paired with a constraint. In SQL, this typically means:

typescript
// Atomic decrement with row-level lock
UPDATE inventory 
SET quantity = quantity - ?
WHERE product_id = ? 
  AND quantity >= ?
RETURNING quantity;

The quantity >= ? clause is critical: it ensures the update only succeeds if enough stock exists after the decrement. If the update affects 0 rows, the decrement failed—you're out of stock.

In practice, wrap this in a transaction with explicit locking:

typescript
async function convertReservationToSale(
  reservationId: string,
  quantity: number
): Promise<boolean> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');

    // Lock the inventory row
    const lockResult = await client.query(
      `SELECT quantity FROM inventory 
       WHERE product_id = (
         SELECT product_id FROM stock_reservations 
         WHERE id = $1
       ) FOR UPDATE`,
      [reservationId]
    );

    if (!lockResult.rows.length) {
      await client.query('ROLLBACK');
      return false;
    }

    const currentQuantity = lockResult.rows[0].quantity;
    if (currentQuantity < quantity) {
      await client.query('ROLLBACK');
      return false;
    }

    // Atomic decrement
    await client.query(
      `UPDATE inventory SET quantity = quantity - $1 
       WHERE product_id = (
         SELECT product_id FROM stock_reservations 
         WHERE id = $2
       )`,
      [quantity, reservationId]
    );

    // Mark reservation as confirmed
    await client.query(
      `UPDATE stock_reservations 
       SET status = 'confirmed', order_id = $1 
       WHERE id = $2`,
      [orderId, reservationId]
    );

    await client.query('COMMIT');
    return true;
  } catch (error) {
    await client.query('ROLLBACK');
    console.error('Reservation conversion failed:', error);
    return false;
  } finally {
    client.release();
  }
}

The key elements here are the FOR UPDATE lock (which prevents other transactions from modifying that row), the SERIALIZABLE isolation level (which eliminates phantom reads), and the atomic update with a guard clause. If concurrent requests both attempt this, one will block, the other will succeed, and the loser will see quantity insufficient.

Release reservations on timeout or failure

Reservations that expire must be released promptly so the inventory becomes available again. Otherwise, your dashboard will show stock as available, but the database still holds phantom reservations, and you'll oversell.

Implement a background job that runs every few minutes:

typescript
async function releaseExpiredReservations(): Promise<number> {
  const result = await pool.query(
    `UPDATE stock_reservations
     SET status = 'released'
     WHERE status = 'active' 
       AND expires_at < NOW()
     RETURNING id`
  );
  
  console.log(`Released ${result.rowCount} expired reservations`);
  return result.rowCount;
}

// Run every 5 minutes
setInterval(releaseExpiredReservations, 5 * 60 * 1000);

Also release reservations explicitly if payment fails. When your payment gateway returns a decline, immediately mark that reservation as released:

typescript
async function handlePaymentFailure(reservationId: string): Promise<void> {
  await pool.query(
    `UPDATE stock_reservations 
     SET status = 'released' 
     WHERE id = $1`,
    [reservationId]
  );
  
  // Notify customer, log event, etc.
}

In a distributed system with multiple services, consider using a message queue (Kafka, RabbitMQ) to emit a ReservationExpired event that other services can consume. This decouples the release logic and makes your system more resilient.

Show 'low stock' honestly

Users should see an honest picture of availability. If you have 1 unit left but 3 active reservations, you're not selling anymore—tell them that.

Calculate available stock by subtracting active reservations from physical inventory:

typescript
async function getAvailableStock(productId: string): Promise<number> {
  const result = await pool.query(
    `SELECT 
       (i.quantity - COALESCE(r.reserved, 0)) as available
     FROM inventory i
     LEFT JOIN (
       SELECT product_id, SUM(quantity) as reserved
       FROM stock_reservations
       WHERE status = 'active' AND expires_at > NOW()
       GROUP BY product_id
     ) r ON i.product_id = r.product_id
     WHERE i.product_id = $1`,
    [productId]
  );

  if (!result.rows.length) return 0;
  return Math.max(0, result.rows[0].available);
}

On the frontend, show this clearly:

  • If available > 10: "In stock"
  • If available 3–10: "Only 5 left – order soon"
  • If available 1–2: "Last 2 available"
  • If available = 0: "Out of stock" (disable checkout)

Some teams also show a reservation timer: "Your reservation expires in 8 minutes" once checkout begins. This manages expectations and encourages faster payment.

Common mistakes

  • Forgetting the expiry cleanup job. Reservations that aren't cleaned up become invisible inventory debt. Set a calendar reminder to verify your cleanup is actually running in production.
  • Using application-level locking instead of database locks. A Redis lock or mutex in your app doesn't survive crashes or multi-instance deployments. Always rely on the database for the final decrement.
  • Reserving at the wrong time. Reserve at checkout initiation, not at "add to cart." Adding to cart is too early; customers abandon carts constantly. Reserve when they've committed to paying.
  • Not accounting for reservations in stock queries. If your dashboard or API doesn't subtract active reservations from available count, it will mislead customers and your team.
  • Setting reservation expiry too short. If a customer's bank is slow, a 5-minute window isn't enough. 15–30 minutes is more forgiving. Too long, and you lock up inventory unnecessarily.

Key takeaways

  • Prevent overselling by creating temporary reservations at checkout, not by immediately decrementing stock.
  • Use atomic, database-level decrements with guard clauses to ensure one item can't be sold twice.
  • Release expired reservations in a background job running every few minutes so inventory doesn't get stuck.
  • Calculate and display available stock by subtracting active reservations from physical inventory, and be transparent about low-stock situations.
  • Always rely on the database for the final increment or decrement operation; application-level locking won't survive concurrent requests or deployment restarts.
  • Test your reservation logic with load testing and chaos injection to catch race conditions before production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles