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. 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 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 object, which is an instance of 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:

api/actions/syncProducts.js
JavaScript
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, };
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.

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:

api/actions/processOrder.js
JavaScript
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); };
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 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 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:

api/actions/syncInventory.js
JavaScript
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 }); } };
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:

api/actions/syncInventory.js
JavaScript
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 }); } };
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 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 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:

api/actions/migrateRecords.js
JavaScript
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 }); } };
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:

api/actions/pollStatus.js
JavaScript
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 }); } };
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:

api/actions/syncToExternalService.js
JavaScript
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()); } };
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:

api/actions/fetchMultipleSources.js
JavaScript
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... };
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:

JavaScript
// 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); }); });
// 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.

Was this page helpful?