Skip to content

Reduce UI lag: dedup and throttle UTXO engine emissions#446

Open
paullinator wants to merge 4 commits intomasterfrom
paul/fixUiLag
Open

Reduce UI lag: dedup and throttle UTXO engine emissions#446
paullinator wants to merge 4 commits intomasterfrom
paul/fixUiLag

Conversation

@paullinator
Copy link
Member

@paullinator paullinator commented Feb 25, 2026

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Reduces RN JS thread starvation from UTXO engine callbacks. Four targeted fixes:

  • Skip emit for already-known transactions: processAddressForTransactions now only emits TRANSACTIONS events when a transaction is genuinely new or its blockHeight has changed.
  • Don't reset processedPercent on start(): Treats processedPercent as an in-memory high-water-mark that persists across pause/unpause cycles. Prevents the reported sync ratio from rolling backwards when start() is called again or setLookAhead inflates the denominator.
  • Typed dedup emit methods on EngineEmitter: Adds emitBlockHeightChanged, emitWalletBalanceChanged, and emitAddressesChecked with last-value tracking. Only emits when the value actually changes.
  • Global 500ms throttle on emitAddressesChecked: Caps aggregate sync-progress bridge traffic to 2 emissions/sec across all UTXO engines (ratio=1 always passes immediately).

Note

Medium Risk
Changes event emission semantics (frequency, monotonicity, and when transactions fire) which could affect UI sync/progress behavior and any consumers relying on prior callback patterns.

Overview
Reduces UI/JS-thread load from UTXO engines by deduping and throttling key EngineEmitter events. EngineEmitter gains typed emitBlockHeightChanged, emitWalletBalanceChanged, and emitAddressesChecked helpers that suppress unchanged values, with a global 500ms throttle for ADDRESSES_CHECKED (except final ratio 1).

UTXO sync progress reporting is adjusted to be a monotonic high-water mark (no backwards ratios) across start() restarts and lookahead expansion, and transaction callbacks are reduced by emitting TRANSACTIONS only when a tx is new or its blockHeight changes. Separately, UTXO toEdgeTransaction now derives confirmations from blockHeight when missing/unconfirmed, and BLOCK_HEIGHT_CHANGED is no longer forwarded to core callbacks (kept internal for engine logic).

Written by Cursor Bugbot for commit 6a886a2. This will update automatically on new commits. Configure here.

On every start(), the UTXO engine re-queried blockbook for every address
and re-emitted every returned transaction even if it already existed on
disk unchanged. Skipping the emit when existingTx exists and its
blockHeight hasn't changed eliminated 199+ redundant onTransactions calls
per wallet on restart.

Also computes confirmations directly from blockHeight in toEdgeTransaction
so UTXO txs report confirmed/unconfirmed correctly without waiting for
onBlockHeightChanged.
start() unconditionally reset processedCount and processedPercent to
zero, causing the sync ratio to walk 0%→100% on every login and emitting
ADDRESSES_CHECKED for each increment. Guarding the reset prevents
redundant progress walks within the same session.

Also adds a high-water-mark guard to updateProgressRatio to prevent
progress from going backwards when setLookAhead inflates the denominator.
EngineEmitter had zero deduplication — BLOCK_HEIGHT_CHANGED,
WALLET_BALANCE_CHANGED, and ADDRESSES_CHECKED all fired unconditionally.
Adding last-value tracking via typed emit methods (emitBlockHeightChanged,
emitWalletBalanceChanged, emitAddressesChecked) suppresses duplicate
emissions for block height, balance, and sync ratio.

Also stops forwarding BLOCK_HEIGHT_CHANGED to core-js via
onBlockHeightChanged; UTXO txs now set confirmations directly so the
callback is no longer needed.
Even with per-engine dedup, multiple UTXO engines syncing simultaneously
produced frequent onAddressesChecked callbacks. A global rate limiter
(max 1 per 500ms, ratio=1 always passes) reduces aggregate sync-progress
volume across all wallets.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issues.

