Ahead of the Race: How to Design Your Web3 App Against Race Conditions
All posts
Web3 Security Blockchain Smart Contracts DeFi

Ahead of the Race: How to Design Your Web3 App Against Race Conditions

Tim IllguthJanuary 12, 202610 min read

After auditing dozens of smart contracts and building DeFi applications, I've learned that race conditions in Web3 are fundamentally different from traditional software development. If you come from a web development background, you're probably thinking about threads competing for resources. But in blockchain, the race is public, adversarial, and can cost your users millions.

The Web3 Race Condition Mindset

Here's the shift you need to make: every transaction your user submits is visible to the entire world before it executes. Attackers can see it, copy it, and race ahead of it by paying higher gas fees. This isn't a bug—it's a feature of blockchain transparency that creates an entirely new attack surface.

During a recent audit of an Automated Market Maker (AMM) with flash loan capabilities, I discovered three critical race condition vulnerabilities. Let me walk you through each one and the design patterns that fixed them.

Race Condition #1: Front-Running and Sandwich Attacks

The Problem

When a user submits a transaction, it doesn't execute immediately. It sits in a public mempool where anyone can see it. Miners prioritize transactions with higher gas fees, which creates an opportunity for attackers.

Here's how a sandwich attack works:

  1. Attacker sees your large swap transaction in the mempool
  2. They submit a buy order with higher gas (executes first)
  3. Your transaction executes, moving the price
  4. Attacker sells at the new price for instant profit

You get a worse price. The attacker gets risk-free profit.

The Vulnerability I Found

In the AMM's arbitrage contract, I discovered this critical flaw:

// VULNERABLE: Accepts any output amount amountOut = dexA.swapFirstToken(amount, 0, deadline);

That 0 means "I'll accept any amount of tokens, no matter how bad the price." This is like telling a car dealer you'll pay whatever they ask.

The Fix: Price-Conscious Trading

The solution is to calculate what you expect to receive and refuse anything worse:

// SECURE: Calculate expected output and enforce minimum uint256 expectedAmountOut = dexA.calculateFirstTokenSwap(amount); uint256 minAmountOut = (expectedAmountOut * 995) / 1000; // 0.5% slippage tolerance uint256 amountOut = dexA.swapFirstToken(amount, minAmountOut, deadline);

Key design principles:

  • Calculate expected output before trading
  • Set reasonable slippage tolerance (0.5% in this case)
  • Enforce minimum acceptable amount
  • Use transaction deadlines to prevent stale executions

This makes sandwich attacks unprofitable. If an attacker tries to front-run, the price moves beyond your slippage tolerance and the transaction reverts.

Race Condition #2: Reentrancy Attacks

The Problem

This is the attack that drained $60 million from The DAO in 2016. Here's how it works:

Your contract calls an external contract (like sending ETH). Before your contract can update its state, the external contract calls back into your function. Since your state hasn't updated yet, it thinks the user still has funds and sends them again. Repeat until drained.

The attack flow:

  1. Attacker calls withdraw()
  2. Your contract sends ETH
  3. Attacker's contract receives ETH, triggering fallback()
  4. fallback() immediately calls withdraw() again
  5. Your contract still shows the old balance
  6. Sends ETH again... and again... until empty

The Fix: Reentrancy Guards

I always use OpenZeppelin's battle-tested ReentrancyGuard. Here's how the AMM implemented it:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract AutomatedMarketMaker is ReentrancyGuard { function swapFirstToken(...) external nonReentrant returns (...) { // All state-changing logic protected } function addLiquidity(...) external nonReentrant returns (...) { // Protected from reentrancy } }

The nonReentrant modifier sets a lock before executing and releases it after. If someone tries to call back in, the lock is already set and the transaction reverts.

Pro tip: Also follow the Checks-Effects-Interactions pattern:

  1. Checks: Validate conditions first
  2. Effects: Update state variables
  3. Interactions: Call external contracts last

This double-layer of protection makes reentrancy attacks virtually impossible.

Race Condition #3: Block-Level Manipulation

The Problem

Sometimes attackers race against themselves. They'll execute multiple transactions in the same block, in a specific order, to manipulate your contract's state.

For example:

  1. Buy Token A with Token B
  2. Immediately sell Token B for Token A
  3. Repeat multiple times in one block
  4. Manipulate price oracles or exploit wash-trading vulnerabilities

The Fix: Block-Level Protections

The AMM I audited had sophisticated defenses against this:

Trade Cooldown:

mapping(address => uint256) public lastTradeBlock; modifier tradeCooldown() { require(block.number > lastTradeBlock[msg.sender] + TRADE_COOLDOWN, "Too soon"); _; lastTradeBlock[msg.sender] = block.number; }

Price Impact Limits:

uint256 constant MAX_BLOCK_PRICE_IMPACT = 500; // 5% // Track cumulative price impact per block mapping(uint256 => uint256) public blockPriceImpact; require(blockPriceImpact[block.number] + impact <= MAX_BLOCK_PRICE_IMPACT);

Reverse Trade Prevention:

// Prevent A→B then B→A in same block require(lastTradeDirection[msg.sender][block.number] != opposite);

These protections acknowledge that blockchain state is only truly consistent between blocks, not within them.

Design Principles for Race-Resistant Web3 Apps

After years of auditing and building DeFi applications, I've learned that security isn't something you add later—it's fundamental to your architecture. Here's my checklist for every Web3 project:

1. Assume Adversarial Conditions

  • Every transaction is public before execution
  • Attackers will front-run profitable transactions
  • External contracts may be malicious
  • Users will try to exploit block-level timing

2. Implement Defense in Depth

  • Slippage protection on all trades
  • Transaction deadlines to prevent stale executions
  • Reentrancy guards on state-changing functions
  • Block-level limits on price impact and trade frequency

3. Use Battle-Tested Libraries

Don't roll your own security primitives. Use:

  • OpenZeppelin's ReentrancyGuard
  • Established DEX patterns (Uniswap, Curve)
  • Audited oracle solutions (Chainlink)

4. Test Adversarially

Write tests that simulate:

  • Front-running attacks
  • Reentrancy attempts
  • Rapid sequential transactions
  • Extreme market conditions

Conclusion

Web3 race conditions are fundamentally different from traditional software. The mempool is public, execution is adversarial, and mistakes are permanent. But by designing with these threats in mind from day one, you can build applications that protect your users even in the most hostile environment.

The AMM project I audited is now hardened against these attacks. Your project can be too—just remember that in Web3, paranoia isn't a bug, it's a feature.