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
withIndexinstead ofcollect()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.