common.emitter.emit(EngineEvent.TRANSACTIONS, [
{ isNew, transaction: edgeTx }
])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transaction emission skipped when ourAmount changes but blockHeight unchanged

Medium Severity

The new dedup guard only emits when existingTx is null or blockHeight changed, but saveTransaction can also change ourOuts, ourIns, and ourAmount when a new scriptPubkey is discovered for an already-known transaction. During initial sync, when setLookAhead discovers a new address that is an output of an existing confirmed transaction, the merged edgeTx (with updated nativeAmount) is saved to the DataLayer but never emitted to core-js. The transaction list then displays a stale nativeAmount indefinitely for that transaction.

Fix in Cursor Fix in Web

}

return super.emit(EngineEvent.ADDRESSES_CHECKED, progressRatio)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throttled progress ratios pollute dedup cache prematurely

Low Severity

emitAddressesChecked sets lastAddressesCheckedRatio before the global 500ms throttle check. When the throttle suppresses an emission, the dedup cache records a value that was never actually delivered to listeners. This makes the "last-value tracking" semantically incorrect — it tracks the last input rather than the last emitted value. In the current single-call-site usage with monotonically increasing ratios, this has no observable effect, but it introduces a latent correctness issue if the method is ever reused elsewhere.

Fix in Cursor Fix in Web

@cursor
Copy link

cursor bot commented Feb 25, 2026

Bugbot Autofix prepared fixes for 2 of the 2 bugs found in the latest run.

  • ✅ Fixed: Transaction emission skipped when ourAmount changes but blockHeight unchanged
    • Added check for existingTx.ourAmount !== processedTx.ourAmount to the emission guard so transactions are emitted when ourAmount changes due to discovering new scriptPubkeys.
  • ✅ Fixed: Throttled progress ratios pollute dedup cache prematurely
    • Moved the lastAddressesCheckedRatio assignment to after the throttle check so the dedup cache only tracks values that were actually emitted.

Create PR

Or push these changes by commenting:

@cursor push 22783161e3
Preview (22783161e3)
diff --git a/src/common/plugin/EngineEmitter.ts b/src/common/plugin/EngineEmitter.ts
--- a/src/common/plugin/EngineEmitter.ts
+++ b/src/common/plugin/EngineEmitter.ts
@@ -121,7 +121,6 @@
 
   emitAddressesChecked(progressRatio: number): boolean {
     if (this.lastAddressesCheckedRatio === progressRatio) return false
-    this.lastAddressesCheckedRatio = progressRatio
 
     if (progressRatio !== 1) {
       const now = Date.now()
@@ -129,6 +128,7 @@
       acLastEmitTime = now
     }
 
+    this.lastAddressesCheckedRatio = progressRatio
     return super.emit(EngineEvent.ADDRESSES_CHECKED, progressRatio)
   }
 }

diff --git a/src/common/utxobased/engine/UtxoEngineProcessor.ts b/src/common/utxobased/engine/UtxoEngineProcessor.ts
--- a/src/common/utxobased/engine/UtxoEngineProcessor.ts
+++ b/src/common/utxobased/engine/UtxoEngineProcessor.ts
@@ -1270,8 +1270,12 @@
         // The tx unconfirmed or confirmed after/at the last seenTxCheckpoint
         (tx.blockHeight === 0 || tx.blockHeight > seenTxBlockHeight)
 
-      // Only emit if tx is new or changed (blockHeight changed)
-      if (existingTx == null || existingTx.blockHeight !== tx.blockHeight) {
+      // Only emit if tx is new or changed (blockHeight or ourAmount changed)
+      if (
+        existingTx == null ||
+        existingTx.blockHeight !== tx.blockHeight ||
+        existingTx.ourAmount !== processedTx.ourAmount
+      ) {
         common.emitter.emit(EngineEvent.TRANSACTIONS, [
           { isNew, transaction: edgeTx }
         ])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant