Background actions have a configurable timeout that limits how long they can run. You can set a custom timeout using the timeoutMSaction 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:
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.