Delayed vs live stock prices look identical on screen. Both render as a number next to a ticker, maybe with a green or red arrow. Nothing in the digits tells the user whether the AAPL price in front of them describes the market now or the market as it stood some minutes ago. The difference lives in the timestamp, in the state of your connection, and in what the market itself was doing at the time.
This post is about three decisions: which features in your app are fine with a delayed price, which ones need a live one, and how to say on screen which of the two the user is looking at. Detection mechanics, heartbeats, and reconnect logic are covered in the stale market data guide; this post links there rather than repeating it.
What the timestamp actually tells you#
Start with what the value is. A SiftingIO stock price is a reference price: one value per symbol, formed from multiple independent venues, published as a snapshot over REST or a tick over WebSocket. It is not a raw last-sale print from any single exchange, and the timestamp on it is not the time of a trade.
The REST snapshot returns the latest top-of-book quote or the latest reference trade value for a symbol:
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/last/quote/stocks/AAPL"
# {"s":"AAPL","b":"331.3859","B":"302","a":"331.4522","A":"302","t":1789478287119}
The t field is an int64 Unix epoch in milliseconds. It is the engine's timestamp for the value it published, so the age you compute from it answers one question only: how long ago SiftingIO last published a value for this symbol. That is a useful number, but it is not "how long ago a trade happened", and it does not tell you why the value is the age it is.
The WebSocket tick frame carries the same fields. Connect with the key in the query string, subscribe to the US equities product, and branch on the f discriminator:
const ws = new WebSocket(`wss://stream.sifting.io/ws/v1?key=${process.env.SIFTING_KEY}`);
ws.onopen = () => {
ws.send(JSON.stringify({ op: 'subscribe', product: 'us', symbols: ['AAPL', 'MSFT'] }));
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.f === 'tick') feed.record(msg.s, { price: msg.p, bid: msg.b, ask: msg.a, t: msg.t, receivedAt: Date.now() });
if (msg.f === 'pong') feed.heartbeat();
};
On subscribe the server first sends one snapshot tick per channel, the last known value from its cache, before the live stream begins. Its shape is identical to a live frame. Outside market hours that first value can be hours old, which matters for the label logic below.
Four things that look like "old" and are not the same#
A price that has not changed for a while can mean any of four things, and the age of the timestamp cannot tell them apart:
- Intentional delay. Some data agreements deliver prices with a fixed lag by design. Whether your feed is delayed is a property of the feed you signed up for, not something to infer from a number that happens to look 15 minutes old. Know it from the source and disclose it as such.
- Stale data. The market is moving but no new value is arriving for this symbol. This is the case the stale-data guide is about, and it needs the comparison to a related symbol or a market-wide signal to establish.
- An unchanged price. Nothing traded, or nothing traded at a different price. Quiet is not stale. A thinly traded symbol in the last hour of the session can legitimately sit still.
- Interrupted delivery. Your connection dropped, a proxy stalled, or the upstream stopped sending. The last value on screen is old because nothing can reach you, not because the market is quiet.
The things that do separate these cases are cheap to check: whether messages are still arriving at all (heartbeats), whether the market is in a session where trading is expected, whether related symbols are moving, and what your plan says about delay. The label on screen should be built from those inputs together, never from age alone.
When delayed data is enough#
The honest answer to "does this need a live feed" is per feature, not per app. A number the user reads for orientation tolerates a lag as long as the lag is visible. A number the user is about to act on does not.
Delayed is enough for a portfolio balance on the home screen, a watchlist, a daily performance chart, an end-of-day summary, and most reporting. In each case the question is rough and "as of 2:47 PM" next to the value sets the expectation correctly.
Live matters for an order preview that estimates cost before the user hands off to their broker, a price alert, unrealized profit and loss on a position opened in the last hour, and any "movers" list ranked by percentage change. An order preview built on a lagging price is the worst of these: the user accepts an estimate and the fill lands somewhere else. Price alerts fail more quietly. A lagging feed fires the alert after the threshold was crossed, by which time the price may have reversed, and the notification looks wrong even though the logic was right.
A workable rule: if the user will act on the number within the next minute, it needs a fresh value and an honest freshness label, or it needs to be called an estimate. Everything else can lag as long as the timestamp is on screen. Live in this sense still means a reference price. The app shows what the market looks like, and execution happens elsewhere.
Measuring freshness without fooling yourself#
Two mistakes make a freshness display lie. The first is trusting the client clock. A phone with a wrong clock produces a wrong age, and a negative age is the symptom. Clamping it to zero and printing "Live, 0s ago" hides the fault instead of reporting it. The second is updating the age only when a message arrives. A stalled stream then keeps showing the age it had at the last frame, and the live indicator stays on for a feed that has stopped.
The fix for both is to anchor age to the time the value was received, treat an implausible timestamp as a distinct state, and recompute on a timer:
const MAX_SKEW_MS = 5_000; // beyond this the client clock is not trusted
const HEARTBEAT_WINDOW_MS = 45_000; // longest silence before the stream is stalled
function freshness(rec, { marketOpen, now = Date.now() }) {
if (!rec) return { state: 'none' };
if (!Number.isFinite(rec.t) || rec.t < 1e12 || rec.t > now + 1e10) return { state: 'invalid_timestamp' };
const skewMs = rec.receivedAt - rec.t; // publish-to-receive gap as seen by this client
if (skewMs < -MAX_SKEW_MS) return { state: 'clock_uncertain', skewMs };
const ageMs = (now - rec.receivedAt) + Math.max(0, skewMs);
if (now - feed.lastMessageAt() > HEARTBEAT_WINDOW_MS) return { state: 'stalled', ageMs };
if (!marketOpen) return { state: 'closed', ageMs };
return { state: 'flowing', ageMs };
}
setInterval(() => render(freshness(feed.latest('AAPL'), { marketOpen: market.isOpen() })), 1000);
Three details carry the weight. The age is the time since the value was received plus the publish-to-receive gap, so a wrong client clock cannot shrink it below zero. A timestamp that is not a finite millisecond value, or that sits far in the future, becomes its own state instead of a number. And the render runs every second whether or not a frame arrived, so a stalled stream is reported within the heartbeat window rather than never.
The market state comes from the market hours endpoint, which reports the open or closed state and the next transition:
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/fnd/markets/us_equities/status"
# {"data":{"market":"us_equities","is_open":false,"state":"closed","next_open":"2026-09-15T13:30:00Z", ...}}
The weekly hours endpoint for the same market lists regular, pre-market, and post-market sessions separately, and the status above reported "closed" during the pre-market window, so "closed" refers to the regular session. The status does not say whether the last published value came from the regular session or an extended one, so a closed state is a reason to soften the label, not a proof that the value on screen is the day's close.
Communicating the difference#
The label has more than two states, and the wording should say what is known rather than what is hoped:
- Flowing during the regular session, small age: "Live, updated 3s ago". This is the only state that lights the live indicator.
- Flowing, unchanged for a while, delivery healthy: "Last update 2:47 PM, no change since". Quiet is not stale, and the user should not be told it is.
- Stalled: "Connection stalled, last value 2:47 PM". Keep the old number visible with its time; a blank field reads as a broken app.
- Clock uncertain or invalid timestamp: "Freshness unknown", with the last value shown and the live indicator off.
- Regular session closed: "Last published 4:03 PM, market closed". Do not write "At close". If the screen needs the closing price, ask for it explicitly with the previous daily close endpoint, which returns the close and the trading date it belongs to, rather than reusing whatever value was last published.
- Feed delivered with an intentional lag: say so in a fixed line such as "Prices delayed by 15 minutes", separate from the freshness label. Delay is a property of the feed; freshness is a property of the moment.
Three rules keep the label honest. The word for the state appears in text, not only as a colour, because colour alone is invisible to many users. The absolute time carries the user's zone abbreviation, since a bare "2:47 PM" is the most common way an as-of label misleads. And any number the user will act on carries the word "estimate" unless the state is flowing and the age is small. If bid and ask are on the same screen, the wide or stale spread check belongs in the same render path, and the REST vs WebSocket comparison covers which transport suits which screen.
Common pitfalls#
The first WebSocket frame is a cached snapshot. A client that lights the live indicator on the first tick shows a possibly hours-old number as live. Run the freshness function on every frame, including the first, and never light the indicator from a connection event.
Seconds versus milliseconds. The t field is milliseconds. Passed into a function that expects seconds, the age comes out at tens of thousands of years, and a Date built from a seconds value lands in January 1970. Convert once at the boundary where data enters the app, and assert the unit in a test.
Treating a closed-market value as the close. The last published value before the status flipped to closed may come from the regular session or from after it, and the status endpoint does not say which. Label it by its time, and fetch the previous daily close when the screen actually means the close.


