| name | convex-write-conflicts |
| description | Diagnose and permanently fix Convex OCC "Retried due to write conflicts in table X" errors. Use when an Insight or log shows a mutation retrying on the same hot document, especially heartbeat/last-active/counter style writes. |
Fixing Convex write conflicts (OCC)
Convex uses optimistic concurrency control. A mutation is retried when a
document it read or wrote changed during execution. If parallel mutations keep
fighting over the same document, you see:
Retried due to write conflicts in table X
Reference: https://docs.convex.dev/error#1
How to read the Insight
In the Convex dashboard Insight Breakdown, look at:
- Table name in the title (which table is hot)
- Conflicting Document ID (one specific row is usually the culprit)
- Conflicting Function ("Self" means the mutation conflicts with other calls to itself)
- Recent Events timestamps (bursts within the same second = many parallel calls on one row)
A single repeated document ID + "Self" + same-second bursts means: too many
parallel writes to one hot document.
Root cause patterns
- Heartbeat / "last active" timestamp written on every tick.
- Counters incremented on a single document.
- A mutation that reads a whole table then writes one row (any insert elsewhere conflicts).
- A mutation called in a loop from an action.
The fix (in priority order)
-
Do not write on every call. Gate the write behind a staleness threshold so
most calls become read-only no-ops. Read-only calls do not cause write
conflicts, and a losing write retry reads the fresh value and early-returns.
const ACTIVITY_UPDATE_THRESHOLD_MS = 2 * 60 * 1000;
if (args.lastActiveAt - doc.lastActiveAt >= ACTIVITY_UPDATE_THRESHOLD_MS) {
await ctx.db.patch(doc._id, { lastActiveAt: args.lastActiveAt });
}
-
Read only what you need. Use withIndex instead of collect() over a table,
so the read set is small and conflicts are less likely.
-
Spread writes across documents. For true high-throughput counters use the
Sharded Counter component instead of one row.
-
De-synchronize clients. Add jitter to client intervals so heartbeats from
many tabs/clients do not fire in lockstep:
const intervalMs = 60000 + Math.floor(Math.random() * 15000);
-
Do not call a mutation many times in an action loop.
Reference fix in this repo
convex/judges.ts -> updateActivity was retrying ~1.6K times on one judge row.
Fix: 2-minute server-side staleness threshold (writes become rare no-ops) plus
jittered client heartbeat in src/pages/JudgingInterfacePage.tsx. This is the
canonical pattern for any "last active" or heartbeat write in this codebase.
Verify the fix
- Deploy and watch Health -> Insights for the function. The retry count should
stop climbing and the warning should clear within a couple hours (Insight
data lags).
- Confirm the feature still works (activity still updates, just less often).
- Threshold precision is intentionally coarse; activity tracking only needs
minute-level accuracy.