Sync Behaviours
Description
Section titled “Description”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
Section titled “Prerequisites”- LocalNet running (via
algokit localnet start)
Run This Example
Section titled “Run This Example”From the repository’s examples/subscriber directory:
cd examples/subscribernpx tsx 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);});Other examples
Section titled “Other examples”- Basic Poll Once
- Continuous Subscriber
- Payment Filters
- Asset Transfer Subscription
- App Call Subscription
- Multiple Named Filters
- Balance Change Tracking
- ARC-28 Event Subscription
- Inner Transaction Subscription
- Batch Handling & Data Mappers
- Watermark Persistence
- Sync Behaviours
- Custom Filters
- Stateless Subscriptions
- Lifecycle Hooks & Error Handling