Constraints and gotchas

Understanding the rules and limitations when building with Resonate.

Resonate enables you to write distributed applications that survive failures and restarts, but this power comes with a few important rules you need to follow. This page explains what those rules are and, more importantly, why they exist.

The four rules#

When writing durable functions with Resonate, you must follow these constraints:

  1. Functions must be deterministic - Same inputs always produce the same outputs
  2. Functions must be idempotent - Safe to retry multiple times
  3. Activations cannot outlive the process - Long-running work needs Resonate primitives
  4. Bound the promise count of a single execution - Replay cost scales with accumulated child promises

Let's explore each one.


Functions must be deterministic#

What does deterministic mean?#

A function is deterministic if calling it with the same inputs always produces the same outputs and side effects, in the same order.

When your process crashes mid-execution, Resonate replays your function from the beginning using recorded results from previous steps. If your function behaves differently during replay (non-deterministic), Resonate won't be able to reconstruct the correct state, and your execution will diverge from what actually happened.

Examples of non-deterministic operations#

These operations will cause problems if used directly in durable functions:

Random number generation

code
function* processOrder(ctx: Context, orderId: string) {
  const confirmationCode = Math.random(); // ⚠️ Different value on replay!
  // ...
}

Current time

code
function* scheduleReminder(ctx: Context, userId: string) {
  const now = Date.now(); // ⚠️ Different value on replay!
  // ...
}

External API calls

code
function* getUserData(ctx: Context, userId: string) {
  const response = await fetch(`https://api.example.com/users/${userId}`); // ⚠️ Could return different data on replay!
  // ...
}

Reading from files or databases

code
function* processData(ctx: Context) {
  const data = fs.readFileSync("./data.json"); // ⚠️ File might have changed!
  // ...
}

The solution: Use Context methods#

Resonate provides deterministic versions of these operations through the Context API.

Deterministic random numbers

Deterministic random generation
function* processOrder(ctx: Context, orderId: string) {
  const confirmationCode = yield* ctx.math.random(); // ✅ Same value on replay
  // ...
}

Deterministic time

Deterministic timestamps
function* scheduleReminder(ctx: Context, userId: string) {
  const now = yield* ctx.date.now(); // ✅ Same value on replay
  // ...
}

Deterministic external calls

Wrap external calls with ctx.run
function* getUserData(ctx: Context, userId: string) {
  const userData = yield* ctx.run(async () => {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return response.json();
  }); // ✅ Result recorded, replays use recorded value
  // ...
}
Use Context methods for anything that can change

If an operation might return different results when called again (time, random, I/O, external APIs), wrap it with a Context method. Resonate records the result the first time and uses the recorded value during replays.

See also:


Functions must be idempotent#

What does idempotent mean?#

A function is idempotent if executing it multiple times with the same inputs has the same effect as executing it once.

Resonate automatically retries failed function executions. If your function has side effects that aren't safe to repeat (like charging a credit card or sending an email), retries could cause duplicate charges or duplicate messages.

Examples of non-idempotent operations#

These operations are not safe to retry:

Incrementing a counter

code
function* recordView(ctx: Context, postId: string) {
  await db.query("UPDATE posts SET views = views + 1 WHERE id = ?", [postId]); 
  // ⚠️ Retry = double counting!
}

Appending to a list

code
function* addToCart(ctx: Context, userId: string, item: string) {
  await db.query("INSERT INTO cart_items (user_id, item) VALUES (?, ?)", [userId, item]);
  // ⚠️ Retry = duplicate cart items!
}

Sending notifications without deduplication

code
function* sendWelcomeEmail(ctx: Context, userId: string) {
  await emailService.send({
    to: userId,
    subject: "Welcome!",
    body: "..."
  }); // ⚠️ Retry = duplicate emails!
}

The solution: Design for retries#

Make your operations safe to retry:

Use upsert instead of insert

