HOW IT WORKS

The journal is the process.
The worker is disposable.

Everything Hopskip does follows from one decision: the durable record of a run lives outside the process that executes it. Here is the whole lifecycle, no hand-waving.

01

Write the steps

A workflow is a plain function. Each hop.step() wraps one side effect — a charge, an email, an API call. Between steps you can branch, loop, throw, and sleep, in your language, with your libraries.

There is no workflow DSL to learn and no state machine to draw. If you can write a function, you have already learned the API.

checkout.tsordinary code
export async function checkout(hop, order) {
  await hop.step("reserve_inventory", () =>
    inventory.hold(order.sku));

  await hop.step("charge_card", () =>
    stripe.charge(order));

  await hop.sleep("refund_window", "30d");

  await hop.step("ship_order", () =>
    carrier.create(order));

  await hop.step("notify_customer", () =>
    email.send(order.user, "shipped"));
}
journal · run 01H8Z4K9durable · ordered · fsync'd
FRAMESTEPRESULT
0reserve_inventory{ hold_id: "h_291" }
1charge_card{ charge: "ch_8x2", amount: 2000 }
2sleep(refund_window)wake 2026-09-16T04:00Z
3ship_orderpending
4notify_customerpending
02

Every step commits a frame

When a step completes, its result is committed to the journal before execution continues. The frame stores the return value, the attempt count, and an idempotency key — so a replay knows the step already ran and what it returned.

Sleeps are frames too. A 30-day wait costs no worker, no thread, no open connection — the run simply has a wake time, and nothing needs to be running until it arrives.

03

Crash, replay, continue

When a worker dies, its runs are reassigned. The new worker re-executes the function from the top — but every hop.step() that already has a committed frame returns the recorded result instead of running again. Execution fast-forwards to the exact point of death, then proceeds for real.

timeline · checkout.order_8842✗ death · ✓ committed · ○ replayed from journal
worker_03  reserve_inventory  ── charge_card  ── ship_order ✗ SIGKILLjournal survives ▼worker_07  reserve_inventory ○ ── charge_card ○ ─── ship_order ✓ attempt 2 ── notify_customer 
           (replayed in 3ms — no side effects re-run, charge ch_8x2 returned from frame 1)

Exactly-once steps

A committed frame never re-executes. An in-flight step retries with the same idempotency key, so your provider sees one charge either way.

Deploys are free

New code picks up old runs mid-process. Versioned steps let you change a workflow without stranding the runs already inside it.

Replay for debugging

Any historical run can be replayed locally against the recorded frames — step through what production actually did, not what the logs imply.

Now go kill something.

Try the live demo Read the docs