Coming from Temporal

If you are coming from Temporal, this guide will help you understand the differences and similarities between Temporal and Resonate.

SDK version

This page reflects @resonatehq/sdk v0.10.2 (TypeScript, current on npm), resonate-sdk v0.6.7 (Python), resonate-sdk v0.4.0 (Rust, on crates.io), and the Resonate Go SDK (pre-release — no semver tag yet; tracks main).

Are you coming from Temporal?

This guide is meant to help you understand the differences and similarities between Temporal and Resonate — concept by concept, with side-by-side migration code in every SDK.

About the code

Temporal blocks below are excerpted from the official temporalio/samples-{typescript,python,go} repos and the Rust SDK's in-repo examples (temporalio/sdk-rust/crates/sdk/examples/) — trimmed for focus, elisions marked, never rewritten. Resonate blocks come from the examples library, pinned to the latest released SDKs. Some patterns don't have a Resonate example in every language yet; those gaps are noted inline.

Durability#

One of Temporal’s core value propositions is Durable Execution. Essentially, Durable Execution is the ability for a function to recover in another process after a hard failure and resume from where it left off.

Resonate also provides Durable Execution.

In Resonate, if your process crashes, your function can recover and resume from where it left off in another process.

Programming model#

If you have experience with Temporal, then you are familiar with thinking in terms of Workflows and Activities.

Resonate does not have proprietary function types such as Workflows and Activities; it just uses functions.

Here is the most basic migration — a Workflow that calls one Activity, and the equivalent Resonate function calling one step:

Temporalsamples-python/hello/hello_activity.py·python
@activity.defn
def compose_greeting(input: ComposeGreetingInput) -> str:
    return f"{input.greeting}, {input.name}!"

@workflow.defn
class GreetingWorkflow:
    @workflow.run
    async def run(self, name: str) -> str:
        return await workflow.execute_activity(
            compose_greeting,
            ComposeGreetingInput("Hello", name),
            start_to_close_timeout=timedelta(seconds=10),
        )
Resonateexample-hello-world-py/main.py·python
def bar(_: Context, greetee: str) -> str:
    return f"Hello {greetee} from bar!"

@resonate.register
def foo(ctx: Context, greetee: str) -> Generator[Any, Any, str]:
    bar_greeting = yield ctx.run(bar, greetee=greetee)
    return bar_greeting
See it in action: example-hello-world-py

@resonate.register (and its per-SDK equivalents) replaces the @workflow.defn / @activity.defn split. A step is just a function passed to ctx.run — no Task Queue wiring, no mandatory timeout policy.

Resonate promotes a procedural programming model that enables you to compose functions indefinitely and recursively.

Consider this factorial example. How might you implement it in Temporal?

python
@resonate.register
def factorial(ctx: Context, n: int) -> int:
    if n <= 1:
        return 1
    r = yield ctx.rpc("factorial", n - 1).options(target="poll://any@factorial-worker", id=f"factorial-{n-1}")
    return n * r


def main():
    result = factorial.run("factorial-5", n=5)
    print(result)
See it in action: example-recursive-factorial-py

In Temporal, recursion like this means child workflows (executeChild / ExecuteChildWorkflow) — each a separately registered Workflow type, each accruing its own event history. That history accounting is what makes deep or unbounded recursion awkward in Temporal; in Resonate a function simply calls itself by its registered name (ctx.run / ctx.rpc), with no separate type and no per-child history to manage.

Zero-dependency development#

In Temporal, you always need a Temporal Service for your Worker to connect to, whether that is a local development instance or a production instance.

Resonate offers a zero-dependency development experience, where a Worker (single node application) can run without connecting to a Resonate Server.

python
from resonate import Resonate

resonate = Resonate.local()

#...

Human-in-the-loop#

In Temporal, you can use Signals to unblock a Workflow Execution with input from a human — define a signal, register a handler, flip a flag, and await a condition:

samples-python/hello/hello_signal.py:

python
@workflow.defn
class GreetingWorkflow:
    @workflow.run
    async def run(self) -> List[str]:
        greetings: List[str] = []
        while True:
            await workflow.wait_condition(
                lambda: not self._pending_greetings.empty() or self._exit)
            # …drain self._pending_greetings into greetings…
            if self._exit:
                return greetings

    @workflow.signal
    def exit(self) -> None:
        self._exit = True

