Coming from DBOS

Compare Resonate with DBOS to understand key differences and similarities.

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).

About the code

DBOS blocks below are excerpted from the official dbos-inc/dbos-demo-apps repo and the DBOS SDK repos (dbos-transact-{py,ts,golang}) — trimmed for focus, elisions marked, never rewritten. They reflect dbos v2.23.0 (Python), @dbos-inc/dbos-sdk v4.19.8 (TypeScript), and dbos-transact-golang v0.17.0 (Go). Resonate blocks come from the examples library, pinned to the latest released SDKs (the Rust examples track the SDK's main branch). DBOS ships SDKs for TypeScript, Python, Go, and Java; it has no Rust SDK, so the Rust tabs below show Resonate only. Some patterns don't have a Resonate example in every language yet; those gaps are noted inline.

Are you coming from DBOS?

If so, this guide is meant to help you understand the differences and similarities between Resonate and DBOS — concept by concept, with side-by-side migration code.

DBOS and Resonate have a lot in common, and DBOS is a genuinely good system: you add durability by annotating ordinary functions, the only required infrastructure is a database you already know, and you get built-in observability for free. This guide focuses on how the two map onto each other, and is honest about what each does that the other does not.

Durability#

A core value proposition of DBOS is its durable workflows. A durable workflow or Durable Execution is one that can recover in another process after a hard failure and resume from where it left off.

Resonate also provides durable workflows (Durable Executions).

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 DBOS, then you are familiar with thinking in terms of Workflows and Steps. You annotate a function with @DBOS.workflow() and the functions it calls with @DBOS.step() (or run them inline with DBOS.runStep / DBOS.RunAsStep). DBOS checkpoints each step to the database so a restarted workflow resumes from the last completed step.

Resonate does not have proprietary function types such as Workflows and Steps – you just write functions. A step is any function you pass to ctx.run, which makes its result durable. There is no decorator split, no task-queue wiring, and no mandatory per-step timeout policy.

Here is the most basic migration — a workflow that runs a few steps, and the equivalent Resonate function:

DBOSdbos-demo-apps/python/dbos-app-starter/app/main.py·python
@DBOS.step()
def step_one():
    time.sleep(5)
    DBOS.logger.info("Completed step 1!")

@DBOS.workflow()
def workflow():
    step_one()
    # ... step_two(), step_three() ...
Resonateexample-hello-world-py/main.py·python
def bar(_: Context, greetee: str) -> str:
    return f"Hello {greetee} from bar!"

def baz(_: Context, greetee: str) -> str:
    return f"Hello {greetee} from baz!"

@resonate.register
def foo(ctx: Context, greetee: str) -> Generator[Any, Any, str]:
    foo_greeting = f"Hello {greetee} from foo!"
    bar_greeting = yield ctx.run(bar, greetee=greetee)
    baz_greeting = yield ctx.run(baz, greetee=greetee)
    greeting = f"{foo_greeting} {bar_greeting} {baz_greeting}"
    return greeting
See it in action: example-hello-world-py

ctx.run (and registering the function) replaces the @DBOS.workflow() / @DBOS.step() split. One thing to carry over from DBOS: a DBOS workflow function must be deterministic — non-deterministic work (network calls, the clock, random numbers) belongs inside steps. The same discipline applies in Resonate: do side-effecting work inside ctx.run so it is checkpointed and replayed from the durable record, not re-executed.

Composing and recursing#

In DBOS you compose by invoking one registered workflow from another — a child workflow — with DBOS.start_workflow(fn, ...args) (Python), DBOS.startWorkflow(fn)(...args) (TypeScript), or dbos.RunWorkflow(ctx, fn, args) (Go). For example, the widget-store checkout starts its dispatch workflow as a child:

python
# dbos-demo-apps/python/widget-store/widget_store/main.py
DBOS.start_workflow(dispatch_order_workflow, order_id)

Resonate promotes a procedural programming model that lets you compose functions indefinitely and recursively — a function can simply call itself. Consider this factorial example.

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

Zero-dependency development#

DBOS keeps workflow state in a database. The Python SDK makes this nearly invisible for local development: if you don't set a system database URL, it defaults to an on-disk SQLite file (sqlite:///{app_name}.sqlite), so a Python app runs with no Postgres to install. The TypeScript SDK requires Postgres — it ships npx dbos postgres start to bring one up in Docker — and the Go SDK uses Postgres by default (it can run on SQLite if you wire one in). In every case, there is a database holding workflow state.

Resonate offers a zero-dependency development experience: a Worker (single-node application) runs entirely in-memory, with no database and no Resonate Server, until you choose to connect one.

DBOS — Postgres optional; omit the URL and DBOS uses an embedded SQLite file (dbos-demo-apps/python/dbos-app-starter/app/main.py sets it from the environment):

python
config: DBOSConfig = {
    "name": "dbos-app-starter",
    "system_database_url": os.environ.get("DBOS_SYSTEM_DATABASE_URL"),
    # ...
}
DBOS(config=config)

Resonate — no database, no server:

python
from resonate import Resonate

resonate = Resonate.local()

# ...

Fan-out / fan-in#

To run many units of work in parallel and then join the results, DBOS uses durable queues: you enqueue each task and collect each handle's result. Resonate starts each unit with a non-blocking invocation and then awaits each promise.

DBOS queues do more than fan-out

DBOS queues are a real strength worth calling out: beyond parallelism, they give you per-queue and per-process concurrency limits, rate limits, priorities, and debouncing — all backed by Postgres, with no separate broker. If you rely on those controls, plan how you'll reproduce them; Resonate's fan-out primitive is parallelism plus join, not a managed queue.

DBOS — DBOS docs, Python queue tutorial:

python
DBOS.register_queue("example_queue")

@DBOS.workflow()
def process_tasks(tasks):
    task_handles = []
    # Enqueue each task so all tasks are processed concurrently.
    for task in tasks:
        handle = DBOS.enqueue_workflow("example_queue", process_task, task)
        task_handles.append(handle)
    # Wait for each task to complete and retrieve its result.
    return [handle.get_result() for handle in task_handles]
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

Durable sleep and scheduled work#

DBOS workflows pause with DBOS.sleep, which records the wakeup time in the database so the sleep survives crashes and restarts. Resonate's equivalent is ctx.sleep — a single durable promise that survives crashes.

Watch the time units

Both systems vary the unit by SDK. DBOS: TypeScript DBOS.sleep takes milliseconds, Python DBOS.sleep takes seconds, Go dbos.Sleep takes a time.Duration. Resonate matches almost exactly: TypeScript milliseconds, Python seconds, Go and Rust a native Duration.

DBOSdbos-demo-apps/python/widget-store/widget_store/main.py·python
@DBOS.workflow()
def dispatch_order_workflow(order_id):
    for _ in range(10):
        DBOS.sleep(1)                          # seconds
        update_order_progress(order_id)
Resonateexample-durable-sleep-py/worker.py·python
@resonate.register
def sleeping_workflow(ctx: Context, wf_id: str, secs: float):
    print(f"Workflow {wf_id} starting, will sleep for {secs} seconds.")
    yield ctx.sleep(secs)                      # seconds (float)
    return f"Workflow {wf_id} completed after sleeping for {secs} seconds."
See it in action: example-durable-sleep-py

For recurring work, DBOS schedules a workflow on a cron expression — either the @DBOS.scheduled(cron) decorator (the workflow receives the scheduled and actual fire times) or the database-backed DBOS.create_schedule(...) / apply_schedules(...) API. Resonate registers a schedule with resonate.schedule(...) (TypeScript and Rust) or resonate.schedules.create(...) (Python), and the Resonate Server fires the promise on the cron.

Mind the cron fields

DBOS uses a 6-field cron with a leading seconds component — * * * * * * means every second. Resonate takes a standard 5-field cron — * * * * * means every minute. Drop the leading seconds field when migrating, or a * * * * * * copied across fires far more often than you intend.

DBOSdbos-transact-py/tests/test_scheduler_decorator.py·python
@DBOS.scheduled("* * * * * *")
@DBOS.workflow()
def test_workflow(scheduled: datetime, actual: datetime) -> None:
    # scheduled = the cron-planned fire time; actual = when DBOS fired it
    # ...

Resonate — registers a cron schedule with the server (example-schedule-py):

python
resonate.schedules.create(
    id="my-schedule",
    cron="* * * * *",
    promise_id="my-schedule.{{.timestamp}}",
    # ... also required: promise_timeout, promise_data (the function-invoke payload), promise_tags
)
See it in action: example-schedule-py

Human-in-the-loop#

With DBOS, a workflow calls DBOS.recv() to block and await a message — for example, a human decision — while an outside caller delivers it with DBOS.send(). A companion pair, DBOS.set_event() / DBOS.get_event(), lets the workflow publish status that external callers read. All of it is persisted to the database.

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

DBOSdbos-demo-apps/python/widget-store/widget_store/main.py·python
@DBOS.workflow()
def checkout_workflow():
    # ...
    DBOS.set_event(PAYMENT_ID, DBOS.workflow_id)   # publish status to the caller
    payment_status = DBOS.recv(PAYMENT_STATUS)     # block until the webhook sends it
    # ...

# elsewhere, an HTTP handler delivers the decision:
DBOS.send(payment_id, payment_status, PAYMENT_STATUS)
Resonateexample-human-in-the-loop-py·python
# worker.py
@resonate.register()
def foo(ctx, workflow_id):
    blocking_promise = yield ctx.promise()
    yield ctx.lfc(send_email, blocking_promise.id)
    # ...
    # wait for the promise to be resolved
    yield blocking_promise
    # ...
See it in action: example-human-in-the-loop-py
python
# gateway.py — resolve from anywhere by promise id
resonate.promises.resolve(id=promise_id, ikey=promise_id)

DBOS gives you a richer built-in messaging surface here — point-to-point send/recv plus key-value setEvent/getEvent, all addressed by workflow ID. Resonate collapses the same use case to one primitive: a latent Durable Promise the workflow awaits, resolvable from anywhere by its ID (HTTP-addressable through the Resonate Server).

Saga / compensation#

A saga runs a sequence of steps and, on failure, runs compensations to undo the completed ones. Neither DBOS nor Resonate ships a saga DSL — in both, you write the compensation in the failure branch and rely on durable execution to guarantee it runs to completion. DBOS checks each step's result and calls an explicit undo step on the failure path; Resonate writes the undo inline in a try/catch.

DBOSdbos-demo-apps/python/widget-store/widget_store/main.py·python
@DBOS.workflow()
def checkout_workflow():
    order_id = create_order()

    inventory_reserved = reserve_inventory()
    if not inventory_reserved:
        update_order_status(order_id=order_id, status=OrderStatus.CANCELLED.value)
        DBOS.set_event(PAYMENT_ID, None)
        return
    # ...
    if payment_status == "paid":
        update_order_status(order_id=order_id, status=OrderStatus.PAID.value)
        DBOS.start_workflow(dispatch_order_workflow, order_id)
    else:
        undo_reserve_inventory()                 # compensation
        update_order_status(order_id=order_id, status=OrderStatus.CANCELLED.value)
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

Distributed execution#

With Resonate, you can run multiple Workers in a group, and Resonate will automatically load-balance invocations across them. DBOS can also distribute work across multiple processes: you enqueue workflows onto durable queues that those processes poll, with the per-queue and per-process flow control described above. The meaningful difference is in crash recovery, which we cover below.

Consider the following Resonate example.

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)

