GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||
GeezerVison is showing trigger.dev — Idempotency - Trigger.dev Idempotency keys deduplicate task triggers. If you trigger a task with the same idempotencyKey twice, the second request does not create a new run. It returns the original run’s handle, so you can track the existing run’s progress.
The key applies to the trigger call and nothing else. It deduplicates trigger(), triggerAndWait(), and batchTrigger(); it does not make the code inside a task’s run() function idempotent. See Side effects inside run() for what that means when a task calls an external API.
Why use idempotency keys?The most common use case is preventing duplicate child tasks when a parent task retries. Without idempotency keys, each retry of the parent would trigger a new child task run: Other common use cases include:
Side effects inside
An idempotency key stops a task from being triggered twice. It does not stop the code inside that task from running twice. When a run retries, |
| Scope | What gets hashed | Description | Use case |
|---|---|---|---|
"run" | key + parentRunId | Key is combined with the parent run ID | Prevent duplicates within a single parent run (default) |
"attempt" | key + parentRunId + attemptNumber | Key is combined with the parent run ID and attempt number | Allow child tasks to re-run on each retry of the parent |
"global" | key | Key is used as-is, no context added | Ensure a task only runs once ever, regardless of parent |
run scope (default)run scope makes the idempotency key unique to the current parent task run. This is the default behavior for both raw strings and idempotencyKeys.create().
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const processOrder = task({
id: "process-order",
retry: { maxAttempts: 3 },
run: async (payload: { orderId: string; email: string }) => {
// This key is scoped to this specific run of processOrder
// If processOrder retries, the same key still refers to the same child run
const idempotencyKey = await idempotencyKeys.create(`send-confirmation-${payload.orderId}`);
// sendEmail will only be triggered once, even if processOrder retries multiple times
await sendEmail.trigger(
{ to: payload.email, subject: "Order confirmed" },
{ idempotencyKey }
);
// ... more processing that might fail and cause a retry
},
});
run scope, if you trigger processOrder twice with different run IDs, both will send emails because the idempotency keys are different (they include different parent run IDs).
attempt scopeattempt scope makes the idempotency key unique to each attempt of the parent task. Use this when you want child tasks to re-execute on each retry.
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const syncData = task({
id: "sync-data",
retry: { maxAttempts: 3 },
run: async (payload: { userId: string }) => {
// This key changes on each retry attempt
const idempotencyKey = await idempotencyKeys.create(`fetch-${payload.userId}`, {
scope: "attempt",
});
// fetchLatestData will run again on each retry, getting fresh data
const result = await fetchLatestData.triggerAndWait(
{ userId: payload.userId },
{ idempotencyKey }
);
// Process the fresh data...
},
});
global scopeglobal scope makes the idempotency key truly global across all runs. Use this when you want to ensure a task only runs once ever (until the TTL expires), regardless of which parent task triggered it.
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const onboardUser = task({
id: "onboard-user",
run: async (payload: { userId: string; email: string }) => {
// This key is global - the welcome email will only be sent once per user,
// even if onboardUser is triggered multiple times from different places
const idempotencyKey = await idempotencyKeys.create(`welcome-email-${payload.userId}`, {
scope: "global",
});
await sendWelcomeEmail.trigger(
{ to: payload.email },
{ idempotencyKey, idempotencyKeyTTL: "7d" }
);
},
});
global scope, idempotency keys are still isolated to the specific task and environment. Using the same key to trigger different tasks will not deduplicate - both tasks will run. See Environment and task scoping for more details.idempotencyKey option, it is automatically processed with run scope:
// These two are equivalent when called inside a task:
await childTask.trigger(payload, { idempotencyKey: "my-key" });
await childTask.trigger(payload, { idempotencyKey: await idempotencyKeys.create("my-key") });
global scope. Starting in v4.3.1, raw strings now default to run scope. If you’re upgrading and relied on the previous global behavior, you must now explicitly use idempotencyKeys.create("key", { scope: "global" }).scope: "global":
// For global idempotency, you must use idempotencyKeys.create with scope: "global"
const idempotencyKey = await idempotencyKeys.create("my-key", { scope: "global" });
await childTask.trigger(payload, { idempotencyKey });
run and attempt scopes behave the same as global since there’s no run ID or attempt number to inject:
// In your backend code (e.g., an API route)
import { idempotencyKeys, tasks } from "@trigger.dev/sdk";
// All three of these behave the same when called outside a task:
await tasks.trigger("my-task", payload, { idempotencyKey: "my-key" });
await tasks.trigger("my-task", payload, {
idempotencyKey: await idempotencyKeys.create("my-key", { scope: "run" }),
});
await tasks.trigger("my-task", payload, {
idempotencyKey: await idempotencyKeys.create("my-key", { scope: "global" }),
});
idempotencyKeyTTL optionidempotencyKeyTTL option defines a time window during which a task with the same idempotency key will only run once. Here’s how it works:
idempotencyKeyTTL: "5m", it creates a 5-minute window.
By default idempotency keys are stored for 30 days. You can change this by passing the idempotencyKeyTTL option when triggering a task:
import { idempotencyKeys, task, wait } from "@trigger.dev/sdk";
export const myTask = task({
id: "my-task",
retry: {
maxAttempts: 4,
},
run: async (payload: any) => {
const idempotencyKey = await idempotencyKeys.create("my-task-key");
// The idempotency key will expire after 60 seconds
await childTask.trigger({ foo: "bar" }, { idempotencyKey, idempotencyKeyTTL: "60s" });
await wait.for({ seconds: 61 });
// The idempotency key will have expired, so the childTask will be triggered again
await childTask.trigger({ foo: "bar" }, { idempotencyKey });
// Do something else, that may throw an error and cause the task to be retried
throw new Error("Something went wrong");
},
});
idempotencyKeyTTL option:
s for seconds (e.g. 60s)m for minutes (e.g. 5m)h for hours (e.g. 2h)d for days (e.g. 3d)idempotencyKeys.reset():import { idempotencyKeys } from "@trigger.dev/sdk";
// Clear the idempotency key to allow re-triggering
await idempotencyKeys.reset("my-task", "my-idempotency-key");
// Now you can trigger the task again
await myTask.trigger(payload, { idempotencyKey: "my-idempotency-key" });
// Key expires after 5 minutes
await myTask.trigger(payload, {
idempotencyKey: "my-key",
idempotencyKeyTTL: "5m"
});
import { idempotencyKeys, task } from "@trigger.dev/sdk";
import { createHash } from "node:crypto";
// Somewhere in your code
const idempotencyKey = await idempotencyKeys.create(hash(childPayload));
// childTask will only be triggered once with the same idempotency key
await tasks.trigger("child-task", { some: "payload" }, { idempotencyKey });
// Create a hash of the payload using Node.js crypto
// Ideally, you'd do a stable serialization of the payload before hashing, to ensure the same payload always results in the same hash
function hash(payload: any): string {
const hash = createHash("sha256");
hash.update(JSON.stringify(payload));
return hash.digest("hex");
}
idempotencyKeys.reset(
taskIdentifier: string,
idempotencyKey: string,
requestOptions?: ZodFetchOptions
): Promise<{ id: string }>
| Parameter | Description |
|---|---|
taskIdentifier | The identifier of the task (e.g., "my-task") |
idempotencyKey | The idempotency key hash to reset (the 64-character hash string) |
requestOptions | Optional fetch options for the API request |
idempotencyKeys.create()IdempotencyKey created with idempotencyKeys.create(), the scope and original key are automatically extracted, making it easy to reset:
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const parentTask = task({
id: "parent-task",
run: async (payload) => {
const key = await idempotencyKeys.create("my-key", { scope: "global" });
await childTask.trigger(payload, { idempotencyKey: key });
// Later in the same task, reset it - options extracted automatically
await idempotencyKeys.reset("child-task", key);
},
});
import { idempotencyKeys } from "@trigger.dev/sdk";
// From anywhere - inside a task or from your backend code
await idempotencyKeys.reset("my-task", "my-key", { scope: "global" });
"run" scope (the default) include the parent run ID in the hash. When resetting from inside the same task, the run ID is automatically available:
import { idempotencyKeys, task } from "@trigger.dev/sdk";
export const parentTask = task({
id: "parent-task",
run: async (payload) => {
// Create a run-scoped key (default)
const key = await idempotencyKeys.create("my-key");
await childTask.trigger(payload, { idempotencyKey: key });
// Reset works automatically inside the task - run ID is available from context
await idempotencyKeys.reset("child-task", key);
},
});
parentRunId if the key was created within a task context:
import { idempotencyKeys } from "@trigger.dev/sdk";
// If the key was created within a task, you need the parent run ID
await idempotencyKeys.reset("my-task", "my-key", {
scope: "run",
parentRunId: "run_abc123"
});
scope: "global" when resetting."attempt" scope include both the parent run ID and attempt number. When resetting from outside a task, you must provide both:
import { idempotencyKeys } from "@trigger.dev/sdk";
// From your backend code
await idempotencyKeys.reset("my-task", "my-key", {
scope: "attempt",
parentRunId: "run_abc123",
attemptNumber: 1
});
"run" or "attempt" scoped key from outside a task without providing the required parentRunId (and attemptNumber for attempt scope), it will throw an error.
"run" scope in different parent runs = different hashes = both tasks run"global" scope in different parent runs = same hash = only first task runsWas this page helpful?