Skip to main content
Debounce jobs accumulate incoming events and flush them in a single batch execution. The flush is triggered either after a configurable delay from the first event or when the event count reaches a maximum.

Debounce.make(config)

Creates an unregistered debounce job definition. The job name is assigned later via the key you use in createDurableJobs.
import { Debounce } from "@durable-effect/jobs";
import { Schema, Effect } from "effect";

const webhookBatcher = Debounce.make({
  eventSchema: Schema.Struct({
    type: Schema.String,
    contactId: Schema.String,
    data: Schema.Unknown,
  }),
  flushAfter: "5 seconds",
  execute: (ctx) =>
    Effect.gen(function* () {
      const state = yield* ctx.state;
      yield* sendWebhookBatch(state);
    }),
});

DebounceMakeConfig fields

eventSchema
Schema.Schema<I>
required
Effect Schema for validating incoming events. Every event passed to .add() is decoded against this schema before being stored.
flushAfter
string | number | Duration
required
How long to wait after the first event before flushing. Accepts any Duration.DurationInput — a string such as "5 seconds", a Duration value, or milliseconds as a number. The timer resets if maxEvents has not been reached.
execute
(ctx: DebounceExecuteContext<S>) => Effect<void, E, never>
required
The function called when the debounce flushes. Must return Effect<void, E, never>. Use .pipe(Effect.provide(layer)) to satisfy any service requirements.
stateSchema
Schema.Schema<S>
Schema for the persisted state. When omitted the state schema defaults to eventSchema, and each new event replaces the previous state with itself. Provide an explicit stateSchema when you want onEvent to accumulate events into a different shape.
maxEvents
number
Maximum number of events to accumulate before flushing immediately, regardless of the flushAfter timer. Omit to rely solely on the timer.
onEvent
(ctx: DebounceEventContext<I, S>) => Effect<S>
A reducer called for every incoming event. It receives the current state and the new event, and must return the updated state as Effect<S, never, never>. When omitted, the default behaviour is to replace the state with the latest event (equivalent to Effect.succeed(ctx.event as S)).
onEvent: (ctx) =>
  Effect.succeed({
    events: [...ctx.state.events, ctx.event],
    count: ctx.state.count + 1,
  }),
retry
RetryConfig
Automatic retry configuration for execute failures.
logging
boolean | LogLevel
default:"false"
Controls the log level for this job’s internal logs.
ValueBehavior
false (default)LogLevel.Error — only failures
trueLogLevel.Debug — all logs
LogLevel.WarningWarnings and above
LogLevel.NoneSilent

DebounceExecuteContext<S>

The context passed to execute when the debounce flushes.
PropertyTypeDescription
stateEffect<S>Accumulated state at flush time
eventCountEffect<number>Total events received since the debounce started
flushReason"maxEvents" | "flushAfter" | "manual"What triggered this flush
debounceStartedAtEffect<number>Timestamp (ms) when the first event arrived
executionStartedAtnumberTimestamp (ms) when this execution started
attemptnumberCurrent retry attempt (1 = first try)
isRetrybooleantrue when this is a retry of a previous failure
state
Effect<S>
Lazily loads the accumulated state from Durable Object storage. Yield it to get the value:
const state = yield* ctx.state;
eventCount
Effect<number>
The total number of events accumulated in this debounce window. Yield it to get the count:
const count = yield* ctx.eventCount;
flushReason
"maxEvents" | "flushAfter" | "manual"
Why this flush was triggered:
ValueDescription
"flushAfter"The flushAfter timer expired
"maxEvents"The maxEvents threshold was reached
"manual"The client called .flush() directly
debounceStartedAt
Effect<number>
Unix timestamp in milliseconds of when the first event in this window arrived.
executionStartedAt
number
Unix timestamp in milliseconds of when this specific execution started. Available synchronously (no yield needed).
attempt
number
Retry attempt number within this flush. 1 on the first attempt, 2+ on retries.
isRetry
boolean
true when attempt > 1.

DebounceEventContext<I, S>

The context passed to onEvent for each incoming event.
PropertyTypeDescription
eventIThe incoming event (already validated against eventSchema)
stateSCurrent accumulated state (synchronous value, not an Effect)
eventCountnumberNumber of events accumulated so far (synchronous)
instanceIdstringDurable Object instance ID
On the very first event, state is initialized to event (cast to S). If your stateSchema differs from eventSchema, ensure the first event is always a valid S or handle the first-event case in onEvent.
event
I
The incoming event, already decoded against eventSchema.
state
S
The current accumulated state. On the first event this equals event cast to S. This is a direct value — no yield needed.
eventCount
number
The total number of events accumulated before this one. 0 on the first event. Synchronous — no yield needed.
instanceId
string
The Durable Object instance ID in the format debounce:{jobName}:{userProvidedId}.

Full example

The following example batches contact webhook events, accumulating them into an array and flushing to an external endpoint every 5 seconds or when 100 events arrive.
import { Effect, Schema } from "effect";
import { Debounce, createDurableJobs } from "@durable-effect/jobs";

const webhookBatcher = Debounce.make({
  // Schema for each incoming webhook event
  eventSchema: Schema.Struct({
    type: Schema.String,
    contactId: Schema.String,
    data: Schema.Unknown,
  }),

  // Separate state shape that accumulates events
  stateSchema: Schema.Struct({
    events: Schema.Array(Schema.Unknown),
    count: Schema.Number,
  }),

  flushAfter: "5 seconds",
  maxEvents: 100,

  // Reducer: append each event to the array
  onEvent: (ctx) =>
    Effect.succeed({
      events: [...ctx.state.events, ctx.event],
      count: ctx.state.count + 1,
    }),

  // Process the accumulated batch
  execute: (ctx) =>
    Effect.gen(function* () {
      const state = yield* ctx.state;
      const count = yield* ctx.eventCount;

      yield* Effect.log(
        `Flushing ${count} events (reason: ${ctx.flushReason})`
      );
      yield* sendWebhookBatch(state.events);
    }),
});

const { Jobs, JobsClient } = createDurableJobs({ webhookBatcher });
export { Jobs };

export default {
  async fetch(request: Request, env: Env) {
    const client = JobsClient.fromBinding(env.JOBS);

    // Add an event — creates the instance if it doesn't exist
    const result = await Effect.runPromise(
      client.debounce("webhookBatcher").add({
        id: "contact-456",
        event: { type: "contact.updated", contactId: "456", data: {} },
      })
    );

    // result.created       — true if this was the first event
    // result.eventCount    — total events in this window
    // result.willFlushAt   — timestamp (ms) when the flush will fire

    return new Response("OK");
  },
};

Build docs developers (and LLMs) love