Files
wiki/.cursor/rules/convex-write-conflicts.mdc
Wayne Sutton 97081dc82d fix(stats): resolve write conflicts in activeSessions table
- Add 10-second dedup window to heartbeat mutation for idempotency
- Add frontend debouncing (5s) with refs to prevent duplicate calls
- Track pending heartbeat state to block overlapping async calls
- Early return when session already updated with same path

Backend (convex/stats.ts):
- Add HEARTBEAT_DEDUP_MS constant (10 seconds)
- Check if session was recently updated before patching

Frontend (src/hooks/usePageTracking.ts):
- Add isHeartbeatPending, lastHeartbeatTime, lastHeartbeatPath refs
- Wrap sendHeartbeat in useCallback with debounce logic
- Import useCallback from React

Fixes write conflict retries shown in Convex dashboard for stats:heartbeat
2025-12-15 12:52:34 -08:00

772 lines
20 KiB
Plaintext

---
description: Guidelines for preventing write conflicts when using React, useEffect, and Convex
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx
alwaysApply: true
---
# Preventing Write Conflicts in Convex with React
Write conflicts occur when two functions running in parallel make conflicting changes to the same table or document. This rule provides patterns to avoid these conflicts.
## Understanding Write Conflicts
According to [Convex documentation](https://docs.convex.dev/error#1), write conflicts happen when:
1. Multiple mutations update the same document concurrently
2. A mutation reads data that changes during execution
3. Mutations are called more rapidly than Convex can execute them
Convex uses optimistic concurrency control and will retry mutations automatically, but will eventually fail permanently if conflicts persist.
## Backend Protection: Idempotent Mutations
### Always Make Mutations Idempotent
Mutations should be safe to call multiple times with the same result.
**Good Pattern:**
```typescript
export const completeTask = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
// Early return if document doesn't exist
if (!task) {
return null;
}
// Early return if already in desired state
if (task.status === "completed") {
return null;
}
// Only update if state change is needed
await ctx.db.patch(args.taskId, {
status: "completed",
completedAt: Date.now(),
});
return null;
},
});
```
**Bad Pattern:**
```typescript
// This will cause conflicts if called multiple times rapidly
export const completeTask = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
// No check if already completed
await ctx.db.patch(args.taskId, {
status: "completed",
completedAt: Date.now(),
});
return null;
},
});
```
### Avoid Unnecessary Reads - Patch Directly
When you only need to update fields, patch directly without reading first. Database operations throw if the document doesn't exist.
**Good Pattern:**
```typescript
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// Patch directly without reading first
// ctx.db.patch throws if document doesn't exist
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
```
**Bad Pattern:**
```typescript
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// Reading the document first creates a conflict window
const note = await ctx.db.get(args.id);
if (!note) throw new Error("Not found");
// When typing rapidly, multiple mutations fire
// Each reads the same version, then all try to write, causing conflicts
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
```
### Minimize Data Reads
Only read the data you need. Avoid querying entire tables when you only need specific documents.
**Good Pattern:**
```typescript
export const updateUserCount = mutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
// Only query tasks for this specific user
const tasks = await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
await ctx.db.patch(args.userId, {
taskCount: tasks.length,
});
return null;
},
});
```
**Bad Pattern:**
```typescript
export const updateUserCount = mutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
// Reading entire table creates conflicts with any task change
const allTasks = await ctx.db.query("tasks").collect();
const userTasks = allTasks.filter((t) => t.userId === args.userId);
await ctx.db.patch(args.userId, {
taskCount: userTasks.length,
});
return null;
},
});
```
### Use Indexes to Reduce Read Scope
Always define and use indexes to limit the scope of data reads.
```typescript
// In schema.ts
tasks: defineTable({
userId: v.string(),
status: v.string(),
content: v.string(),
}).index("by_user", ["userId"])
.index("by_user_and_status", ["userId", "status"]),
```
## Frontend Protection: Preventing Duplicate Calls
### Use Refs to Track Mutation Calls
When mutations should only be called once per state change, use refs to track calls.
**Good Pattern:**
```typescript
export function TimerComponent() {
const [session, setSession] = useState(null);
const hasCalledComplete = useRef(false);
const completeSession = useMutation(api.timer.completeSession);
useEffect(() => {
if (timeRemaining <= 0 && session && !hasCalledComplete.current) {
hasCalledComplete.current = true;
completeSession({ sessionId: session._id });
}
}, [timeRemaining, session, completeSession]);
// Reset ref when starting new session
const handleStartNewSession = async () => {
hasCalledComplete.current = false;
await startSession();
};
return <div>...</div>;
}
```
**Bad Pattern:**
```typescript
export function TimerComponent() {
const [session, setSession] = useState(null);
const completeSession = useMutation(api.timer.completeSession);
useEffect(() => {
// This can be called multiple times if timeRemaining updates
if (timeRemaining <= 0 && session) {
completeSession({ sessionId: session._id });
}
}, [timeRemaining, session, completeSession]);
return <div>...</div>;
}
```
### Debounce Rapid Mutations
For user-triggered actions that can happen rapidly (typing, dragging), debounce the mutation calls. Recommended delay: 300-500ms.
**Good Pattern:**
```typescript
import { useMutation } from "convex/react";
import { useCallback } from "react";
import debounce from "lodash/debounce";
export function EditableNote() {
const updateNote = useMutation(api.notes.updateNote);
// Debounce updates to prevent conflicts during rapid typing (300-500ms)
const debouncedUpdate = useCallback(
debounce((noteId: Id<"notes">, content: string) => {
updateNote({ noteId, content });
}, 500), // 500ms delay recommended
[updateNote]
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = e.target.value;
setLocalContent(content);
debouncedUpdate(noteId, content);
};
return <textarea onChange={handleChange} value={localContent} />;
}
```
### Avoid Calling Mutations in Loops
Never call mutations inside loops without proper batching or rate limiting.
**Good Pattern:**
```typescript
// Use a single mutation that handles batch updates
export const updateMultipleTasks = mutation({
args: {
updates: v.array(
v.object({
taskId: v.id("tasks"),
completed: v.boolean(),
}),
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const update of args.updates) {
const task = await ctx.db.get(update.taskId);
if (task && task.completed !== update.completed) {
await ctx.db.patch(update.taskId, {
completed: update.completed,
});
}
}
return null;
},
});
// Call once from frontend
const updates = selectedTasks.map((task) => ({
taskId: task._id,
completed: true,
}));
await updateMultipleTasks({ updates });
```
**Bad Pattern:**
```typescript
// Calling mutation in a loop
for (const task of selectedTasks) {
await updateTask({ taskId: task._id, completed: true });
}
```
### Use Parallel Updates with Promise.all
When updating multiple independent documents, use Promise.all for parallel writes instead of sequential loops.
**Good Pattern:**
```typescript
export const reorderItems = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
// Patch all items in parallel
const updates = args.itemIds.map((id, index) =>
ctx.db.patch(id, { order: index }),
);
await Promise.all(updates);
return null;
},
});
```
**Bad Pattern:**
```typescript
export const reorderItems = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
// Sequential reads and writes create conflict windows
for (let i = 0; i < args.itemIds.length; i++) {
const item = await ctx.db.get(args.itemIds[i]); // Read
await ctx.db.patch(args.itemIds[i], { order: i }); // Write
}
return null;
},
});
```
### Check Mutation Status Before Calling Again
Use the mutation's loading state to prevent duplicate calls.
**Good Pattern:**
```typescript
export function TaskItem() {
const [isPending, setIsPending] = useState(false);
const toggleTask = useMutation(api.tasks.toggleTask);
const handleToggle = async () => {
if (isPending) return; // Prevent duplicate calls
setIsPending(true);
try {
await toggleTask({ taskId: task._id });
} finally {
setIsPending(false);
}
};
return (
<button onClick={handleToggle} disabled={isPending}>
{task.completed ? "Completed" : "Not completed"}
</button>
);
}
```
## Schema Design to Avoid Conflicts
### Use Event Records for High-Frequency Counters
Instead of updating a counter on a document (which creates conflicts), create individual event records and aggregate them in queries.
**Good Pattern:**
```typescript
// Separate view tracking documents
export const trackView = mutation({
args: { pageId: v.id("pages") },
returns: v.null(),
handler: async (ctx, args) => {
// Create individual view records instead
await ctx.db.insert("views", {
pageId: args.pageId,
timestamp: Date.now(),
});
return null;
// Aggregate views in a query or scheduled function
},
});
// Query to get view count
export const getViewCount = query({
args: { pageId: v.id("pages") },
returns: v.number(),
handler: async (ctx, args) => {
const views = await ctx.db
.query("views")
.withIndex("by_page", (q) => q.eq("pageId", args.pageId))
.collect();
return views.length;
},
});
```
**Bad Pattern:**
```typescript
// Global counter updated by all users - creates conflicts
export const trackView = mutation({
args: { pageId: v.id("pages") },
returns: v.null(),
handler: async (ctx, args) => {
const page = await ctx.db.get(args.pageId);
if (!page) throw new Error("Not found");
// Many users updating the same document simultaneously
await ctx.db.patch(args.pageId, {
views: page.views + 1,
});
return null;
},
});
```
### Minimize Document Updates
Design your schema so frequently changing data is isolated.
**Good Pattern:**
```typescript
// Separate frequently updated data
sessions: defineTable({
userId: v.string(),
startTime: v.number(),
}).index("by_user", ["userId"]),
sessionMetrics: defineTable({
sessionId: v.id("sessions"),
remainingTime: v.number(),
status: v.string(),
lastUpdated: v.number(),
}).index("by_session", ["sessionId"]),
```
**Bad Pattern:**
```typescript
// Mixing static and frequently updated data
sessions: defineTable({
userId: v.string(),
startTime: v.number(),
// These fields update frequently and cause conflicts
remainingTime: v.number(),
status: v.string(),
lastUpdated: v.number(),
}).index("by_user", ["userId"]),
```
### Use Single User Sessions
For data like timers or active sessions, use a pattern where only one document per user exists.
```typescript
export const getActiveSession = query({
args: {},
returns: v.union(v.object({...}), v.null()),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("sessions")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.first();
},
});
export const startSession = mutation({
args: {},
returns: v.id("sessions"),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Delete existing session first
const existing = await ctx.db
.query("sessions")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.first();
if (existing) {
await ctx.db.delete(existing._id);
}
return await ctx.db.insert("sessions", {
userId: identity.subject,
startTime: Date.now(),
});
},
});
```
## Authorization Patterns
When you need to verify ownership before updates, use these patterns to minimize conflicts:
### Option A: Use Indexes for User-Scoped Queries
```typescript
export const updateNote = mutation({
args: {
id: v.id("notes"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// Only query notes the user owns
const note = await ctx.db
.query("notes")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.filter((q) => q.eq(q.field("_id"), args.id))
.unique();
if (!note) throw new Error("Not found");
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
```
### Option B: Internal Mutations with Schema-Level Security
```typescript
// Internal mutation with no auth check (fast, no conflicts)
export const _updateNote = internalMutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
// Public mutation with auth check
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const note = await ctx.db.get(args.id);
if (!note || note.userId !== identity.subject) {
throw new Error("Not found");
}
// Call internal mutation (no read before write)
await ctx.runMutation(internal.notes._updateNote, {
id: args.id,
content: args.content,
});
return null;
},
});
```
## Monitoring Write Conflicts
Check your Convex dashboard for:
- **Insight Breakdown**: Shows which mutations are retrying due to conflicts
- **Error Logs**: Permanent failures after retries
- **Function Latency**: High latency may indicate frequent retries
Regular monitoring helps you identify and fix conflict patterns early.
## Checklist for Preventing Write Conflicts
**Backend (Convex mutations):**
- [ ] Make mutations idempotent with early returns
- [ ] Patch directly without reading first when possible
- [ ] Check if document exists before updating (only when necessary)
- [ ] Check if update is needed (current state vs desired state)
- [ ] Use indexed queries with selective filters
- [ ] Avoid reading entire tables
- [ ] Minimize the scope of data reads
- [ ] Use Promise.all for parallel independent updates
- [ ] Design schema to separate frequently updated fields
- [ ] Use event records pattern for high-frequency counters
- [ ] Consider internal mutations for auth-checked operations
**Frontend (React components):**
- [ ] Use refs to track one-time mutation calls
- [ ] Reset refs when starting new operations
- [ ] Debounce rapid user input mutations (300-500ms)
- [ ] Check mutation loading state before calling again
- [ ] Avoid calling mutations in loops
- [ ] Batch multiple updates into single mutation calls
- [ ] Use proper useEffect dependencies
## Resources
- [Convex Write Conflicts Documentation](https://docs.convex.dev/error#1)
- [Optimistic Concurrency Control](https://docs.convex.dev/database/advanced/occ)
- [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/)
## When Write Conflicts Are Acceptable
Some write conflicts are expected and acceptable:
- Very high-frequency updates (gaming leaderboards, live counters)
- Genuinely concurrent user actions on the same resource
- Temporary spikes in activity
In these cases, ensure your mutations are idempotent and let Convex's retry mechanism handle the conflicts. Add user-facing retry logic if needed.
## App-Specific Patterns: Markdown Blog
This app uses specific patterns to avoid write conflicts in the `activeSessions` table for real-time visitor tracking.
### Heartbeat Mutation with Dedup Window
The `stats:heartbeat` mutation uses a 10-second dedup window to prevent rapid updates:
```typescript
// convex/stats.ts
const HEARTBEAT_DEDUP_MS = 10 * 1000;
export const heartbeat = mutation({
args: {
sessionId: v.string(),
currentPath: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
// Find existing session by sessionId using index
const existingSession = await ctx.db
.query("activeSessions")
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
.first();
if (existingSession) {
// Early return if same path and recently updated (idempotent)
if (
existingSession.currentPath === args.currentPath &&
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
) {
return null;
}
// Patch directly with new data
await ctx.db.patch(existingSession._id, {
currentPath: args.currentPath,
lastSeen: now,
});
return null;
}
// Create new session only if none exists
await ctx.db.insert("activeSessions", {
sessionId: args.sessionId,
currentPath: args.currentPath,
lastSeen: now,
});
return null;
},
});
```
### Frontend Debouncing with Refs
The `usePageTracking` hook uses refs to prevent duplicate heartbeat calls:
```typescript
// src/hooks/usePageTracking.ts
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
export function usePageTracking(): void {
const heartbeatMutation = useMutation(api.stats.heartbeat);
// Track heartbeat state to prevent duplicate calls
const isHeartbeatPending = useRef(false);
const lastHeartbeatTime = useRef(0);
const lastHeartbeatPath = useRef<string | null>(null);
const sendHeartbeat = useCallback(
async (path: string) => {
const sessionId = sessionIdRef.current;
if (!sessionId) return;
const now = Date.now();
// Skip if heartbeat is already pending
if (isHeartbeatPending.current) {
return;
}
// Skip if same path and sent recently (debounce)
if (
lastHeartbeatPath.current === path &&
now - lastHeartbeatTime.current < HEARTBEAT_DEBOUNCE_MS
) {
return;
}
isHeartbeatPending.current = true;
lastHeartbeatTime.current = now;
lastHeartbeatPath.current = path;
try {
await heartbeatMutation({
sessionId,
currentPath: path,
});
} catch {
// Silently fail
} finally {
isHeartbeatPending.current = false;
}
},
[heartbeatMutation],
);
// Send heartbeat on interval and path change
useEffect(() => {
const path = location.pathname;
sendHeartbeat(path);
const intervalId = setInterval(() => {
sendHeartbeat(path);
}, HEARTBEAT_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [location.pathname, sendHeartbeat]);
}
```
### Key Patterns Used
1. **Backend idempotency**: Early return when session was updated within 10 seconds with same path
2. **Frontend debouncing**: 5-second debounce window using refs
3. **Pending state tracking**: `isHeartbeatPending` ref prevents overlapping calls
4. **Indexed queries**: Uses `by_sessionId` index for efficient lookups
5. **Event records pattern**: Page views use `pageViews` table instead of counters
## Summary
**Key Takeaway:** The less you read before writing, the fewer conflicts you'll have. Design your mutations to write directly when possible, and structure your data model to avoid concurrent writes to the same documents.
**Priority actions:**
1. Patch directly without reading first (most common fix)
2. Use indexed queries to minimize read scope
3. Debounce rapid user inputs (300-500ms for typing, 5s for heartbeats)
4. Use event records for high-frequency counters
5. Add backend dedup windows for idempotency (10s for heartbeats)
6. Use refs to track pending mutations and prevent duplicates
7. Monitor your dashboard for conflict patterns