Skip to content
Back to Blog
typescriptcachingfinancial-dashboardttlarchitecture

Fresh Enough to Render: How I Encode Market-Data Trust in the Cache Layer

Daniel Anthony Romitelli Jr. · March 29, 2026

When 63 seconds is too old

The dashboard showed a stock price that was 63 seconds old, and a user almost traded on it. The cache had done exactly what I asked of it. It served the value fast. What it couldn't do was notice that the number had already aged out for that asset class, because nothing in it knew that a stock quote at 61 seconds and a news article at 14 minutes are two very different kinds of old. The cache itself needed to know that difference.

So I built the financial dashboard cache around that. Most of what a cache does is avoid repeat calls; this one also carries the freshness contract, the record of whether a piece of market data is still trustworthy enough to render. That decision lives in the TTLs, the key shape, and the read path.

One expiration for everything

The naive version of this problem is easy to picture: one cache, one expiration, one rule. It holds up right until the dashboard starts mixing data with very different lifetimes. A quote wants one cadence, intraday history wants another, and search results can sit around far longer than either. Flatten all of that into a single timeout and you've forced the dashboard to treat unlike data as if it aged the same way.

So I split the cache by data type and made TTL selection part of the write path. The cache layer now does two jobs at once: it stores values, and it encodes the freshness contract for each kind of market data. On a read it either hands the value back immediately or deletes the expired entry and forces a miss. Nothing in between. There's no "maybe stale" state leaking upward into the UI. That lines up with the usual cache-aside flow, check the cache first and repopulate on a miss. AWS describes that pattern this way in its caching guidance.

The shape of that flow is the whole trick. The component never guesses at freshness, and the fetch layer never needs to know which UI state was trying to render. The cache decides. Everything downstream follows.

How the cache is shaped

Two closely related cache patterns ended up in this codebase. The market data cache is specialized for market data, with typed accessors for each category. The cache module keeps a more general-purpose in-memory cache and a reusable withCache helper.

The market dashboard version is the more interesting one, because it makes the data types explicit. Entries live in a Map<string, CacheEntry<unknown>>, and each one carries the data plus an expiresAt timestamp. The key gets built from a type prefix and the identifying parts of the request.

interface CacheEntry<T> {
  data: T;
  expiresAt: number;
}

const TTL = {
  quote: 60 * 1000,
  intradayHistory: 5 * 60 * 1000,
  dailyHistory: 60 * 60 * 1000,
  news: 15 * 60 * 1000,
  indices: 60 * 1000,
  search: 24 * 60 * 60 * 1000,
};

class StockDataCache {
  private cache = new Map<string, CacheEntry<unknown>>();

  private getKey(type: string, ...parts: string[]): string {
    return `${type}:${parts.join(':')}`;
  }

  private get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return null;
    }
    return entry.data as T;
  }

  private set<T>(key: string, data: T, ttl: number): void {
    this.cache.set(key, { data, expiresAt: Date.now() + ttl });
  }
}

Storing the expiration with the value, rather than in some separate table of metadata, is the part I like. It keeps the read path blunt: timestamp passed, entry gone. Nothing ambiguous left for the dashboard to interpret later.

The general cache in the cache module follows the same instinct with a broader API. It stores data, timestamp, and ttl, and it adds a cleanup() pass that removes expired entries across the map. That file also ships the withCache helper, which reads through the cache first and only invokes the async function on a miss.

interface CacheEntry<T = any> {
  data: T
  timestamp: number
  ttl: number
}

class MemoryCache {
  private cache = new Map<string, CacheEntry>()
  
  set<T>(key: string, data: T, ttlMs: number = 300000): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl: ttlMs
    })
  }
  
  get<T>(key: string): T | null {
    const entry = this.cache.get(key)
    if (!entry) return null
    
    if (Date.now() - entry.timestamp > entry.ttl) {
      this.cache.delete(key)
      return null
    }
    
    return entry.data as T
  }
}

export async function withCache<T>(
  key: string,
  fn: () => Promise<T>,
  ttlMs: number = 300000
): Promise<T> {
  const cached = cache.get<T>(key)
  if (cached !== null) {
    return cached
  }
  
  const result = await fn()
  cache.set(key, result, ttlMs)
  return result
}

Same idea in generic form: trust the cached value while it's still inside its window, otherwise fetch and repopulate. I reach for this pattern when I want the freshness rule sitting right beside the fetch call instead of copied across several routes.

Why the TTLs are different

The TTL table in the stock data cache module is where the product rule becomes visible. Quotes and indices get one minute. Intraday history gets five. Daily history gets an hour, news gets fifteen minutes, and search holds its results for a full day.

