Ablauf

Real-time Updates

Stream live progress to clients with WebSocket hibernation

Real-time Updates

Ablauf uses Cloudflare's Hibernatable WebSocket API for real-time workflow progress updates. Stream progress bars, status messages, and completion notifications to your users as the workflow runs — while the Durable Object hibernates between events, keeping memory costs near zero.

Two methods: broadcast() for live-only updates, and emit() for persisted messages that new clients can replay.

Defining Update Types

Update messages are typed with Zod schemas, just like events and payloads:

import { defineWorkflow } from '@der-ablauf/workflows';

const ImportWorkflow = defineWorkflow((t) => ({
	type: 'data-import',
	input: t.object({ fileUrl: t.string() }),
	sseUpdates: {
		progress: t.object({ percent: t.number(), message: t.string() }),
		error: t.object({ message: t.string() }),
		complete: t.object({ recordCount: t.number() }),
	},
	run: async (step, payload, sse) => {
		sse.broadcast('progress', { percent: 0, message: 'Starting import...' });

		const parsed = await step.do('parse-file', async () => {
			// Parse the file
			return { records: 1000 };
		});

		sse.broadcast('progress', { percent: 50, message: 'Importing records...' });

		await step.do('import-records', async () => {
			// Import to database
		});

		// emit() persists the message — new clients will see it even after it was sent
		sse.emit('complete', { recordCount: parsed.records });

		return { imported: parsed.records };
	},
}));

TypeScript autocompletes the message types and validates the payload shape. You can't send an invalid update message.

broadcast() vs emit()

MethodLive clientsNew clientsPersisted
broadcast()YesNoNo
emit()YesYesYes

Use broadcast() for transient progress updates that don't need to be replayed. Perfect for progress bars, status messages, or "still working..." heartbeats.

Use emit() for important milestones that new clients connecting later should see. Think "Step 1 complete", "Payment processed", or final results.

Example: Progress Updates

for (let i = 0; i < 100; i += 10) {
	sse.broadcast('progress', { percent: i, message: `Processing... ${i}%` });
	await step.sleep('1s');
}

sse.emit('complete', { message: 'All done!' });

Live clients see all the progress updates. A client connecting after completion only sees the "All done!" message.

Connecting from a Client

Using the Ablauf Client Package

The @der-ablauf/client package provides a type-safe client with WebSocket subscriptions:

import { createAblaufClient } from '@der-ablauf/client';
import { ImportWorkflow } from './workflows';

const client = createAblaufClient({
	url: 'https://my-worker.example.com/__ablauf',
});

for await (const update of client.subscribe<typeof ImportWorkflow>('wf-123')) {
	if (update.event === 'progress') {
		updateProgressBar(update.data.percent);
	}

	if (update.event === 'complete') {
		showSuccessMessage(update.data.recordCount);
	}
}

Using Native WebSocket

You can also connect directly using the browser's WebSocket API:

const ws = new WebSocket(`wss://my-worker.example.com/__ablauf/workflows/${workflowId}/ws`);

ws.addEventListener('message', (event) => {
	const { event: name, data } = JSON.parse(event.data);
	if (name === 'progress') {
		updateProgressBar(data);
	}
	if (name === 'close') {
		ws.close();
	}
});

ws.addEventListener('error', () => {
	console.error('WebSocket connection error');
});

Replay Behavior

During workflow replay, broadcast() calls are automatically skipped — no duplicate progress messages for your users. emit() messages are persisted and replayed to new clients. Ablauf handles this for you.

When a workflow resumes after a sleep or event wait, it replays from the beginning to rebuild its execution state. Without special handling, broadcast() calls would fire again, spamming connected clients with duplicate messages.

Ablauf detects replay and skips broadcast() automatically. Only new broadcast() calls (for steps executing for the first time) send messages to live clients.

emit() messages are persisted in SQLite, so new clients connecting mid-workflow or after completion can see the full history.

Common Patterns

Progress Bar for Long Operations

const BatchJob = defineWorkflow((t) => ({
	type: 'batch-job',
	input: t.object({ itemIds: t.array(t.string()) }),
	sseUpdates: {
		progress: t.object({ percent: t.number() }),
	},
	run: async (step, payload, sse) => {
		const total = payload.itemIds.length;

		for (let i = 0; i < total; i++) {
			await step.do(`process-item-${i}`, async () => {
				await processItem(payload.itemIds[i]);
			});

			const percent = Math.round(((i + 1) / total) * 100);
			sse.broadcast('progress', { percent });
		}

		return { processed: total };
	},
}));

Multi-Stage Updates

const DeploymentWorkflow = defineWorkflow((t) => ({
	type: 'deployment',
	input: t.object({ appId: t.string() }),
	sseUpdates: {
		build: t.object({ message: t.string() }),
		test: t.object({ message: t.string() }),
		deploy: t.object({ message: t.string() }),
		complete: t.object({ url: t.string() }),
	},
	run: async (step, payload, sse) => {
		sse.emit('build', { message: 'Building application...' });
		await step.do('build', async () => {
			/* ... */
		});

		sse.emit('test', { message: 'Running tests...' });
		await step.do('test', async () => {
			/* ... */
		});

		sse.emit('deploy', { message: 'Deploying to production...' });
		await step.do('deploy', async () => {
			/* ... */
		});

		sse.emit('complete', { url: 'https://app.example.com' });

		return { deployed: true };
	},
}));

New clients connecting during the test stage will see the build and test messages, but not the deploy message (yet).

Error Messages via Updates

You can use updates to notify clients of errors without throwing:

const RobustWorkflow = defineWorkflow((t) => ({
	type: 'robust',
	input: t.object({ taskId: t.string() }),
	sseUpdates: {
		info: t.object({ message: t.string() }),
		error: t.object({ message: t.string() }),
	},
	run: async (step, payload, sse) => {
		try {
			await step.do('risky-operation', async () => {
				// Might fail...
			});
		} catch (err) {
			sse.emit('error', { message: 'Operation failed, retrying...' });
			throw err; // Still let retries happen
		}
	},
}));

Clients can show error toasts or warnings while the workflow continues retrying in the background.

Performance Notes

  • broadcast() is very cheap — sends to connected WebSocket clients in memory
  • emit() writes to SQLite, so use it for milestones, not every iteration of a tight loop
  • WebSocket connections use Cloudflare's Hibernatable WebSocket API — the Durable Object can sleep between events, keeping memory cost near zero even with many connected clients
  • Ablauf automatically sends a close frame and disconnects clients after workflow completion

Real-time updates make Ablauf workflows feel interactive and responsive. Your users see live progress instead of staring at a spinner. And with WebSocket hibernation, you get this with minimal resource cost.

On this page