Y
Yasindu Nethmina
Full-Stack Product Engineer
MENU
Projects
Blog
Experience
About
Contact
©2026 Yasindu Nethmina. Made with in Colombo, Sri Lanka.
BLOG

Don't Cache the Silence: Self-Healing Audio Resolution for Earnings Calls

PUBLISHED

March 5, 2026

READ TIME

18 min read

TOPICS
CachingHLSReal-TimeAPI Design
SUMMARY

The engineering behind earnings call audio: how a single quarter field eliminates an entire class of N+1 requests, why null is deliberately never cached so the system stays self-healing during peak traffic hours, how reading one HLS playlist tag determines live stream state without any polling, and a dual TTL strategy that matches cache freshness precisely to the rate at which data changes.

Earnings calls are the most time-sensitive events in an investor's calendar. Every quarter, public companies get on a call with analysts and walk through their results. Revenue, margins, guidance, the unexpected stuff. The transcript of that call becomes a primary source that investors, journalists, and analysts read and re-read for days afterward.

I was building the documents section of the stock platform, and earnings call transcripts were one of the major features. The feature sounds simple: show a list of earnings calls for a company, let users click into one, read the transcript, and listen to the audio recording.

The implementation had a few interesting problems worth unpacking.

What the API Actually Returns

Transcripts come from a financial data API. Two endpoints are involved: /stock/transcripts/list for metadata and /stock/transcripts for full transcript content fetched by ID.

The list endpoint is what you call first to populate the tab. Pass a symbol, get back an array of transcript records. Each one has an ID, a title, a timestamp, a year, and a quarter field.

The problem is that "transcripts associated with a symbol" does not mean "quarterly earnings calls only." The API's list includes investor conferences, technology summits, capital markets days, special investor events, and other occasions where management speaks publicly. These events have transcripts. But they don't have quarterly earnings audio. They don't map to a fiscal quarter. For investors specifically looking for Q1, Q2, Q3, Q4 earnings calls they are noise.

When I first shipped the tab it showed everything unfiltered. A company with 20 actual earnings calls might also have 15 conference appearances mixed in. The tab felt like a raw data dump rather than a curated document library. The client flagged it.

The fix required a reliable way to identify real earnings calls without making extra API requests per item.

The Naive Approach and Its Cost

The obvious verification approach: fetch the list, then for each item call the full transcript endpoint or the audio endpoint to check whether audio exists, keep only the items that have it.

That's N+1 API calls. A company with 35 items in the list means 35 individual requests just to decide what to show. All of them consuming API quota, all of them adding latency, all of them happening before the user sees a single result. The provider enforces a global rate limit across the platform. During earnings season, when multiple companies report in the same week and multiple users are loading transcript tabs simultaneously, N+1 would eat that budget fast.

There was no way to ship that cleanly.

The Field That Was Already There

Looking more carefully at what the list endpoint returns, the answer was already in the response.

The transcript record includes a quarter field. The API's own documentation describes it as "Quarter of earnings result in the case of earnings call transcript." That phrase is doing real work. For actual quarterly earnings calls, this field is set to 1, 2, 3, or 4. For conferences, investor days, and everything else, it is 0. The field is not just display metadata. It is a type discriminator built directly into the API contract.

The filter is one line:

const allItems = (await fetchTranscriptsList(stock.symbol)).filter(
  (item) => item.quarter > 0,
);

No additional API calls. No per-item verification. The information needed to filter was in the list response the whole time. Zero extra requests.

The Caching Strategy: Cache Everything, Filter Downstream

The raw API fetch is handled by a private function that owns the caching layer separately from the public service function that applies the filter:

const fetchTranscriptsList = async (
  symbol: string,
): Promise<StockTranscriptListItemSchemaType[]> => {
  const cacheId = `stock-transcripts-list:${CACHE_VERSION}:${symbol}`;
  let data = await MEMORY.getJSON<StockTranscriptListItemSchemaType[] | null>(
    cacheId,
  );

  if (!data) {
    const response = await FINNHUB.GET('/stock/transcripts/list', {
      params: { query: { symbol } },
    });
    data = (response.transcripts ?? []).map(stockTranscriptListItemSchema);
    await MEMORY.setJSON(cacheId, data, CACHE_TTL_TRANSCRIPTS);
  }

  return data;
};

This caches the complete transformed array (all items including conference entries with quarter === 0) for 1 hour in Redis. The filtering happens in getTranscriptsList, one level up.

