DEV Community

Eddy Adegnandjou
Eddy Adegnandjou

Posted on

Eventual Consistency Guarantees Correctness. Your job is to Make Users Believe It.

Correct Systems Are Not Enough. Users Must Perceive Correctness.

Your system says the transaction succeeded. Your user says it didn't.

A user transfers money.

They see:

Transaction successful

They refresh their balance.

Nothing changed.

They refresh again.
Still nothing.

Now doubt sets in:

  • Was I charged?
  • Did it fail?
  • Should I try again?

From the user's perspective, the system is broken.

From the system's perspective... everything is working perfectly.

Two realities, one system

Behind the scenes, your system already knows the truth:

  • The transaction has been recorded
  • The operation is valid
  • The system state is correct

But what the user sees is different:

  • The balance hasn't updated
  • The UI reflects stale data
  • The confirmation feels unreliable

You now have two realities:

1. The system truth (what actually happened)
2. The perceived truth (what the user sees)

And they are temporarily out of sync.

This is Eventual consistency

Modern systems are rarely fully synchronous.

To scale and remain resilient, they rely on:

  • Asynchronous processing
  • Distributed data stores
  • Replication across services

Which leads to:

The system will become consistent... eventually

But that "eventually" is where problems begin.

What really happens under the hood

Let's break it down:

  1. User initiates transaction

  2. API writes to the primary database

  3. An event is published

  4. A background worker processes it

  5. A read replica updates later

At any given moment:

  • One part of the system says: "done"
  • Another part says: "not yet"

Both are technically correct.

Correct systems are not enough. Users must perceive correctness.

Models

The models define the core domain of the system and illustrate how the backend maintains correctness while the user sees a delayed view.

# models.py
class Account(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)


class Transaction(models.Model):
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=20, default="pending")
    created_at = models.DateTimeField(auto_now_add=True)

class LedgerEntry(models.Model):
    account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="ledger_entries")
    transaction = models.ForeignKey(Transaction, on_delete=models.CASCADE)
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)
Enter fullscreen mode Exit fullscreen mode

Account

  • Represents a user's financial account.
  • balance shows the current amount, but it may be temporarily outdated due to async processing.

Transaction

  • Represents a single user-initiated transaction.
  • Starts with status pending, showing the action was received by the system.
  • Updated to completed after async processing finishes.

LedgerEntry

  • Provides an immutable record of all transactions applied to an account.
  • Ensures an auditable source of truth for balances.
  • Even if the user's view is delayed, the ledger guarantees correctness.
  • Reinforces eventual consistency and auditability.

Celery Task

This task handles the asynchronous processing, demonstrating how backend truth is updated safely while users perceive a delay.

# tasks.py (Celery)
from django.db import transaction
from celery import shared_task
from .models import Transaction, LedgerEntry

@shared_task 
def process_transaction(transaction_id):
    with transaction.atomic():
        tx = Transaction.objects.select_for_update().get(id=transaction_id)
        account = tx.account

        LedgerEntry.objects.create(
            account=account,
            transaction=tx,
            amount=tx.amount
        )

        account.balance += tx.amount
        account.save()

        tx.status = "completed"
        tx.save()
Enter fullscreen mode Exit fullscreen mode

process_transaction

  • Runs asynchronously, updating the transaction and account balance.
  • Uses select_for_update within a transaction to prevent race conditions when multiple transactions happen simultaneously.
  • Creates a LedgerEntry to record the transaction.
  • Updates account balance and marks the transaction completed.

View

The view shows how the system immediately communicates to the user while the actual processing happens in the background.

# views.py
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from .models import Transaction, Account
from .tasks import process_transaction

def create_transaction(request):
    account_id = request.POST.get("account_id")
    amount = float(request.POST.get("amount"))

    account = get_object_or_404(Account, id=account_id, user=request.user)

    tx = Transaction.objects.create(account=account, amount=amount)

    process_transaction.delay(tx.id)

    return JsonResponse({
        "transaction_id": tx.id,
        "status": tx.status,
        "message": "Transaction received and processing..."
    })
Enter fullscreen mode Exit fullscreen mode

create_transaction

  • Receives account_id and amount from the request.
  • Ensures the account belongs to the requesting user.
  • Creates a Transaction in pending state and triggers the async task.
  • Returns immediate feedback to the user.

Closing Thought

Eventual consistency ensures correctness in distributed systems. But trust is a UX problem.

  • Show pending states
  • Use optimistic UI

The system is correct. Users must believe it.

Top comments (0)