DEV Community

Alair Joao Tavares
Alair Joao Tavares

Posted on • Originally published at activi.dev

Fortifying Transactional Integrity: A Full-Stack Guide to Preventing Double Submissions and Race Conditions with Python & React

In the world of web applications, especially those handling financial transactions or critical user actions, data integrity is paramount. A seemingly innocuous double-click on a 'Submit' button can cascade into a series of unintended consequences: duplicate orders, double charges, or corrupted state. While a quick UI fix might seem sufficient, it only addresses the tip of the iceberg. The more insidious threat lies hidden in the backend: the race condition, where concurrent requests clash in a battle to modify the same resource, leading to unpredictable and often disastrous outcomes.

This article tackles this two-headed problem from a full-stack perspective. We'll explore how to build a robust defense, starting with a user-friendly guard on the frontend using React and TypeScript. Then, we'll dive deep into the backend, implementing a powerful database-level locking mechanism in Python with Django and PostgreSQL to ensure transactional atomicity. By fortifying both the client and server, we can build systems that are not just resilient but truly trustworthy.

The Frontend Fortress: Disarming the Double-Click in React

The first line of defense is the user interface. Users are often the source of duplicate submissions, whether through an impatient series of clicks on a slow network or a simple mistake. Preventing this at the source provides immediate feedback, improves the user experience, and reduces unnecessary load on your backend.

The most effective strategy is to implement a loading state guard. When a user initiates an action, we disable the submit button and provide visual feedback (like a spinner) until the request completes. This makes it physically impossible for the user to submit the form again.

Let's see how to implement this in a React component using TypeScript and state hooks.

Building a Guarded Form Component

Imagine a form where a user accepts a financial proposal. We want to ensure they can only accept it once per click.

// src/components/ProposalAcceptanceForm.tsx

import React, { useState } from 'react';

// A mock API client function
const api = {
  acceptProposal: async (proposalId: string, bankDetails: object): Promise<{ success: boolean }> => {
    // Simulate a network request that takes 2 seconds
    return new Promise(resolve => setTimeout(() => resolve({ success: true }), 2000));
  }
};

interface ProposalAcceptanceFormProps {
  proposalId: string;
  onSuccess: () => void;
}

