Skip to content
v2.14 · concurrency keys accept expressions

Kill the worker. The checkout still finishes.

Spindrel checkpoints every step of an ordinary async function. When a process dies mid-run, another worker picks it up at the step that was in flight, with every earlier result replayed from storage.

npm i @spindrel/sdk

Node 20+, Python 3.10+, Go 1.22+. Apache-2.0 engine, hosted or self-run.

// Crash simulator

Choose when the worker dies, then read the last line.

import { workflow, step } from "@spindrel/sdk";import { payments, inventory, mail, shipping } from "./clients";export const checkout = workflow("checkout", async (order: Order) => {  const hold = await step.run("reserveInventory", () =>    inventory.reserve(order.items),  );  // Retried with the same key, so the card is charged once.  const charge = await step.run("chargeCard", () =>    payments.charge(order.total, { idempotencyKey: step.key }),  );  await step.run("sendReceipt", () => mail.receipt(order.email, charge.id));  await step.run("scheduleShipment", () =>    shipping.book(hold.warehouse, order.address),  );  return { orderId: order.id, chargeId: charge.id };});
Kill the worker
spindrel run checkout --input @order.json→ run_01JDX4R7 queued · leased by node-3  ✓ reserveInventory    41 ms   hold_3Wk · warehouse RNO-2  ✓ chargeCard         212 ms   ch_9fK2 · $86.40  ▸ sendReceipt       running…  ✗ worker node-3 lost (SIGKILL) during sendReceipt  … lease expired after 5.0 s · node-1 picks up run_01JDX4R7  ↺ reserveInventory     0 ms   replayed from checkpoint  ↺ chargeCard           0 ms   replayed from checkpoint  ✓ sendReceipt         88 ms   attempt 2  ✓ scheduleShipment    63 ms   label 1Z-5550-0127✓ completed in 5.49 s · 4 steps · 1 retry · 2 replayed, 0 re-executed

What you see after the deploy

The dashboard is the same data your CLI reads. Five panels from a real-looking Tuesday.

run_01JDY2M9● completed · 3.12 s
  1. fetchInvoicestarts at 0 ms, succeeded140ms
  2. renderPdfstarts at 150 ms, failed and retried410ms
  3. renderPdf #2starts at 590 ms, succeeded380ms
  4. sleep 2sstarts at 980 ms, sleeping2.0s
  5. emailCustomerstarts at 2990 ms, succeeded130ms

okfailed, retriedsleeping, no worker held

Run waterfall

One run, every step, including the retry and the 2-second sleep between them.

p50
31 ms
p95
188 ms
p99
604 ms
Step executions by latency bucket, milliseconds
LatencyExecutions
<103,120
10–2511,840
25–5014,210
50–1007,930
100–2503,560
250–500912
0.5–1s271
>1s63

Step latency, last 24 h

41,906 step executions across 7 workflows.

table spindrel.events
ColumnTypeNote
run_idtextpk
seqint4pk
kindevent_kindStepStarted…
steptextnull for run events
payloadjsonb≤ 4 MB
attimestamptz

Event history

Append-only. Export it to Parquet or query it in Postgres if you self-host.

  1. 14:02:11.204 INFO run_01JDY2M9 step=fetchInvoice ok 140ms
  2. 14:02:11.766 WARN step=renderPdf attempt=1 timeout after 400ms
  3. 14:02:12.149 INFO step=renderPdf attempt=2 ok 380ms
  4. 14:02:12.150 INFO sleep 2s · worker released
  5. 14:02:14.163 INFO resumed on go-7 · step=emailCustomer
  6. 14:02:14.296 ERROR run_01JDY2RB step=syncLedger 409 conflict, retry in 4s
  7. tailing

Log tail

Logs are tied to run and step, so you grep by order, not by pod.

  • tenant:harrow-print

    4/4 running212 queued

  • tenant:kilnworks

    2/4 running0 queued

  • tenant:odd-lot

    3/4 running0 queued

  • tenant:pellmore

    1/4 running0 queued

Concurrency keys

One noisy tenant queues behind its own limit, not everyone else's.

Three lines, and what the engine does with them

  1. Sleeping costs nothing

    await step.sleep("trial-ends", "14d");

    The worker returns. No process, timer or open connection waits for two weeks. On day 14 the run is queued again and the next free worker continues from the following line.

    worker held
    0 s
    resumes
    2026-11-05 09:00 UTC
    billed
    1 step
  2. Retries with a ceiling

    await step.run("syncLedger", push, { retries: 6, backoff: "exp" });

    Failures retry at 1, 2, 4, 8, 16 and 32 seconds, then the run parks as failed with the full error and input. Fix the bug, press Resume, and it continues from this step.

    attempts
    up to 7
    longest wait
    32 s
    after that
    parked, resumable
  3. Waiting on a person

    const ok = await step.waitForSignal("approved", { timeout: "72h" });

    The run parks until something calls spindrel.signal(runId, "approved"): an admin button, a webhook, a chat bot. If nobody answers in 72 hours, ok is false and your code decides.

    parked runs
    no limit
    signal latency
    p95 140 ms
    on timeout
    ok = false

Changelog

We ship on Thursdays. Breaking changes get a major version and a migration script.

All 10 releases
  1. v2.14.0

    Concurrency keys accept expressions

    Pass a function to concurrency.key, e.g. (input) => `tenant:${input.orgId}`.

    • Added
  2. v2.13.2

    Lease renewal during long GC pauses

    Workers on Node 22 could lose a lease during a stop-the-world pause over 4 s. Renewal now runs on a separate timer thread.

    • Fixed
  3. v2.13.0

    Go SDK: typed step results

    sp.Run is generic over its return type; no more interface{} casts.

    • Added
    • Changed
  4. v2.12.0

    Event history export

    Nightly export of spindrel.events to Parquet in your own bucket.

    • Added

Pay per step. Sleeping is free.

A step is one call to step.run, step.sleep or step.waitForSignal. Replays from checkpoint are not billed.

Cost calculator

Estimated monthly bill

$42.40

Team plan

steps / month
2,800,000
base, 2,000,000 included
$40.00
800,000 × $0.30/100k
$2.40

// Plans

Plan comparison: Hobby, Team and Scale
Hobby$0100k steps / monthSide projects and the first prototype. No card.Teammost teams$40per month, 2M steps included, then $0.30 per 100kProduction workloads for one company.Scale$0.12per 100k steps above 300M / monthDedicated cluster, your region, a named engineer.
Included steps100k2MCommitted volume
History retention3 days14 days90 days
Environments15Unlimited
Concurrent runs202,000Contracted
Max step payload1 MB4 MB4 MB + references
SSO (SAML, OIDC)NoNoYes
SupportForumEmail, 1 business dayShared channel, 1 hour
Start building, Hobby planStart building, Team planTalk to an engineer, Scale plan

Before you install

6 questions, short answers

What counts as a step?

One call to step.run, step.sleep or step.waitForSignal. Retries count as steps. Replays from checkpoint after a crash do not.

Do I have to restructure my code?

Wrap the side effects you want checkpointed in step.run. Everything else stays a normal function. Most teams port a cron job in an afternoon.

What if Spindrel itself is down?

Steps already running finish and buffer their results on the worker for up to 10 minutes. New runs queue in the SDK and are sent when the API answers again.

Can I run it myself?

Yes. The engine is a single Apache-2.0 binary plus Postgres 15 or newer. The hosted dashboard is the only closed part.

Where does my data live?

Virginia or Frankfurt, your choice per project. Step payloads are encrypted with a per-project key you can rotate.

Does code have to be deterministic?

Only between steps. Put clocks, random numbers and network calls inside step.run and the replay will match.

Your next 3 a.m. page is a retry button.

Free up to 100k steps a month. Install takes one command and the first workflow about eleven minutes.