Skip to content

Detach — Fire-and-forget child flowcharts

The footprintjs/detach subpath gives you a primitive for scheduling child flowcharts off the parent stage’s hot path — telemetry exports, parallel evaluations, audit log shipping, cache warm-up, anything that should ride alongside the main pipeline rather than inside it.

Two semantics × two surfaces × six drivers.

MethodReturnsUse when
detachAndJoinLaterDetachHandleYou want the result later (await, status check, fan-out)
detachAndForgetvoidPure fire-and-forget (telemetry, audit log, etc.)

Both are sync at the call site — the parent stage returns immediately. The child runs on whichever driver you pick.

CallerrefId prefix
scope.$detachAndJoinLater<runtimeStageId>:detach:<n>
executor.detachAndJoinLater__executor__:detach:<n>

The synthetic __executor__ prefix is honest about provenance — there is no source stage to point back to.

import {
microtaskBatchDriver,
immediateDriver,
setImmediateDriver,
setTimeoutDriver,
createSendBeaconDriver,
createWorkerThreadDriver,
} from 'footprintjs/detach';
Driver / factoryWhenCapabilities
microtaskBatchDriverCoalesces N detaches into one microtask flush.browser + node + edge
immediateDriverMarks running synchronously; starts each child on a microtask, without batching.browser + node + edge
setImmediateDriverNode-only. Yields to I/O before running.node
setTimeoutDriverCross-runtime. Configurable delay.browser + node + edge
createSendBeaconDriver()Browser-only. Survives page-unload via navigator.sendBeacon.browser, survivesUnload
createWorkerThreadDriver()CPU-isolated execution in Node Worker / Web Worker.node, cpuIsolated

The first four ship as ready-to-use singletons. sendBeacon and worker-thread ship as factories only (createSendBeaconDriver / createWorkerThreadDriver) — they take config, so you construct an instance per use site.

Driver is a required first argument — no library-default. Pass it explicitly so the choice of scheduling algorithm is visible at every call site.

Parent does its work, fires telemetry, returns. The handle is discarded.

