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