ElementsClock

Durable Sleep

Journaled pauses that resume across restarts — fx.clock.sleep(label, duration) on durable Flows.

Durable sleep suspends a Flow until a future instant without holding a worker thread. Call await fx.clock.sleep(label, duration) inside a durable: true Flow — the journal stores wakeAt, releases the run lease, and any instance may resume when due.

For developers building trial reminders, delayed digests, and multi-day provisions on okengine.

The one rule

Signature is fx.clock.sleep(label, duration) — two arguments. A single duration string is treated as the label, not the wait. Sleep only parks when the Flow is durable.

Durable sleep — survives restart

sleep("wait-for-payment", "7d")
sleep
durable: true · journaled wakeAt
  1. 01

    sleep

    fx.clock.sleep("wait-for-payment", "7d")

  2. 02

    journal

    wakeAt + label written — Console shows the sleeping run

  3. 03

    restart

    Deploy / crash — process gone; journal stays

  4. 04

    wake → resume

    Continue at the next unfinished step — prior steps never re-run

Non-durable flows resolve the same call immediately so tests read identically — durability is the option that journals.

Smallest Example

Sleep inside a durable Flow

src/flows/trials/start.ts
import { on, flow, http } from "okengine";
import { z } from "zod";

export const start = on(
  http.post({
    in: z.object({ email: z.string().email() }),
  }),
  flow({
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("mark-trial", async () => {
        await fx.call(startTrial, { email });
      });
      await fx.clock.sleep("expiry-window", "3d");
      await fx.step("notify", async () => {
        await fx.send(trialExpiringEmail, { to: email });
      });
    },
  }),
);

Call the endpoint

curl -X POST http://localhost:6530/trials/start \
  -H "content-type: application/json" \
  -d '{"email":"ada@example.com"}'

Response: 204 No Content (empty body). The HTTP client is done — wake continues on a worker three days later. Wrap side effects in fx.step so replay does not re-send mail.

Detailed section

If you only need a pause, the example above is enough. Below: park physics, wake-early, nested-call limits, non-durable no-ops, and how sleep fits with steps / compensation.

Progressive Patterns

From a labelled pause to HTTP parking, nested-call limits, and non-durable behavior:

Durations: "200ms" · "30s" · "2m" · "1h" · "7d". Labels show up in the journal and Console:

await fx.clock.sleep("morning-window", "8h");
await fx.clock.sleep("verify-window", "2m");

Wrong: fx.clock.sleep("8h")"8h" is the label; duration is missing → immediate resolve / unexpected behavior.

Sleep Reference

Call / optionTypeDefaultMeaning
durable: trueFlow optionfalseJournals steps and sleep; required to park
fx.clock.sleep(label, duration)parkWrites wakeAt, status sleeping, releases lease
labelstringJournal / Console step name
durationstring"200ms" · "30s" · "2m" · "1h" · "7d"

Outcomes of a durable attempt that hits sleep: status "sleeping" with wakeAt and label. After wake, prior fx.step bodies replay from the journal and do not re-execute.

Duration units

Same grammar as fx.clock.ago / fromNow / clock.every:

UnitExampleMilliseconds
ms"200ms"200
s"30s"30_000
m"2m"120_000
h"1h"3_600_000
d"7d"604_800_000

Integer + unit only — no weeks. "d" is exactly 86_400_000 ms, not a calendar day across DST. Unknown strings parse as 0 ms (wake immediately).

Park Physics

Detailed section

If you only need await fx.clock.sleep("label", "8h"), jump past this section. The park writes a journal entry, sets status sleeping, and releases the run lease so a parked flow does not hold a 30s lock for days.

fx.clock.sleep(label, duration)
  → journal entry { kind: "sleep", label, duration, wakeAt }
  → status = sleeping, lease released
  → HTTP / caller sees success (often 204)
  → later: claim due sleep → resume → replay steps → continue after sleep

Any instance may claim the row when wakeAt is due (shared journal + lease). Resume replays completed fx.step values from the journal, then continues past the sleep entry.

PhaseStatusLeaseWhat happens
Before sleeprunningHeldSteps append; side effects execute once
At sleepsleepingReleasedwakeAt stored; worker free
After wakeAtclaim → runningRe-acquiredReplay steps; sleep entry is a no-op past due
Donecompleted / failedReleasedTerminal — resume refused

Consequence: multi-day sleeps are safe across deploys — the journal row is the schedule, not an in-memory timer.

With Steps

Each verb of durable work binds the same way — checkpoint, park, then more checkpoints:

Wrap every side effect in a uniquely named fx.step. Sleep sits between steps:

src/flows/onboarding/welcome.ts
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";

export const sendWelcome = on(
  userSignedUp,
  flow("onboarding.welcome", {
    durable: true,
    do: async ({ email }, fx) => {
      await fx.step("provision", async () => {
        await fx.call(createWorkspace, { email });
      });
      await fx.clock.sleep("morning-window", "8h");
      await fx.step("notify", async () => {
        await fx.send(welcomeEmail, { to: email });
      });
    },
  }),
);

Consequence: after wake, provision returns the journaled value and never re-runs; notify runs for the first time.

Nested Calls

Detailed section

If you sleep only on the root durable Flow, skip this section. fx.call waits for the callee to return — a sleeping callee does not keep the caller parked.

fx.call of a durable sleeper: the caller receives undefined and continues; the child wakes later as its own run.

PatternSafe?Why
Sleep on the root durable FlowYesOne journal row, clear park
fx.emit → durable consumer that sleepsYesSeparate run owns the wait
fx.call(sleeperFlow, …) then use the resultNoCaller gets undefined; child parks alone

Consequence: sleep on the root durable Flow, or split with fx.emit to a consumer — do not nest sleep behind fx.call.

Wake Early

Operators can advance wakeAt to now from Console (waiting-on / wake-early). With a flow resolver, the run resumes immediately; without it, only the wake time moves forward.

OutcomeMeaning
ResumedwakeAt set to now and runDurable continued past the sleep
Wake time onlywakeAt advanced; scheduler / next claim picks it up
ClockResourceNotFoundErrorrun "{id}" not found — missing id, or status is not sleeping

Missing or non-sleeping run id → ClockResourceNotFoundErrorrun "{id}" not found.

Non-durable & Tests

Without a journal session, fx.clock.sleep resolves immediately (tests and sync Flows). There is no thread sleep and no setTimeout.

For deterministic waits in tests, use a frozen clock and advance:

import { createTimeTravel } from "okengine";

const t = createTimeTravel(0);
// Park a durable run with now: () => t.now()
t.advance("7d");
// Resume — sleep entry is past due and continues

Pin journal for the env you need:

DriverDefault envBest for
postgresdev, prodShared durable runs across replicas (DATABASE_URL)
memorytestProcess-local; lost on exit
filepin explicitlyOne machine — .oke/journal.json

Unknown ids throw oke boot: unknown journal driver "…" (expected memory · file · postgres). Postgres without a URL throws oke boot: journal driver "postgres" needs DATABASE_URL.

Full lease / orphan / status tables live on Workflows · Journal.

Troubleshooting

Learn more

  • Workflowsdurable, fx.step, compensate, full sleep section
  • HTTP — request envelope; 204 from park / undefined
  • Clock overview — schedules and fx.clock helpers
  • Schedules — cron / every (calendar time, not durable pause)
  • fxfx.clock.sleep, fx.step, fx.using
  • Configurationdrivers.journal
  • ErrorsClockResourceNotFoundError

Next

On this page