export const ProposalAcceptanceForm: React.FC<ProposalAcceptanceFormProps> = ({ proposalId, onSuccess }) => {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  const [bankDetails, setBankDetails] = useState({ accountNumber: '', routingNumber: '' });

  const handleSubmit = async (event: React.FormEvent) => {
    event.preventDefault();

    // If a request is already in flight, do nothing.
    if (isLoading) {
      return;
    }

    setIsLoading(true);
    setError(null);

    try {
      const response = await api.acceptProposal(proposalId, bankDetails);
      if (response.success) {
        onSuccess();
      }
    } catch (err) {
      setError('An unexpected error occurred. Please try again.');
      console.error(err);
    } finally {
      // CRITICAL: Always reset the loading state, even on failure.
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Accept Proposal</h2>
      {/* Form fields for bank details would go here */}
      <div>
        <label>Account Number:</label>
        <input 
          type="text" 
          value={bankDetails.accountNumber}
          onChange={e => setBankDetails({...bankDetails, accountNumber: e.target.value})}
          disabled={isLoading}
        />
      </div>
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Processing...' : 'Accept and Continue'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

In this example, we use the isLoading state variable to control the UI's behavior.

  1. State Management: const [isLoading, setIsLoading] = useState<boolean>(false); initializes our loading flag.
  2. Guard Condition: The if (isLoading) { return; } check at the start of handleSubmit acts as an extra safeguard, though the disabled button is the primary deterrent.
  3. UI Feedback: The button's text changes and it's disabled via disabled={isLoading}. This is crucial for UX.
  4. Error Handling: The try...catch...finally block is essential. The finally block guarantees that setIsLoading(false) is called, re-enabling the form for another attempt even if the API call fails.

This simple pattern effectively solves the user-driven double submission problem.

The Backend Guardian: When UI Fixes Aren't Enough

A disabled button is a great start, but it's not a security measure. A malicious actor, or even a simple script, can easily bypass the UI and send multiple concurrent requests directly to your API endpoint. If two requests for accepting the same proposal arrive at nearly the same instant, your backend might process both before the state of the first one is saved.

This is a classic race condition. Consider this sequence of events:

  1. Request A arrives to process Proposal #123.
  2. The backend code loads Proposal #123 from the database. Its status is PENDING.
  3. Simultaneously, Request B arrives for the same Proposal #123.
  4. The backend code for Request B also loads Proposal #123. Its status is still PENDING because Request A hasn't committed its changes yet.
  5. Both requests see a valid, pending proposal and proceed to process it.
  6. Request A updates the proposal's status to ACCEPTED and saves.
  7. Request B also updates the proposal's status to ACCEPTED and saves, potentially processing the transaction a second time.

The result is data corruption. To prevent this, we must ensure that the operation to check and update the resource is atomic. This is where database-level locking comes into play.

Pessimistic Locking with Django's select_for_update

When we anticipate high contention for a resource, we can use pessimistic locking. This strategy assumes that conflicts are likely and locks the resource at the beginning of a transaction, preventing any other transaction from modifying it until the first one is complete. It's like saying, "I'm about to work on this data, so nobody else touch it until I'm done."

In Django, with a database like PostgreSQL that supports row-level locking, the select_for_update() queryset method is the perfect tool. It translates to a SELECT ... FOR UPDATE SQL statement, which locks the selected rows until the current transaction is committed or rolled back.

Let's implement this on our Python/Django backend.

Implementing an Atomic Service Function

First, let's define our Django model.

# core_app/models.py
from django.db import models

class FinancialCommitment(models.Model):
    class Status(models.TextChoices):
        PENDING = 'PENDING', 'Pending'
        PROCESSING = 'PROCESSING', 'Processing'
        ACCEPTED = 'ACCEPTED', 'Accepted'
        EXPIRED = 'EXPIRED', 'Expired'

    status = models.CharField(
        max_length=20, 
        choices=Status.choices, 
        default=Status.PENDING
    )
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    deadline = models.DateTimeField()
    # ... other fields
Enter fullscreen mode Exit fullscreen mode

Now, let's create a service function that atomically processes a commitment. This is where the magic happens.

# core_app/services.py
from django.db import transaction
from django.utils import timezone
from .models import FinancialCommitment

class CommitmentError(Exception):
    pass

def accept_commitment(commitment_id: int):
    """
    Atomically accepts a financial commitment, preventing race conditions.
    """
    try:
        # `transaction.atomic` ensures the whole block is one database transaction.
        with transaction.atomic():
            # `select_for_update()` locks the row.
            # If another transaction has a lock, this line will wait until it's released.
            commitment = (
                FinancialCommitment.objects.select_for_update()
                .get(pk=commitment_id)
            )

            # --- Start of Critical Section ---
            # All checks and modifications happen *after* the lock is acquired.

            # 1. Business Logic Validation: Check status
            if commitment.status != FinancialCommitment.Status.PENDING:
                raise CommitmentError(f"Commitment is not pending, but in '{commitment.status}' state.")

            # 2. Business Logic Validation: Check deadline
            if commitment.deadline < timezone.now():
                # Optionally update status to EXPIRED here
                commitment.status = FinancialCommitment.Status.EXPIRED
                commitment.save()
                raise CommitmentError("Commitment has expired.")

            # 3. Process the commitment
            print(f"Processing commitment {commitment.id} for amount {commitment.amount}...")
            # ... perform other business logic like creating transactions, etc.

            # 4. Update the state
            commitment.status = FinancialCommitment.Status.ACCEPTED
            commitment.save()

            # --- End of Critical Section ---

            return {"status": "success", "commitment_id": commitment.id}

    except FinancialCommitment.DoesNotExist:
        raise CommitmentError("Commitment not found.")

    # The transaction will be automatically rolled back if any exception
    # (including CommitmentError) is raised within the `atomic` block.

Enter fullscreen mode Exit fullscreen mode

Let's break down the key components of this robust function:

  1. transaction.atomic(): This Django context manager wraps our logic in a single database transaction. If any exception occurs inside this block, all database operations are automatically rolled back, leaving the database in its original state.
  2. select_for_update(): This is the core of our concurrency control. When this line executes, the database finds the FinancialCommitment row with the given ID and places a lock on it. If another concurrent request tries to execute the same line for the same ID, it will be forced to wait until our current transaction either commits or rolls back.
  3. Critical Section: The code between acquiring the lock and the end of the atomic block is our critical section. It's vital that all state checks (like commitment.status) and modifications happen inside this locked, atomic block. This guarantees that we are operating on the most up-to-date version of the data and that no other process can interfere.
  4. Business Logic Validation: Notice we're not just locking; we're also performing crucial business rule validations, such as checking the commitment's status and deadline. This holistic approach ensures both concurrency safety and data integrity.

Writing Tests for Concurrent Operations

How can we be sure our lock is working? Testing for race conditions can be tricky because they are hard to reproduce reliably. However, we can simulate a high-contention scenario in our tests using Python's built-in threading module.

The goal is to fire off multiple requests to our service function at the same time and assert that only one succeeds.

# core_app/tests.py
from django.test import TestCase
from threading import Thread
from .models import FinancialCommitment
from .services import accept_commitment, CommitmentError
from django.utils import timezone
from datetime import timedelta

class ConcurrencyTests(TestCase):

    def test_commitment_acceptance_race_condition(self):
        """
        Ensures that two threads trying to accept the same commitment
        results in only one success due to `select_for_update` locking.
        """
        # 1. Setup
        commitment = FinancialCommitment.objects.create(
            status=FinancialCommitment.Status.PENDING,
            amount=1000.00,
            deadline=timezone.now() + timedelta(days=1)
        )

        results = []

        # 2. Define the worker function for each thread
        def worker():
            try:
                result = accept_commitment(commitment.id)
                results.append(result)
            except CommitmentError:
                results.append(None) # Mark as failed

        # 3. Create and start threads
        thread1 = Thread(target=worker)
        thread2 = Thread(target=worker)

        thread1.start()
        thread2.start()

        # 4. Wait for both threads to complete
        thread1.join()
        thread2.join()

        # 5. Assertions
        # Refresh the object from the database to get its final state
        commitment.refresh_from_db()

        self.assertEqual(commitment.status, FinancialCommitment.Status.ACCEPTED)

        # Check that one call succeeded and one failed
        successful_calls = [r for r in results if r is not None]
        failed_calls = [r for r in results if r is None]

        self.assertEqual(len(successful_calls), 1, "Exactly one thread should succeed.")
        self.assertEqual(len(failed_calls), 1, "Exactly one thread should fail with CommitmentError.")
Enter fullscreen mode Exit fullscreen mode

This test simulates the race condition perfectly:

  • It creates a single FinancialCommitment in a PENDING state.
  • It defines a worker function that encapsulates the call to our service.
  • It launches two threads, both targeting the same worker function and thus the same commitment ID.
  • It then asserts that after both threads have finished, only one was able to successfully process the commitment, while the other raised a CommitmentError (because by the time it acquired the lock, the status was no longer PENDING).

Conclusion: A Layered Defense for Data Integrity

Transactional integrity is not a single feature but a result of a deliberate, multi-layered defense strategy. By combining frontend safeguards with backend atomicity, you create a system that is robust from the user's browser all the way down to the database.

Here are the key takeaways:

  • Start at the Frontend: Use UI loading states in frameworks like React to prevent accidental double submissions. This improves user experience and cuts down on benign duplicate requests.
  • Never Trust the Client: Frontend guards are for UX, not security. Always assume your API can and will receive concurrent requests for the same resource.
  • Embrace Pessimistic Locking: For critical, high-contention operations, pessimistic locking using Django's select_for_update() within a transaction.atomic() block is a powerful and reliable pattern to prevent race conditions.
  • Validate Inside the Lock: Perform all your business logic checks after you've acquired the database lock to ensure you're working with the most current and secure state of your data.
  • Test for Concurrency: Don't leave race conditions to chance. Write explicit tests using threading or other concurrency tools to prove that your locking mechanism works as expected under pressure.

Top comments (0)