Coming from Temporal
If you are coming from Temporal, this guide will help you understand the differences and similarities between Temporal and Resonate.
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.
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:
@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),
)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// activities.ts
export async function greet(name: string): Promise<string> {
return `Hello, ${name}!`;
}
// workflows.ts
const { greet } = proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
});
export async function example(name: string): Promise<string> {
return await greet(name);
}async function bar(_: Context, greetee: string): Promise<string> {
return `Hello ${greetee} from bar!`;
}
function* foo(ctx: Context, greetee: string): Generator<any, string, any> {
const barGreeting = yield* ctx.run(bar, greetee);
return barGreeting;
}
const fooR = resonate.register("foo", foo);#[workflow]
pub struct HelloWorldWorkflow;
#[workflow_methods]
impl HelloWorldWorkflow {
#[run]
pub async fn run(ctx: &mut WorkflowContext<Self>, name: String) -> WorkflowResult<String> {
ctx.start_activity(
GreetingActivities::greet, name,
ActivityOptions::start_to_close_timeout(Duration::from_secs(10)),
).await
}
}
#[activities]
impl GreetingActivities {
#[activity]
pub async fn greet(_ctx: ActivityContext, name: String) -> Result<String, ActivityError> {
Ok(format!("Hello, {}!", name))
}
}#[resonate::function]
async fn greet(ctx: &Context, name: String) -> Result<String> {
let greeting = ctx.run(format_greeting, name).await?;
Ok(greeting)
}
#[resonate::function]
async fn format_greeting(name: String) -> Result<String> {
Ok(format!("Hello, {name}!"))
}func Workflow(ctx workflow.Context, name string) (string, error) {
ao := workflow.ActivityOptions{StartToCloseTimeout: 10 * time.Second}
ctx = workflow.WithActivityOptions(ctx, ao)
var result string
err := workflow.ExecuteActivity(ctx, Activity, name).Get(ctx, &result)
return result, err
}
func Activity(ctx context.Context, name string) (string, error) {
return "Hello " + name + "!", nil
}func greet(_ *resonate.Context, args GreetArgs) (string, error) {
return fmt.Sprintf("hello, %s!", args.Name), nil
}
func main() {
r, _ := resonate.New(resonate.Config{URL: "http://localhost:8001"})
defer r.Stop()
greetFn, _ := resonate.Register(r, "greet", greet)
h, _ := greetFn.Run(ctx, id, GreetArgs{Name: "world"})
out, _ := h.Result(ctx)
fmt.Println(out)
}@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?
@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)function* factorial(ctx: Context, n: number): Generator<any, number, any> {
if (n <= 1) {
return 1;
}
const result = yield* ctx.rpc(
"factorial",
n - 1,
ctx.options({ target: "poll://any@factorial-workers" }),
);
return n * result;
}
const factorialR = resonate.register("factorial", factorial);
async function main() {
const result = await factorialR.run("factorial-5", 5);
console.log(result);
}#[resonate::function]
async fn factorial(ctx: &Context, n: u64) -> Result<u64> {
if n <= 1 {
return Ok(1);
}
let result: u64 = ctx
.rpc("factorial", n - 1)
.target("poll://any@factorial-workers")
.await?;
Ok(n * result)
}
#[tokio::main]
async fn main() {
let resonate = Resonate::local();
resonate.register(factorial).unwrap();
let result: u64 = resonate.run("factorial-5", factorial, 5).await.unwrap();
println!("{result}");
resonate.stop().await.unwrap();
}type Args struct {
N int `json:"n"`
}
func factorial(ctx *resonate.Context, args Args) (int, error) {
if args.N <= 1 {
return 1, nil
}
f, err := ctx.RPC("factorial", Args{N: args.N - 1})
if err != nil {
return 0, err
}
var result int
if err := f.Await(&result); err != nil {
return 0, err
}
return args.N * result, nil
}
func main() {
r, _ := resonate.New(resonate.Config{URL: "http://localhost:8001"})
defer func() { _ = r.Stop() }()
factorialFn, _ := resonate.Register(r, "factorial", factorial)
h, _ := factorialFn.Run(context.Background(), "factorial-5", Args{N: 5})
result, _ := h.Result(context.Background())
fmt.Println(result)
}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.
from resonate import Resonate
resonate = Resonate.local()
#...import { Resonate } from "@resonatehq/sdk";
const resonate = new Resonate();
// ...use resonate::prelude::*;
let resonate = Resonate::local();
// ...import (
resonate "github.com/resonatehq/resonate-sdk-go"
"github.com/resonatehq/resonate-sdk-go/localnet"
)
// Local development mode runs the server state machine in-process,
// using in-memory storage for promises and tasks.
pid := "dev-worker"
r, _ := resonate.New(resonate.Config{
Network: localnet.NewLocal("default", &pid),
Heartbeat: resonate.NoopHeartbeat{}, // localnet has no lease endpoint to heartbeat against
})
// ...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:
@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 = TrueUnblock it externally: await handle.signal(GreetingWorkflow.exit).
samples-typescript/signals-queries/src/workflows.ts:
export const unblockSignal = wf.defineSignal('unblock');
export const isBlockedQuery = wf.defineQuery<boolean>('isBlocked');
export async function unblockOrCancel(): Promise<void> {
let isBlocked = true;
wf.setHandler(unblockSignal, () => void (isBlocked = false));
wf.setHandler(isBlockedQuery, () => isBlocked);
await wf.condition(() => !isBlocked); // blocks here
}Unblock it externally: await handle.signal(unblockSignal);.
sdk-rust/crates/sdk/examples/message_passing/workflows.rs:
#[workflow]
pub struct MessagePassingWorkflow { counter: i32 }
#[workflow_methods]
impl MessagePassingWorkflow {
#[run]
pub async fn run(ctx: &mut WorkflowContext<Self>, target: i32) -> WorkflowResult<i32> {
ctx.wait_condition(|s| s.counter >= target).await; // blocks here
Ok(ctx.state(|s| s.counter))
}
#[signal]
pub fn increment(&mut self, _ctx: &mut SyncWorkflowContext<Self>, amount: i32) {
self.counter += amount;
}
}Unblock it externally by sending the increment signal. The Resonate Rust
equivalent is in the tabs below.
samples-go/await-signals/await_signals_workflow.go (excerpt):
func AwaitSignalsWorkflow(ctx workflow.Context) (err error) {
var a AwaitSignals
workflow.Go(ctx, a.Listen) // signal handlers in a goroutine
err = workflow.Await(ctx, func() bool { // blocks until Signal1 arrives
return a.Signal1Received
})
// … then awaits Signal2 and Signal3 via workflow.AwaitWithTimeout …
return
}
// a.Listen: selector.AddReceive(workflow.GetSignalChannel(ctx, "Signal1"), …)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.
# 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
# ...# client.py
def main():
# ...
resonate.promises.resolve(
id=promise_id,
data=json.dumps(data)
)function* foo(context: Context) {
const blockingPromise = yield* context.promise();
// ...
// wait for the promise to be resolved
data = yield* blockingPromise;
// ...
}// client.ts
// promises.resolve does not run the codec, so encode the payload the way the
// codec expects: JSON → base64 string in the `data` field.
const data = Buffer.from(JSON.stringify(value), "utf8").toString("base64");
await resonate.promises.resolve(promiseId, { data });// worker.rs
#[resonate::function]
async fn foo(ctx: &Context) -> Result<()> {
// Latent durable promise — no function backing it. Resolved externally.
let blocking_promise = ctx.promise::<bool>();
let _promise_id = blocking_promise.id().await?;
// ...
// suspend until the promise is resolved (survives crashes)
let _data = blocking_promise.await?;
// ...
Ok(())
}use resonate::types::Value;
// resolve from anywhere
resonate
.promises
.resolve(&promise_id, Value::from_serializable(data)?)
.await?;func foo(ctx *resonate.Context, _ struct{}) (string, error) {
// Latent durable promise — no function backing it. Resolved externally.
f, err := ctx.Promise()
if err != nil {
return "", err
}
promiseID := f.ID() // hand this ID to whoever resolves it
_ = promiseID
// ...
// Await suspends the workflow until the promise settles (survives crashes).
var data string
if err := f.Await(&data); err != nil {
return "", err
}
// ...
return data, nil
}# The Go SDK has no high-level promises sub-client yet (pre-release), so
# resolve the latent promise from outside via the Resonate CLI or server API.
# `data` must be a JSON-quoted string: the CLI base64-encodes it before storing,
# and the worker's codec base64-decodes then JSON-parses it. A bare "approved"
# would decode to the bytes `approved`, which is not valid JSON (DecodingError).
resonate promises resolve <promise-id> --value '{"headers": {}, "data": "\"approved\""}'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.
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.
# raced against a completion signal inside asyncio.wait(...):
await workflow.sleep(timedelta(days=30)) # timedelta@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."await Promise.race([sleep('30 days'), condition(() => isComplete)]);function* sleepingWorkflow(ctx: Context, ms: number) {
yield* ctx.sleep(ms); // milliseconds
return `Slept for ${ms / 1000} seconds`;
}#[run]
pub async fn run(ctx: &mut WorkflowContext<Self>) -> WorkflowResult<String> {
ctx.timer(Duration::from_secs(1)).await; // durable timer
// …select! race + cancellable-timer demo omitted…
}#[resonate::function]
async fn sleeping_workflow(ctx: &Context, secs: u64) -> Result<String> {
ctx.sleep(Duration::from_secs(secs)).await?; // std::time::Duration
Ok(format!("Slept for {secs} seconds"))
}selector := workflow.NewSelector(ctx)
selector.AddFuture(workflow.NewTimer(ctx, time.Hour*24*30), func(f workflow.Future) {})
selector.AddReceive(sigChan, func(c workflow.ReceiveChannel, more bool) { isComplete = true })
selector.Select(ctx)func sleepingWorkflow(ctx *resonate.Context, args SleepArgs) (string, error) {
d := time.Duration(args.Secs) * time.Second
f, err := ctx.Sleep(d) // time.Duration
if err != nil {
return "", err
}
if err := f.Await(nil); err != nil { // Await(nil) — no return value
return "", err
}
return fmt.Sprintf("slept for %d second(s)", args.Secs), nil
}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 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.
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 …
)# 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_pconst responseArray = await Promise.all(
names.map((name) => executeChild(childWorkflow, { args: [name] })),
);// fan-out: beginRun returns a future immediately
const emailFuture = yield* ctx.beginRun(sendEmail, event);
const smsFuture = yield* ctx.beginRun(sendSms, event);
// fan-in: await each
const results = [yield* emailFuture, yield* smsFuture];Temporal Rust has no dedicated fan-out sample — its child_workflows example starts children sequentially. The Resonate Rust equivalent fans out with .spawn():
// fan-out: .spawn() returns a handle immediately
let h1 = ctx.run(process_item, items[0].clone()).spawn().await?;
let h2 = ctx.run(process_item, items[1].clone()).spawn().await?;
// fan-in: await each
let r1 = h1.await?;
let r2 = h2.await?;var results []workflow.Future
for i := 0; i < processorCount; i++ { // fan-out
results = append(results, workflow.ExecuteActivity(ctx, ChunkProcessingActivity, i+1))
}
for i := 0; i < processorCount; i++ { // fan-in
var r ChunkResult
_ = results[i].Get(ctx, &r)
}futures := make([]*resonate.Future, 0, len(args.Channels))
for _, ch := range args.Channels { // fan-out
f, _ := ctx.RPC("send", SendArgs{Channel: ch, Message: args.Message})
futures = append(futures, f)
}
for _, f := range futures { // fan-in
var d Delivery
_ = f.Await(&d)
}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.
Temporal — samples-python/message_passing/waiting_for_handlers_and_compensation/workflows.py (the compensation itself is a single activity call):
async def workflow_compensation(self):
await workflow.execute_activity(
activity_executed_to_perform_workflow_compensation,
start_to_close_timeout=timedelta(seconds=10),
)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"}const compensations: Compensation[] = [];
try {
await addAddress({ accountId, address });
compensations.unshift({ message: '…', fn: () => clearPostalAddresses({ accountId }) });
// …addClient + its compensation omitted for brevity…
await addBankAccount({ accountId, details });
compensations.unshift({ message: '…', fn: () => disconnectBankAccounts({ accountId }) });
} catch (err) {
await compensate(compensations); // drains the stack LIFO
throw err;
}let flightId: string | undefined;
let hotelId: string | undefined;
try {
flightId = yield* ctx.run(bookFlight, tripId);
hotelId = yield* ctx.run(bookHotel, tripId);
const carId = yield* ctx.run(bookCarRental, tripId, shouldFail,
ctx.options({ retryPolicy: noRetry }));
return { status: "success", tripId, flightId, hotelId, carId };
} catch (error) {
if (hotelId) yield* ctx.run(cancelHotel, tripId, hotelId);
if (flightId) yield* ctx.run(cancelFlight, tripId, flightId);
return { status: "failed", tripId, error: (error as Error).message };
}Temporal pushes typed Compensation closures onto a stack and drains it in a separate compensate() function. Resonate writes the undo inline in catch, guarded by if (hotelId) — no stack, no helper.
#[run]
pub async fn run(ctx: &mut WorkflowContext<Self>, trip_id: String) -> WorkflowResult<Vec<String>> {
let mut saga = Saga::new(ctx, activity_opts());
match Self::book_trip(&mut saga, trip_id).await {
Ok(ids) => Ok(ids),
Err(e) => { saga.compensate().await; Err(e.into()) } // run compensations
}
}
// book_trip: saga.step(book_hotel, …, cancel_hotel).await? — one step + compensation per booking#[resonate::function]
async fn transfer_money(ctx: &Context, args: TransferArgs) -> Result<TransferResult> {
// Step 1 — debit the source (durable checkpoint).
ctx.run(apply_entry, EntryArgs {
op_id: format!("{}-debit", args.transfer_id),
account: args.source.clone(),
amount: -args.amount,
note: "debit".to_string(),
}).await?;
// Step 2 — credit the target; on failure, compensate by reversing the debit.
let credit_outcome = ctx.run(credit_target, CreditArgs {
op_id: format!("{}-credit", args.transfer_id),
target: args.target.clone(),
amount: args.amount,
fail: args.simulate_credit_failure,
}).await;
if let Err(err) = credit_outcome {
// compensate inline — also durable + idempotent
ctx.run(apply_entry, EntryArgs {
op_id: format!("{}-reversal", args.transfer_id),
account: args.source.clone(),
amount: args.amount,
note: "reversal".to_string(),
}).await?;
return Ok(TransferResult {
transfer_id: args.transfer_id,
status: "compensated".to_string(),
error: Some(err.to_string()),
});
}
Ok(TransferResult {
transfer_id: args.transfer_id,
status: "committed".to_string(),
error: None,
})
}Temporal — samples-go/saga/workflow.go (uses stacked defer for LIFO compensation):
func TransferMoney(ctx workflow.Context, td TransferDetails) (err error) {
err = workflow.ExecuteActivity(ctx, Withdraw, td).Get(ctx, nil)
if err != nil { return err }
defer func() {
if err != nil {
errC := workflow.ExecuteActivity(ctx, WithdrawCompensation, td).Get(ctx, nil)
err = multierr.Append(err, errC)
}
}()
err = workflow.ExecuteActivity(ctx, Deposit, td).Get(ctx, nil)
// …Deposit's own defer-compensation + a final failing step omitted for brevity…
return err
}There is currently no example-saga-booking-go or example-money-transfer-go. The migration maps the same way as the other languages — ctx.Run(...) per step, compensation inline in the error branch — but until a Go example ships, treat this cell as a known gap rather than a worked sample.
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.
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.
export async function loopingWorkflow(iteration = 0): Promise<void> {
if (iteration === 10) return;
await sleep(1000);
await continueAsNew<typeof loopingWorkflow>(iteration + 1);
}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):
function* playGame(ctx: Context, n: number) {
// ...one iteration of work...
yield* ctx.detached(playGame, n + 1); // tail call, then return
}func SampleChildWorkflow(ctx workflow.Context, totalCount, runCount int) (string, error) {
if runCount <= 0 {
return "", errors.New("invalid run count")
}
totalCount++
runCount--
if runCount == 0 {
return fmt.Sprintf("completed after %v runs", totalCount), nil
}
return "", workflow.NewContinueAsNewError(ctx, SampleChildWorkflow, totalCount, runCount)
}func heartbeatWorkflow(ctx *resonate.Context, args WorkflowArgs) (WorkflowResult, error) {
for i := 0; args.Iterations == 0 || i < args.Iterations; i++ {
tickF, err := ctx.Run(heartbeatTick, TickArgs{Tick: i + 1})
if err != nil { return WorkflowResult{}, err }
if err := tickF.Await(nil); err != nil { return WorkflowResult{}, err }
sleepF, err := ctx.Sleep(time.Duration(args.IntervalSec) * time.Second)
if err != nil { return WorkflowResult{}, err }
if err := sleepF.Await(nil); err != nil { return WorkflowResult{}, err }
}
return WorkflowResult{Ticks: args.Iterations}, nil
}The Go SDK also has ctx.Detached for the unbounded case; the shipped example uses a plain for loop.
example-infinite-workflow-py does not exist. Temporal's source is samples-python/hello/hello_continue_as_new.py (workflow.continue_as_new(iteration + 1)). The Resonate equivalent is a plain generator loop (while … : yield ctx.run(...) ; yield ctx.sleep(...)), with ctx.detached(self, n + 1) for the unbounded case — but until an example ships, treat this as a known gap.
example-infinite-workflow-rs does not exist yet. Temporal's Rust continue_as_new example (sdk-rust/crates/sdk/examples/continue_as_new) maps to a plain Resonate loop/while with ctx.detached for the unbounded case — treat the missing Resonate example as a known gap until one ships.
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.
This pattern currently has a Resonate example in TypeScript only.
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);
}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.
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.
This pattern currently has a Resonate example in TypeScript only.
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… */ }
}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.
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.
# worker.py
resonate = Resonate.remote(
group="workers"
# ...
)# client.py
resonate = Resonate.remote(
group="client"
)
def main():
# ...
result = resonate.options(target="poll://any@workers").rpc(promise_id, "function_name", params)// worker.ts
const resonate = new Resonate({
url: "http://localhost:8001",
group: "workers",
});//client.ts
const resonate = new Resonate({
url: "http://localhost:8001",
group: "client",
});
async function main() {
resonate.rpc(
promiseId,
"function_name",
params,
resonate.options({
target: "poll://any@workers",
})
);
}// worker.rs
let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
group: Some("workers".into()),
..Default::default()
});// client.rs
let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
group: Some("client".into()),
..Default::default()
});
let result: u64 = resonate
.rpc(&promise_id, "function_name", params)
.target("poll://any@workers")
.await?;// worker
r, _ := resonate.New(resonate.Config{
Network: httpnet.NewHTTP("http://localhost:8001", httpnet.HTTPOptions{
Group: "workers",
}),
})// client
r, _ := resonate.New(resonate.Config{
Network: httpnet.NewHTTP("http://localhost:8001", httpnet.HTTPOptions{
Group: "client",
}),
})
h, _ := r.RPC(context.Background(), promiseID, "function_name", params,
resonate.RPCOptions{Target: "poll://any@workers"})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.
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.