Those numbers are a trust model. Nobody picked them to shave milliseconds. I wanted each category to age at whatever pace matched the dashboard's behavior. A quote shouldn't linger long enough to mislead the user. A search result doesn't need to churn every time someone types the same ticker again. The cache is the policy boundary.

And the TTL is chosen when the value is written, which is the part that matters. For history, the code checks the timeframe and assigns either the intraday or the daily TTL. For quotes, news, indices, and search, each setter applies its own fixed window.

getQuote<T>(ticker: string): T | null {
  return this.get<T>(this.getKey('quote', ticker));
}

setQuote<T>(ticker: string, data: T): void {
  this.set(this.getKey('quote', ticker), data, TTL.quote);
}

getHistory<T>(ticker: string, timeframe: string): T | null {
  return this.get<T>(this.getKey('history', ticker, timeframe));
}

setHistory<T>(ticker: string, timeframe: string, data: T): void {
  const ttl = timeframe === '1D' ? TTL.intradayHistory : TTL.dailyHistory;
  this.set(this.getKey('history', ticker, timeframe), data, ttl);
}

getNews<T>(ticker: string): T | null {
  return this.get<T>(this.getKey('news', ticker));
}

setNews<T>(ticker: string, data: T): void {
  this.set(this.getKey('news', ticker), data, TTL.news);
}

The split inside setHistory is the non-obvious bit. I didn't want every history request aging the same way, since the dashboard can ask for short-lived intraday data and longer-lived daily data in the same breath. The timeframe carries enough meaning on its own to justify the TTL decision.

What the dashboard actually asks

The dashboard never asks "Is this cached?" It asks whether the cached value is still inside the lifetime I assigned to that kind of market data. Better question. It binds the UI to the freshness rule instead of to the storage mechanism.

In the stock data cache module, get<T> computes the answer. A missing key returns null. An expired timestamp deletes the entry and also returns null. Only live entries make it back to the caller.

Which means the UI can treat null as a clean miss and go upstream, with no second branch for "stale but maybe acceptable." Stale data is a business decision, not a rendering detail. Once the cache calls a value dead, nothing else in the dashboard has to relitigate it.

The general cache in the cache module behaves the same way, with cleanup() as an extra maintenance pass for when I want to sweep the map. The read path is still the authority on freshness, and that's the part I trust most, because it's evaluated at the moment of use.

The timeline that makes the rule visible

A cache like this reads better as a timeline than as a table. Three states in a row: fresh, stale, refreshed. A value starts fresh after a write, goes stale when its TTL passes, then gets replaced by a new fetch when the next read misses.

write ───── fresh window ───── expiry ───── miss → upstream fetch → refreshed write

That one line is the operational contract. The cache doesn't pretend a stale value is good enough. It stops returning it, and the next read repopulates the slot with a value that starts a new window.

Predictability falls out of this almost for free. When a read succeeds I know why: the value is still inside its assigned window. When it fails I know why then too. The cache had already declared it too old to trust.

Why I kept a general cache alongside the market one

The cache module exists because not every in-memory cache in the app needs market-specific semantics. Some paths just want a simple TTL wrapper around async work, and for those the MemoryCache plus withCache pattern is plenty.

The market cache is opinionated on purpose. It knows about quotes, history, news, indices, and search. It also knows that history isn't one thing, that it has at least two freshness profiles depending on timeframe. That specialization is exactly what makes it useful in the dashboard.

Keeping the two apart stops the generic helper from turning into a junk drawer. The specialized cache can encode product rules without dragging those assumptions into places that have no use for them.

Nuances that matter in practice

The cache key shape matters as much as the TTL. getKey(type, ...parts) in the stock data cache module joins the parts with colons, which gives me a simple namespace boundary between quote, history, news, indices, and search entries. A quote for one ticker can't collide with a history entry for the same ticker. The type prefix keeps them apart.

Then there's deletion on expiry. I remove expired entries at read time instead of leaving them around as stale records, which keeps the map from accumulating dead values and keeps read behavior simple. A value is acceptable or it's absent.

The cache API is deliberately tiny. No elaborate invalidation protocol lives in this file. The dashboard gets a small set of accessors, each with a clear TTL policy, and for the data this app renders that's enough.

The cache module exposes a clear() and a cleanup() path, which helps when I want broader cache hygiene. The market-specific cache has its own clear() method, so resetting the whole store is one call.

Where that leaves the dashboard

Once freshness lives in the cache, the dashboard stops guessing. A quote either earned its way onto the screen or it didn't. The rule holds or it doesn't.