Caching the unfiltered set is deliberate. The document overview endpoint also calls fetchTranscriptsList to get the total count for the tab badge. If the cache held only filtered results, the overview count would only reflect earnings calls, not the full set the provider has on record. By caching the complete dataset and filtering at the point of use, each consumer applies its own view from a single shared cache entry. One network call, two different uses of the result.

The cache key includes a version prefix: CACHE_VERSION = 'v2'. When the mapped schema changes, bumping this constant invalidates all keys across the system without a manual Redis flush. Every cache namespace in the module follows this convention.

The Quarter Label

Every item gets a computed quarter_label field that the frontend uses as a visible badge:

export const formatQuarterLabel = (
  year: number | null,
  quarter: number | null,
): string | null => {
  if (!year || year <= 0) return null;
  if (!quarter || quarter <= 0) return null;
  const shortYear = year.toString().slice(-2);
  return `${shortYear}Q${quarter}`;
};

Q1 2026 becomes "26Q1". Q3 2024 becomes "24Q3". Null for anything without valid year and quarter values. This field does not exist in the API response. It is derived at mapping time and stored in the cache alongside the other fields. The frontend never recomputes it.

Fetching the Full Transcript

The list endpoint returns lightweight metadata only: ID, title, timestamp, year, quarter, quarter_label. The actual transcript content (the spoken words, the participant list, the Q&A sections) lives behind a separate endpoint.

A full call for a major company can be substantial: an hour of prepared remarks and Q&A, dozens of participants, thousands of words. Fetching all of this on list load for every item would be expensive and mostly wasted since users read one at a time.

Each detail is cached in Redis for 1 hour, keyed by transcript ID. The content structure is worth noting. Each section of the transcript has a session field set to either "Prepared Remarks" or "Q&A", which separates management's opening statements from the analyst question period. Each participant has a role field: "executive" or "analyst". This lets the frontend render them differently without any client-side logic to figure it out.

The Audio URL Problem

The transcript detail includes an audio field. For most historical calls that field has a direct MP3 URL and everything is straightforward. But for recent calls (ones that happened in the past few hours or days) that field is frequently empty even though audio does exist. The transcript indexing pipeline and the live earnings endpoint operate on different schedules. The transcript gets indexed first. The audio URL gets attached later.

This creates a window where the transcript is readable but the audio is temporarily unavailable via the normal path.

Two Paths to Audio

The audio resolver handles this with two sequential paths:

export const resolveTranscriptAudioUrl = async (
  id: string,
): Promise<string | null> => {
  const transcript = await getTranscriptDetail(id);
  if (transcript.audio_url) return transcript.audio_url;

  const { symbol, year, quarter, time } = transcript;
  if (!symbol || !year || !quarter || !time) return null;

  const cacheId = `stock-live-audio:${CACHE_VERSION}:${symbol}:${year}:${quarter}`;
  const cached = await MEMORY.getJSON<string | null>(cacheId);
  if (cached) return cached;

  const dt = DateTime.fromISO(time, { zone: 'utc' });
  if (!dt.isValid) return null;

  const from = dt.toISODate()!;
  const to = dt.plus({ days: 1 }).toISODate()!;

  const response = await FINNHUB.GETWithoutError('/stock/earnings-call-live', {
    params: { query: { symbol, from, to } },
  });

  const match = (response?.event ?? []).find(
    (e) => e.symbol === symbol && e.year === year && e.quarter === quarter,
  );

  const audioUrl = match?.recording || match?.liveAudio || null;

  if (audioUrl) {
    await MEMORY.setJSON(cacheId, audioUrl, CACHE_TTL_TRANSCRIPTS);
  }

  return audioUrl;
};

Path 1: check transcript.audio_url. If it exists, return it immediately. This is the fast path for any call the provider has fully indexed.

Path 2: fall back to the live earnings endpoint. This endpoint tracks live and recently completed earnings events. It has audio URLs available faster than the transcript detail because it exists specifically to serve call data in real time, not as a historical archive.

The Hot Window: Why Null Is Never Cached

The most important detail in the entire resolver is near the bottom:

if (audioUrl) {
  await MEMORY.setJSON(cacheId, audioUrl, CACHE_TTL_TRANSCRIPTS);
}

The write to Redis is conditional. If audioUrl is null (the live endpoint found no recording and no active stream) we return null to the caller but we do not cache it.

