Files
triggerdotdev--trigger.dev/docs/how-to-reduce-your-spend.mdx
Matt Aitken 205bdc3103 docs(wait): separate compute billing from concurrency release (#4405)
The wait docs describe the 5 second compute-billing threshold as if it
were also the suspension threshold. It isn't, and the gap is confusing
when you're sizing a poll interval:

- **Compute** stops being charged for any wait longer than 5 seconds.
- **Concurrency** is only released once the machine has been snapshotted
and shut down. For `wait.for` and `wait.until` that happens 60 seconds
into the wait — a shorter wait stays `EXECUTING` and holds its
concurrency slot for the whole wait, even though the compute is free.

So `await wait.for({ seconds: 30 })` in a polling loop never releases
its slot, which looks like a bug if the docs told you waits over 5
seconds checkpoint.

## Changes

**`docs/snippets/paused-execution-free.mdx`** — rendered on `/wait`,
`/wait-for` and `/wait-until`. Drops "we checkpoint and" from the
billing sentence so it's purely about compute, then adds one paragraph
for the concurrency half.

**`docs/queue-concurrency.mdx`** — the "Waits and concurrency" section
states flatly that waiting runs don't consume slots. Adds a short
subsection for the time-based exception.

**`docs/how-to-reduce-your-spend.mdx`** — "Waits longer than 5 seconds
automatically checkpoint your task, meaning you don't pay for compute" →
the compute claim only. Code comments follow, plus a pointer that
waiting doesn't always free concurrency.

**`docs/how-it-works.mdx`** — the Checkpoint-Resume walkthrough used
`wait.for({ seconds: 30 })` as *the* example of a wait that suspends.
Bumped to 5 minutes and noted the sub-60s exception.

No behaviour change — docs only.
2026-07-28 15:20:00 +01:00

212 lines
6.7 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "How to reduce your spend"
description: "Tips and best practices to reduce your costs on Trigger.dev"
---
## Check out your usage page regularly
Monitor your usage dashboard to understand your spending patterns. You can see:
- Your most expensive tasks
- Your total duration by task
- Number of runs by task
- Spikes in your daily usage
![Usage dashboard](./images/usage-dashboard.png)
You can view your usage page by clicking the "Organization" menu in the top left of the dashboard and then clicking "Usage".
## Set billing limits and alerts
Configure billing limits and alerts in your dashboard to protect against unexpected usage and get notified when you approach spending thresholds. This helps you:
- Set a monthly compute spend limit for your organization
- Catch unexpected cost increases early
- Identify runaway tasks before they become expensive
You can open the settings from the **Organization** menu in the top left of the dashboard, then **Settings** → **Billing limits**. [Read the full billing limits and alerts docs](/billing-limits) for how limits are enforced, the grace period, and resuming after hitting a limit.
## Reduce your machine sizes
The larger the machine, the more it costs per second. [View the machine pricing](https://trigger.dev/pricing#computePricing).
Start with the smallest machine that works, then scale up only if needed:
```ts
// Default: small-1x (0.5 vCPU, 0.5 GB RAM)
export const lightTask = task({
id: "light-task",
// No machine config needed - uses small-1x by default
run: async (payload) => {
// Simple operations
},
});
// Only use larger machines when necessary
export const heavyTask = task({
id: "heavy-task",
machine: "medium-1x", // 1 vCPU, 2 GB RAM
run: async (payload) => {
// CPU/memory intensive operations
},
});
```
You can also override machine size when triggering if you know certain payloads need more resources. [Read more about machine sizes](/machines).
## Avoid duplicate work using idempotencyKey
Idempotency keys prevent expensive duplicate work by ensuring the same operation isn't performed multiple times. This is especially valuable during task retries or when the same trigger might fire multiple times.
When you use an idempotency key, Trigger.dev remembers the result and skips re-execution, saving you compute costs:
```ts
export const expensiveApiCall = task({
id: "expensive-api-call",
run: async (payload: { userId: string }) => {
// This expensive operation will only run once per user
await wait.for(
{ seconds: 30 },
{
idempotencyKey: `user-processing-${payload.userId}`,
idempotencyKeyTTL: "1h",
}
);
const result = await processUserData(payload.userId);
return result;
},
});
```
You can use idempotency keys with various wait functions:
```ts
// Skip waits during retries
const token = await wait.createToken({
idempotencyKey: `daily-report-${new Date().toDateString()}`,
idempotencyKeyTTL: "24h",
});
// Prevent duplicate child task execution
await childTask.triggerAndWait(
{ data: payload },
{
idempotencyKey: `process-${payload.id}`,
idempotencyKeyTTL: "1h",
}
);
```
The `idempotencyKeyTTL` controls how long the result is cached. Use shorter TTLs (like "1h") for time-sensitive operations, or longer ones (up to 30 days default) for expensive operations that rarely need re-execution. This prevents both unnecessary duplicate work and stale data issues.
## Do more work in parallel in a single task
Sometimes it's more efficient to do more work in a single task than split across many. This is particularly true when you're doing lots of async work such as API calls most of the time is spent waiting, so it's an ideal candidate for doing calls in parallel inside the same task.
```ts
export const processItems = task({
id: "process-items",
run: async (payload: { items: string[] }) => {
// Process all items in parallel
const promises = payload.items.map((item) => processItem(item));
// This works very well for API calls
await Promise.all(promises);
},
});
```
## Don't needlessly retry
When an error is thrown in a task, your run will be automatically reattempted based on your [retry settings](/tasks/overview#retry-options).
Try setting lower `maxAttempts` for less critical tasks:
```ts
export const apiTask = task({
id: "api-task",
retry: {
maxAttempts: 2, // Don't retry forever
},
run: async (payload) => {
// API calls that might fail
},
});
```
This is very useful for intermittent errors, but if there's a permanent error you don't want to retry because you will just keep failing and waste compute. Use [AbortTaskRunError](/errors-retrying#using-aborttaskrunerror) to prevent a retry:
```ts
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
export const someTask = task({
id: "some-task",
run: async (payload) => {
const result = await doSomething(payload);
if (!result.success) {
// This is a known permanent error, so don't retry
throw new AbortTaskRunError(result.error);
}
return result;
},
});
```
## Use appropriate maxDuration settings
Set realistic maxDurations to prevent runs from executing for too long:
```ts
export const boundedTask = task({
id: "bounded-task",
maxDuration: 300, // 5 minutes max
run: async (payload) => {
// Task will be terminated after 5 minutes
},
});
```
## Use waitpoints instead of polling
You don't pay for compute during any wait longer than 5 seconds. Use `wait.for()`, `wait.until()`, or `triggerAndWait()` instead of polling loops.
```ts
import { task, wait } from "@trigger.dev/sdk";
export const waitpointTask = task({
id: "waitpoint-task",
run: async (payload) => {
// You aren't charged for compute during this wait
await wait.for({ minutes: 5 });
// The parent isn't charged either while it waits for a child task
const result = await childTask.triggerAndWait({ data: payload });
return result;
},
});
```
Waiting saves you compute, but it doesn't always free up concurrency — see [Wait](/wait).
## Use debounce to consolidate multiple triggers
When a task might be triggered multiple times in quick succession, use debounce to consolidate them into a single run. This is useful for document indexing, webhook aggregation, cache invalidation, and real-time sync scenarios.
```ts
// Multiple rapid triggers consolidate into 1 run
await updateIndex.trigger(
{ docId: "doc-123" },
{ debounce: { key: "doc-123", delay: "5s" } }
);
// Use trailing mode to process the most recent payload
await processUpdate.trigger(
{ version: 2 },
{ debounce: { key: "update-123", delay: "10s", mode: "trailing" } }
);
```
[Read more about debounce](/triggering#debounce).