Both preparation steps had finished when I stopped the Node process. The Restate server stayed running. After I started the service again, the pending invocation returned ["A", "B"], and the preparation writes were still present only once each.

This is the small recovery demo I wanted for Restate's new durable coroutines: two routines with their own steps, an interruption in the middle, and a file that makes the executed effects easy to inspect. There is no language model in this example.

Two Restate coroutines prepare work, wait on durable timers and finish after a Node worker restart. The server journal stays running. The recorded effect file contains prepare-b, prepare-a, finish-b and finish-a.

Diagram of the completed restart trial. The spacing is schematic, not a latency measurement. Image by the author.

Put the work inside the routines

Restate's announcement describes generator-based coroutines that let its scheduler control when routines advance. Their I/O can overlap while completion order is recorded for recovery. [1] I used Restate Server 1.8.1, Node 22.22.1, and both JavaScript packages at 1.18.2.

The service writes a short line during preparation, waits on a durable timer, then writes a finish line. A returned value travels through the generator's local variable into the final result.

In a new package with "type": "module", install the matching dependencies:

npm install --save-exact @restatedev/restate-sdk@1.18.2 @restatedev/restate-sdk-gen@1.18.2 

Save the service as service.mjs:

import { createServer } from "node:http2"; import { appendFileSync } from "node:fs"; import * as restate from "@restatedev/restate-sdk"; import { gen, execute, run, sleep, spawn, all } from "@restatedev/restate-sdk-gen"; function lane(name) { return gen(function* () { const value = yield* run(async () => { appendFileSync("effects.log", `prepare-${name}\n`); return name.toUpperCase(); }, { name: `prepare-${name}` }); console.log(`prepared-${name}`); yield* sleep(8000); yield* run(async () => { appendFileSync("effects.log", `finish-${name}\n`); return true; }, { name: `finish-${name}` }); return value; }); } const demo = restate.service({ name: "CoroutineDemo", handlers: { run: async (ctx) => execute(ctx, gen(function* () { const a = spawn(lane("a")); const b = spawn(lane("b")); return yield* all([a, b]); })), }, }); const endpoint = restate.endpoint().bind(demo); createServer(endpoint.http2Handler()).listen(12880, "127.0.0.1"); 

The timer is set to 8,000 milliseconds to leave a clear interruption window. It is a demo setting, not a measured overhead result.

gen() receives a generator factory. spawn() registers a routine and gives back a task handle; the scheduler advances it when the current routine yields. all() waits for both handles and returns their values in input order. The package's own README distinguishes these operations and notes that spawned work can be abandoned when the main operation exits; explicitly joining the tasks avoids relying on fire-and-forget behavior. [2]

Keep the journal outside the worker process

The service is only one part of the setup. It needs a running Restate server and a registered deployment. I used this local server configuration, saved as restate.toml:

disable-telemetry = true bind-address = "127.0.0.1:15122" advertised-address = "http://127.0.0.1:15122" rocksdb-total-memory-size = "256 MiB" default-num-partitions = 1 [admin] bind-address = "127.0.0.1:19070" [ingress] bind-address = "127.0.0.1:18080" 

After installing Server 1.8.1 from its official release, start it in one terminal with its own data directory: [3]

restate-server --config-file restate.toml --base-dir ./restate-data --no-logo 

Start node service.mjs in another terminal. Once it is listening, register that endpoint and invoke it through the server:

curl --fail-with-body --silent -X POST http://127.0.0.1:19070/deployments \ -H 'Content-Type: application/json' \ --data '{"uri":"http://127.0.0.1:12880"}' curl --fail-with-body --silent -X POST http://127.0.0.1:18080/CoroutineDemo/run \ -H 'Content-Type: application/json' --data 'null' 

The final command returned ["A", "B"] in the uninterrupted check. Calling the Node endpoint directly would skip the server that owns this journal.

I first let an invocation finish without interruption. It returned ["A", "B"] and wrote each preparation and finish line once. That was the control before introducing a restart.

For the restart trial, I waited until both prepared-... messages appeared. These messages come after the corresponding yield* run(...) has resolved. I then killed only the Node service process, started it again on the same port, and left the original request waiting for its response.

Read the effects, not just the replay messages

The restarted worker logged that it was replaying the invocation. It also printed prepared-b and prepared-a again: those diagnostic statements are outside the durable closures, so seeing them again is expected.

The file tells a more specific story. Immediately before the crash it held:

prepare-b prepare-a 

After recovery and completion it held:

prepare-b prepare-a finish-b finish-a 

The response was ["A", "B"]. The order of returned values follows all([a, b]); it need not be the order in which the effects finish. In the uninterrupted control, the finish lines appeared in the opposite order.

That is the useful division in this example: normal JavaScript flow is reconstructed, while acknowledged durable steps supply their recorded results. The local value variable is rebuilt through replay; it is not a heap snapshot copied out of the crashed process.

There is still a boundary around external side effects. This trial interrupted the worker after the preparation results had resolved. It did not kill the process between a file append and acknowledgement of that step. The append itself has no idempotency key, so I would not infer exactly-once writes for every possible crash point.

The Diagrid team's explanation of at-least-once workflow execution describes that effect-before-recording window in Dapr. [4] It is a useful question to bring to any recovery demo: which side of the acknowledgement did the failure happen on?

Here, the server was never restarted, no remote tool or LLM was called, and no throughput benchmark was run. The completed check is service-process recovery with two joined coroutines and inspectable effects.

References and resources

  1. Igal Shilman, Stephan Ewen and Giselle van Dongen, Durable Coroutines for TypeScript.
  2. Restate contributors, generator SDK package and API documentation.
  3. Restate contributors, Restate Server 1.8.1.
  4. Diagrid, Understanding At-Least-Once Execution and Why It Matters for Workflow & Agent Developers.

Asset credits

Original recovery diagram based on the executed local service and its effect files. The service example is original; Restate dependencies retain their upstream licences.

AI disclosure

AI was used to draft the article.