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()
| Method | Live clients | New clients | Persisted |
|---|---|---|---|
broadcast() | Yes | No | No |
emit() | Yes | Yes | Yes |
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 memoryemit()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.