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>
| Option | Default | Description |
|---|---|---|
backoffSequence | Fibonacci (ms) | Delay sequence between attempts |
baseTimeout | 10 | Fallback delay once the sequence is exhausted |
maxAttempts | 15 | Max number of attempts before giving up |
requiredSuccesses | 3 | Number 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: 100with the Fibonacci sequence represents roughly 3β4 seconds of total wait time. If the predicate never stabilizes within that window, the function returnsfalseβ the caller is responsible for handling that case in the UI.