Idempotent database operations
function* recordView(ctx: Context, postId: string, sessionId: string) {
  yield* ctx.run(async () => {
    // Use session ID to make it idempotent
    await db.query(
      "INSERT INTO view_log (post_id, session_id, timestamp) VALUES (?, ?, ?) ON CONFLICT DO NOTHING",
      [postId, sessionId, Date.now()]
    );
  });
}

Include idempotency keys

Idempotent API calls with keys
function* chargeCustomer(ctx: Context, orderId: string, amount: number) {
  yield* ctx.run(async () => {
    await stripe.charges.create({
      amount: amount,
      currency: "usd",
      idempotency_key: orderId, // ✅ Stripe deduplicates by key
    });
  });
}

Let promise IDs handle deduplication

Idempotent because promise ID is deterministic
function* sendWelcomeEmail(ctx: Context, userId: string) {
  // ctx.run automatically creates a deterministic promise ID
  // based on the function and arguments
  yield* ctx.run(async () => {
    await emailService.send({ 
      to: userId, 
      subject: "Welcome!",
      idempotency_key: `welcome-${userId}` // Email service deduplicates
    });
  });
  // If this retries, same promise ID = same result returned from storage
  // Email service never gets called again
}

When you use ctx.run() or ctx.rpc(), Resonate automatically generates a deterministic promise ID based on your function name and arguments. On retry, the same inputs produce the same promise ID, so Resonate returns the stored result instead of re-executing the work.

This means if you use Resonate's APIs correctly, you don't have to manually check for duplicates - the framework handles it for you.

Use deterministic promise IDs

When you call operations through ctx.run() or ctx.rpc(), Resonate generates promise IDs that are the same across retries. This gives you automatic idempotency without manual deduplication logic.

What ctx.run retries by default#

When you call ctx.run(fn) without supplying a retryPolicy, the SDK applies:

  • TypeScriptExponential() for regular async functions, Never() for generator functions. Exponential() defaults: delay = 1000 ms, factor = 2, maxDelay = 30 000 ms, maxRetries = Number.MAX_SAFE_INTEGER.
  • PythonExponential() for regular functions, Never() for generator functions. Exponential() defaults: delay = 1 s, factor = 2, max_delay = 30 s, max_retries = sys.maxsize.
  • Rust — no per-invocation retry policy yet; retries are governed by the server's --tasks-retry-timeout flag.

This means a ctx.run(fn) call that keeps failing will keep retrying with exponential backoff, capped at 30 seconds between attempts, effectively forever — until the surrounding invocation's timeout fires.

See the Defaults reference for the full per-SDK table.


Activations cannot outlive the process#

What does this mean?#

An activation is a single execution attempt of your function. If your process crashes or restarts, the current activation ends immediately. Your function cannot keep running in the background after the process exits.

Resonate is not a job queue or background task runner. It coordinates work across processes, but each individual function execution lives inside a process. When that process ends (crashes, deploy, scale-down), the execution stops.

What happens on process restart?#

When your process restarts, Resonate will:

  1. Detect the activation ended (heartbeat timeout)
  2. Start a new activation (automatic retry)
  3. Replay from the beginning using recorded results (determinism)

This is how Resonate achieves fault tolerance - your work survives crashes, but individual activations do not.

Long-running work#

If you need work to span hours, days, or weeks, use Resonate primitives instead of blocking:

Blocking sleep (wrong)

code
function* longRunningJob(ctx: Context) {
  await new Promise(resolve => setTimeout(resolve, 3600000)); // ⚠️ 1 hour - process will likely crash!
  // ...
}

Durable sleep (correct)

Sleep that survives process restarts
function* longRunningJob(ctx: Context) {
  yield* ctx.sleep(3600000); // ✅ 1 hour - process can restart safely
  // ...
}

How it works:

ctx.sleep() doesn't block your process. Instead:

  1. Resonate records a "wake up at time X" checkpoint
  2. Your function yields control (process can do other work)
  3. At time X, Resonate resumes your function
  4. If the process crashed in between, Resonate restarts and resumes after the sleep

