Temporal vs Resonate

Side-by-side comparison of Temporal and Resonate — programming model, deployment, and guarantees — to help you evaluate Resonate as a Temporal alternative.

SDK version

This page reflects @resonatehq/sdk v0.11.4 (TypeScript, current on npm), resonate-sdk v0.7.4 (Python), resonate-sdk v0.6.0 (Rust, on crates.io), and the Resonate Go SDK 0.1.0.

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
async def foo(ctx: Context, greetee: str) -> str:
    bar_greeting = await 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
async def factorial(ctx: Context, n: int) -> int:
    if n <= 1:
        return 1
    r = await ctx.options(target="factorial-workers").rpc("factorial", n - 1)
    return n * r


async def main():
    handle = resonate.run("factorial-5", factorial, n=5)
    result = await handle.result()
    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.resonate import Resonate

resonate = Resonate()  # no url, no RESONATE_URL/RESONATE_HOST env -> in-memory, no server

#...

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:

Temporalsamples-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·python
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
async def foo(ctx: Context) -> str:
    blocking_promise = ctx.promise()
    promise_id = await blocking_promise.id()
    # ... hand promise_id to the human, e.g. via a leaf that emails/pages them ...
    # wait for the promise to be resolved
    data = await blocking_promise
    # ...
    return data
See it in action: example-human-in-the-loop-py
python
# client.py
from resonate.types import Value

async def main():
    # ...
    await resonate.promises.resolve(promise_id, Value(data=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 a timedelta, 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
async def sleeping_workflow(ctx: Context, wf_id: str, secs: float) -> str:
    await ctx.sleep(timedelta(seconds=secs))    # timedelta, not a bare number
    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.

Spawning differs per SDK

The non-blocking spawn step — TS ctx.beginRun, Rust .spawn(), Go ctx.RPC — each returns a future immediately; you await them after all are started. Python needs no separate spawn call: ctx.run/ctx.rpc already return a future the moment they're called, so fanning out is just calling several before awaiting any of them.

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: ctx.run returns a future immediately, without blocking
email_f = ctx.run(send_email, event)
sms_f = ctx.run(send_sms, event)
# fan-in: await each
email = await email_f
sms = await sms_f
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
from resonate.retry import Never


async def transfer_money(ctx: Context, source, target, amount, *, simulate_credit_failure=False):
    transfer_id = ctx.info.id                    # the workflow id is the transfer id
    await ctx.run(apply_entry, f"{transfer_id}-debit", source, -amount, "debit")
    try:
        await ctx.options(retry_policy=Never()).run(
            credit_target, f"{transfer_id}-credit", target, amount,
            fail=simulate_credit_failure,
        )
    except Exception as err:
        # compensate inline
        await 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(
    url="http://localhost:8001",
    group="workers",
    # ...
)
See it in action: example-load-balancing-py
python
# client.py
resonate = Resonate(
    url="http://localhost:8001",
    group="client",
)

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

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 run to tens of thousands of lines of code to manage the complexity of their implementation — roughly 48,000 in TypeScript, 69,000 in Python, and 84,000 in Go. The Resonate SDKs are between about 6,000 and 14,000 lines depending on the language. (SDK source only, excluding tests and generated protobuf bindings, measured at the time of writing — clone the repos and check.)

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 defines four separate Activity timeouts — schedule-to-close, start-to-close, schedule-to-start, and heartbeat. Only one of them is strictly required (either schedule-to-close or start-to-close), but each covers a different failure mode, so choosing the right combination is a decision you make per Activity. Long-running Activities that need failure detected before the timeout expires record heartbeats themselves, and Workers expose a set of concurrency and rate-limiting knobs to tune.

Resonate exposes a single timeout to the developer — the resolution deadline of the Durable Promise. Failure detection, heartbeating, and load balancing across Workers are handled by the platform: the SDK heartbeats the task lease, the server notices a dead Worker, and the task is re-dispatched without any application code.

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.