Handling Race Conditions in Supabase Applications
Prevent Supabase race conditions with transactions, isolation levels, row and advisory locks, optimistic locking, and unique constraints.

Introduction
If you've ever had two customers technically “buy” the last item in stock, or watched a user's edits get quietly overwritten by someone else's save, you've run into a race condition. In Supabase apps, this shows up most often around inventory counts, forms that get submitted twice, real-time subscriptions, and anything more than one person can touch at the same time. These bugs are especially annoying because they're intermittent — everything looks fine in testing and then breaks under real traffic. This post covers why that happens in Supabase specifically, and the handful of database-level techniques — transactions, isolation levels, row and advisory locks, optimistic locking, and unique constraints — that actually fix it for good.
Quick Answer
The short version: don't let your client-side code read a value, decide something based on it, and then write it back — that gap between the read and the write is exactly where race conditions live. Push that logic into the database instead, using atomic statements like UPDATE ... RETURNING, explicit transactions, row locks (FOR UPDATE), Postgres functions called through supabase.rpc(), unique constraints, or a version column for optimistic locking. PostgreSQL is genuinely good at handling concurrent writes correctly. The trick is giving it the whole operation at once instead of doing it in pieces from the client.
What Are Race Conditions?
A race condition is what happens when the outcome of your code depends on timing you don't control — two things happening “at the same time” and stepping on each other. In a typical Supabase app, that tends to look like:
- Two users hitting “Buy” on the last item in stock within the same second
- A user double-clicking submit and ending up with two records instead of one
- A background job and a user action both updating the same row
- Several real-time clients reacting to the same event and each writing their own update
Worth saying clearly: Supabase's own building blocks — Auth, Storage, Realtime, PostgREST — aren't the problem here. PostgreSQL handles row-level concurrency correctly on its own. The risk is almost always in how the application talks to the database, specifically when a “check this, then update it” operation gets split across two separate network calls.
Common Supabase Race Conditions
Most Supabase race conditions fall into a few common patterns. Here are the ones that come up constantly in real apps, before they show up in a bug report:
- Coupon codes redeemed twice — a user double-clicks “apply,” or fires the request from two open tabs, and gets the discount applied twice before either write finishes
- A limited-time reward claimed more than once — same root cause: two near-simultaneous requests both read “not yet claimed” before either one writes back
- More people joining an event than there are slots — several users hit “RSVP” on the last spot at once, and without a database-level check, more than one of them gets confirmed
- Realtime subscribers stepping on each other — two clients react to the same INSERT event and each fire off a follow-up update, and those updates collide
Every one of these is the same underlying problem wearing a different costume — and every one of them is fixed with the same set of techniques covered below.
The Problem
Here's the pattern that trips people up, and it's an easy one to write without thinking twice:
// UNSAFE: read-then-write race condition
const { data: product } = await supabase
.from("products")
.select("stock")
.eq("id", productId)
.single();
if (product.stock > 0) {
await supabase
.from("products")
.update({ stock: product.stock - 1 })
.eq("id", productId);
}It looks reasonable at first glance. The problem is timing: if two requests hit this within a few milliseconds of each other, both can read stock: 1, both pass the if check, and both decrement — leaving stock at -1 with two customers thinking they bought the same item. This is the textbook “lost update” problem, and Supabase apps are actually a bit more exposed to it than a typical monolithic backend, because every one of those Supabase calls is a network round trip — and that latency is exactly the window where two requests overlap.
The Solution
The fix isn't cleverer client code — it's not doing the read-then-write dance on the client at all. Hand the whole operation to PostgreSQL as a single atomic step, so the database decides who wins when two requests collide, not your JavaScript. A handful of techniques cover most of what you'll run into:
Client Request
↓
Supabase Client / RPC Call
↓
PostgreSQL Function (atomic transaction)
↓
Row-level lock/constraint enforcement
↓
Consistent, race-safe result- Atomic SQL updates — do the check and the update in a single statement
- Explicit transactions — group multi-table operations so they succeed or fail together
- Row locks (FOR UPDATE) — lock a row while you inspect it, so nothing else can touch it first
- Database functions (RPC) — wrap multi-step logic in a Postgres function that runs as one transaction
- Optimistic locking — use a version or timestamp column to catch conflicting writes
- Unique constraints + upsert — let the database reject duplicates instead of checking for them first
- Advisory locks — serialize access to a custom critical section that spans more than one check
Understanding Transaction Isolation
Every technique above sits on top of a concept worth understanding on its own: transaction isolation. PostgreSQL lets you choose how strictly concurrent transactions are shielded from each other, and that choice determines which race conditions are even possible in the first place. You can check what you're currently running with:
show transaction isolation level;Postgres gives you three practical levels to work with:
- Read Committed (the default) — each statement sees whatever was committed before it started. This is what most Supabase apps run on, and it's exactly why the atomic-update and locking patterns in this guide matter — Read Committed on its own won't stop a lost update.
- Repeatable Read — a transaction sees one consistent snapshot for its entire duration. This rules out some anomalies but write skew can still slip through in certain cases.
- Serializable — the strongest guarantee Postgres offers: the outcome behaves as if transactions ran one after another, never in parallel. This can prevent race conditions automatically, without any manual locking.
For something like a banking ledger or another financial workflow, wrapping the transaction in SERIALIZABLE can remove a lot of manual lock management — at the cost of more contention, and occasional serialization failures your application needs to catch and retry:
set transaction isolation level serializable;Note: When you use SERIALIZABLE isolation, PostgreSQL may abort one of the conflicting transactions with a serialization error rather than let it commit. This is expected behavior, not a bug — your application code should catch that error and retry the transaction.
Prerequisites
- A Supabase project with a PostgreSQL database
- Basic SQL knowledge
- Supabase JavaScript client (@supabase/supabase-js) installed
- A working understanding of Row Level Security (RLS)
- A Node.js environment for testing concurrent requests
Step-by-Step Implementation
Step 1: Replace Read-Modify-Write With a Single Atomic Update
For simple counters or stock decrements, do the check and the update in one SQL statement instead of two separate calls:
UPDATE products
SET stock = stock - 1
WHERE id = :product_id AND stock > 0
RETURNING stock;If no row comes back, stock was already at zero — no negative values, no race condition, because PostgreSQL locks the row for the duration of the statement. From the client, it's just a single RPC call:
const { data, error } = await supabase.rpc("decrement_stock", {
product_id: productId,
});
if (!data || data.length === 0) {
console.log("Out of stock");
}Step 2: Create a Postgres Function for Multi-Step Logic
Once more than one table or condition is involved, wrap the logic in a plpgsql function so it runs as a single transaction:
create or replace function decrement_stock(product_id uuid)
returns table (stock int) as $$
begin
return query
update products
set stock = stock - 1
where id = product_id and stock > 0
returning products.stock;
end;
$$ language plpgsql;Call it from the client with supabase.rpc(), same as Step 1. A function call executes within a transaction — if an error occurs partway through and isn't handled, PostgreSQL rolls back everything the function did, so you never end up with a half-applied change. That's a slightly different guarantee than saying concurrent calls are automatically serialized: two calls to this function can still run at the same time, each acquiring its own row lock. What actually prevents the race condition is the atomic UPDATE statement inside the function (as in Step 1), not the function wrapper by itself.
Step 3: Use Transactions for Multi-Table Operations
Some operations touch more than one row or table and need to succeed or fail as a single unit — the classic example is transferring money between two accounts:
begin;
update accounts
set balance = balance - 100
where id = sender_id;
update accounts
set balance = balance + 100
where id = receiver_id;
commit;A transaction gives you one guarantee that matters here:
- All operations succeed together, or all of them fail together
- There's no window where one update lands and the other doesn't
- This is essential for money transfers, inventory reservations that span multiple tables, and multi-step order processing
If anything fails partway through — a constraint violation, a dropped connection — Postgres rolls back everything, so you never end up with money leaving one account and never arriving in the other. Inside a Postgres function (Step 2), every statement already runs inside an implicit transaction, so you get this for free; the explicit begin/commit mainly matters when you're running raw SQL directly, such as in a migration or the SQL editor.
Step 4: Lock Rows Explicitly With FOR UPDATE
Atomic updates and Postgres functions cover a lot of ground, but sometimes you need to read a row, run some application logic against it, and only then decide what to write — without another transaction sneaking in during that gap. That's what a row lock is for:
select *
from products
where id = product_id
for update;This locks the row the moment it's selected, so any other transaction trying to SELECT ... FOR UPDATE the same row has to wait until yours commits or rolls back. It's the natural middle ground between a single atomic statement and a full advisory lock, and it's a common pattern in payment workflows, inventory reservations, and order fulfillment — anywhere you need to inspect a row's state before deciding what to do with it.
In practice, that looks like this: a payment processor locks the order row with FOR UPDATE, checks that the order is still unpaid, then marks it as paid — all inside the same transaction. Any other request trying to process a payment for that same order has to wait for the lock to release before it can even read the row's current state, so two payment attempts can never both succeed.
Step 5: Add Optimistic Locking for Multi-Field Updates
For records like documents or orders, where several fields can change at once, add a version column:
alter table orders add column version int default 1;const { data, error } = await supabase
.from("orders")
.update({ status: "shipped", version: currentVersion + 1 })
.eq("id", orderId)
.eq("version", currentVersion);
if (data.length === 0) {
// Someone else updated this row first — reload and retry
}If the version no longer matches, the update touches zero rows — that's your signal there was a conflict, and your app can retry or let the user know.
If you'd rather not add a new column, plenty of Supabase projects already have an updated_at timestamptz column, and you can use that the same way:
.eq("updated_at", previousUpdatedAt)It works on the same principle as a version column — if updated_at no longer matches what you last read, someone else got there first. It's a smaller lift for an existing table, though a dedicated version integer is a bit more explicit about intent.
Step 6: Prevent Duplicates With Unique Constraints and Upsert
Instead of checking “does this record already exist?” before inserting, let the database enforce that for you:
alter table orders add constraint unique_idempotency_key unique (idempotency_key);const { error } = await supabase
.from("orders")
.upsert(
{ idempotency_key: requestId, user_id: userId, total: amount },
{ onConflict: "idempotency_key", ignoreDuplicates: true }
);This kills double-submission race conditions without any pre-check at all.
If you're writing raw SQL instead of going through the JS upsert() helper, the same idea has a more familiar name in plain PostgreSQL — ON CONFLICT DO NOTHING:
insert into orders (idempotency_key, user_id, total)
values ($1, $2, $3)
on conflict (idempotency_key) do nothing;Same mechanism, same guarantee — worth knowing both forms exist, since ON CONFLICT is what most PostgreSQL documentation and discussions refer to.
Step 7: Use Advisory Locks for Custom Critical Sections
For anything more involved — booking a seat, processing a payout — use a Postgres advisory lock inside a function to serialize access:
create or replace function book_seat(seat_id uuid, user_id uuid)
returns boolean as $$
begin
perform pg_advisory_xact_lock(hashtext(seat_id::text));
if exists (select 1 from bookings where seat_id = book_seat.seat_id) then
return false;
end if;
insert into bookings (seat_id, user_id) values (seat_id, user_id);
return true;
end;
$$ language plpgsql;The lock releases automatically when the transaction ends, and concurrent calls for the same seat_id get queued rather than racing each other. One thing worth flagging: hashtext() can theoretically collide — two different seat_ids hashing to the same lock key, which would serialize unrelated bookings for no real reason. If your ID is already numeric, lock on that directly instead:
perform pg_advisory_xact_lock(seat_numeric_id);If you're stuck with UUIDs and want a lower collision risk than hashtext(), you can derive a bigint from an md5 hash:
perform pg_advisory_xact_lock(
('x' || md5(seat_id::text))::bit(64)::bigint
);Either approach works. hashtext() is fine for most apps — collisions are rare and just cause occasional unnecessary waiting, not incorrect behavior — but it's worth knowing the more precise options exist.
Step 8: Test Under Real Concurrency
Don't just trust the fix — fire a batch of simultaneous requests and see what happens:
await Promise.all(
Array.from({ length: 20 }).map(() =>
supabase.rpc("decrement_stock", { product_id: productId })
)
);Check the final stock value afterward — it should never dip below zero, no matter how many requests fired at once.
Choosing the Right Technique
With this many options, it helps to have a quick reference for which one fits which situation:
| Technique | Best For | Complexity |
| :---- | :---- | :---- |
| Atomic Update | Counters, inventory | Low |
| Transactions (BEGIN/COMMIT) | Multi-table operations, transfers | Low–Medium |
| Row Locks (FOR UPDATE) | Payments, reservations, order fulfillment | Medium |
| Unique Constraint | Duplicate prevention | Low |
| Optimistic Locking | Document editing, collaborative records | Medium |
| Advisory Locks | Custom critical sections | High |
Common Problems / Errors
Stock or Balance Goes Negative
Almost always a client-side read-then-write pattern instead of an atomic SQL update. Move the logic into a database function (Step 2) and it goes away.
Duplicate Records From Double Submission
Happens when there's no unique constraint on the identifying field. Add one, then use upsert with ignoreDuplicates or onConflict.
Update Silently Overwritten by Another User
A sign you're missing optimistic locking. Add a version or updated_at check to the WHERE clause of your updates.
Deadlocks Under Heavy Concurrency
Can happen when advisory locks or row locks get acquired in inconsistent order across functions. Always lock resources in a predictable order — for example, always lock the lower ID first.
Realtime Updates Triggering Duplicate Writes
Several clients reacting to the same Realtime event and each writing back independently causes conflicting updates. Guard those write paths with the same atomic patterns above, and avoid triggering writes directly off a Realtime payload without a server-side check.
Note: Supabase Realtime delivers events after the database transaction commits, but it doesn't guarantee that client-side handlers execute in any particular order. Don't rely on event timing for consistency — enforce it in PostgreSQL with the patterns in this guide instead.
Best Practices
- Push critical read-modify-write logic into PostgreSQL functions instead of client-side JavaScript
- Use UPDATE ... WHERE condition RETURNING instead of separate SELECT and UPDATE calls
- Wrap multi-table operations in explicit transactions so they succeed or fail as a unit
- Add unique constraints for anything that must not be duplicated — orders, bookings, idempotency keys
- Use optimistic locking (a version or updated_at column) for records that multiple users can edit
- Keep transactions short to reduce lock contention
- Test concurrency with real parallel requests before shipping, not just in theory
- Pair RLS policies with server-side functions so security and atomicity are enforced together, not one instead of the other
Performance and Security Considerations
Atomic database functions cut down on network round trips compared to multiple client calls, which helps performance under load as a side effect. Advisory locks are lightweight, but scope them narrowly — per seat or per order, not globally — or you'll end up serializing requests that had nothing to do with each other. SERIALIZABLE isolation gives the strongest guarantees but increases contention and requires your app to catch and retry serialization failures, so reserve it for the operations that truly need it. On the security side, moving logic into SECURITY DEFINER Postgres functions can bypass RLS if you're not careful, so validate the calling user's permissions inside the function itself rather than assuming RLS alone has it covered.
Monitoring and Debugging Concurrency Issues
When something is slow or stuck under concurrency, PostgreSQL gives you the tools to see exactly what's happening. Two queries cover most of it:
select * from pg_locks;Shows every lock currently held or waited on — useful for spotting a query that's been holding a row lock longer than expected.
select * from pg_stat_activity;Shows every active connection and query, including how long each has been running. Cross-referencing this with pg_locks is usually how you track down a blocked query and whatever is blocking it.
Reach for these when a request hangs instead of failing outright (likely waiting on a lock), when you suspect a deadlock (Postgres detects and aborts these automatically, but the logs will point you back to pg_locks), or when lock contention is intermittently slowing down writes under load.
When Should You Use These Techniques?
Reach for atomic updates and locking when:
- Multiple users can act on the same resource — inventory, seats, balances
- A duplicate submission would cause real business impact — double charges, duplicate orders
- Records are edited collaboratively by more than one person
- Realtime subscriptions trigger writes based on shared state
A simpler approach is usually fine when:
- Data is scoped to a single user with no concurrent access
- Operations are read-only or purely additive, with no shared counters
- Eventual consistency is acceptable for what you're building
FAQ
Does Supabase handle race conditions automatically?
No. PostgreSQL guarantees consistency at the row and transaction level, but application code that does a separate read and write can still introduce a race condition. The database only protects what happens inside one atomic statement or transaction — not what happens across two calls from your client.
What's the safest way to prevent race conditions in Supabase?
Move the read-modify-write logic into a PostgreSQL function called through supabase.rpc(), so the entire operation runs as a single transaction instead of multiple round trips from the client.
What's the difference between a transaction and an advisory lock?
A transaction groups a set of operations so they succeed or fail together — it's about atomicity. An advisory lock stops other sessions from running the same critical section at the same time — it's about serialization. They solve different problems and are often used together: wrap the critical section in a transaction, and use an advisory lock inside it so only one session executes that section at once.
Is optimistic locking better than advisory locks?
They're solving different problems. Optimistic locking is better for catching conflicting edits on user-facing records — documents, profiles, that kind of thing. Advisory locks are better for serializing access to a genuinely shared resource, like limited inventory or booking slots.
Can Row Level Security prevent race conditions?
RLS controls who can access which rows, not the timing of concurrent operations. Use it alongside atomic updates — it's not a substitute for them.
Do I need to worry about race conditions with Supabase Realtime?
Yes, if Realtime events trigger writes back to the database from more than one client. Apply the same atomic patterns to those write paths as you would to any other user-triggered update, and don't rely on the order events arrive in.
Conclusion
Race conditions in Supabase apps almost always come down to the same root cause: a read and a write split across multiple client calls instead of one atomic operation handled by PostgreSQL. The fix is consistent across situations — use single atomic SQL statements, wrap multi-step or multi-table logic in explicit transactions, lock rows with FOR UPDATE when you need to inspect before writing, add unique constraints wherever duplicates aren't acceptable, and reach for optimistic locking or advisory locks when a resource is shared or edited collaboratively. Put these patterns in place early, understand the isolation level you're running under, test everything under real concurrency, and you avoid the expensive version of this bug — overbooked inventory, duplicate charges, lost updates — showing up in production instead of in a code review.
Need Help Building Reliable Supabase Applications?
Race conditions are just one of the concurrency issues that tend to surface as an application scales. Our development team helps businesses design, build, and harden Supabase, Next.js, and PostgreSQL applications for production. Contact us to talk through your project.

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


