---
title: "PayoutEngine — Concurrency-Safe Payout System"
description: "Prevents double-spending when two payout requests hit the same merchant balance at the same instant — verified with real threading tests, not mocks."
canonical: "https://shivanshutiwari.in/projects/payout-engine"
lastUpdated: "2026-09-02"
---

# PayoutEngine — Concurrency-Safe Payout System

Prevents double-spending when two payout requests hit the same merchant balance at the same instant — verified with real threading tests, not mocks.

**Stack:** Python, Django, Django REST Framework, React, PostgreSQL, Redis, Celery, Vite, Tailwind CSS, Render, Vercel, Stripe  
**Date:** 2026  

## The Problem

International agencies and freelancers in India receive USD payments and need to withdraw to INR bank accounts. The core failure mode I wanted to eliminate: two simultaneous payout requests both check the balance, both see it as sufficient, both deduct — and the merchant is overdrawn. Failed bank settlements also need to release held funds cleanly, not leak them.

## What I Built

I gave merchants no mutable balance field at all — balance is always SUM(credits) − SUM(debits), computed live from an immutable ledger table on every request. Payouts move through a strict state machine I enforce at the model level (pending → processing → completed/failed), and every balance check during payout creation takes a pessimistic row lock (select_for_update) to close the race condition at the database level rather than trying to catch it after the fact.

## Architecture

```
React Frontend ──▶ Django REST API ──▶ PostgreSQL (Ledger table)
                        │                      ▲
                        ▼                      │
                  Celery Workers ────────── Redis (broker)
                        │
                        ▼
                Bank Settlement (simulated)
```

## Key Decisions & Tradeoffs

- I chose pessimistic locking over optimistic — it closes the double-spend race completely, at the cost of reduced throughput under high concurrency. For a payout system, I decided correctness beats speed.
- I built an immutable ledger with live aggregation — every balance read recomputes from the ledger instead of trusting a cached field. More expensive reads, but the balance can never drift out of sync with its own history.
- I used atomic claim-and-set for Celery workers — prevents the same payout from being processed twice if two workers pick it up simultaneously.
- I set a 24-hour idempotency key expiry — protects against duplicate submissions within a session, while still letting a genuinely new attempt go through after a day.

## Highlights

- Immutable ledger with no mutable balance field — balance is always SUM(credits) − SUM(debits), computed live on every request
- Pessimistic row locking (select_for_update) closes the double-spend race at the database level
- Verified with real Python threading tests: two simultaneous 6,000 paise requests against 10,000 — exactly one succeeds, no mocks
- Strict state machine enforced at the model level (pending → processing → completed/failed) with atomic claim-and-set for Celery workers
- 24-hour idempotency key expiry protects against duplicate submissions while allowing legitimate retries

---
*Source: https://shivanshutiwari.in/projects/payout-engine*
