DEV Community

Kane
Kane

Posted on

StableRoute Frontend: Resilient Polling with Adaptive Exponential Backoff

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

StableRoute Frontend is the web interface for StableRoute, an open-source decentralized liquidity routing protocol built on the Stellar network. The application is built with Next.js (App Router), React, and TypeScript.
The platform provides a comprehensive suite of tools for traders, liquidity providers, and developers:
Payment and Liquidity Routing: An interactive interface for querying optimal swap and cross-currency path routing quotes across Stellar asset pools.
Liquidity Pool Management: Workflows to register, inspect, and configure supported asset pairs.
Real-Time Protocol Monitoring: A live status dashboard that polls protocol health, operational state (active vs. paused), and liquidity pool counts.
Developer and Infrastructure Controls: Management consoles for generating and revoking API keys, registering webhook event subscribers, viewing immutable system audit logs, and inspecting OpenAPI documentation.

Bug Fix or Performance Improvement

The Bug

In src/app/stats/Client.tsx, dashboard statistics were updated using a raw setInterval:useEffect(() => {
const id = setInterval(refetch, POLL_MS);
return () => clearInterval(id);
}, [refetch]);

This caused three critical issues:

  1. Overlapping In-Flight Requests (Race Conditions) setInterval fired strictly on a 5-second timer regardless of how long the network request took. If a request stalled or took 6 seconds due to network lag, a second request was fired before the first one finished. This created stacked concurrent requests where an older, delayed response could overwrite newer data in React state.
  2. Backend Hammering During Outages (Thundering Herd) When the backend experienced downtime, high load, or returned errors (500s/429s), the frontend never backed off. It continued hammering the failing endpoint every 5 seconds unabated, causing rate limits and thrashing the UI with re-rendering error alerts.
  3. Timer Churn on Re-render Because refetch was in the dependency array, whenever parent component updates created a new function reference, the old interval was cleared and a new one started from scratch, disrupting regular polling cadence.

How I Fixed It

I replaced setInterval with a custom, lifecycle-aware scheduling hook called useBackoffInterval:

  1. Settled-State Scheduling Instead of a blind clock, the hook waits for the request to settle into either 'success' or 'error' before scheduling the next timer. While a request is in the 'loading' state, no timers run, completely eliminating overlapping requests.
  2. Exponential Backoff on Failures
    We track consecutive failures using a ref (failureCountRef). When errors occur, the delay doubles progressively:

        ⎛          failures      ⎞
    

    delay = min ⎝baseMs × 2 ,maxMs⎠

This increases the retry delay from 5s to 10s, 20s, 40s, and caps at 60s, giving backend services breathing room to recover.

  1. Instant Cadence Recovery On the very first successful response (status === 'success'), the failure counter resets to 0 and the polling interval immediately returns to the standard 5-second cadence without requiring user intervention.
  2. Ref-Stabilized Callback We stored the callback in useRef(callback) and synced it on every render (callbackRef.current = callback). This guarantees that the timer always executes the freshest callback closure without tearing down and restarting the active timer on unrelated UI re-renders.
  3. Clean Unmount Teardown Each scheduled timer returns a cleanup function calling clearTimeout(timeoutId), preventing memory leaks and state updates on unmounted components.

Code

Pull Request / Branch: https://github.com/comzzy-comzzy/Stableroute-frontend/tree/performance/stats-polling-backoff
Target Repository: https://github.com/StableRoute-Org/Stableroute-frontend
Commit: 786106d62e4697aa4a9b536ab75bb320101edd9b (perf(stats): back off polling interval after repeated failures)

// Custom hook in src/app/stats/Client.tsx

export function useBackoffInterval(
status: PollStatus,
callback: () => void,
options: BackoffIntervalOptions = {}
): void {
const {
baseMs = POLL_MS, // 5,000ms
maxMs = MAX_POLL_MS, // 60,000ms
schedule = scheduleTimeout,
cancel = cancelTimeout,
} = options;

const callbackRef = useRef(callback);
const failureCountRef = useRef(0);
callbackRef.current = callback;

useEffect(() => {
// Only schedule next tick after a request settles
if (status !== 'success' && status !== 'error') return;

if (status === 'success') {
  failureCountRef.current = 0;
} else {
  failureCountRef.current += 1;
}

const delayMs =
  status === 'success'
    ? baseMs
    : Math.min(baseMs * 2 ** failureCountRef.current, maxMs);

const timeoutId = schedule(() => callbackRef.current(), delayMs);
return () => cancel(timeoutId);
Enter fullscreen mode Exit fullscreen mode

}, [baseMs, cancel, maxMs, schedule, status]);
}

My Improvements

  1. Custom useBackoffInterval Hook: Instead of hacking state inside the UI component, I decoupled retry scheduling into a reusable, lifecycle-aware hook.
  2. Settled-State Gating: Scheduling only runs when the request status reaches 'success' or 'error'. While the status is 'idle' or 'loading', no timers are scheduled, strictly preventing concurrent in-flight requests.
  3. Deterministic Exponential Backoff with Upper Bound: Consecutive failures increase the delay exponentially (baseMs * 2^failures), progressing from 10s to 20s, 40s, and capping at 60s (maxMs). As soon as an update succeeds, failureCount resets to 0 and normal 5-second polling immediately resumes.
  4. Ref-Stabilized Callback Execution: Storing the refetch callback in a ref (callbackRef.current = callback) prevents timer teardown and re-creation when parent component props change across renders.
  5. Inversion of Control for Deterministic Testing: The hook accepts optional schedule and cancel functions. This allowed writing deterministic unit tests that execute synchronously without flakey time-dependent assertions.
  6. Comprehensive Unit and Integration Test Suite: Added comprehensive test coverage in src/app/stats/page.test.tsx verifying: .Base 5-second cadence on consecutive successful responses. .Delay doubling under network failures up to the 60-second limit. Immediate delay reset to 5 seconds upon recovery. .Prevention of duplicate alert renders during error backoff. .Clean timer cancellation on component unmount.
  7. Contract Documentation: Documented the behavior, parameter options, and lifecycle guarantees in docs/hooks.md.

Best Use of Google AI

Throughout the development, architecture design, debugging, and testing phases of this project and related protocol repositories, I utilized Google Antigravity paired with the Gemini 3.5 Flash (High) model as a primary engineering assistant across multiple tech stacks (React/TypeScript, Rust Soroban, Solidity, and Node.js microservices).

  1. Primary Project: Frontend Exponential Backoff & Lifecycle Safety (Stableroute-frontend) PR / Branch: performance/stats-polling-backoff (Commit 786106d) How Google AI Was Used: Root-Cause Diagnosis: Analyzed client network waterfall logs to isolate why client browsers flooded backend endpoints with requests during API degradation. Gemini 3.5 Flash (High) identified the unmanaged setInterval concurrency hazard. Hook Architecture: Formulated the useBackoffInterval pattern. The model advised storing active callbacks in useRef to prevent timer re-creation on renders while gating execution strictly to settled status states ('success' | 'error'). Deterministic Test Harness: Generated the Jest mock-timer test suite (src/app/stats/page.test.tsx), verifying exact progressive delay doubling (10s, 20s, 40s, 60s cap) and unmount cleanups.

Top comments (0)