examples/runtime-features/detach/01-fire-and-forget.ts
/**
* Detach — Fire-and-Forget Telemetry
*
* The parent stage processes an order, then fires a telemetry chart via
* `microtaskBatchDriver`. The handle is discarded — caller never waits.
*
* Pipeline:
* ProcessOrder → (commits + returns)
* │
* └─► driver flushes ─► TelemetryChart
*
* Run: npx tsx examples/runtime-features/detach/01-fire-and-forget.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { microtaskBatchDriver } from 'footprintjs/detach';
// ── Side-effect chart: a single stage that records the event ──────────
const telemetryEvents: unknown[] = [];
const telemetryChart = flowChart('ShipTelemetry', async (scope) => {
// In real life this would POST to a telemetry endpoint. For the example
// we just push to an array so the test can verify it ran.
telemetryEvents.push(scope.$getArgs());
}, 'ship-telemetry').build();
// ── Main chart: process an order, fire telemetry, return ──────────────
interface OrderState {
orderId: string;
parentReturnedAt: number;
}
const main = flowChart<OrderState>('ProcessOrder', async (scope) => {
scope.orderId = 'order-42';
// Fire-and-forget — driver schedules the work, we don't wait.
scope.$detachAndForget(microtaskBatchDriver, telemetryChart, {
event: 'order.processed',
orderId: scope.orderId,
});
scope.parentReturnedAt = performance.now();
}, 'process-order').build();
// ── Run + inspect ─────────────────────────────────────────────────────
(async () => {
const exec = new FlowChartExecutor(main);
const t0 = performance.now();
await exec.run();
const parentRunWall = performance.now() - t0;
// At this point: parent has returned, but the telemetry microtask may
// not have flushed yet. Yield twice to give it a chance.
await Promise.resolve();
await Promise.resolve();
console.log(`Parent run wall: ${parentRunWall.toFixed(2)}ms`);
console.log(`Telemetry events shipped: ${telemetryEvents.length}`);
console.log(`First event: ${JSON.stringify(telemetryEvents[0])}`);
// ── Regression guards ──
if (telemetryEvents.length !== 1) {
console.error(`REGRESSION: expected 1 telemetry event, got ${telemetryEvents.length}.`);
process.exit(1);
}
const evt = telemetryEvents[0] as { event: string; orderId: string };
if (evt.event !== 'order.processed' || evt.orderId !== 'order-42') {
console.error('REGRESSION: telemetry payload wrong.', evt);
process.exit(1);
}
// Parent should have returned fast — definitely under 50ms.
if (parentRunWall > 50) {
console.error(`REGRESSION: parent run wall too high (${parentRunWall}ms) — detach should not block.`);
process.exit(1);
}
console.log('OK — fire-and-forget telemetry flushed cleanly.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

Fire N children in parallel, gather their results in a downstream stage. Combine many handles via Promise.all.

examples/runtime-features/detach/02-join-later-fanout.ts
/**
* Detach — Join-Later Fan-Out
*
* Fan out 5 parallel sub-evaluations using `$detachAndJoinLater`,
* then await all of them in a downstream stage via `Promise.all`.
*
* Pipeline:
* Fanout (queue 5 detaches) → Join (await all handles)
*
* Run: npx tsx examples/runtime-features/detach/02-join-later-fanout.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { microtaskBatchDriver } from 'footprintjs/detach';
import type { DetachHandle } from 'footprintjs/detach';
// ── Sub-evaluation: pretend to score a prompt variant ─────────────────
const variantChart = flowChart('ScoreVariant', async (scope) => {
const args = scope.$getArgs<{ variant: string }>();
// Simulate variable work time per variant.
await new Promise((r) => setTimeout(r, 5));
// RETURN the score so it surfaces as the chart's run() result and
// shows up on `handle.wait()`'s resolved `{ result }`.
return args.variant.length;
}, 'score-variant').build();
// ── Main chart ────────────────────────────────────────────────────────
interface FanoutState {
variants: string[];
bestScore: number;
}
// Closure-local — handles must NOT live in scope state (see README gotcha).
const handles: DetachHandle[] = [];
const main = flowChart<FanoutState>('Init', async (scope) => {
scope.variants = ['short', 'medium-len', 'a-much-longer-variant', 'tiny', 'middle'];
}, 'init')
.addFunction('Fanout', async (scope) => {
for (const variant of scope.variants) {
handles.push(scope.$detachAndJoinLater(microtaskBatchDriver, variantChart, { variant }));
}
// Parent returns immediately — children are queued for microtask flush.
}, 'fanout')
.addFunction('Join', async (scope) => {
// Await every handle in parallel.
const settled = await Promise.allSettled(handles.map((h) => h.wait()));
const scores = settled
.map((r) => (r.status === 'fulfilled' ? (r.value.result as number) : 0));
scope.bestScore = Math.max(...scores);
}, 'join')
.build();
// ── Run + inspect ─────────────────────────────────────────────────────
(async () => {
const exec = new FlowChartExecutor(main);
await exec.run();
const snap = exec.getSnapshot();
const bestScore = snap.sharedState.bestScore as number;
console.log(`Variants scored: ${handles.length}`);
console.log(`Statuses: ${handles.map((h) => h.status).join(', ')}`);
console.log(`Best score: ${bestScore}`);
// ── Regression guards ──
if (handles.length !== 5) {
console.error(`REGRESSION: expected 5 handles, got ${handles.length}.`);
process.exit(1);
}
if (!handles.every((h) => h.status === 'done')) {
console.error('REGRESSION: not all handles reached "done".', handles.map((h) => h.status));
process.exit(1);
}
// 'a-much-longer-variant' = 21 chars — that's the best score.
if (bestScore !== 21) {
console.error(`REGRESSION: expected best score 21, got ${bestScore}.`);
process.exit(1);
}
console.log('OK — fan-out + Promise.all pattern works end-to-end.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

When you have a FlowChartExecutor and want to fire side-effects alongside (not inside) the main chart — analytics pings, audit writes, health checks.

examples/runtime-features/detach/03-bare-executor.ts
/**
* Detach — From Outside Any Chart (bare executor entry)
*
* The host process holds a FlowChartExecutor and wants to fire several
* side-effect charts (analytics, audit, health check) AROUND the main
* chart's run. No parent stage available — uses the executor's bare
* `detachAndJoinLater` / `detachAndForget` methods.
*
* Run: npx tsx examples/runtime-features/detach/03-bare-executor.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { microtaskBatchDriver } from 'footprintjs/detach';
// ── Two side-effect charts: analytics + audit log ─────────────────────
const collected: string[] = [];
const analyticsChart = flowChart('ShipAnalytics', async (scope) => {
const tag = scope.$getArgs<{ tag: string }>().tag;
collected.push(`analytics:${tag}`);
}, 'ship-analytics').build();
const auditChart = flowChart('WriteAudit', async (scope) => {
const tag = scope.$getArgs<{ tag: string }>().tag;
collected.push(`audit:${tag}`);
return tag;
}, 'write-audit').build();
// ── Trivial main chart (the executor is the unit under test here) ─────
const mainChart = flowChart('Main', async (scope) => {
scope.$setValue('mainRan', true);
}, 'main').build();
(async () => {
const exec = new FlowChartExecutor(mainChart);
// Side-effect BEFORE run (forget) — discard handle.
exec.detachAndForget(microtaskBatchDriver, analyticsChart, { tag: 'before' });
// Side-effect WITH a handle (joinLater) — we want to await its result.
const auditHandle = exec.detachAndJoinLater(microtaskBatchDriver, auditChart, { tag: 'mid' });
// Now run the main chart.
await exec.run();
// Side-effect AFTER run (forget).
exec.detachAndForget(microtaskBatchDriver, analyticsChart, { tag: 'after' });
// Await the joinable side-effect.
const auditResult = await auditHandle.wait();
// Yield twice to let the forget detaches flush.
await Promise.resolve();
await Promise.resolve();
console.log(`Collected: ${collected.sort().join(', ')}`);
console.log(`Audit handle: status=${auditHandle.status}, result=${JSON.stringify(auditResult)}`);
console.log(`Audit refId: ${auditHandle.id}`);
// ── Regression guards ──
const sorted = collected.sort();
if (sorted.length !== 3) {
console.error(`REGRESSION: expected 3 collected events, got ${sorted.length}.`);
process.exit(1);
}
if (
sorted[0] !== 'analytics:after' ||
sorted[1] !== 'analytics:before' ||
sorted[2] !== 'audit:mid'
) {
console.error('REGRESSION: collected events wrong.', sorted);
process.exit(1);
}
if (auditResult.result !== 'mid') {
console.error('REGRESSION: audit result wrong.', auditResult);
process.exit(1);
}
if (!auditHandle.id.startsWith('__executor__:detach:')) {
console.error(`REGRESSION: audit refId should start with __executor__:detach:, got ${auditHandle.id}`);
process.exit(1);
}
console.log('OK — bare-executor detach paths all behaved correctly.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

4 · Immediate driver (deterministic for tests)

Section titled “4 · Immediate driver (deterministic for tests)”

immediateDriver advances the handle to running synchronously. Useful when you want to assert handle state without managing microtask draining.

examples/runtime-features/detach/04-immediate-for-tests.ts
/**
* Detach — Immediate Driver for Tests
*
* Demonstrates the contrast with microtaskBatchDriver: the immediate
* driver advances the handle to `running` SYNCHRONOUSLY inside
* `schedule()`. Useful in tests where you want to assert handle state
* before the next async tick.
*
* Run: npx tsx examples/runtime-features/detach/04-immediate-for-tests.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { immediateDriver, microtaskBatchDriver } from 'footprintjs/detach';
import type { DetachHandle } from 'footprintjs/detach';
// ── A trivial child chart ─────────────────────────────────────────────
const tinyChart = flowChart('Tiny', async (scope) => {
scope.$setValue('done', true);
return true;
}, 'tiny').build();
// ── Main: snap two handles, compare initial status ────────────────────
let immediateHandle: DetachHandle | undefined;
let microtaskHandle: DetachHandle | undefined;
const initialStatusImmediate: string[] = [];
const initialStatusMicrotask: string[] = [];
const main = flowChart('Capture', async (scope) => {
immediateHandle = scope.$detachAndJoinLater(immediateDriver, tinyChart, undefined);
initialStatusImmediate.push(immediateHandle.status); // expect 'running'
microtaskHandle = scope.$detachAndJoinLater(microtaskBatchDriver, tinyChart, undefined);
initialStatusMicrotask.push(microtaskHandle.status); // expect 'queued'
}, 'capture').build();
(async () => {
const exec = new FlowChartExecutor(main);
await exec.run();
await immediateHandle?.wait();
await microtaskHandle?.wait();
console.log(`Immediate driver initial status: ${initialStatusImmediate[0]}`);
console.log(`Microtask driver initial status: ${initialStatusMicrotask[0]}`);
console.log(`Both terminal? immediate=${immediateHandle?.status}, microtask=${microtaskHandle?.status}`);
// ── Regression guards ──
if (initialStatusImmediate[0] !== 'running') {
console.error(`REGRESSION: immediate driver should snap to 'running' synchronously, got ${initialStatusImmediate[0]}.`);
process.exit(1);
}
if (initialStatusMicrotask[0] !== 'queued') {
console.error(`REGRESSION: microtask driver should remain 'queued' synchronously, got ${initialStatusMicrotask[0]}.`);
process.exit(1);
}
if (immediateHandle?.status !== 'done' || microtaskHandle?.status !== 'done') {
console.error('REGRESSION: at least one handle did not reach done.');
process.exit(1);
}
console.log('OK — immediate vs microtask driver telescoping verified.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

A child that throws does not propagate to the parent. The driver catches it and routes to handle.error. Sibling detaches in the same batch are not poisoned.

The three batching drivers also contain scheduling failures. If setImmediate, queueMicrotask, or setTimeout is unavailable or throws, schedule() returns a handle already marked failed; that batch never starts and its handles are removed from the in-flight registry. Later calls can run once the scheduler works again. Failed work is not retried, and the driver never switches to another scheduler.

Use detachAndJoinLater to inspect handle.error or catch handle.wait() rejecting. detachAndForget deliberately discards the handle, so choose it only when you do not need to observe failure. An optional driver.validate?.() is a separate, explicit configuration check that may throw; detach calls never invoke it automatically.

examples/runtime-features/detach/05-error-handling.ts
/**
* Detach — Error Handling
*
* A child throws. We show:
* 1) `wait()` rejects with the original Error
* 2) `handle.status === 'failed'` and `handle.error` is set
* 3) Sibling detaches in the same batch are NOT poisoned
*
* Run: npx tsx examples/runtime-features/detach/05-error-handling.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { createMicrotaskBatchDriver } from 'footprintjs/detach';
import type { DetachHandle } from 'footprintjs/detach';
// ── A child runner that fails for one input value ─────────────────────
const failingDriver = createMicrotaskBatchDriver(async (_chart, input) => {
if (input === 'bad') throw new Error('vendor 503: temporarily unavailable');
return `ok:${input}`;
});
// Stand-in chart — driver doesn't actually execute it (we replaced runChild).
const dummyChart = flowChart('dummy', async () => {}, 'dummy').build();
// ── Main: fire 3 detaches; the middle one will fail ───────────────────
let okHandleA: DetachHandle | undefined;
let badHandle: DetachHandle | undefined;
let okHandleC: DetachHandle | undefined;
const main = flowChart('Trigger', async (scope) => {
okHandleA = scope.$detachAndJoinLater(failingDriver, dummyChart, 'first');
badHandle = scope.$detachAndJoinLater(failingDriver, dummyChart, 'bad');
okHandleC = scope.$detachAndJoinLater(failingDriver, dummyChart, 'third');
}, 'trigger').build();
(async () => {
const exec = new FlowChartExecutor(main);
await exec.run();
// Await each handle independently so one failure doesn't short-circuit.
let captured: Error | undefined;
try {
await badHandle?.wait();
} catch (e) {
captured = e as Error;
}
const a = await okHandleA?.wait();
const c = await okHandleC?.wait();
console.log(`Sibling A: status=${okHandleA?.status}, result=${JSON.stringify(a)}`);
console.log(`Failing: status=${badHandle?.status}, error=${badHandle?.error?.message}`);
console.log(`Sibling C: status=${okHandleC?.status}, result=${JSON.stringify(c)}`);
console.log(`Captured via catch: ${captured?.message}`);
// ── Regression guards ──
if (okHandleA?.status !== 'done' || (a?.result as string) !== 'ok:first') {
console.error('REGRESSION: sibling A did not complete cleanly.');
process.exit(1);
}
if (badHandle?.status !== 'failed' || badHandle.error?.message !== 'vendor 503: temporarily unavailable') {
console.error('REGRESSION: failing handle should have status=failed with the original Error.');
process.exit(1);
}
if (okHandleC?.status !== 'done' || (c?.result as string) !== 'ok:third') {
console.error('REGRESSION: sibling C did not complete (sibling failure poisoned the batch?).');
process.exit(1);
}
if (!captured || captured.message !== 'vendor 503: temporarily unavailable') {
console.error('REGRESSION: wait() did not reject with the original Error.');
process.exit(1);
}
console.log('OK — error containment + sibling-isolation invariants hold.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

The handle is intentionally not Promise-shaped — no .then(). Reading .status is a plain property access, useful for backpressure checks, status banners, and “still in flight?” gates that shouldn’t depend on async.

examples/runtime-features/detach/06-status-polling.ts
/**
* Detach — Status Polling (Synchronous Property Reads)
*
* The handle is NOT Promise-shaped. Reading `handle.status` is a plain
* property access — useful for backpressure checks, status banners, and
* "still in flight?" gates that shouldn't depend on async.
*
* This example fires 10 detaches with random work durations, then polls
* `.status` until they're all terminal — without ever calling `wait()`.
*
* Run: npx tsx examples/runtime-features/detach/06-status-polling.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { createMicrotaskBatchDriver } from 'footprintjs/detach';
import type { DetachHandle } from 'footprintjs/detach';
// ── A child runner with variable work duration ────────────────────────
const driver = createMicrotaskBatchDriver(async (_chart, input) => {
// Pretend each unit takes 5–25ms.
const ms = 5 + ((input as number) % 5) * 5;
await new Promise((r) => setTimeout(r, ms));
return input;
});
const dummyChart = flowChart('dummy', async () => {}, 'dummy').build();
// ── Main: fire 10 detaches, then poll ─────────────────────────────────
const handles: DetachHandle[] = [];
const main = flowChart('Fire', async (scope) => {
for (let i = 0; i < 10; i++) {
handles.push(scope.$detachAndJoinLater(driver, dummyChart, i));
}
}, 'fire').build();
function inFlightCount(): number {
return handles.filter((h) => h.status === 'queued' || h.status === 'running').length;
}
(async () => {
const exec = new FlowChartExecutor(main);
await exec.run();
// Snap initial status (right after schedule but before microtask flush).
const initialInFlight = inFlightCount();
console.log(`Initial in-flight: ${initialInFlight}`);
// Poll loop — no await on any handle, just status property.
let pollCount = 0;
while (inFlightCount() > 0) {
pollCount += 1;
await new Promise((r) => setTimeout(r, 5));
if (pollCount > 200) {
console.error('REGRESSION: handles never terminated within 1s.');
process.exit(1);
}
}
const doneCount = handles.filter((h) => h.status === 'done').length;
const failedCount = handles.filter((h) => h.status === 'failed').length;
console.log(`Poll cycles: ${pollCount}`);
console.log(`Final: done=${doneCount}, failed=${failedCount}`);
console.log(`Sample results: ${handles.slice(0, 3).map((h) => String(h.result)).join(', ')}`);
// ── Regression guards ──
if (initialInFlight !== 10) {
console.error(`REGRESSION: expected 10 initial in-flight handles, got ${initialInFlight}.`);
process.exit(1);
}
if (doneCount !== 10) {
console.error(`REGRESSION: expected 10 done, got ${doneCount}.`);
process.exit(1);
}
if (failedCount !== 0) {
console.error(`REGRESSION: expected 0 failed, got ${failedCount}.`);
process.exit(1);
}
console.log('OK — sync status polling pattern works without any wait() calls.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

7 · Graceful shutdown — flushAllDetached

Section titled “7 · Graceful shutdown — flushAllDetached”

Drain all in-flight detached children before the process exits. Returns { done, failed, pending }. pending === 0 means the drain ran to completion. Useful in SIGTERM handlers and test cleanup.

This is not a history of all detached work: handles that have already settled and unregistered, including immediate scheduling failures, are not counted. Keep their handles if you need their outcomes.

examples/runtime-features/detach/07-graceful-shutdown.ts
/**
* Detach — Graceful Shutdown via `flushAllDetached`
*
* Simulates a server that scheduled 20 telemetry events via
* `detachAndForget` and now needs to drain them all before
* "process.exit". Without `flushAllDetached`, exiting immediately
* would lose any not-yet-flushed events.
*
* Run: npx tsx examples/runtime-features/detach/07-graceful-shutdown.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { flushAllDetached, microtaskBatchDriver } from 'footprintjs/detach';
// ── Side-effect chart — slow enough that the drain matters ────────────
const drained: number[] = [];
const telemetryChart = flowChart('Ship', async (scope) => {
const seq = scope.$getArgs<{ seq: number }>().seq;
// Pretend each event takes 5ms to "ship" (network round-trip).
await new Promise((r) => setTimeout(r, 5));
drained.push(seq);
}, 'ship').build();
// ── Main — schedule a burst of 20 detaches, then drain ────────────────
const main = flowChart('Burst', async (scope) => {
for (let seq = 0; seq < 20; seq++) {
scope.$detachAndForget(microtaskBatchDriver, telemetryChart, { seq });
}
}, 'burst').build();
(async () => {
const exec = new FlowChartExecutor(main);
await exec.run();
// At this point, 20 detaches are in flight. Without flushAllDetached,
// exiting now would lose most of them.
console.log(`Detaches in flight after main run: ${20 - drained.length}`);
const stats = await flushAllDetached({ timeoutMs: 5000 });
console.log(`After flush: drained=${drained.length}, stats=${JSON.stringify(stats)}`);
// ── Regression guards ──
if (drained.length !== 20) {
console.error(`REGRESSION: expected 20 telemetry events drained, got ${drained.length}.`);
process.exit(1);
}
if (stats.pending !== 0) {
console.error(`REGRESSION: expected pending=0 after successful drain, got ${stats.pending}.`);
process.exit(1);
}
// The drain ran to completion, no leftover work.
console.log('OK — graceful shutdown drained every in-flight detach.');
})().catch((e) => {
console.error(e);
process.exit(1);
});

Make detach a labeled chart stage so it shows up in narrative + visualizations + Mermaid diagrams. Pure sugar over addFunction — zero engine changes.

For addDetachAndJoinLater, the handle goes to a consumer-supplied onHandle callback (closure pattern) — handles can’t survive shared-state storage because of the structuredClone step.

examples/runtime-features/detach/08-builder-native.ts
/**
* Detach — Builder-Native Composition
*
* Demonstrates `addDetachAndForget` (fire-and-forget as a chart stage)
* and `addDetachAndJoinLater` with `onHandle` callback pattern.
*
* Pipeline:
* Seed → [DetachAndForget: telemetry]
* → [DetachAndJoinLater: eval-a] (handle pushed to closure)
* → [DetachAndJoinLater: eval-b] (handle pushed to closure)
* → Join (await Promise.all)
*
* Run: npx tsx examples/runtime-features/detach/08-builder-native.ts
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { createMicrotaskBatchDriver } from 'footprintjs/detach';
import type { DetachHandle } from 'footprintjs/detach';
// ── Side-effect chart: telemetry ──────────────────────────────────────
const telemetryShipped: unknown[] = [];
const telemetryChart = flowChart('ShipTelemetry', async (scope) => {
telemetryShipped.push(scope.$getArgs());
}, 'ship-telemetry').build();
// ── Eval chart: returns the input × 2 (just for demonstration) ────────
const evalChart = flowChart('ScoreVariant', async (scope) => {
const input = scope.$getArgs<{ value: number }>().value;
await new Promise((r) => setTimeout(r, 5));
return input * 2;
}, 'score-variant').build();
// ── Closure-local handle bag (see "Concurrency note" in the .md) ──────
const evalHandles: DetachHandle[] = [];
// ── Driver: build a fresh one so the example is hermetic ──────────────
const driver = createMicrotaskBatchDriver();
// ── Main chart with builder-native detach stages ──────────────────────
interface MainState {
orderId: string;
configA: number;
configB: number;
evalSum?: number;
}
const main = flowChart<MainState>('Seed', async (scope) => {
scope.orderId = 'order-99';
scope.configA = 7;
scope.configB = 13;
}, 'seed')
.addDetachAndForget('telemetry', telemetryChart, {
driver,
inputMapper: (scope) => ({ event: 'order.created', orderId: scope.orderId }),
})
.addDetachAndJoinLater('eval-a', evalChart, {
driver,
inputMapper: (scope) => ({ value: scope.configA }),
onHandle: (h) => evalHandles.push(h),
})
.addDetachAndJoinLater('eval-b', evalChart, {
driver,
inputMapper: (scope) => ({ value: scope.configB }),
onHandle: (h) => evalHandles.push(h),
})
.addFunction('Join', async (scope) => {
const settled = await Promise.all(evalHandles.map((h) => h.wait()));
scope.evalSum = settled.reduce((acc, r) => acc + (r.result as number), 0);
}, 'join')
.build();
(async () => {
const exec = new FlowChartExecutor(main);
await exec.run();
// Yield so the forget-detach has a chance to flush.
await Promise.resolve();
await Promise.resolve();
const snap = exec.getSnapshot();
const evalSum = snap.sharedState.evalSum as number;
console.log(`Telemetry shipped: ${telemetryShipped.length}, payload: ${JSON.stringify(telemetryShipped[0])}`);
console.log(`Eval handles created: ${evalHandles.length}`);
console.log(`Eval handle statuses: ${evalHandles.map((h) => h.status).join(', ')}`);
console.log(`Eval sum: ${evalSum} (expected: ${(7 + 13) * 2})`);
// ── Regression guards ──
if (telemetryShipped.length !== 1) {
console.error(`REGRESSION: expected 1 telemetry event, got ${telemetryShipped.length}.`);
process.exit(1);
}
const evt = telemetryShipped[0] as { event: string; orderId: string };
if (evt.event !== 'order.created' || evt.orderId !== 'order-99') {
console.error('REGRESSION: telemetry payload wrong.', evt);
process.exit(1);
}
if (evalHandles.length !== 2) {
console.error(`REGRESSION: expected 2 eval handles, got ${evalHandles.length}.`);
process.exit(1);
}
if (!evalHandles.every((h) => h.status === 'done')) {
console.error('REGRESSION: not every eval handle reached done.', evalHandles.map((h) => h.status));
process.exit(1);
}
if (evalSum !== 40) {
console.error(`REGRESSION: expected eval sum 40, got ${evalSum}.`);
process.exit(1);
}
console.log('OK — builder-native detach stages compose cleanly with downstream join.');
})().catch((e) => {
console.error(e);
process.exit(1);
});
PropertyType
idstring — refId minted from the source stage
status'queued' | 'running' | 'done' | 'failed'
resultunknown — set when status === 'done'
errorError — set when status === 'failed'
wait()Promise<DetachWaitResult> — cached

wait() returns the same Promise on every call — no re-running, no duplicated work. Errors land on handle.error and reject wait() with the same Error.

The refId format makes log correlation easy:

sf-tools/exec-tool#42:detach:7
└────── runtimeStageId ──────┘:detach:<counter>

Grep for the refId in your logs to find every event tied to a specific detached child.

Drivers are plain objects that satisfy the DetachDriver interface. The createXxxDriver(runChild) factories take a custom ChildRunner if you just want to wrap the executor (e.g., for tracing context).

This factory accepts an application-supplied extension buffer. Its host must keep queued work alive after the request ends; an ordinary in-process array cannot provide the survivesUnload guarantee.

import type { DetachDriver } from 'footprintjs/detach';
import { createHandle, asImpl } from 'footprintjs/detach';
interface BufferedWork {
refId: string;
child: Parameters<DetachDriver['schedule']>[0];
input: unknown;
handle: ReturnType<DetachDriver['schedule']>;
}
export function createExtensionDriver(sharedBuffer: { push(work: BufferedWork): void }): DetachDriver {
return {
name: 'lambda-extension',
capabilities: { nodeSafe: true, survivesUnload: true },
schedule(child, input, refId) {
const handle = createHandle(refId);
try {
sharedBuffer.push({ refId, child, input, handle });
} catch (error) {
asImpl(handle)._markFailed(error instanceof Error ? error : new Error('Could not enqueue work'));
}
return handle;
},
};
}

When your buffer flushes, call asImpl(handle)._markRunning() / _markDone(result) / _markFailed(error) to advance the lifecycle.

  • All 8 examples live at examples/runtime-features/detach/ — each has a .md companion + regression guards. They run automatically as integration tests, so the snippets on this page never go stale.