Skip to content

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)
})