Simulation Engine
Synode's tick-based simulator advances a clock forward in discrete steps, running user journeys across a configurable time window. This guide covers every aspect of the simulation: time windows, ticks and lanes, waits and cooloffs, journey selection, the Duration type, pacing, and streaming mode.
Time Windows
The time option defines the simulated period. There are six valid combinations:
| Combination | Meaning |
|---|---|
{ duration } | Rolling window ending now |
{ start, duration } | Fixed window from start for duration |
{ end, duration } | Fixed window ending at end |
{ start, end } | Fixed window between two dates |
{ stream: true } | Endless real-time stream, no end |
{ duration, stream: true } | Rolling window with streaming flush |
// Fixed 90-day window
time: { start: new Date('2026-01-01'), duration: '90d' }
// Last 30 days (rolling)
time: { duration: '30d' }
// Up to a specific end date
time: { end: new Date('2026-03-31'), duration: '90d' }
// Between two explicit dates
time: { start: new Date('2026-01-01'), end: new Date('2026-03-31') }
// Streaming — no time limit
time: { stream: true }
// Streaming with a rolling window
time: { duration: '24h', stream: true }Ticks, Lanes, and Scatter
Ticks
The tick interval is the smallest unit of simulated time. Every tick, the simulator wakes each active lane and advances any user whose next scheduled action falls within the current tick.
simulation: { users: 1000, lanes: 4, tick: '15m' }Smaller ticks give finer timing resolution but more iterations. A tick of '15m' over a 90-day window produces 8,640 ticks. A tick of '1h' produces 2,160 ticks. Choose a tick that is smaller than the shortest wait you expect in your journeys.
Lanes
Lanes are fixed execution slots. Each tick, all lanes are processed concurrently. When a lane's user completes all their journeys, the next user from the pool is assigned to that lane.
simulation: { users: 50000, lanes: 8, tick: '1h' }
// 8 users are always in-flight; 50,000 users flow through over timeScatter
Without scatter, users in the same lane tend to act on aligned tick boundaries. The scatter option adds a random offset to each user's start time, smoothing the event distribution.
simulation: { users: 5000, lanes: 4, tick: '30m', scatter: 0.15 }
// Each user's start is jittered by up to ±15% of one tick intervalA scatter value of 0–1 represents the fraction of one tick interval to randomize over. 0.1–0.2 is a good default for most simulations.
Waits and Cooloffs
Action wait
wait on an action delays the simulated clock before that action executes. Use it to model the time a user spends before clicking, scrolling, or converting.
defineAction({
id: 'add-to-cart',
name: 'add_to_cart',
wait: '2m', // user pauses 2 minutes before this action
fields: { productId: (ctx) => ctx.faker.string.uuid() },
});Adventure wait and cooloff
wait on an adventure delays the user before entering that adventure (session gap).
cooloff prevents the adventure from being entered again until the cooloff period has elapsed since the last time it ran.
defineAdventure({
id: 'browse',
name: 'Browse Products',
wait: { min: '5m', max: '30m' }, // time before this session starts
cooloff: '1h', // can't run again within 1 hour
actions: [...],
});Journey cooloff
cooloff on a journey prevents re-entry until the period has elapsed after the journey last completed. Useful for rate-limiting flows like email campaigns or onboarding sequences.
defineJourney({
id: 'weekly-digest',
name: 'Weekly Digest',
cooloff: '7d', // at most once per 7 days
adventures: [...],
});Journey Selection: Prerequisites and Weights
Prerequisites
Set prerequisites (or requires) to an array of journey IDs. The engine only attempts the journey after all listed journeys have completed for that user.
const purchase = defineJourney({
id: 'purchase',
name: 'Purchase',
prerequisites: ['signup', 'browse'], // both must be done first
adventures: [...],
});Weights
When multiple journeys are eligible for a user in a given tick, the engine selects one using weighted random selection. Higher weight means the journey is chosen more often.
const pageView = defineJourney({
id: 'page-view',
name: 'Page View',
weight: 10, // 10x more likely than a weight-1 journey
adventures: [...],
});
const purchase = defineJourney({
id: 'purchase',
name: 'Purchase',
weight: 1,
prerequisites: ['page-view'],
adventures: [...],
});Omitting weight defaults to 1. A journey with weight: 0 is never selected automatically (it can still run via prerequisites triggering it).
Duration Format
The Duration type accepts both string shorthand and object notation.
String shorthand
| Format | Meaning |
|---|---|
'30s' | 30 seconds |
'5m' | 5 minutes |
'2h' | 2 hours |
'1d' | 1 day |
'2h30m' | 2 hours 30 minutes |
'1d12h' | 1 day and 12 hours |
'90d' | 90 days |
Units can be combined: '1d2h30m15s' is valid. Units must appear in descending order (days before hours before minutes before seconds).
Object notation
{ days: 1, hours: 2, minutes: 30, seconds: 15 }
{ hours: 2, minutes: 30 }
{ minutes: 5 }All fields are optional and default to 0. Object notation is useful when computing durations programmatically.
Range notation
Both wait and cooloff accept a range that is sampled uniformly per user:
wait: { min: '5m', max: '30m' }
cooloff: { min: '6h', max: '24h' }Pacing
pacing controls how the simulation clock relates to wall-clock time. This is most useful in streaming mode.
// Default: run as fast as possible
simulation: { ..., pacing: 'none' }
// Match real time (1 simulated second = 1 real second)
simulation: { ..., pacing: 'realtime' }
// 60x faster than real time
simulation: { ..., pacing: { mode: 'realtime', speed: 60 } }
// Fixed wall-clock delay between ticks
simulation: { ..., pacing: { mode: 'fixed', delayMs: 100 } }Streaming Mode
In streaming mode the simulator flushes each tick's events before advancing the clock. This allows downstream consumers to process events as they are produced rather than waiting for the full run.
await generate(journey, {
time: { duration: '24h', stream: true },
simulation: {
users: 1000,
lanes: 8,
tick: '5m',
pacing: { mode: 'realtime', speed: 60 }, // 24 simulated hours in 24 real minutes
},
adapter: new CallbackAdapter((event) => forwardToKafka(event)),
});See the Streaming guide for more examples including continuous mode and AbortSignal.
Full Example
import {
defineJourney,
defineAdventure,
defineAction,
definePersona,
generate,
weighted,
oneOf,
} from '@synode/core';
import { FileAdapter } from '@synode/adapter-file';
const persona = definePersona({
id: 'users',
name: 'Platform Users',
attributes: {
locale: weighted({ en: 0.6, de: 0.2, fr: 0.2 }),
device: weighted({ mobile: 0.6, desktop: 0.3, tablet: 0.1 }),
},
});
const browseJourney = defineJourney({
id: 'browse',
name: 'Browse',
weight: 8,
adventures: [
defineAdventure({
id: 'session',
name: 'Session',
wait: { min: '1m', max: '10m' },
cooloff: '30m',
actions: [
defineAction({
id: 'page-view',
name: 'page_viewed',
fields: { url: oneOf(['/home', '/products', '/pricing']) },
}),
],
}),
],
});
const purchaseJourney = defineJourney({
id: 'purchase',
name: 'Purchase',
weight: 2,
prerequisites: ['browse'],
cooloff: '1d',
adventures: [
defineAdventure({
id: 'checkout',
name: 'Checkout',
wait: { min: '5m', max: '20m' },
actions: [
defineAction({
id: 'purchased',
name: 'purchase_completed',
fields: { total: (ctx) => ctx.faker.number.float({ min: 20, max: 500 }) },
}),
],
}),
],
});
await generate([browseJourney, purchaseJourney], {
time: { start: new Date('2026-01-01'), duration: '90d' },
simulation: { users: 500, lanes: 8, tick: '15m', scatter: 0.15 },
persona,
adapter: new FileAdapter({ dir: './output', format: 'jsonl', partitionByDay: true }),
});