Two hundred people opening the same watchlist do not necessarily need two hundred requests for the same price. A server-side cache can reuse a recent response; request coalescing lets simultaneous requests share one fetch. If you need data for your own implementation, create a SiftingIO account and get an API key, then start with the Forex API and API documentation.
The short answer: cache identical, authorized requests for a short, explicit lifetime; combine simultaneous misses; preserve the original data timestamp. Cache age tells you when you fetched a response. It does not tell you when its price was produced.
This guide builds a small, testable Node.js cache. It is an application component, not a SiftingIO endpoint, and the examples use synthetic data without a key or a network call.
What should a market data API cache actually reuse?#
A cache should reuse a result only when the next caller is allowed to receive the same result. A EURUSD snapshot and a EURUSD historical-bar request are not interchangeable. Neither are two responses obtained under different customer credentials.
| Data being requested | Cache decision | What must stay separate |
|---|---|---|
| Latest price or quote | Use a short lifetime that fits the display's update budget | Product, symbol, fields and access scope |
| The currently forming OHLCV bar | Refresh while the bar can still change | Interval, session and adjustment settings |
| Older historical bars | A longer lifetime may be appropriate, with a correction/revalidation policy | Date range, interval, pagination and dataset version |
| Account usage or permissions | Keep separate from shared market-data results | Account identity; revocations must still take effect |
There is no universal best TTL, or time to live. A one-second cache in the code below is an example setting, not a claim about a provider's update rate or a recommendation for every application. If the display needs every delivered update, consider a server-side stream rather than lengthening a snapshot cache to save requests.
Caching is an engineering choice, not permission to redistribute data. Use it within the storage, display and distribution rights of your agreement.
Why a TTL alone does not stop a cache stampede#
A cache stampede happens when many requests discover the same missing or expired entry and all fetch it again. The response is not in the cache until the first fetch finishes.
Imagine 200 requests arriving together for the same EURUSD snapshot. Each sees an empty cache. A basic "check, fetch, store" implementation can make 200 upstream calls, even with a one-second TTL.
Request coalescing, also called single-flight, adds a second lookup: is someone already fetching this exact result? If so, wait for that operation instead of starting another.
Here is a deterministic illustration. There are three waves of 200 callers at simulated times 0, 500 and 1,000 milliseconds. Each wave arrives before its fetches complete; the test clock stays fixed during each wave. The TTL is 1,000 milliseconds and expires at the boundary.
| Implementation | Wave at 0 ms | Wave at 500 ms | Wave at 1,000 ms | Total upstream calls |
|---|---|---|---|---|
| No cache | 200 | 200 | 200 | 600 |
| TTL cache without coalescing | 200 | 0 | 200 | 400 |
| TTL cache with coalescing | 1 | 0 | 1 | 2 |
These are counts from a controlled example, not a production savings forecast. Different symbols, access scopes, arrival times and server instances change the result. For a broader traffic budget, use the market data API request calculator guide.
Build a bounded, single-flight cache in Node.js#
Save this as snapshot-cache.mjs and run it on a supported Node.js LTS release. The allowed keys are a small, server-configured set: arbitrary browser input cannot grow the cache indefinitely.
The loader must return a validated, structured-cloneable value or throw. In a real integration it must also enforce a request timeout, check the HTTP status and validate the response schema. This helper does not perform those HTTP tasks.
import { performance } from "node:perf_hooks";
export function createSnapshotCache({
load, allowedKeys, ttlMs = 1000,
now = () => performance.now(),
}) {
if (typeof load !== "function" ||
!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new TypeError("A loader and a positive finite TTL are required");
}
const allowed = new Set(allowedKeys);
if (!allowed.size || [...allowed].some(k => typeof k !== "string")) {
throw new TypeError("Provide a bounded set of string keys");
}
const cache = new Map();
const inFlight = new Map();
return async function read(key) {
if (!allowed.has(key)) throw new Error("Unknown cache key");
const hit = cache.get(key);
if (hit && now() - hit.savedAt < ttlMs) {
return structuredClone(hit.value);
}
cache.delete(key);
let pending = inFlight.get(key);
if (!pending) {
pending = Promise.resolve()
.then(() => load(key))
.then(value => {
const snapshot = structuredClone(value);
cache.set(key, { value: snapshot, savedAt: now() });
return snapshot;
})
.finally(() => inFlight.delete(key));
inFlight.set(key, pending);
}
return structuredClone(await pending);
};
}
The elapsed-time clock comes from Node's performance API. It is suitable for measuring a local duration; it is not an epoch timestamp to compare with a price's date.
The TTL begins after the loader finishes. Reads do not extend it. Failed loads are not cached, and finally removes the in-flight entry after success or failure, so a failed promise cannot remain stuck in the lookup. MDN documents this cleanup behavior.
Each caller receives a separate copy. Otherwise, one caller changing result.price could silently change the cached object for everyone else. Keep the allowed set and response sizes bounded; cloning a large historical dataset on every read is not the intended use of this helper.
Here is a runnable demonstration. Save it as demo.mjs beside the first file, then run node demo.mjs:
import { createSnapshotCache } from "./snapshot-cache.mjs";
// Application-owned key and fields, not an API response schema.
const key = JSON.stringify(["account-demo", "fx-snapshot", "EURUSD", "v1"]);
let calls = 0;
const read = createSnapshotCache({
allowedKeys: [key],
load: async () => {
calls++;
return { price: "1.0800", sourceTimeMs: 0 }; // synthetic fixture
},
});
const results = await Promise.all(Array.from({ length: 200 }, () => read(key)));
console.log(calls, results.length); // 1 200
The fixture timestamp of zero is deliberately not a live price. The cache preserves it unchanged. A production freshness check must reject or clearly label that value rather than present it as current.
Keep cache age separate from price age#
Suppose a response contains a price timestamp of 12:00:00. Your server receives it at 12:00:08 and serves it at 12:00:08.4. Assuming synchronized clocks, its cache age is 0.4 seconds but its data age is 8.4 seconds. Re-fetching the same value does not reset the latter.
A fresh cache entry therefore needs a separate suitability check before display or calculation:
- Preserve the provider's original timestamp and its documented meaning.
- After reading from the cache, calculate data age against a synchronized wall clock in matching units.
- Apply the application's session-aware freshness policy on every read, including cache hits.
- If it fails, show an unavailable or explicitly last-known state; do not stamp the old price with the current time.
The helper enforces only cache lifetime. It does not decide whether a price is fresh, validate clock synchronization or implement a market calendar. Those are separate controls. Our stale market data guide discusses freshness detection; this article addresses the cache that sits between the fetch and that check.
Cache keys, permissions and failure behavior#
Build keys from a server-controlled request description. Include the upstream account or tenant scope, product, instrument, relevant parameters and response version. Use an internal scope identifier, never the API secret itself. A key named only EURUSD is insufficient once different users can receive different data.
Authenticate and authorize before every cache lookup, not just before an upstream call. When permissions change, deny the request immediately; use a new scope version or invalidate affected entries if the permitted response also changes. The server-side API key guide covers that route-level boundary.
| Situation | Behavior in this helper | Application responsibility |
|---|---|---|
| Matching requests arrive together | They share one pending load | Check access for each caller first |
| Entry reaches its TTL | Wait for a new result | Do not label an old result as live while waiting |
| Loader throws or rejects | All waiting callers receive the failure; no failed result is cached | Return a clear error; apply bounded retry/backoff |
| Loader never settles | Its key remains in flight | Enforce the timeout inside the loader |
| One caller mutates its result | Other callers and the stored copy stay unchanged | Keep response sizes small |
Coalescing is not rate limiting. After a failure, a later request can try again immediately. Add a separate retry policy for sustained failures; the HTTP 429 guide explains how to avoid a retry storm. A cancelled browser request should not cancel a shared upstream load that other callers still need.
What changes with multiple servers or a CDN?#
This cache belongs to one function instance inside one process. Create it once and reuse it; creating it inside each HTTP request removes the benefit. Five application workers have five independent caches and can issue five upstream loads for one simultaneous miss wave. Serverless instances do not share their JavaScript maps either.
If that duplication matters, use a shared cache with a deliberate cross-instance coordination strategy, or a single ingestion service that distributes updates. Shared storage alone does not combine simultaneous misses. For streaming applications, the WebSocket fan-out guide covers the distribution pattern.
Application caching is also separate from browser and CDN caching. For an authenticated quote route that should not be stored by HTTP caches, return Cache-Control: no-store. This does not disable the explicit server-side map above. no-cache means a stored HTTP response must be validated before reuse; it does not mean "never store." See MDN's Cache-Control reference. Do not enable public CDN caching for account-specific responses just to improve hit rate.
The code and three-wave table were tested locally with synthetic values and a controlled clock. Checks covered simultaneous misses, TTL boundaries, independent keys, failed-load recovery, preserved timestamps and caller mutation. They do not measure the live API's latency or validate your application's access controls.
Start with one instrument and measure cache hits, shared in-flight waits, upstream calls and data age separately. Get your SiftingIO API key, connect the loader using the documentation, and add the cache after the first uncached request works. That gives you a baseline to compare against instead of hiding integration errors behind a cached success.