Unblock it externally: await handle.signal(GreetingWorkflow.exit).

With Resonate, you can achieve the same effect by creating a Durable Promise that is not attached to any function execution, and then await on it. Then, you can resolve it from anywhere, optionally sending data into the function while doing so.

python
# worker.py
@resonate.register()
def foo(ctx: Context) -> None:
    blocking_promise = yield ctx.promise()
    # ...
    # wait for the promise to be resolved
    data = yield blocking_promise
    # ...
See it in action: example-human-in-the-loop-py
python
# client.py
def main():
    # ...
    resonate.promises.resolve(
        id=promise_id,
        data=json.dumps(data)
    )

Durable timers#

Temporal's sleep-for-days shows a durable timer racing against a signal. Resonate's equivalent is ctx.sleep — a single durable promise that survives crashes.

Watch the time units

Temporal accepts human strings ('30 days') or a timedelta. Resonate's units differ per SDK: TypeScript takes milliseconds, Python takes seconds, Go and Rust take a native Duration. Converting '30 days' for the TS SDK means 30 * 24 * 60 * 60 * 1000.

Temporalsamples-python/sleep_for_days/workflows.py·python
# raced against a completion signal inside asyncio.wait(...):
await workflow.sleep(timedelta(days=30))   # timedelta
Resonateexample-durable-sleep-py/worker.py·python
@resonate.register
def sleeping_workflow(ctx: Context, wf_id: str, secs: float):
    yield ctx.sleep(secs)                   # seconds (float)
    return f"Workflow {wf_id} slept for {secs} seconds."
See it in action: example-durable-sleep-py

Fan-out / fan-in#

Start many units of work in parallel, then join all results. In Temporal you spawn activities (or children) without awaiting, then await them together. Resonate is the same shape: start each with a non-blocking invocation, then await each promise.

The only per-SDK wrinkle

The non-blocking spawn primitive's name — TS ctx.beginRun, Python ctx.rfi, Rust .spawn(), Go ctx.RPC — each returns a future immediately; you await them after all are started.

Temporalsamples-python/hello/hello_parallel_activity.py·python
results = await asyncio.gather(
    workflow.execute_activity(say_hello_activity, "user1", start_to_close_timeout=timedelta(seconds=5)),
    workflow.execute_activity(say_hello_activity, "user2", start_to_close_timeout=timedelta(seconds=5)),
    # …user3 … user5 …
)
Resonateexample-fan-out-fan-in-py/workflow.py·python
# fan-out: rfi() returns a handle immediately
email_p = yield ctx.rfi(send_email, event).options(id=f"{ctx.id}.email")
sms_p   = yield ctx.rfi(send_sms, event).options(id=f"{ctx.id}.sms")
# fan-in: await each
email = yield email_p
sms   = yield sms_p
See it in action: example-fan-out-fan-in-py

Saga / compensation#

A saga runs a sequence of steps and, on failure, runs compensations to undo the completed ones. Temporal's samples register compensation closures and drain them on error. Resonate's examples write the compensation inline in a try/catch, because the durable generator itself is the recovery record.

Temporalsamples-python/message_passing/waiting_for_handlers_and_compensation/workflows.py (the compensation itself is a single activity call):

python
async def workflow_compensation(self):
    await workflow.execute_activity(
        activity_executed_to_perform_workflow_compensation,
        start_to_close_timeout=timedelta(seconds=10),
    )
Resonateexample-money-transfer-py/main.py·python
def transfer_money(ctx, transfer_id, source, target, amount, *, simulate_credit_failure=False):
    yield ctx.run(apply_entry, f"{transfer_id}-debit", source, -amount, "debit")
    try:
        yield ctx.run(
            credit_target, f"{transfer_id}-credit", target, amount,
            fail=simulate_credit_failure,
        ).options(retry_policy=Never())
    except Exception as err:
        # compensate inline
        yield ctx.run(apply_entry, f"{transfer_id}-reversal", source, amount, "reversal")
        return {"transfer_id": transfer_id, "status": "compensated", "error": str(err)}
    return {"transfer_id": transfer_id, "status": "committed"}
See it in action: example-money-transfer-py

Long-running loops#

Temporal's continue-as-new exists to reset a Workflow's event history before it hits the size limit. Resonate has no per-execution event history that grows unboundedly, so for finite loops you just… loop.

