
npm install @ikhrustalev/convex-debouncerA server-side debouncing component for Convex. Debounce expensive operations like LLM calls, metrics computation, or any heavy processing that should only run after a period of inactivity.
When users rapidly trigger expensive operations (like typing in a chat that triggers AI responses, or updating data that requires recomputation), you often want to:
This component provides three debouncing modes to handle these scenarios.
npm install @ikhrustalev/convex-debouncerCreate a convex.config.ts file in your app's convex/ folder and install the component:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import debouncer from "@ikhrustalev/convex-debouncer/convex.config.js";
const app = defineApp();
app.use(debouncer);
export default app;import { Debouncer } from "@ikhrustalev/convex-debouncer";
import { components, internal } from "./_generated/api";
// Create a Debouncer instance with default configuration
const debouncer = new Debouncer(components.debouncer, {
delay: 5000, // 5 second delay
mode: "sliding", // Options: "eager" | "fixed" | "sliding"
});
// In your mutation
export const onPropertyUpdate = mutation({
args: { propertyId: v.string(), data: v.any() },
handler: async (ctx, args) => {
// Schedule a debounced metrics computation
await debouncer.schedule(
ctx,
"property-metrics", // namespace
args.propertyId, // key (unique within namespace)
internal.metrics.compute, // function to call
{ propertyId: args.propertyId } // arguments
);
},
});Each call resets the timer. The function only executes after the delay passes with no new calls.
Call 1 ──▶ Start 5s timer
↓ 2s later
Call 2 ──▶ Reset timer to 5s
↓ 5s later (no calls)
Execute with Call 2's argsBest for: Search-as-you-type, auto-save, real-time validation
Timer stays fixed from the first call. Subsequent calls update the arguments but don't extend the timer.
Call 1 ──▶ Start 5s timer ──────────────┐
↓ 2s later │
Call 2 ──▶ Update args (timer unchanged)│
↓ 3s later │
Execute with Call 2's args ◀─┘Best for: Batch processing, rate-limited APIs, periodic syncs
Execute immediately on first call, then queue a trailing call with the latest arguments if more calls come in.
Call 1 ──▶ Execute immediately + start 5s timer ──┐
↓ 1s later │
Call 2 ──▶ Queue trailing (update args) │
↓ 2s later │
Call 3 ──▶ Queue trailing (update to latest args) │
↓ timer ends │
Execute with Call 3's args ◀────────────┘Best for: Real-time collaboration, AI responses (immediate feedback + final state)
const debouncer = new Debouncer(components.debouncer, {
delay: 5000, // Delay in milliseconds
mode: "sliding", // "eager" | "fixed" | "sliding"
});Schedule a debounced function call.
const result = await debouncer.schedule(
ctx, // Mutation context
"namespace", // Logical grouping
"key", // Unique identifier within namespace
internal.myModule.myFunction, // Function reference to call
{ arg1: "value" }, // Arguments for the function
{ delay: 3000, mode: "fixed" } // Optional: override defaults
);
// result: { executed: boolean, scheduledFor: number }Check the status of a pending debounced call.
const status = await debouncer.status(ctx, "namespace", "key");
// status: null (if no pending call) or:
// {
// pending: true,
// scheduledFor: 1234567890, // Unix timestamp
// retriggerCount: 3, // Number of times schedule was called
// mode: "sliding",
// hasTrailingCall: false // (eager mode) whether trailing execution is queued
// }Cancel a pending debounced call.
const cancelled = await debouncer.cancel(ctx, "namespace", "key");
// cancelled: true if a call was cancelled, false if nothing was pendingconst debouncer = new Debouncer(components.debouncer, {
delay: 5000,
mode: "sliding",
});
export const onDataChange = mutation({
args: { entityId: v.string() },
handler: async (ctx, args) => {
// Multiple rapid changes will only trigger one computation
await debouncer.schedule(
ctx,
"metrics",
args.entityId,
internal.metrics.recompute,
{ entityId: args.entityId }
);
},
});const debouncer = new Debouncer(components.debouncer, {
delay: 10000,
mode: "eager", // Respond immediately, then ensure we process final state
});
export const onUserMessage = mutation({
args: { conversationId: v.string(), message: v.string() },
handler: async (ctx, args) => {
const result = await debouncer.schedule(
ctx,
"ai-responses",
args.conversationId,
internal.ai.generateResponse,
{ conversationId: args.conversationId }
);
// result.executed is true for the first message (immediate execution)
// Subsequent messages will queue a trailing execution
},
});const debouncer = new Debouncer(components.debouncer, {
delay: 30000, // Process every 30 seconds
mode: "fixed", // Timer doesn't extend
});
export const queueItem = mutation({
args: { batchId: v.string(), itemId: v.string() },
handler: async (ctx, args) => {
// First item starts the timer, subsequent items just update
await debouncer.schedule(
ctx,
"batches",
args.batchId,
internal.batch.process,
{ batchId: args.batchId }
);
},
});npm i
npm run devRun tests:
npm testApache-2.0
Found a bug? Feature request? File it here.