This article shows how one SQL trigger and one extra WHERE clause let a vanilla JavaScript client detect and resolve edit conflicts — with no locking system, no hand-maintained version column, and no custom server.
The example assumes you already know SQL and JavaScript. With that background, Supabase can feel deceptively easy to pick up: it's Postgres with a JavaScript client bolted on. That's mostly true, except for one spot where the two layers stop being separate and have to hand off responsibility to each other — what happens when two people edit the same row at the same time.
What you'll build
A single-page notes app: a form to add notes, a list of note cards, and inline edit, save, and delete actions. Multiple browser tabs — or multiple people — can have the app open at once, and a Supabase realtime channel keeps them in sync.
This article covers one slice of that app: what happens when two clients edit the same note at the same time. The realtime sync and the basic CRUD operations are standard Supabase patterns, so the article moves through them quickly to spend most of its time on the conflict-detection logic described above.
Set up your Supabase project
Your schema.sql file does more than create a table. Supabase requires two settings that a plain, self-managed Postgres setup doesn't need, plus one more piece — a trigger — that the rest of this article depends on.
Enable Row Level Security
In a typical backend, your server connects to Postgres with credentials that already have table permissions, and that's the end of it. Supabase flips this: the client ships an anon key straight into the browser, so anyone holding that key can call the API. Row Level Security (RLS) is the gate that decides what the anon key can actually do.
alter table notes enable row level security;
create policy "public full access (demo only)"
on notes
for all
using (true)
with check (true);
With RLS enabled and no policy, Supabase denies all access by default — the opposite of a typical open Postgres setup. The policy above allows everything, which works for learning but is wrong for anything real.
Enable realtime updates
Creating a table doesn't automatically stream its changes. You also need to add the table to Supabase's realtime publication:
alter publication supabase_realtime add table notes
where not exists (
select 1 from pg_publication_tables
where pubname = 'supabase_realtime' and tablename = 'notes'
);
Skip this step, and postgres_changes subscriptions on notes will connect successfully but never fire. This silent failure is worth knowing about before you go looking for bugs elsewhere.
Add the updated_at trigger
The rest of this article depends on one more piece: a trigger that stamps every update with a server-generated timestamp.
create or replace function set_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
create trigger trg_notes_updated_at
before update on notes
for each row
execute function set_updated_at();
On every UPDATE to this table, the database overwrites updated_at unconditionally, before it writes the row. The client can send whatever it wants in that field — the trigger ignores it. Hold onto that fact: it's what makes the conflict detection in this article trustworthy.
Connect the JavaScript client
On the JavaScript side, setup is one line:
const db = supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
This client is your only connection to Postgres from here on. Every query in the rest of this article is a method chain on db.
Add the standard CRUD operations
fetchNotes, createNote, and deleteNote map onto SQL about as literally as you'd expect. Here's fetchNotes:
async function fetchNotes() {
const { data, error } = await db
.from('notes')
.select('*')
.order('created_at', { ascending: true });
if (error) throw error;
return data;
}
createNote and deleteNote follow the same pattern:
-
.from('notes').select('*')isSELECT * FROM notes. -
.insert({...}).select().single()is anINSERT ... RETURNING, unwrapped from an array to a single object because exactly one row comes back. -
.delete().eq('id', id).select()is aDELETE ... RETURNING; the client uses the returned row to confirm a row actually existed to delete.
None of this logic is Supabase-specific — it's SQL wearing a JavaScript accent. The next section covers the one operation that needs more than a literal translation: UPDATE.
Detect conflicts with a compare-and-swap WHERE clause
Here's the bug this app is designed around. Two tabs open the same note. Both load it with the same updated_at. Tab B saves first. If Tab A's save just runs UPDATE notes SET title = ..., content = ... WHERE id = X, it silently clobbers Tab B's change — no error, no warning, just wrong data. This is the classic "last write wins" trap, and it's invisible until two people actually collide.
The fix isn't more JavaScript. It's one extra clause in the SQL:
async function updateNote(id, title, content, knownUpdatedAt) {
const { data, error } = await db
.from('notes')
.update({ title, content })
.eq('id', id)
.eq('updated_at', knownUpdatedAt)
.select();
if (error) throw error;
return data.length === 0 ? null : data[0]; // null = conflict
}
That second .eq() turns this into UPDATE notes SET ... WHERE id = X AND updated_at = Y. Postgres evaluates the whole WHERE clause atomically — there's no gap in time between "check if this row still matches" and "write to it" for another transaction to sneak into. Either the row's updated_at still equals what this client last saw, and the write happens, or it doesn't, and Postgres touches zero rows. This is a compare-and-swap, done entirely inside a single statement, with no application-level locking at all.
The JS side just has to notice which of those two outcomes happened, and .select() after an .update() gives it a clean signal for free: if the WHERE matched nothing, Supabase returns an empty array — not an error. data.length === 0 is the conflict.
This is the seam the whole app is built around: SQL owns the atomicity, JS owns interpreting the result.
The other half of the compare-and-swap lives in the trigger you added during setup. The database — not the browser — sets updated_at on every write. That gives the value three guarantees the client can rely on:
- It can't be stale from clock skew.
- It can't be forged by a malicious client.
- It can't be forgotten or left unset.
In effect, you get a free, tamper-proof version number, without adding a version column or writing any bookkeeping logic yourself:
function startEdit(id) {
const note = notesCache.find(n => n.id === id);
editingNoteId = id;
editingBaselineUpdatedAt = note.updated_at; // the "version" this edit started from
editingDraft = { title: note.title, content: note.content };
render();
}
startEdit() captures editingBaselineUpdatedAt once, when editing begins. On save, updateNote() sends that exact value back into its WHERE clause.
Use realtime updates to warn, not resolve, conflicts
You might be tempted to let the realtime subscription resolve conflicts directly: see an incoming change, merge it into the draft, done. This app deliberately doesn't — and the reason matters:
if (eventType === 'UPDATE') {
const idx = notesCache.findIndex(n => n.id === newRow.id);
if (idx !== -1) notesCache[idx] = newRow;
if (editingNoteId === newRow.id && newRow.updated_at !== editingBaselineUpdatedAt) {
showToast(`"${newRow.title || 'Untitled'}" was changed in another tab while you're editing it.`);
}
}
This handler never touches editingDraft. It only updates the background cache and, if the edited row just changed underneath the editor, shows a toast. The actual conflict resolution happens later, when Save runs and hits the WHERE clause in updateNote().
That separation is deliberate. Websocket messages can arrive late or not at all, and a dropped connection must never cause a bad write — at worst, it should only delay or drop the warning. The app's correctness can't depend on the realtime message arriving; it depends only on the SQL compare-and-swap, which runs synchronously inside the save itself. Realtime's job is entirely about user experience: it gives someone a heads-up before they hit Save, instead of a surprise after. Delete the realtime subscription entirely, and the conflict detection stays airtight — you just lose the early warning.
Trace a two-tab conflict
Open two tabs on the same note, side by side. Here's the sequence that follows:
- Tab A clicks Edit. The app captures
editingBaselineUpdatedAtasT1. - Tab B clicks Edit on the same note. Its baseline is also
T1. - Tab B saves first. The clause
WHERE id = X AND updated_at = T1matches, the trigger bumps the row toT2, and Tab B's edit lands. - Realtime pushes that update to Tab A.
handleRealtimeChangesees thateditingNoteIdmatches andT2 !== T1, so it shows the toast but leaves Tab A's draft untouched. - Tab A clicks Save anyway, missing or ignoring the warning. The query runs
WHERE id = X AND updated_at = T1, but the row'supdated_atis nowT2. Zero rows match, andupdateNote()returnsnull. -
saveEdit()treats thenullas a conflict. It refetches the current notes from the server, shows a toast explaining that the save failed, and returns Tab A to read mode.
if (result === null) {
notesCache = await fetchNotes();
showToast('Save failed: this note changed elsewhere. Showing the latest version — please re-apply your edit.');
cancelEdit();
return;
}
Nobody's edit vanished silently. Tab B's write is safely on the server. The app rejected Tab A's conflicting write cleanly, explained exactly why, and gave Tab A a path to redo the edit on the current data.
Apply this pattern beyond notes apps
This pattern extends beyond notes apps. Whenever two clients might write the same row, don't do a "check, then write" in your application code — there's always a race window between those two steps. Push the check into the write, as part of the same WHERE clause, and let Postgres's transactional guarantees make it atomic for free. A database-owned timestamp — or a dedicated version column, if you want one — gives you a value to compare against without trusting the client to report it honestly.
Keep realtime in its lane: it's a way to tell users something changed, not a mechanism for deciding what to do about that change. The moment a postgres_changes handler starts mutating in-progress state, you've reintroduced the exact race condition the SQL layer eliminates.
That's the real overlap between Supabase's JavaScript and SQL layers: not how to call .update(), but where the responsibility for correctness lives — and making sure your client code never quietly takes it back.
Top comments (0)