# Handling timeouts  Background actions have a configurable timeout that limits how long they can run. You can set a custom timeout using the `timeoutMS` [action option](https://docs.gadget-canary.xyz/guides/actions/code#timeoutms). When a background action exceeds its timeout, Gadget reports a `GGT_ACTION_TIMEOUT` error to the caller and marks the action as failed. ## Timeouts do not stop your code  This is the most important thing to understand about timeouts: a timeout is a logical boundary, not a hard kill. When a background action times out, Gadget reports the error to the caller, but **your action code does not immediately stop executing**. This means that after a timeout: * Database writes and other side effects keep executing * External API calls are still made * If [retries](https://docs.gadget-canary.xyz/guides/actions/background-actions#retrying-on-failure) are configured, the retry might start while the original code is still running Gadget uses cooperative timeouts rather than hard-killing your action. When a process is forcefully terminated, database transactions can be left half-written, open connections leak until they time out, and in-flight work is silently lost. Cooperative timeouts let your code decide where it is safe to stop, such as between database writes, after releasing locks, or after rolling back a transaction. You are responsible for deciding what your action should do when a timeout occurs. Use the `signal` from the action context to detect timeouts and stop work gracefully. ## Using `signal` to stop work on timeout  Each action is passed a [signal](https://docs.gadget-canary.xyz/guides/actions/writing-actions#signal) object, which is an instance of [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). When the action times out, `signal.aborted` becomes `true`. Check this value inside loops and before expensive operations to stop work early. The most common pattern in background actions is a batch-processing loop that enqueues child actions. Check the signal before each batch to avoid enqueuing work after the action has already timed out: ```typescript import { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ api, logger, trigger, signal }) => { const productIds = await api.product.fetchAll({ select: { id: true } }); const batchSize = 50; for (let i = 0; i < productIds.length; i += batchSize) { if (signal.aborted) { logger.warn( { processed: i, total: productIds.length }, "timed out before all batches were enqueued" ); return; } const batch = productIds.slice(i, i + batchSize); await api.enqueue( api.syncProductBatch, { ids: batch }, { id: `${trigger.id}-batch-${i}`, onDuplicateID: "ignore", } ); } }; export const options: ActionOptions = { actionType: "custom", timeoutMS: 300000, }; ``` Setting the queue `id` to `trigger.id` with `onDuplicateID: "ignore"` ensures that retries skip batches already enqueued in a previous attempt. Because `trigger.id` stays the same across retries, the generated batch IDs match exactly and duplicates are silently ignored. New invocations receive a different `trigger.id`, so their child actions never collide with those from previous runs. Learn more about [deduplicating background actions](https://docs.gadget-canary.xyz/guides/actions/background-actions#deduplicate-enqueued-background-actions). You can also use `signal.throwIfAborted()` before irreversible side effects. This throws immediately if the action has timed out, avoiding repeated `if (signal.aborted) return` checks: ```typescript export const run: ActionRun = async ({ params, api, signal }) => { const order = await api.order.findOne(params.orderId); signal.throwIfAborted(); await chargePayment(order); signal.throwIfAborted(); await sendConfirmationEmail(order); }; ``` See the [writing actions guide](https://docs.gadget-canary.xyz/guides/actions/writing-actions#signal) for additional `signal` patterns, including passing `signal` to `fetch` and using `throwIfAborted()` to roll back transactions. ## Timeouts and retries  If a timed-out action has [retries](https://docs.gadget-canary.xyz/guides/actions/background-actions#retrying-on-failure) configured, Gadget starts the retry while the original code may still be running. Two copies of the same work then execute at the same time. Without `signal` checks, this overlap can cause real problems: * The timed-out attempt keeps calling external APIs while the retry does the same * Both attempts enqueue child actions with `api.enqueue()`, doubling the work * Both attempts write to the database, potentially corrupting data For example, a background action that syncs inventory without checking `signal` will keep running after it times out. When the retry starts, both attempts call the external API and write to the database simultaneously: ```typescript export const run: ActionRun = async ({ api }) => { const products = await api.product.findMany(); for (const product of products) { // no signal check, so this keeps running after a timeout // if a retry starts, both attempts call the warehouse API and update the DB const stock = await fetchWarehouseStock(product.sku); await api.internal.product.update(product.id, { inStock: stock.quantity }); } }; ``` Adding a `signal` check stops the timed-out attempt so the retry can take over cleanly: ```typescript export const run: ActionRun = async ({ api, signal }) => { const products = await api.product.findMany(); for (const product of products) { if (signal.aborted) return; const stock = await fetchWarehouseStock(product.sku); await api.internal.product.update(product.id, { inStock: stock.quantity }); } }; ``` ## Side effects and idempotency  Even with `signal` checks, there is always a small window where your code could complete a side effect right before the timeout fires. For actions that perform irreversible work, design for idempotency so that retries are safe even if the previous attempt partially completed. If your action enqueues child actions, use `trigger.id` with `onDuplicateID: "ignore"` to prevent retries from enqueuing duplicates. See [deduplicating child actions across retries](https://docs.gadget-canary.xyz/guides/actions/background-actions#deduplicating-child-actions-across-retries) for a full example. For external API calls like payments or webhooks, pass an idempotency key derived from the record so that retries do not duplicate the call. The `signal` also fires when a background action is [cancelled](https://docs.gadget-canary.xyz/guides/actions/background-actions#cancelling-background-actions) via `handle.cancel()`. Code that checks `signal.aborted` handles both timeout and cancellation without any extra work. ## Common edge cases  ### Database queries complete after timeout  Database writes and reads run to completion once started. An `api.internal.*` call that has already been sent to the database will finish executing even after the signal fires. Always check `signal` between database operations rather than only at the start of a loop. Because each write completes independently, design these operations to be idempotent so that a retry can safely re-run updates that already succeeded: ```typescript export const run: ActionRun = async ({ api, signal }) => { const records = await api.widget.findMany(); for (const record of records) { // check signal BETWEEN each database write, not just at the top of the loop if (signal.aborted) return; // this update is idempotent — running it twice produces the same result await api.internal.widget.update(record.id, { migrated: true }); } }; ``` ### `setTimeout` keeps running after timeout  A classic `setTimeout(callback, delay)` does not respect `AbortSignal`. If your action schedules delayed work with `setTimeout`, that callback will still fire after the action times out. Use the promise-based `setTimeout` from `timers/promises` with the `signal` option so the delay is cancelled automatically: ```typescript import { setTimeout } from "timers/promises"; export const run: ActionRun = async ({ api, signal }) => { while (!signal.aborted) { const status = await checkExternalStatus(); if (status === "complete") return; // this delay is cancelled when the signal fires — no lingering timer await setTimeout(5000, undefined, { signal }); } }; ``` ### Third-party SDKs may not respect the signal  Not all libraries honor `AbortSignal` even if they accept it as an option. Some SDKs will start an HTTP request and run it to completion regardless. Always check `signal.aborted` between external SDK calls rather than relying solely on the SDK to abort. If retries are configured, consider passing an idempotency key to the SDK so that retried calls do not duplicate work: ```typescript export const run: ActionRun = async ({ api, signal }) => { const records = await api.widget.findMany(); for (const record of records) { if (signal.aborted) return; // externalSdk.push may not abort even if you pass signal await externalSdk.push(record.toJSON()); } }; ``` ### `Promise.all` does not cancel remaining work  `Promise.all` short-circuits its return value when one promise rejects, but it does not abort the other in-flight promises. Each operation must individually check or accept the `signal` to actually stop work: ```typescript export const run: ActionRun = async ({ signal }) => { const urls = [ "https://api.example.com/a", "https://api.example.com/b", "https://api.example.com/c", ]; // pass signal to each fetch so all requests are cancelled on timeout const results = await Promise.all(urls.map((url) => fetch(url, { signal }))); // process results... }; ``` ### `throwIfAborted` inside Promise constructors  Calling `signal.throwIfAborted()` inside a `new Promise()` executor does not reject the promise — the thrown error is silently swallowed by the Promise constructor. Use `reject(signal.reason)` instead: ```typescript // wrong — throwIfAborted() is silently swallowed inside a Promise constructor const result = await new Promise((resolve, reject) => { signal.throwIfAborted(); // this error disappears someCallback((err, data) => { if (err) reject(err); else resolve(data); }); }); // correct — use reject() to properly reject the promise const result = await new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason); return; } someCallback((err, data) => { if (err) reject(err); else resolve(data); }); }); ``` ## Billing and resource implications  Code that continues running after a timeout still consumes compute and counts toward your usage. Using `signal` to stop work early avoids paying for code that is no longer producing useful results. Combined with idempotent patterns, this also prevents duplicate work when retries overlap with timed-out attempts. For more strategies to reduce background action costs, see the [optimizing your bill guide](https://docs.gadget-canary.xyz/guides/account-and-billing/optimizing-your-bill).