- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
Race Conditions Explained With a Bank Account: Where the Missing Money Went
About Post
An account has 100 in it. Two withdrawals arrive at the same moment: one for 30, one for 50. Both succeed. The final balance is 50.
Not 20. Fifty. Thirty just evaporated, and every line of code involved is "correct". Each request read the balance, checked there was enough, subtracted, and saved. That's a race condition, and it's one of those bugs that passes every test you write, because your tests run one request at a time and your users don't.
What actually happened
Here's the timeline. Each row is a moment; the two columns are two PHP workers handling two requests in parallel:
| Time | Request A (withdraw 30) | Request B (withdraw 50) |
|---|---|---|
| 1 | Reads balance: 100 | |
| 2 | Reads balance: 100 | |
| 3 | 100 ≥ 30, OK. Saves 70 | |
| 4 | 100 ≥ 50, OK. Saves 50 |
Request B made its decision using a value that was already out of date. Its write silently replaced A's. This is called a lost update, and it's the most common race condition in web apps.
The code that does it looks completely innocent:
$account = Account::findOrFail($id);
if ($account->balance < $amount) {
throw new InsufficientFunds();
}
$account->balance = $account->balance - $amount;
$account->save();
The gap between "read" and "write" is a few milliseconds. On a quiet test server that window almost never gets hit. On a busy production server, or with a user double-tapping a button on a slow connection, it will be.
And no, wrapping it in a transaction doesn't fix it on its own. With MySQL's default isolation level, a plain SELECT inside a transaction still reads without locking, so both requests see 100.
Fix 1: let the database do the maths
The simplest fix is to stop reading the value into PHP at all. Put the check and the change in a single SQL statement, which the database runs atomically:
$updated = Account::whereKey($id)
->where('balance', '>=', $amount)
->decrement('balance', $amount);
if ($updated === 0) {
throw new InsufficientFunds();
}
That runs UPDATE accounts SET balance = balance - 30 WHERE id = ? AND balance >= 30. The row is locked for the duration of the statement, so the second request sees the new balance. If there isn't enough money, zero rows change and you know about it.
This is my default for counters, stock levels, seat counts and balances. It's fast, it doesn't hold locks for long, and there's nothing to forget.
Fix 2: lock the row while you decide
Sometimes the logic is too rich for one UPDATE. You need to read the account, check a few rules, write a ledger entry and update the balance together. Then you want a pessimistic lock: "I'm working on this row, everyone else waits."
DB::transaction(function () use ($id, $amount) {
$account = Account::whereKey($id)->lockForUpdate()->firstOrFail();
if ($account->balance < $amount) {
throw new InsufficientFunds();
}
$account->decrement('balance', $amount);
$account->ledgerEntries()->create([
'type' => 'withdrawal',
'amount' => $amount,
]);
});
lockForUpdate() adds FOR UPDATE to the query. Request B now blocks at that line until A's transaction commits, then reads the fresh balance of 70. Two rules make or break this:
- The lock only works inside a transaction. Outside one, it's released the moment the query finishes.
- Keep the transaction short. No HTTP calls to a payment gateway, no emails, no PDF generation inside it. Everyone else waiting for that row is waiting for your slowest line.
When you lock more than one row, as in a transfer between two accounts, always lock them in the same order (for example, lowest id first). Otherwise A locks account 1 and waits for 2, while B locks 2 and waits for 1, and the database has to kill one of them as a deadlock. Laravel can retry for you: DB::transaction($callback, 3) re-runs the closure up to three times on a deadlock.
Fix 3: optimistic locking for long edits
Locks are wrong for things like a staff member editing a contract in a form for ten minutes. You can't hold a database lock while someone goes to get coffee.
Here, add a version column. Save with "update this row only if the version is still the one I loaded", and bump it:
$updated = Contract::whereKey($contract->id)
->where('version', $request->integer('version'))
->update([...$request->validated(), 'version' => DB::raw('version + 1')]);
if ($updated === 0) {
// Someone else saved first: reload and show them the changes
}
Nobody waits, and nobody silently overwrites a colleague's work.
Fix 4: unique constraints for "check, then insert"
Another family of races doesn't update anything. It creates duplicates. "Does this user already have a booking for this slot? No? Insert one." Two requests both see "no" and both insert.
The fix is a unique index on the columns that define "duplicate", plus handling the error:
try {
Booking::create(['unit_id' => $unitId, 'slot' => $slot, 'user_id' => $userId]);
} catch (UniqueConstraintViolationException) {
return back()->withErrors(['slot' => 'That slot was just taken.']);
}
Your application check is for a friendly message. The unique index is the actual guarantee, because the database enforces it even when two inserts arrive in the same millisecond.
And when the shared thing isn't a row?
If two workers must not run the same job at once, like generating this month's invoices, a cache-based atomic lock does the job: Cache::lock('invoices:'.$month, 120)->get(fn () => $this->generateInvoices($month)). It needs a cache driver that supports atomic locks, such as Redis or the database driver.
The one-line test: if your code reads a value, makes a decision, and then writes based on that decision, ask "what if another request does the same thing in between?" If the answer is bad, make the read-decide-write atomic: one SQL statement, a row lock, a version check, or a unique constraint.
How to see it with your own eyes
Race conditions are easier to believe once you've caused one. Fire a burst of parallel requests at a local endpoint:
seq 20 | xargs -P 20 -I{} curl -s -X POST http://localhost:8000/api/withdraw -d amount=30
Run it against the naive version and against Fix 1, and compare the final balances. It's a five-minute experiment that changes how you read code.
Cheat sheet
- Counters, balances, stock: atomic
UPDATE ... WHERE. - Multi-step logic on a row:
lockForUpdate()in a short transaction. - Long human edits: optimistic locking with a version column.
- Duplicates: a unique index, and catch the exception.
- Jobs and non-database resources: atomic cache locks.
Where did a race condition first get you: money, stock, bookings, or something stranger?

Be first to comment it...