Human-in-the-loop (wait for external input)

Wait indefinitely for human approval
function* approvalWorkflow(ctx: Context, requestId: string) {
  const approvalPromise = yield* ctx.promise();
  
  // Send approval request email with promise ID
  yield* ctx.run(sendApprovalEmail, approvalPromise.id);
  
  // Wait for human to resolve the promise (could be hours/days)
  const approved = yield* approvalPromise; // ✅ Survives process restarts
  
  if (approved) {
    // continue workflow
  }
}
Use Resonate primitives for long-running work

For work that takes hours, days, or weeks, use ctx.sleep() or Durable Promises instead of blocking. This lets your process restart safely without losing progress.

See also:


Bound the promise count of a single execution#

What does this mean?#

Replay duration grows with the count of executed promises in a single root invocation. On cold-start, every prior step is replayed before the next can proceed.

Each ctx.run(), ctx.rpc(), ctx.sleep(), or other Context call inside a root invocation adds one promise to that root's history. When the worker restarts (deploy, scale-down, crash, task reassignment), Resonate replays the function from the beginning, walking every recorded promise to reconstruct state. The more promises accumulated under a single root, the longer replay takes.

When this becomes a problem#

Workflows that loop indefinitely or accumulate many child promises within a single root.

On serverless platforms with fixed acquired-task leases (e.g., @resonatehq/gcp default ttl = 5 min), replay duration eventually exceeds the lease, the server reassigns the task mid-execution (error code 1199 — Task is not acquired), and cadence collapses.

Single root accumulates children forever

code
function* playGame(ctx: Context, n: number) {
  while (true) {                                  // ⚠️ one durable invocation forever
    // ...play one game (ctx.run, ctx.sleep, etc.)...
    // every yield adds a child promise to this root's history;
    // after thousands of iterations, replay > task lease
  }
}

The solution: Recursive tail call with ctx.detached#

Have the per-iteration function play exactly one iteration, and as its last yield, spawn the next iteration as a brand-new root via ctx.detached. The current invocation returns; the next invocation starts with a fresh origin id and an empty history.

Each invocation = one iteration, replay scope bounded

code
function* playGame(ctx: Context, n: number) {
  // ...play exactly one game (ctx.run, ctx.sleep, etc.)...
  yield* ctx.detached(playGame, n + 1); // ✅ last yield, fresh root, bounded replay
}
The split must happen *inside* the per-iteration function

A parent loop that calls ctx.detached repeatedly — for (;;) yield* ctx.detached(work, n) — still records each call in the parent's history, reproducing the same accumulation problem on the parent. The recursive-tail shape (per-iteration function spawns its own successor as the last yield, then returns) is what bounds replay scope.

The same pattern applies to any forever-running workflow: poll loops, monitoring agents, recurring jobs, demo loops. The per-iteration function plays one iteration, then yield* ctx.detached(self, next) as its final yield.

One iteration = one root

If a workflow loops indefinitely or accumulates many child promises, split iterations with ctx.detached(). Each iteration gets a fresh root and a bounded replay scope, regardless of how long the overall workflow runs.

See also:


Summary#

ConstraintWhy it existsSolution
DeterministicFunctions are replayed on restart - must produce same resultsUse ctx.math.random(), ctx.date.now(), ctx.run()
IdempotentFunctions are retried on failure - must be safe to repeatUse idempotency keys, upserts, deduplication checks
Process lifetimeActivations end when process ends - can't outlive itUse ctx.sleep(), Durable Promises for long-running work
Bounded promise countReplay walks all prior promises - cost scales linearlyUse ctx.detached() for forever-loop iterations

Following these rules ensures your distributed application survives process crashes and restarts, retries safely without duplicating work, and handles long-running workflows spanning hours or days.

To see these patterns in action, check out the TypeScript SDK guide, Python SDK guide, or browse the example applications.