Here is what happens in the hours right after an earnings call ends:

  • -The call finishes. The transcript gets indexed quickly, but the audio field is empty. The recording pipeline is still processing the call.
  • -First user requests audio. Path 1 misses (transcript has no audio_url). Audio cache misses (nothing stored yet). We hit the live endpoint. Recording not ready. Return null. Nothing cached.
  • -Second user requests audio five minutes later. Same result. Path 1 misses, cache misses, live endpoint checked again, null returned. Still nothing cached.
  • -Recording finishes processing. Now the live endpoint returns a URL.
  • -Next user requests audio. Path 1 still misses (transcript detail cache is the stale 1-hour version without audio_url). Audio cache misses. Live endpoint now returns the recording URL. We cache it for 1 hour. Return it.
  • -Every subsequent user within that hour: audio cache hits immediately. Live endpoint not called again.

If we had cached null on step 2, every user during the hot window would be served a stale null for up to an hour after the recording became available. The feature would look broken (the call happened, everyone knows it happened, but there is no audio) for a full cache cycle. By deliberately not caching null, the system keeps checking until it finds something, then locks in once it does.

The hot window is precisely when this feature is most used. Earnings calls attract the most traffic in the hours immediately after they end. Getting this behavior right matters more for recent calls than for anything else.

The Date Window and AMC Calls

The live endpoint query uses a date range derived from the call's own timestamp. The window runs from the call date to the call date plus one day. The extra day is specifically for AMC calls (After Market Close). Many companies report earnings after trading ends for the day. The call runs into the evening. The recording gets processed and uploaded overnight. By the time it is available it is technically the next calendar day in UTC.

A query scoped only to the call date would miss those recordings entirely. The +1 day window ensures that calls which ended in the evening and got processed by midnight are still found.

Recording vs. Live Audio

The live endpoint returns two possible audio fields on each event:

const audioUrl = match?.recording || match?.liveAudio || null;

recording is the finished MP3 file. Available after the call ends and gets processed. liveAudio is an m3u8 HLS stream. Available during the call and immediately after, before the MP3 is ready.

The resolver prefers recording. A direct MP3 is a better playback experience than an HLS stream for a completed call. The HLS fallback exists for the narrow window where the call has just ended but the processed recording is not yet available. In that case returning the live stream is better than returning nothing.

The Frontend Playback Layer

The backend produces two possible audio types (a finished MP3 recording and an HLS stream) and the frontend has to decide which one to use and when. The decision is a four-step priority chain:

// Priority: live HLS → recording MP3 → historical transcript audio
if (liveEvent?.status === 'live' && liveEvent.live_audio_url) {
  openTrack({ src: liveEvent.live_audio_url, live: true, autoplay: true });
  return;
}

if (liveEvent?.status === 'ended' && liveEvent.live_audio_url) {
  openTrack({ src: liveEvent.live_audio_url, autoplay: true });
  return;
}

if (liveEvent?.status === 'ended' && liveEvent.recording_url) {
  openTrack({ src: liveEvent.recording_url, autoplay: true });
  return;
}

// Fallback: historical transcript audio
const t = await getTranscript();
if (t?.audio_url) openTrack({ src: t.audio_url, autoplay: true });

The ordering is deliberate. A live call gets the HLS stream with live: true, which tells the audio player to show a live indicator and a "Jump to Live" button. An ended call with live_audio_url but no recording_url means the call just finished and the MP3 hasn't been processed yet, so the HLS stream is the best available option, served without the live flag. Once recording_url exists, the MP3 takes over. The final fallback is the historical audio_url from the transcript detail, for any call where the live endpoint has no data at all.

Two reactive effects bridge the gap between polling cycles. The first handles the live-to-ended transition: when the backend's 30-second poll returns a status that is no longer 'live', a useEffect clears the live flag immediately. The second handles the edge case where the stream ends naturally before the next poll cycle, triggering a proactive cache invalidation.

The Live Call Status System

The transcript audio resolver handles the documents tab. But there is a second entirely separate system that runs alongside it: the live earnings call calendar.

This endpoint is built for a different problem. Rather than "give me audio for this specific transcript," it answers "what earnings calls are happening today, and what is their current state?" It serves a live calendar view where users can see upcoming calls, tune into calls that are currently streaming, and access recordings right after calls end.

Each event in the response carries an explicit status: upcoming, live, or ended. Determining that status correctly is the interesting part.

