A user selects EURUSD, then immediately switches to GBPUSD. The second request finishes first. The widget displays GBPUSD correctly until the slower EURUSD response arrives and replaces it.
Both requests can succeed. The bug is that the screen accepts a result after the user has stopped asking for it.
A reliable selector needs a simple rule: only the latest selection may update the widget. Use an AbortController to stop unnecessary fetch work where possible, and a request counter to decide which completion still owns the screen.
Two safeguards with different jobs
Passing an abort signal to fetch() lets the browser cancel the request or response-body consumption. Calling abort() normally causes the affected operation to reject with AbortError. See MDN's cancellation reference.
Cancellation does not undo work already performed by your server. Treating it as a transaction rollback would be a separate bug. A price lookup should be a read operation, and its server may still finish processing after the browser loses interest.
The request counter protects the interface even when an asynchronous adapter ignores cancellation, or when additional processing cannot be interrupted. Increment it for every selection, including an empty selection and a refresh of the same symbol.
Comparing symbol strings alone is insufficient. If someone selects EURUSD, switches away, and returns to EURUSD, the first request still belongs to an older interaction.
Give each request ownership of its state
Save this function as devto-price-loader.mjs. It accepts a price-fetching function and a synchronous renderer. The small response contract, { price: number }, belongs to this example.
export function createPriceLoader(fetchPrice, render) {
let latest = 0;
let active = null;
return async function selectPrice(symbol) {
const id = ++latest;
active?.abort();
active = null;
if (!symbol) {
render({ status: "idle", symbol: "" });
return;
}
const controller = new AbortController();
active = controller;
render({ status: "loading", symbol });
try {
const data = await fetchPrice(symbol, controller.signal);
if (id !== latest) return;
if (!data || !Number.isFinite(data.price)) {
throw new Error("Invalid price");
}
render({ status: "ready", symbol, price: data.price });
} catch (error) {
if (id !== latest) return;
render({
status: "error",
symbol,
error: error?.name === "AbortError"
? "Request cancelled."
: "Price unavailable. Try again.",
});
} finally {
if (id === latest) active = null;
}
};
}
The counter changes before the previous controller is aborted. Any resulting rejection therefore belongs to an obsolete request.
After the awaited work finishes, the success path checks ownership before validating or rendering the result. The error path makes the same check. Otherwise, an old network error could replace a newer successful price with a failure message.
The finally block is guarded too. An old completion must not erase the reference to the current controller. If it did, the next selection could no longer abort that current request. Loading is represented by the rendered state, so there is no separate unguarded loading = false operation.
Notice that a current AbortError produces an explicit message. Silently ignoring every abort could leave the widget showing “Loading” indefinitely. Superseded requests are ignored by their request ID, regardless of their error type.
Reproduce the race without a data subscription
Save this page beside the module. Serve the folder with a local static server, such as python3 -m http.server 8000, and open the page through localhost.
The numbers and delays below are invented teaching fixtures. They are not live prices, historical observations, or a provider payload. The fixture deliberately ignores its signal so that cancellation alone cannot make the demonstration pass.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Price request race</title>
<label for="symbol">Illustrative pair</label>
<select id="symbol">
<option value="">Choose a pair</option>
<option>EURUSD</option>
<option>GBPUSD</option>
</select>
<p id="output" role="status">Choose a pair.</p>
<script type="module">
import { createPriceLoader } from "./devto-price-loader.mjs";
const fixtures = {
EURUSD: { price: 1.08, delay: 900 },
GBPUSD: { price: 1.27, delay: 100 },
};
// Invented values. Intentionally ignores the signal for this demo.
function fixturePrice(symbol, signal) {
return new Promise((resolve) => {
const row = fixtures[symbol];
setTimeout(() => resolve({ price: row.price }), row.delay);
});
}
const output = document.querySelector("#output");
const selectPrice = createPriceLoader(fixturePrice, (state) => {
output.textContent =
state.status === "idle" ? "Choose a pair." :
state.status === "loading" ? "Loading " + state.symbol + "..." :
state.status === "error" ? state.symbol + ": " + state.error :
state.symbol + ": " + state.price;
});
document.querySelector("#symbol").addEventListener("change", (event) => {
void selectPrice(event.target.value);
});
</script>
</html>
Choose EURUSD, then GBPUSD within 900 milliseconds. GBPUSD should remain visible when the slower fixture completes. Choose the blank option while a request is pending: the page should stay empty after that request finishes.
The renderer writes text with textContent, so a label is treated as text rather than HTML. It replaces the whole message on each state change, avoiding a previous pair's number lingering beneath the newly selected pair.
Connect your own server adapter
For a real integration, replace fixturePrice with a function that calls your own server. This route is an illustrative application route that you would implement. It is not a SiftingIO endpoint.
async function fetchServerPrice(symbol, signal, fetchImpl = fetch) {
const response = await fetchImpl(
"/api/price?symbol=" + encodeURIComponent(symbol),
{ signal }
);
if (!response.ok) throw new Error("HTTP " + response.status);
return response.json();
}
Add the function above the selectPrice initialization and pass fetchServerPrice instead of fixturePrice.
Check response.ok: an HTTP error response does not automatically reject a fetch promise. Parsing the body is also asynchronous, so the ownership check must happen after the adapter has completed that work. MDN's Fetch guide covers both behaviours.
Your server adapter should map the provider response to the widget's contract and retain the provider API key on the server. If you use SiftingIO, take its current route, authentication and response fields from the public documentation, rather than copying the illustrative route above.
A finite numeric price is only a basic shape check. A real adapter also needs the relevant instrument identity, quotation units and observation time. Those concerns do not disappear because the request order is correct.
Test completion order, not network luck
Slow network throttling can reveal the bug, but it is an unreliable regression test. A controlled promise lets a test decide exactly which request resolves or rejects first.
| Case | Required result |
|---|---|
| Old success arrives after new success | Keep the new price |
| Old error arrives after new success | Keep the new price without an error |
| Old request finishes while new one loads | Keep the new loading state and controller |
| Selection is cleared during a request | Stay in the empty state |
| Same symbol is requested twice | Accept only the newer request |
| Current request fails | Show an error and allow a retry |
Here is a minimal regression test. Save it as price-loader.test.mjs beside the module and run node --test price-loader.test.mjs. Its fake adapter deliberately ignores cancellation.
import test from "node:test";
import assert from "node:assert/strict";
import { createPriceLoader } from "./devto-price-loader.mjs";
test("an older response cannot replace the current pair", async () => {
const complete = [];
const states = [];
const select = createPriceLoader(
() => new Promise((resolve) => complete.push(resolve)),
(state) => states.push(state)
);
const oldRequest = select("EURUSD");
const newRequest = select("GBPUSD");
complete[1]({ price: 1.27 });
await newRequest;
complete[0]({ price: 1.08 });
await oldRequest;
assert.deepEqual(states.at(-1), {
status: "ready", symbol: "GBPUSD", price: 1.27,
});
});
Both requests finish, but the result still belongs to GBPUSD. Extend this test with the error and empty-selection cases in the table, rather than depending on a server to respond slowly enough to reproduce them.
This pattern controls which user request may update the widget. It does not establish whether the returned market observation is fresh, or coordinate several independent widgets. Give each widget its own loader, and handle observation freshness separately from request ownership.
I work on SiftingIO. This tutorial's fixture and application route are independent examples, not a claim about a deployed SiftingIO interface.
Top comments (1)
good point that comparing symbol strings isn't enough since switching away and back to the same pair still leaves the old request stale. the counter plus abort combo is a neat way to keep it simple, i've been bitten by this exact bug in a little price tracker.