One production caveat

For bounded loops, the Resonate example is a plain while/for — no continue-as-new needed. But for truly unbounded loops, a naive loop in a single durable invocation accumulates child promises that get re-walked on replay; once replay time exceeds the task lease, the worker loop stalls and stops making progress. The production-correct pattern is ctx.detached tail-recursion (the SDK docblock calls this out explicitly). The shipped examples below use the naive loop, which is correct for demos and bounded counts; reach for detached when the loop is genuinely infinite.

Temporalsamples-typescript/continue-as-new/src/workflows.ts·typescript
export async function loopingWorkflow(iteration = 0): Promise<void> {
  if (iteration === 10) return;
  await sleep(1000);
  await continueAsNew<typeof loopingWorkflow>(iteration + 1);
}
Resonateexample-infinite-workflow-ts/src/workflow.ts·typescript
function* healthMonitor(ctx: Context, config: MonitorConfig): Generator<any, MonitorResult, any> {
  let iteration = 0;
  // No continueAsNew — Resonate has no history limit.
  while (iteration < config.maxIterations) {
    iteration++;
    yield* ctx.run(checkAllServices, config.services, iteration);
    if (iteration < config.maxIterations) yield* ctx.sleep(config.intervalMs);
  }
  // ...
}

For a genuinely unbounded loop, split per-iteration with ctx.detached (from the SDK docblock):

typescript
function* playGame(ctx: Context, n: number) {
  // ...one iteration of work...
  yield* ctx.detached(playGame, n + 1);   // tail call, then return
}
See it in action: example-infinite-workflow-ts

Distributed mutex#

Temporal's mutex sample is ~130 lines across two workflows: a lock-manager workflow with a signal queue, uuid4() release tokens, continueAsNew for history management, and timeout-based deadlock prevention. In Resonate, the generator itself is the lock.

TypeScript only

This pattern currently has a Resonate example in TypeScript only.

Temporalsamples-typescript/mutex/src/workflows.ts (excerpt)·typescript
export async function lockWorkflow(requests = Array<LockRequest>()): Promise<void> {
  setHandler(lockRequestSignal, (req) => { requests.push(req); });
  while (!workflowInfo().continueAsNewSuggested) {
    await condition(() => requests.length > 0);
    const req = requests.shift()!;
    const releaseSignalName = uuid4();
    await getExternalWorkflowHandle(req.initiatorId).signal(lockAcquiredSignal, { releaseSignalName });
    let released = false;
    setHandler(defineSignal(releaseSignalName), () => { released = true; });
    await condition(() => released, req.timeoutMs);   // auto-release on timeout
  }
  if (requests.length > 0) await continueAsNew<typeof lockWorkflow>(requests);
}
Resonateexample-distributed-mutex-ts/src/workflow.ts·typescript
function* exclusiveResourceAccess(ctx: Context, resource: string, workers: string[]): Generator<any, MutexResult, any> {
  const results: WorkResult[] = [];
  // The generator IS the lock: each yield* blocks until the previous completes,
  // so no two workers touch the resource at once.
  for (let i = 0; i < workers.length; i++) {
    const result = yield* ctx.run(accessResource, resource, workers[i]!);
    results.push(result);
  }
  return { resource, processed: results };
}

No lock-manager workflow, no dynamic signal names, no uuid4() tokens, no continueAsNew, no deadlock surface. Sequential yield* ctx.run() calls are serialized by the runtime, with crash recovery built in.

See it in action: example-distributed-mutex-ts

Encryption#

Temporal encrypts payloads with a PayloadCodec (encode/decode over Payload[]), a DataConverter wrapping it, and a separate codec server so the Web UI can decrypt. Resonate takes a single Encryptor as one constructor option; workflow code is untouched.

TypeScript only

This pattern currently has a Resonate example in TypeScript only.

