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:
- Functions must be deterministic - Same inputs always produce the same outputs
- Functions must be idempotent - Safe to retry multiple times
- Activations cannot outlive the process - Long-running work needs Resonate primitives
- 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
function* processOrder(ctx: Context, orderId: string) {
const confirmationCode = Math.random(); // ⚠️ Different value on replay!
// ...
}❌ Current time
function* scheduleReminder(ctx: Context, userId: string) {
const now = Date.now(); // ⚠️ Different value on replay!
// ...
}❌ External API calls
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
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
function* processOrder(ctx: Context, orderId: string) {
const confirmationCode = yield* ctx.math.random(); // ✅ Same value on replay
// ...
}@resonate.register
def process_order(ctx: Context, order_id: str):
confirmation_code = yield ctx.random.random() # ✅ Same value on replay
# ...✅ Deterministic time
function* scheduleReminder(ctx: Context, userId: string) {
const now = yield* ctx.date.now(); // ✅ Same value on replay
// ...
}@resonate.register
def schedule_reminder(ctx: Context, user_id: str):
now = yield ctx.time.time() # ✅ Same value on replay
# ...✅ Deterministic external calls
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
// ...
}@resonate.register
def get_user_data(ctx: Context, user_id: str):
def fetch_user(ctx):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
user_data = yield ctx.run(fetch_user) # ✅ Result recorded, replays use recorded value
# ...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
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
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
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
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
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
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.
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:
- TypeScript —
Exponential()for regularasyncfunctions,Never()for generator functions.Exponential()defaults:delay = 1000 ms,factor = 2,maxDelay = 30 000 ms,maxRetries = Number.MAX_SAFE_INTEGER. - Python —
Exponential()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-timeoutflag.
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:
- Detect the activation ended (heartbeat timeout)
- Start a new activation (automatic retry)
- 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)
function* longRunningJob(ctx: Context) {
await new Promise(resolve => setTimeout(resolve, 3600000)); // ⚠️ 1 hour - process will likely crash!
// ...
}✅ Durable sleep (correct)
function* longRunningJob(ctx: Context) {
yield* ctx.sleep(3600000); // ✅ 1 hour - process can restart safely
// ...
}@resonate.register
def long_running_job(ctx: Context):
yield ctx.sleep(3600) # ✅ 1 hour - process can restart safely
# ...How it works:
ctx.sleep() doesn't block your process. Instead:
- Resonate records a "wake up at time X" checkpoint
- Your function yields control (process can do other work)
- At time X, Resonate resumes your function
- If the process crashed in between, Resonate restarts and resumes after the sleep
✅ Human-in-the-loop (wait for external input)
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
}
}@resonate.register
def approval_workflow(ctx: Context, request_id: str):
approval_promise = yield ctx.promise()
# Send approval request email with promise ID
yield ctx.run(send_approval_email, approval_promise.id)
# Wait for human to resolve the promise (could be hours/days)
approved = yield approval_promise # ✅ Survives process restarts
if approved:
# continue workflowFor 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
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
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
}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.
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#
| Constraint | Why it exists | Solution |
|---|---|---|
| Deterministic | Functions are replayed on restart - must produce same results | Use ctx.math.random(), ctx.date.now(), ctx.run() |
| Idempotent | Functions are retried on failure - must be safe to repeat | Use idempotency keys, upserts, deduplication checks |
| Process lifetime | Activations end when process ends - can't outlive it | Use ctx.sleep(), Durable Promises for long-running work |
| Bounded promise count | Replay walks all prior promises - cost scales linearly | Use 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.