Skip to main content

Sliding Window

TLDR

Keep exactly windowSize child Workflows running at all times — each completion signal triggers the next record to start immediately. Use this when your record set is arbitrarily large, you need bounded concurrency to protect downstream systems, and you want higher throughput than a sequential Batch Iterator provides.

Overview

The Sliding Window pattern maintains a fixed-size pool of concurrently running child Workflows. As each child completes it signals the parent, which immediately starts a replacement — keeping the concurrency level constant and progressing at the rate of the fastest processor. Continue-as-New prevents the parent's history from growing without bound.

Problem

The Batch Iterator processes records sequentially — the overall throughput is limited by the slowest record in each page. The Fan-Out pattern starts all children at once, which can overwhelm downstream systems when the record set is large.

You need a way to process an arbitrarily large record set with bounded concurrency, maximum throughput within that bound, and protection against history bloat.

Solution

The parent Workflow starts exactly windowSize child Workflows simultaneously. Each child processes one record and, when finished, signals the parent with a completion notification. The parent maintains a count of completed children and starts a new child for the next record as soon as a slot becomes free.

Continue-as-New is called after the parent has started windowSize children. Because child Workflows have stable Workflow IDs and Continue-as-New preserves the parent's Workflow ID, children started by a previous run can still signal the current run.

The following describes each step in the diagram:

  1. The parent Workflow starts with a list of record IDs and a configured windowSize.
  2. It starts the first windowSize children concurrently, one per record, each receiving the parent's Workflow ID so they know where to signal.
  3. As each child completes, it sends a completion signal to the parent.
  4. The parent receives the signal, increments its completion counter, and starts the next child (the next record in the list).
  5. After starting windowSize children in total, the parent calls continueAsNew with the updated start index. The window slides forward without gaps because the parent's Workflow ID is preserved across runs.
  6. Children from previous runs that have not yet signalled will find the new run when they send the signal, because the parent Workflow ID remains the same.

Implementation

The following examples show how each SDK implements the Sliding Window pattern.

// workflows.ts
import {
ApplicationFailure,
ParentClosePolicy,
condition,
continueAsNew,
defineSignal,
getExternalWorkflowHandle,
proxyActivities,
setHandler,
startChild,
workflowInfo,
} from "@temporalio/workflow";
import type * as activities from "./activities";
import { TASK_QUEUE, WINDOW_SIZE } from "./shared";

const { processRecord } = proxyActivities<typeof activities>({
startToCloseTimeout: "30 seconds",
});

export const completionSignal = defineSignal<[string]>("recordCompleted");

export async function recordProcessorWorkflow(
recordId: string,
parentWorkflowId: string
): Promise<void> {
await processRecord(recordId);
// Ignore NOT_FOUND — the parent's final run may have already completed.
try {
const parent = getExternalWorkflowHandle(parentWorkflowId);
await parent.signal(completionSignal, recordId);
} catch (err) {
if (!(err instanceof ApplicationFailure && err.type === 'NOT_FOUND')) throw err;
}
}

export async function slidingWindowWorkflow(input: SlidingWindowInput): Promise<number> {
const {
recordIds,
windowSize = WINDOW_SIZE,
startIndex = 0,
inFlight = 0,
} = input;
let totalProcessed = input.totalProcessed ?? 0;
const parentId = workflowInfo().workflowId;
let pendingSignals = 0;
let nextIndex = startIndex;
let dispatched = 0;
let active = inFlight;

// Signal handler: each completion frees a slot and increments the total.
setHandler(completionSignal, (_recordId: string) => {
pendingSignals++;
totalProcessed++;
});

// Only start (windowSize - inFlight) new children. Carried-over in-flight
// children from the previous run will signal us when they complete.
const newFill = Math.min(windowSize - inFlight, recordIds.length - startIndex);
for (let i = 0; i < newFill; i++) {
await startChild(recordProcessorWorkflow, {
args: [recordIds[nextIndex], parentId],
workflowId: `${parentId}/record-${recordIds[nextIndex]}`,
taskQueue: TASK_QUEUE,
parentClosePolicy: ParentClosePolicy.ABANDON,
});
nextIndex++;
dispatched++;
active++;
}

// If the window is full after the initial fill, continue-as-new immediately.
if (dispatched >= windowSize) {
await continueAsNew<typeof slidingWindowWorkflow>({ recordIds, windowSize, startIndex: nextIndex, totalProcessed, inFlight: windowSize });
return;
}

// Slide the window: as each slot frees, start the next child.
while (nextIndex < recordIds.length) {
await condition(() => pendingSignals > 0);
pendingSignals--;
active--;
await startChild(recordProcessorWorkflow, {
args: [recordIds[nextIndex], parentId],
workflowId: `${parentId}/record-${recordIds[nextIndex]}`,
taskQueue: TASK_QUEUE,
parentClosePolicy: ParentClosePolicy.ABANDON,
});
nextIndex++;
dispatched++;
active++;

// Continue-as-New after starting windowSize children to keep history short.
// Pass nextIndex (next unstarted record) and inFlight=windowSize (window is full).
if (dispatched >= windowSize) {
await continueAsNew<typeof slidingWindowWorkflow>({
recordIds,
windowSize,
startIndex: nextIndex,
totalProcessed,
inFlight: windowSize,
});
return;
}
}

// Wait for all remaining in-flight children to complete.
await condition(() => pendingSignals >= active);
return totalProcessed;
}

Best Practices

  • Preserve the parent Workflow ID across Continue-as-New. The parent's Workflow ID is stable across continueAsNew runs — do not generate a new one. Children use signalExternalWorkflow with that ID, so they always reach the current run.
  • Use PARENT_CLOSE_POLICY_ABANDON on child Workflows. This lets children that were started by a previous run complete normally even after the parent has continued as new.
  • Size the window conservatively at first. Each in-flight child counts toward the 2,000 unfinished-actions limit for the parent. A window of 50–200 is a reasonable starting point depending on child duration and downstream capacity.
  • Pass only IDs (not full records) to child Workflows. Workflow inputs are stored in event history. Keep them small.
  • Carry minimal state into continueAsNew. Only pass windowSize, startIndex, and the record ID list (or a reference to it). Do not accumulate results in the parent — collect them out-of-band if needed.

Common Pitfalls

  • Losing signals across Continue-as-New. If a child signals before the parent's new run has registered the signal handler, the signal can be buffered and delivered correctly — Temporal buffers signals for existing Workflow IDs. However, ensure the signal handler is registered before any await, not conditionally.
  • Race between CAN and remaining signal draining. After continueAsNew, the new run must handle signals from children started by the previous run. Pass nextIndex (the next unstarted record) and inFlight = windowSize to the new run so it knows how many carried-over children to expect signals from, without re-starting them.
  • Thundering herd on startup. Starting hundreds of children simultaneously causes a burst of Activity polls. Ramp up the window gradually or use the Batch Iterator if rate limiting is more important than throughput.