The meaningful difference is in crash recovery. Resonate automatically recovers and resumes in-progress functions/workflows in another Worker, as part of its open-source core.

With DBOS, when a process restarts it recovers all the workflows that were executing on it before the restart. Recovering a workflow whose process has died onto a different, healthy process is the job of DBOS Conductor — a separate control-plane product that detects an unhealthy executor and signals another to take over its workflows. Without Conductor, each executor recovers only its own workflows on restart.

Where DBOS and Resonate differ#

Both systems value simplicity, add durability by wrapping ordinary functions, and run on infrastructure you already understand. Where they diverge:

DBOS leans on what you already run. Its strengths are real: durability from a couple of annotations; only Postgres in the loop (and an embedded SQLite file for Python local dev); durable queues with built-in concurrency limits, rate limits, priorities, and debouncing; a built-in observability dashboard and workflow-management APIs; exactly-once Kafka consumers; and SDKs in TypeScript, Python, Go, and Java.

Resonate leans on a minimal core. A Worker runs fully in-memory with no datastore until you connect one; the model is plain functions that compose and recurse without a workflow/step type split; automatic cross-process crash recovery ships in the open-source core; the server is a single binary; and you scale a decentralized topology by adding servers rather than sizing one service up front. Resonate also ships a Rust SDK, which DBOS does not.

Which fits depends on your constraints. If you want managed queues with rich flow control and a built-in dashboard on top of Postgres, DBOS gives you a lot out of the box. If you want a tiny core, an in-memory dev loop with no database, recursion as a first-class shape, and distributed recovery without a separate control plane, Resonate is built around those.