Skip to content
Algorand Developer Portal

Sync Behaviours

← Back to Examples

This example demonstrates all 4 sync behaviours and maxRoundsToSync comparison.

  • sync-oldest: incremental catchup from watermark
  • skip-sync-newest: jump to tip, discard history
  • sync-oldest-start-now: hybrid first-start behavior
  • fail: throw when too far behind
  • LocalNet running (via algokit localnet start)

From the repository’s examples/subscriber directory:

Terminal window
cd examples/subscriber
npx tsx 12-sync-behaviours.ts

View source on GitHub

12-sync-behaviours.ts
/**
* Example: Sync Behaviours
*
* This example demonstrates all 4 sync behaviours and maxRoundsToSync comparison.
* - sync-oldest: incremental catchup from watermark
* - skip-sync-newest: jump to tip, discard history
* - sync-oldest-start-now: hybrid first-start behavior
* - fail: throw when too far behind
*
* Prerequisites:
* - LocalNet running (via `algokit localnet start`)
*/
import { algo, AlgorandClient } from '@algorandfoundation/algokit-utils';
import { AlgorandSubscriber } from '@algorandfoundation/algokit-subscriber';
import type { TransactionSubscriptionResult } from '@algorandfoundation/algokit-subscriber/types/subscription';
import {
printHeader,
printStep,
printInfo,
printSuccess,
printError,
shortenAddress,
} from './shared/utils.js';
interface BehaviourResult {
name: string;
syncedRoundRange: [bigint, bigint];
currentRound: bigint;
startingWatermark: bigint;
newWatermark: bigint;
txnCount: number;
note: string;
}
async function pollWithBehaviour(
algod: AlgorandClient['client']['algod'],
senderAddr: string,
watermark: bigint,
syncBehaviour: 'sync-oldest' | 'skip-sync-newest' | 'sync-oldest-start-now' | 'fail',
maxRoundsToSync: number,
): Promise<TransactionSubscriptionResult> {
let wm = watermark;
const subscriber = new AlgorandSubscriber(
{
filters: [
{
name: 'payments',
filter: { sender: senderAddr },
},
],
syncBehaviour,
maxRoundsToSync,
watermarkPersistence: {
get: async () => wm,
set: async (w: bigint) => {
wm = w;
},
},
},
algod,
);
return subscriber.pollOnce();
}
async function main() {
printHeader('12 — Sync Behaviours');
// Step 1: Connect to LocalNet
printStep(1, 'Connect to LocalNet');
const algorand = AlgorandClient.defaultLocalNet();
const status = await algorand.client.algod.status();
printInfo(`Current round: ${status.lastRound.toString()}`);
printSuccess('Connected to LocalNet');
// Step 2: Create and fund accounts
printStep(2, 'Create and fund accounts');
const sender = await algorand.account.fromEnvironment('SYNC_SENDER', algo(100));
const receiver = await algorand.account.fromEnvironment('SYNC_RECEIVER', algo(10));
const senderAddr = sender.addr.toString();
const receiverAddr = receiver.addr.toString();
printInfo(`Sender: ${shortenAddress(senderAddr)}`);
printInfo(`Receiver: ${shortenAddress(receiverAddr)}`);
printSuccess('Accounts created and funded');
// Step 3: Send several transactions to create round history
printStep(3, 'Send 6 transactions to create round history');
const txnRounds: bigint[] = [];
for (let i = 1; i <= 6; i++) {
const result = await algorand.send.payment({
sender: sender.addr,
receiver: receiver.addr,
amount: algo(1),
note: `sync-test-${i}`,
});
const round = result.confirmation.confirmedRound!;
txnRounds.push(round);
printInfo(`Txn ${i}: round ${round}`);
}
const firstTxnRound = txnRounds[0];
const lastTxnRound = txnRounds[txnRounds.length - 1];
printInfo(`Transaction round range: ${firstTxnRound} to ${lastTxnRound}`);
printSuccess('6 transactions sent');
const tipRound = (await algorand.client.algod.status()).lastRound;
printInfo(`Current tip: ${tipRound.toString()}`);
// We'll use a watermark before all our transactions so there's a gap
const oldWatermark = firstTxnRound - 1n;
// Use a small maxRoundsToSync so the gap exceeds it (triggers sync behaviour logic)
const smallMax = 3;
// Use a large maxRoundsToSync so no gap triggers (within threshold)
const largeMax = 500;
const results: BehaviourResult[] = [];
// Step 4: Demonstrate sync-oldest
printStep(4, 'sync-oldest — starts from watermark, syncs forward (limited by maxRoundsToSync)');
const syncOldestResult = await pollWithBehaviour(
algorand.client.algod,
senderAddr,
oldWatermark,
'sync-oldest',
smallMax,
);
printInfo(`Watermark: ${oldWatermark.toString()}`);
printInfo(`maxRoundsToSync: ${smallMax.toString()}`);
printInfo(
`syncedRoundRange: [${syncOldestResult.syncedRoundRange[0]}, ${syncOldestResult.syncedRoundRange[1]}]`,
);
printInfo(`currentRound (tip): ${syncOldestResult.currentRound.toString()}`);
printInfo(`Transactions matched: ${syncOldestResult.subscribedTransactions.length.toString()}`);
console.log();
console.log(' Explanation: sync-oldest starts from watermark+1 and syncs forward');
console.log(` only ${smallMax} rounds. It does NOT jump to the tip. This is useful for`);
console.log(' incremental catchup — each poll processes a batch of rounds.');
results.push({
name: 'sync-oldest',
syncedRoundRange: syncOldestResult.syncedRoundRange as [bigint, bigint],
currentRound: syncOldestResult.currentRound,
startingWatermark: syncOldestResult.startingWatermark,
newWatermark: syncOldestResult.newWatermark,
txnCount: syncOldestResult.subscribedTransactions.length,
note: `Syncs ${smallMax} rounds from watermark`,
});
printSuccess('sync-oldest demonstrated');
// Step 5: Demonstrate skip-sync-newest
printStep(5, 'skip-sync-newest — jumps to tip, only sees latest rounds');
const skipNewestResult = await pollWithBehaviour(
algorand.client.algod,
senderAddr,
oldWatermark,
'skip-sync-newest',
smallMax,
);
printInfo(`Watermark: ${oldWatermark.toString()}`);
printInfo(`maxRoundsToSync: ${smallMax.toString()}`);
printInfo(
`syncedRoundRange: [${skipNewestResult.syncedRoundRange[0]}, ${skipNewestResult.syncedRoundRange[1]}]`,
);
printInfo(`currentRound (tip): ${skipNewestResult.currentRound.toString()}`);
printInfo(`Transactions matched: ${skipNewestResult.subscribedTransactions.length.toString()}`);
console.log();
console.log(' Explanation: skip-sync-newest jumps to currentRound - maxRoundsToSync + 1.');
console.log(' It discards all old history and only sees the newest rounds. Useful for');
console.log(" real-time notifications where you don't care about catching up.");
results.push({
name: 'skip-sync-newest',
syncedRoundRange: skipNewestResult.syncedRoundRange as [bigint, bigint],
currentRound: skipNewestResult.currentRound,
startingWatermark: skipNewestResult.startingWatermark,
newWatermark: skipNewestResult.newWatermark,
txnCount: skipNewestResult.subscribedTransactions.length,
note: `Jumps to tip, scans last ${smallMax} rounds`,
});
printSuccess('skip-sync-newest demonstrated');
// Step 6: Demonstrate sync-oldest-start-now (watermark=0)
printStep(6, 'sync-oldest-start-now — when watermark=0, starts from current round (not round 1)');
const startNowResult = await pollWithBehaviour(
algorand.client.algod,
senderAddr,
0n,
'sync-oldest-start-now',
smallMax,
);
printInfo(`Watermark: 0 (fresh start)`);
printInfo(`maxRoundsToSync: ${smallMax.toString()}`);
printInfo(
`syncedRoundRange: [${startNowResult.syncedRoundRange[0]}, ${startNowResult.syncedRoundRange[1]}]`,
);
printInfo(`currentRound (tip): ${startNowResult.currentRound.toString()}`);
printInfo(`Transactions matched: ${startNowResult.subscribedTransactions.length.toString()}`);
console.log();
console.log(' Explanation: When watermark=0, sync-oldest-start-now behaves like');
console.log(' skip-sync-newest — it jumps to the tip instead of syncing from round 1.');
console.log(' This avoids scanning the entire chain history on first startup.');
console.log(' Once watermark > 0 (after first poll), it behaves like sync-oldest.');
results.push({
name: 'sync-oldest-start-now (wm=0)',
syncedRoundRange: startNowResult.syncedRoundRange as [bigint, bigint],
currentRound: startNowResult.currentRound,
startingWatermark: startNowResult.startingWatermark,
newWatermark: startNowResult.newWatermark,
txnCount: startNowResult.subscribedTransactions.length,
note: 'Watermark=0: jumps to tip like skip-sync-newest',
});
printSuccess('sync-oldest-start-now demonstrated');
// Step 7: Demonstrate fail behaviour
printStep(7, 'fail — throws when gap between watermark and tip exceeds maxRoundsToSync');
let failError: Error | null = null;
try {
await pollWithBehaviour(algorand.client.algod, senderAddr, oldWatermark, 'fail', smallMax);
} catch (err) {
failError = err as Error;
}
if (failError) {
printInfo(`Error thrown: ${failError.message}`);
console.log();
console.log(
' Explanation: fail throws an error when currentRound - watermark > maxRoundsToSync.',
);
console.log(' This is useful for strict deployments where falling behind is unacceptable.');
console.log(' The operator must investigate why the subscriber fell behind.');
results.push({
name: 'fail',
syncedRoundRange: [0n, 0n],
currentRound: tipRound,
startingWatermark: oldWatermark,
newWatermark: oldWatermark,
txnCount: 0,
note: 'Throws error — gap too large',
});
printSuccess('fail behaviour demonstrated (error thrown as expected)');
} else {
// If gap was small enough, fail doesn't throw — show that too
printInfo(`No error: Gap was within maxRoundsToSync, no error thrown`);
results.push({
name: 'fail',
syncedRoundRange: [0n, 0n],
currentRound: tipRound,
startingWatermark: oldWatermark,
newWatermark: oldWatermark,
txnCount: 0,
note: 'No error — gap within threshold',
});
}
// Step 8: Show maxRoundsToSync effect — compare small vs large
printStep(8, 'maxRoundsToSync effect — compare different values');
const smallMaxResult = await pollWithBehaviour(
algorand.client.algod,
senderAddr,
oldWatermark,
'sync-oldest',
smallMax,
);
const largeMaxResult = await pollWithBehaviour(
algorand.client.algod,
senderAddr,
oldWatermark,
'sync-oldest',
largeMax,
);
printInfo(
`sync-oldest maxRoundsToSync=${smallMax}: range=[${smallMaxResult.syncedRoundRange[0]}, ${smallMaxResult.syncedRoundRange[1]}], txns=${smallMaxResult.subscribedTransactions.length}`,
);
printInfo(
`sync-oldest maxRoundsToSync=${largeMax}: range=[${largeMaxResult.syncedRoundRange[0]}, ${largeMaxResult.syncedRoundRange[1]}], txns=${largeMaxResult.subscribedTransactions.length}`,
);
console.log();
console.log(' With a small maxRoundsToSync, sync-oldest only processes a few rounds per poll.');
console.log(
' With a large maxRoundsToSync (or when gap < maxRoundsToSync), it processes all rounds to the tip.',
);
printSuccess('maxRoundsToSync comparison demonstrated');
// Step 9: Print comparison table
printStep(9, 'Comparison table');
console.log();
console.log(
' ┌──────────────────────────────────┬─────────────────────────┬──────────────┬────────────────────────────────────────────┐',
);
console.log(
' │ Behaviour │ syncedRoundRange │ Txn Count │ Note │',
);
console.log(
' ├──────────────────────────────────┼─────────────────────────┼──────────────┼────────────────────────────────────────────┤',
);
for (const r of results) {
const name = r.name.padEnd(32);
const range =
r.name === 'fail'
? 'N/A (error thrown)'.padEnd(23)
: `[${r.syncedRoundRange[0]}, ${r.syncedRoundRange[1]}]`.padEnd(23);
const txns = r.txnCount.toString().padEnd(12);
const note = r.note.padEnd(42);
console.log(` │ ${name}${range}${txns}${note} │`);
}
// Add the maxRoundsToSync comparison rows
const smallRow = {
name: `sync-oldest (max=${smallMax})`,
range: `[${smallMaxResult.syncedRoundRange[0]}, ${smallMaxResult.syncedRoundRange[1]}]`,
txns: smallMaxResult.subscribedTransactions.length,
note: `Limited to ${smallMax} rounds`,
};
const largeRow = {
name: `sync-oldest (max=${largeMax})`,
range: `[${largeMaxResult.syncedRoundRange[0]}, ${largeMaxResult.syncedRoundRange[1]}]`,
txns: largeMaxResult.subscribedTransactions.length,
note: 'Syncs all rounds to tip',
};
console.log(
' ├──────────────────────────────────┼─────────────────────────┼──────────────┼────────────────────────────────────────────┤',
);
for (const row of [smallRow, largeRow]) {
console.log(
` │ ${row.name.padEnd(32)}${row.range.padEnd(23)}${row.txns.toString().padEnd(12)}${row.note.padEnd(42)} │`,
);
}
console.log(
' └──────────────────────────────────┴─────────────────────────┴──────────────┴────────────────────────────────────────────┘',
);
console.log();
// Step 10: Summary explanation
printStep(10, 'Summary');
console.log();
console.log(' ┌─────────────────────────────────────────────────────────────────┐');
console.log(' │ Sync Behaviour Guide │');
console.log(' ├─────────────────────────────────────────────────────────────────┤');
console.log(' │ │');
console.log(' │ sync-oldest: │');
console.log(' │ Processes rounds incrementally from watermark forward. │');
console.log(' │ Safe for catching up. Requires archival node for old data. │');
console.log(' │ │');
console.log(' │ skip-sync-newest: │');
console.log(' │ Jumps to the tip, discards old history. │');
console.log(' │ Best for real-time notifications only. │');
console.log(' │ │');
console.log(' │ sync-oldest-start-now: │');
console.log(' │ Hybrid — skips history on first start (wm=0), then catches │');
console.log(' │ up incrementally like sync-oldest afterward. │');
console.log(' │ │');
console.log(' │ fail: │');
console.log(' │ Throws if too far behind. Forces operator intervention. │');
console.log(' │ │');
console.log(' │ maxRoundsToSync (default 500): │');
console.log(' │ Controls rounds per poll. Affects staleness tolerance for │');
console.log(' │ skip-sync-newest/fail, and catchup speed for sync-oldest. │');
console.log(' │ │');
console.log(' └─────────────────────────────────────────────────────────────────┘');
console.log();
printHeader('Example complete');
}
main().catch(err => {
printError(err.message);
process.exit(1);
});