Back to Dev Blog Dev Log

The Meeting Summary Timer Bug: Why setInterval Isn't Enough for Reliable Scheduling

2026. 02. 18 VORA Team 10 min read

VORA generates a rolling meeting summary every 60 seconds. That's the design. In practice, the summary was arriving at unpredictable intervals: sometimes 30 seconds, sometimes 5 minutes, sometimes seemingly never. Users noticed. We knew it was broken and couldn't immediately explain why. This is the deep dive into the JavaScript timer mechanics that caused the problem and the solution we landed on.

The Original Implementation and Its Hidden Flaw

The original startSummaryTimer() method looked reasonable:

startSummaryTimer() {
    this.stopSummaryTimer();
    // First summary after 20 seconds (data accumulation period)
    setTimeout(() => {
        if (this.fullMeetingTranscript.length > 0) {
            this.generateMeetingSummary();
        }
    }, 20000);
    // Then every 60 seconds
    this.summaryTimer = setInterval(
        () => this.generateMeetingSummary(), 
        this.summaryInterval  // 60000ms
    );
}

The immediate problem: the setTimeout(20s) and setInterval(60s) are registered at the same time. So the sequence of trigger events is:

The first two summaries are 40 seconds apart (T+20 and T+60), not 60 seconds. Then they're 60 seconds apart from T+60 onward. Irregular start, then regular. That's annoying but not the main bug.

The Real Problem: setInterval Doesn't Account for Task Duration

setInterval(fn, 60000) fires the callback every 60 seconds, measured from when the previous tick was scheduled — not from when the previous execution completed. If your callback takes 5 seconds to run, the next tick fires 55 seconds after completion. The effective interval is still 60 seconds from the scheduling perspective.

But VORA's generateMeetingSummary() doesn't just take 5 seconds. It queues a task in the Gemini API priority queue. If the queue is busy (lots of CORRECTION and QA tasks), the SUMMARY task might wait 20-30 seconds before being processed. The actual API call then takes 2-5 seconds. Total execution time: potentially 30+ seconds from trigger to completion.

Here's where it gets worse: setInterval doesn't care about previous execution completion. It fires at T+60s, T+120s, T+180s regardless. If the T+60s summary is still waiting in the queue at T+120s, both summaries are now in the queue simultaneously. When they both eventually execute, users see two summaries generated back-to-back with no gap. Then a long period with nothing. This explains the "sometimes 30 seconds, sometimes 5 minutes" pattern users reported — they were seeing back-to-back executions followed by a gap, not actual irregular scheduling.

The Secondary Problem: Queue Flooding

In extended meetings (90+ minutes), the setInterval approach creates queue flooding. With a SUMMARY task firing every 60 seconds and the task potentially taking 30+ seconds to execute (queue wait + API time), the queue could accumulate multiple pending SUMMARY tasks. Each one, when it eventually executes, would send 40 of the most recent transcript items to Gemini for processing. In a 90-minute meeting, you might have 3-4 SUMMARY tasks simultaneously queued, each requesting a summary of different transcript windows. The resulting summaries would be incoherent — different windows of the meeting, processed out of order, overlapping.

The core issue with setInterval for async tasks: setInterval fires on a schedule independent of whether the previous invocation has completed. For synchronous functions, this is fine. For async functions that depend on external resources (APIs, databases), setInterval creates a runaway accumulation of in-flight operations if the external resource is slow.

The Solution: Recursive setTimeout

The fix is to replace setInterval with a recursive setTimeout pattern. The key difference: the next timer is only scheduled after the current trigger fires (not after it completes — we still want the interval to be from trigger to trigger, not completion to trigger). But we track when the last trigger happened, and if we're called early for any reason, we wait the remaining time.

startSummaryTimer() {
    this.stopSummaryTimer();
    this._lastSummaryTriggerTime = Date.now();
    this._summaryTimerRunning = true;

    const scheduleNext = () => {
        if (!this._summaryTimerRunning) return;

        const now = Date.now();
        const elapsed = now - this._lastSummaryTriggerTime;
        const waitMs = Math.max(0, this.summaryInterval - elapsed);

        this.summaryTimer = setTimeout(() => {
            if (!this._summaryTimerRunning) return;
            this._lastSummaryTriggerTime = Date.now();
            if (this.fullMeetingTranscript.length > 0) {
                this.generateMeetingSummary();
            }
            scheduleNext(); // Schedule next after triggering
        }, waitMs);
    };

    scheduleNext(); // First trigger after summaryInterval (60s)
}

stopSummaryTimer() {
    this._summaryTimerRunning = false;
    if (this.summaryTimer) {
        clearTimeout(this.summaryTimer);
        this.summaryTimer = null;
    }
}

The recursive setTimeout pattern guarantees:

  1. The trigger fires exactly once per interval — there is never a second timer scheduled while the first is running.
  2. The interval is measured from trigger time to trigger time, regardless of how long the queued API call takes.
  3. The timer can be cleanly cancelled by setting _summaryTimerRunning = false.
  4. There is no initial 20-second delay — the first summary fires after exactly one full 60-second interval from recording start.

Why Not Just Guard Against Concurrent Summaries?

An alternative solution would be to add a flag: if (this._summaryInProgress) return; at the start of generateMeetingSummary(). This prevents concurrent summaries from executing. It doesn't prevent them from queuing — the SUMMARY tasks would still pile up in the queue, consuming queue capacity and eventually being discarded when they're dequeued and find the flag set.

More importantly, the concurrent guard doesn't fix the irregular interval problem. With setInterval + concurrent guard, you'd see: trigger at 60s (executes), trigger at 120s (skipped because in-progress), trigger at 180s (executes), trigger at 240s (might skip)... Summaries would happen roughly every 120s on a busy session rather than 60s. Better than random but not the intended behavior.

The recursive setTimeout is the correct solution because it aligns the scheduling mechanism with the actual semantics of what we want: "generate a summary 60 seconds after the previous trigger, reliably, once per cycle."

A JavaScript Scheduling Pattern Worth Knowing

This bug class — setInterval used with async tasks that can take variable time — is common enough that it's worth having the recursive setTimeout pattern as a standard tool. The general form:

// General pattern for reliable async periodic tasks
function startPeriodicTask(fn, intervalMs) {
    let running = true;
    let lastTrigger = Date.now();
    let timer;

    const schedule = () => {
        if (!running) return;
        const elapsed = Date.now() - lastTrigger;
        const wait = Math.max(0, intervalMs - elapsed);
        timer = setTimeout(async () => {
            if (!running) return;
            lastTrigger = Date.now();
            try { await fn(); } catch(e) { console.error(e); }
            schedule(); // Re-schedule after trigger (not after completion)
        }, wait);
    };

    schedule();

    return () => { 
        running = false; 
        clearTimeout(timer); 
    };
}

The schedule() → execute → schedule() chain ensures at most one pending timer at any time. The lastTrigger tracking ensures that if the timer fires late (which JavaScript timers do, especially when the tab is in the background or the system is under load), the next schedule compensates by waiting less. The result is a timer that's accurate to the configured interval measured across multiple cycles, even if any individual cycle runs long.

This fix went out in the same batch as several other corrections. It's the kind of bug that's easy to dismiss when you're deep in feature development — "timers are basically working, meeting summary fires sometimes" — but the user-visible symptom of irregular summaries was one of the more consistently reported complaints. The fix was 40 lines of code. The diagnosis took longer than the fix, as it usually does.