Skip to main content

Retry With Backoff

🎯 Problem​

Some conditions in the browser can't be observed through events or callbacks β€” they can only be detected by polling. This is common when:

  • A third-party component doesn't expose a "ready" state (e.g. Lexical has no lifecycle event for when the editor is fully rendered and stable)
  • A DOM element needs to be present or reach a stable geometry before dependent logic can run (floating toolbars, sidebars, overlays, portals…)
  • An async process outside your control needs to complete before you can proceed

In these situations, a fixed setTimeout is brittle: too short and you race the condition, too long and you degrade UX unnecessarily.

πŸ” Solution: predicate polling with backoff​

Instead of a fixed delay, retryWithBackoff polls a predicate until it returns true stably β€” meaning N consecutive successes β€” with an increasing delay between attempts.

The default backoff sequence follows the Fibonacci series (in ms): [10, 20, 30, 50, 80, 130, 210, 340, 550, 890]. This gives a progressive backoff without hammering on the first attempts.

Key mechanic: requiredSuccesses​

The predicate must return true N consecutive times before the function resolves to true. A single failure resets the streak to zero.

This detects stabilization, not just a transient passing state. For example, when watching element height: we don't validate the first time it stops changing, we validate only once it has remained stable across several consecutive checks.

true, true, true β†’ resolves (requiredSuccesses = 3)
true, true, false, true, true, true β†’ resolves (streak reset on false)

πŸ“¦ API​

retryWithBackoff​

Core function. Takes a predicate and options, returns Promise<boolean>.

retryWithBackoff(predicate, options?): Promise<boolean>
OptionDefaultDescription
backoffSequenceFibonacci (ms)Delay sequence between attempts
baseTimeout10Fallback delay once the sequence is exhausted
maxAttempts15Max number of attempts before giving up
requiredSuccesses3Number of consecutive successes required
signalβ€”AbortSignal to cancel mid-flight
const FIBONACCI_BACKOFF_SEQUENCE = [
10, 20, 30, 50, 80, 130, 210, 340, 550, 890,
];

export interface Logger {
error: (...args: unknown[]) => void;
}

const defaultLogger: Logger = {
error: console.error,
};

export interface RetryWithBackoffOptions {
/** Delay sequence between attempts (ms). Defaults to Fibonacci series. */
backoffSequence?: number[];
/** Fallback delay once the sequence is exhausted (ms). Defaults to 10. */
baseTimeout?: number;
/** Logger for error messages. Defaults to console. */
logger?: Logger;
/** Max number of attempts before giving up. Defaults to 15. */
maxAttempts?: number;
/** Number of consecutive successes required. Defaults to 3. */
requiredSuccesses?: number;
/** AbortSignal to cancel mid-flight. */
signal?: AbortSignal;
}

/**
* Polls a predicate until it returns `true` stably (N consecutive successes)
* with an increasing delay between attempts.
*
* Returns `true` if the predicate stabilized, `false` if it gave up or was aborted.
*/
export async function retryWithBackoff(
predicate: () => Promise<boolean> | boolean,
{
backoffSequence = FIBONACCI_BACKOFF_SEQUENCE,
baseTimeout = 10,
logger = defaultLogger,
maxAttempts = 15,
requiredSuccesses = 3,
signal,
}: RetryWithBackoffOptions = {},
): Promise<boolean> {
let successStreak = 0;
let attempt = 0;
let index = 0;

while (attempt < maxAttempts) {
if (signal?.aborted) {
logger.error("Retry aborted");
return false;
}

attempt++;

const ok = await Promise.resolve(predicate());

if (ok) {
successStreak++;
if (successStreak >= requiredSuccesses) {
return true;
}
index = Math.min(index + 1, backoffSequence.length - 1);
} else {
successStreak = 0;
index = 0;
}

const timeout = backoffSequence[index] ?? baseTimeout;

await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, timeout);
if (signal) {
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new Error("Aborted"));
},
{ once: true },
);
}
}).catch(() => {
return false;
});
}

logger.error("Max attempts reached in retryWithBackoff");

return false;
}

πŸ–ΌοΈ Usage examples​

waitForElement​

Convenience wrapper to wait for a DOM element to appear by its id.

waitForElement(elementId, options?): Promise<boolean>

When an element is injected asynchronously into the DOM (e.g. a portal, a dynamically loaded widget), waitForElement provides a clean way to wait before interacting with it.

const ready = await waitForElement('my-portal-root', { maxAttempts: 20 });
if (ready) {
// safe to interact with the element
}

waitForStableElementPosition​

Convenience wrapper to wait for an element's position to stabilize (variation < 1px across N consecutive checks).

waitForStableElementPosition(element, options?): Promise<boolean>

Detecting that the Lexical editor is ready (React)​

Lexical doesn't expose a readiness state. The strategy here was to watch the height of the editor's anchorElement: as long as it varies between checks, the editor is still building its layout.

export function ActiveEditorPlugin() {
const [editor] = useLexicalComposerContext();

const { anchorElement } = useEditorAnchorElement(editor);
const { setStatus } = useEditorContext();

useEffect(() => {
if (!anchorElement) return;

let editorHeight: number | null = null;
const controller = new AbortController();

const checkForEditorReadiness = () =>
retryWithBackoff(
() => {
const rect = anchorElement.getBoundingClientRect();
const currentHeight = rect.height;

if (editorHeight !== null && Math.abs(currentHeight - editorHeight) < 1) {
return true;
}

editorHeight = currentHeight;
return false;
},
// These values have been chosen empirically to ensure the editor is ready in most cases, they might need adjustments in the future
{ requiredSuccesses: 8, maxAttempts: 100, signal: controller.signal },
);

const runCheck = async () => {
await checkForEditorReadiness();
setStatus(EditorStatus.READY);
};

runCheck();

return () => controller.abort();
}, [anchorElement]);

return null;
}

🧹 Cleanup with AbortController​

Always pass a signal when calling from a React useEffect. If the component unmounts before the check completes, the signal aborts the retry loop cleanly β€” no state updates on an unmounted component.

const controller = new AbortController();

retryWithBackoff(predicate, { signal: controller.signal });

return () => controller.abort();

⚠️ Known limitations​

  • Stability detection is heuristic-based. A condition that appears stable but isn't fully settled (e.g. lazy images still loading, web fonts not yet applied) may pass the check prematurely.
  • maxAttempts: 100 with the Fibonacci sequence represents roughly 3–4 seconds of total wait time. If the predicate never stabilizes within that window, the function returns false β€” the caller is responsible for handling that case in the UI.