Temporalsamples-typescript/encryption/src/encryption-codec.ts (excerpt)·typescript
export class EncryptionCodec implements PayloadCodec {
  async encode(payloads: Payload[]): Promise<Payload[]> {
    return Promise.all(payloads.map(async (payload) => ({
      metadata: {
        [METADATA_ENCODING_KEY]: encode(ENCODING),
        [METADATA_ENCRYPTION_KEY_ID]: encode(this.defaultKeyId),
      },
      data: await encrypt(temporal.api.common.v1.Payload.encode(payload).finish(), this.keys.get(this.defaultKeyId)!),
    })));
  }
  async decode(payloads: Payload[]): Promise<Payload[]> { /* …mirror of encode… */ }
}
Resonateexample-encryption-ts/src/encryptor.ts + index.ts·typescript
export class AesGcmEncryptor implements Encryptor {
  encrypt(plaintext: Value): Value {
    if (!plaintext.data) return plaintext;
    const iv = randomBytes(IV_LENGTH);
    const cipher = createCipheriv(ALGORITHM, this.rawKey, iv);
    const packed = Buffer.concat([iv, cipher.update(plaintext.data, "utf8"), cipher.final(), cipher.getAuthTag()]);
    return { headers: { ...plaintext.headers, "x-encrypted": "true" }, data: packed.toString("base64") };
  }
  decrypt(ciphertext: Value): Value { /* …mirror of encrypt… */ }
}

// wired once at startup — workflow code is unchanged:
const resonate = new Resonate({ encryptor: new AesGcmEncryptor(ENCRYPTION_KEY, "demo-key-v1") });

Temporal: a PayloadCodec over protobuf Payload[], a DataConverter, and a codec server (~7 files). Resonate: one Encryptor (encrypt(Value): Value) passed as a constructor option. The SDK handles promise-store serialization transparently.

See it in action: example-encryption-ts
What doesn't map yet

A few coverage gaps to plan around: saga in Go has no example yet (example-money-transfer-go / example-saga-booking-go); long-running loops in Python and Rust have no example yet (example-infinite-workflow-py / -rs); distributed mutex and encryption are TypeScript-only. The examples library is the source of truth for what exists today.

Distributed execution#

In Temporal, you can run multiple Workers to handle the load of your application. You use Task Queue Names to identify which Workers should handle which Workflows and Activities.

In Resonate, you can register workers in groups and send invocations to the groups. Resonate will automatically load balance the invocations across the Workers in the group.

python
# worker.py
resonate = Resonate.remote(
    group="workers"
    # ...
)
See it in action: example-load-balancing-py
python
# client.py
resonate = Resonate.remote(
    group="client"
)

def main():
    # ...
    result = resonate.options(target="poll://any@workers").rpc(promise_id, "function_name", params)

Decentralized architecture#

In Temporal, any given Workflow Execution is tied to a specific Temporal Service. This forces a star-like topology (centralized system) where all Workers in an application connect to a single Temporal Service.

Additionally, Temporal requires you to size your Temporal Service ahead of time to handle the load of all Workers in your application. If you size it incorrectly, you have to migrate to a new setup, which is a complex process.

Resonate applications, on the other hand, can use multiple Resonate Servers to durably store data (Durable Promises), which can be added dynamically as the application scales.

Centralized Temporal topology: every Worker connects to a single Temporal Service, sized ahead of time.

System complexity#

The level of system complexity is perhaps the biggest difference between Resonate and Temporal.

Resonate at its core believes in simplicity.

This belief drives many of the design decisions in Resonate, and has resulted in a much smaller API surface than Temporal.

This smaller API surface and desire for simplicity have also resulted in a much smaller set of components.

For example, Temporal SDKs have tens of thousands of lines of code needed to manage the complexity of their implementation. Resonate SDKs, on the other hand, have but a few hundred lines of code.

A Temporal Service is a complex system consisting of multiple individual components, each consisting of tens of thousands of lines of code, that need to be configured, deployed, and scaled independently. A Resonate Server, on the other hand, is a single binary that is easy to deploy. And the Resonate system can scale by just adding more Resonate Servers, each with an underlying database.

Additionally, Temporal exposes many complex APIs to the developer which they are required to use for failure detection, load balancing, and more. Resonate's belief in simplicity means that much of this complexity is pushed into the platform and automatically handled for you.

For example, Temporal requires the developer to work with at least 4 different timeouts to detect and handle Activity Execution failures. It also requires the developer to manually develop heartbeats in the Activity, and tune the performance of Workers. Whereas, Resonate exposes a single timeout to the developer for the resolution of the Durable Promise, and it automatically handles failure detection, heartbeating, and load balancing across Workers.

Ultimately, as a developer, you will find many areas where Resonate has chosen to promote a specific model for the sake of simplicity, and to avoid indulging in what we believe to be unnecessary complexity.