AfriBa Labs Logo
AfriBa LabsProduct Studio
Databases & Reliability•
8 min read

What Happens When the Database Says Success but the User Gets an Error?

Unpacking the dual-write dilemma, network cuts during transaction commits, and client recovery.

AL

AfriBa Labs Engineering

Systems & Backend Engineering

Published 2026-09-22T08:00:00Z

Key Takeaways

  • A database commit is a local boundary; an HTTP response is a distributed network boundary.
  • Dual writes across two independent storage systems (e.g., PostgreSQL and Kafka) can never be 100% atomic without coordination.
  • The Transactional Outbox Pattern ensures events and domain data are committed within the same database transaction.
  • Client applications must distinguish between network transport failures and application validation errors.

1. The Boundary Gap: Commit vs. Network Ack

One of the most confusing customer support tickets in software engineering goes like this:

'The user saw a red error banner saying "Failed to create order", but the money was deducted and the order actually appeared in the database.'

Engineers instinctively check the application logs, find no exceptions, and discover that the SQL `COMMIT` executed in under 2 milliseconds. What happened?

The explanation lies in the distinction between a local database boundary and a distributed network boundary. A database commit guarantees durability (the 'D' in ACID). Once the WAL (Write-Ahead Log) is flushed to disk, the database has completed its contract. But returning that fact to the user requires traversing the web server, reverse proxy, load balancer, cell tower, and browser. If any packet drops after the commit finishes, the database has succeeded, but the user experiences an error.

2. The Danger of the Dual-Write Problem

This vulnerability multiplies when application code attempts to write to two different systems sequentially:

typescript
// ANTI-PATTERN: The Unprotected Dual Write
async function registerUser(userData: UserInput) {
  // 1. Write to primary database
  const user = await db.users.create({ data: userData });

  // 2. Publish event to message queue or send welcome email
  // If the server crashes or network partitions here:
  // - The user exists in the database
  // - The event/email is NEVER sent
  await messageQueue.publish("user.registered", { userId: user.id });

  return user;
}
A common dual-write bug where database write and message publish are separate operations

In the snippet above, there is no atomic guarantee spanning the database write and the queue publish. If step 1 succeeds and the process crashes before step 2, your system enters an inconsistent state. Reversing the order is equally dangerous: if the message queue publish succeeds but the database write fails on a duplicate email constraint, an email is sent for a user that never exists in the database.

3. The Solution: The Transactional Outbox Pattern

To guarantee that downstream events and database modifications always stay synchronized, software architects use the Transactional Outbox Pattern. Instead of publishing directly to an external queue in application code, the event is inserted into an `outbox` table within the same local database transaction.

sql
BEGIN;

-- 1. Insert domain entity
INSERT INTO orders (id, customer_id, total_amount, status)
VALUES ('ord_104', 'cust_88', 120.00, 'PENDING');

-- 2. Insert event into outbox table in the SAME transaction
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
  'evt_772',
  'Order',
  'ord_104',
  'ORDER_CREATED',
  '{"orderId": "ord_104", "total": 120.00}'
);

COMMIT; -- Either both are saved, or neither is saved.
Outbox pattern: Domain state and integration event committed atomically

How Outbox Dispatchers Work

A separate background worker or Change Data Capture (CDC) process (such as Debezium or a polling relay) reads records from the `outbox_events` table and publishes them to Kafka, RabbitMQ, or SNS. Even if the relay crashes, it will restart and re-publish, ensuring at-least-once event delivery.

4. Designing Resilient Client Architectures

When designing mobile apps (like our Fuel Pilot application) or frontend single-page applications, never assume that a failed network call means the data was not recorded on the server.

Clients should implement reconciliation queries or provide an idempotency identifier so that subsequent retries fetch the existing record rather than duplicating state.

Tags:#Databases#Transactions#Reliability#Distributed Systems#SQL

Related Technical Guides

Backend & APIs
7 min read•Sep 18, 2026

Why Idempotency Matters When a User Clicks 'Pay' Twice

When a client sends a payment request and experiences a network drop, retrying blindly can result in duplicate transactions. Here is how idempotency keys and atomic locking solve this permanently.

APIsDistributed SystemsIdempotency
By AfriBa Labs EngineeringRead Article