How the Stream State Is Detected

The data source has liveAudio and recording fields per event. But for the calendar view, returning a raw URL and leaving state inference to the frontend is not enough. The frontend needs to know definitively whether to show a "Join Live" button, a recording player, or a countdown. That means the backend needs to resolve the state server-side.

The state machine is:

  • -If release.recording exists → 'ended' (MP3 exists, call is definitively done)
  • -If neither field is populated → 'upcoming' (call hasn't started yet)
  • -If hoursElapsed < 0 → 'upcoming' (stream provisioned early, not started yet)
  • -If hoursElapsed > 24 → 'ended' (definitely over, skip any probing)
  • -Otherwise → probe the m3u8 itself (0-24h window, need real-time check)

Probing the HLS Playlist

HLS streams work by serving a playlist file (an m3u8) that lists the current media segments. For a live stream, the playlist keeps growing as new segments are published. When the stream ends, the encoder appends a specific tag to the playlist: #EXT-X-ENDLIST. This is part of the HLS spec. A conforming client that reads this tag knows the stream is over and no more segments are coming.

The probe function reads that signal directly:

const probeHlsStream = async (
  url: string,
): Promise<'live' | 'ended' | 'unavailable'> => {
  try {
    const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
    if (response.status === 404) return 'unavailable';
    if (!response.ok) return 'live';
    const text = await response.text();
    return text.includes('#EXT-X-ENDLIST') ? 'ended' : 'live';
  } catch {
    return 'live';
  }
};

Fetch the m3u8 playlist with a 5-second timeout. Check for #EXT-X-ENDLIST in the text. Present means the stream has ended. Absent means it is still live.

The failure modes are deliberate. A 404 means the stream URL is dead, which we treat as ended. Any other non-200 response is treated as live because the stream might be having a momentary issue but we do not want to falsely signal that it has ended and break an active listener's playback. A network error or timeout also defaults to live, for the same reason. The safe failure direction is always to assume the call is still going rather than to prematurely cut off a listener.

The Dual TTL Strategy

This is where the caching for the live calendar differs fundamentally from the caching on the transcript tab.

const getCacheTtl = (from: string, to: string): number => {
  const today = toDateStr(new Date());
  if (from <= today && today <= to) return LIVE_CACHE_TTL_TODAY;
  return LIVE_CACHE_TTL_DEFAULT;
};

// LIVE_CACHE_TTL_TODAY = 30     // 30 seconds
// LIVE_CACHE_TTL_DEFAULT = 3600 // 1 hour

If the requested date range includes today, the cache TTL drops to 30 seconds. For any other date range it is 1 hour.

The 30-second TTL is specifically designed to work with the frontend polling interval. The frontend polls the live endpoint every ~30 seconds to check for status changes. With a 30-second backend cache, every frontend poll that hits within the same 30-second window shares a single upstream API call and a single round of m3u8 probes. The cache expires just before the next poll cycle arrives, so each cycle gets a reasonably fresh result without every individual poll triggering its own upstream fetch.

For past or future date ranges the data is stable. A call that happened last week has a permanent recording. A call scheduled for next week has no audio yet. Neither changes within an hour. The 1-hour TTL is appropriate there.

What I Took Away From This

The quarter field is not just a display value. It is a type discriminator. Recognizing that it encodes a meaningful distinction and using it as such eliminated an entire class of N+1 requests. Before designing a verification step, check whether the data you already fetch encodes the answer. In this case it did.

The null caching decision is a small conditional that shapes the entire behavior of the feature during the period when it matters most. Not caching null makes the system self-healing: it keeps checking until it finds something, then stabilizes once it does.

The HLS probe is the detail I find most interesting technically. The m3u8 playlist is a plain text file. It has a well-defined tag that signals stream termination. Reading that tag directly is a clean, spec-compliant way to determine live state without relying on any secondary API field. The failure modes are carefully chosen: anything ambiguous or broken defaults to 'live' rather than 'ended', because the cost of cutting off an active listener is higher than the cost of showing a live indicator that's slightly stale.

The dual TTL (30 seconds for today, 1 hour for everything else) shows how caching strategy needs to adapt to the data's rate of change. Treating all dates identically would mean either polling too aggressively on old stable data or not freshly enough on live data. Splitting by whether today is in the range makes the cache useful on both ends.

EXPLORE MORE
ALL POSTS

Browse all engineering deep-dives and technical write-ups.

Contact