Skip to content
← Back to blog

82 finals for 41 utterances

September 26, 2026 (1mo ago)

One fifteen-minute session gave me 82 final transcripts for about 41 things I actually said.

I had already filed the root cause for it. It sat in my known-issues file for six weeks, and every time I read the file I believed it a little more. It was wrong, and what proved it wrong was not a clever tool. It was counting how many times each of 42 small numbers showed up in a list.

This is that count, what it forced, and the fix.

The setup

Vara is a whiteboard that listens. You talk, a streaming speech-to-text service turns your voice into text, and every time it decides you've finished an utterance, it emits a "final". Each final goes to a planner, and the planner decides what to draw on the board.

Keep in mind every final is a paid planning turn. The free tier is 50 turns. So doubling the finals is not a cosmetic bug, it doubles the bill.

The session in question was a design critique I ran on my own laptop on 2026-07-31. It went badly in a very specific way:

  • 82 finals for roughly 41 utterances
  • the 50-turn quota was gone about 2 minutes in
  • 71 plan requests were refused with a usage-limit error
  • the board ended up as 162 elements in one long row, which is to say junk

It's the same session I wrote about in the 504 post. That one was about the timeouts. This one is about why there was so much traffic in the first place.

What I had filed

My known-issues entry said, more or less: duplicate finals, 82 for about 41 utterances, overlapping or cumulative final pairs ingested as separate turns, transcript normalization boundary, not fixed. My eval strategy doc had a matching acceptance check: no near-duplicate adjacent finals, where near-duplicate meant more than 80% overlap.

So the plan was a normalizer. Something at the point where finals come in, which spots two finals that are mostly the same text and drops one.

I think it was a plausible story, and I want to be fair to the version of me who wrote it. Of course, streaming speech-to-text does sometimes emit a shorter final and then a longer one that contains it. For example, "the three layers" and then "the three layers are ingest, transform, serve". If you look at the character counts of consecutive finals and you see pairs, one a bit longer than the other, "cumulative pairs" is the obvious reading.

There was also a second open item in the same file, filed separately: the mic kept transcribing after I closed it, and only a page refresh stopped it. That one said "needs repro". I treated these as two bugs.

The count that settled it

Every final carries an id that looks like this:

<provider>-<epoch-ms>-<utteranceIndex>

The last part is the important one. utteranceIndex is a counter that lives inside one transcriber object. It starts at 1, and it goes up by one each time that transcriber finishes an utterance. So one transcriber can emit each index at most once. Index 7 shows up once, then 8, then 9, and so on.

Now take all 82 finals from the session, pull out that last number, and count how many times each value appears. That's the whole method. If you want the formal word for it, it's a multiset: a list where you care how many times each value shows up, not just which values exist.

This is what came out:

index:  1  2  3 ... 38 39 40 | 41 42
count:  2  2  2 ...  2  2  2 |  1  1      (82 finals, max index 42)

Every index from 1 to 40 appears exactly twice. 41 and 42 appear once.

A single transcriber cannot produce that. There is no way for one counter to emit 7 twice. So there were two transcribers running at the same time on the same microphone, one that got to 40 and one that got to 42. That adds up to 82.

I think the nice thing about this is what it doesn't need. No text comparison, no overlap threshold, no time window, no tie-breaking. The normalization theory would explain messy text. It can't explain one counter being emitted twice at every single step. The count doesn't argue with the theory, it just rules it out.

The script is under twenty lines, and most of it is parsing:

import json
from collections import Counter
 
s = open("diagnostics.json").read()  # a stream of concatenated JSON objects
dec, i, objs = json.JSONDecoder(), 0, []
while i < len(s):
    while i < len(s) and s[i].isspace():
        i += 1
    if i >= len(s):
        break
    obj, i = dec.raw_decode(s, i)
    objs.append(obj)
 
finals = [o for o in objs if o["event"] == "transcript.completed"]
counts = Counter(int(o["detail"]["itemId"].rsplit("-", 1)[1]) for o in finals)
print(len(finals), max(counts), sorted(set(counts.values())))
# -> 82 42 [1, 2]

The honest part: that diagnostics file had been sitting in the repo the whole six weeks. The data to disprove my own diagnosis was there the day I filed it. I just never counted.

Everything else lined up behind it

Once you know it's two transcribers, the rest of the trace stops looking random.

The mic lifecycle. The session has three mic.started events and two mic.stopped. The interesting part is right at the beginning: a start at 05:01:07.213, a stop at 05:01:07.398, then starts at 05:01:07.867 and 05:01:08.268. Two starts 0.4 seconds apart with no stop between them. Four toggle calls inside 1.06 seconds: start, stop, start, start.

The gap between pairs. The median gap between a final and its nearest twin from the other transcriber is 1.43 seconds. That's two separate connections each deciding "the speaker stopped" at slightly different moments. One connection repeating itself would not have a gap like that.

The length difference. The median difference in character count between the twins is 42%. Only 3 of 42 nearest pairs match exactly. The two transcribers drew the utterance boundaries in different places, so the "duplicates" are near duplicates with different edges.

That 42% is also why the normalizer would have failed. My own acceptance check said "more than 80% overlap". Most of these pairs would sail under it. And if you loosen the threshold enough to catch them, you start merging things I genuinely said twice, like "yes... yes".

The mic that wouldn't stop. Four finals arrived after the last mic.stopped. The last of them landed 134 seconds after I had stopped the mic. That is the second bug from my known-issues file, the one that "needs repro". It didn't need a repro. It was the same bug. The stop worked perfectly, it just stopped the wrong transcriber.

So two open items became one root cause.

The mechanism

The mic button calls a function called toggleMic. Simplified, it did this:

  1. Read a listening flag.
  2. If it's false, await transcriber.start().
  3. After that resolves, put the transcriber in a single ref slot and set listening to true.

Step 2 is not quick. It fetches a session token over the network, loads the speech-to-text client as a separate chunk, then opens a WebSocket and asks the browser for the microphone. In that session the window was somewhere between 0.4 and 1.1 seconds.

During that window, listening is still false. The button still says "Start microphone", and it isn't disabled. Nothing on screen says anything is happening. So you click again, which is exactly what I did.

The second click reads listening, sees false, and starts a second transcriber. When that one resolves, it writes itself into the ref slot, overwriting the first. The first one is now orphaned, but it's still alive. It still has its WebSocket, it still has the microphone, and it still calls the same ingest function with every utterance.

Stop only stops whatever is in the slot. The orphan was never in the slot at stop time, so nothing could reach it except unloading the page. That was the "needed a page refresh" from the second bug, explained exactly.

There's one more thing that made it invisible. The system does de-duplicate events, on an idempotency key. But the key includes the provider id, and the provider id includes Date.now() from whichever transcriber minted it. Two transcribers, two clocks, two different keys for the same speech. The dedupe was working, it just never had anything to collide on.

Why the tests stayed green

The transcriber tests were good tests. They checked that one transcriber replaces partials correctly, accumulates finals, flushes on the end-of-utterance token, and gives each utterance a unique id.

Every one of those assertions still passes when two transcribers run side by side, because each one is individually correct. No test anywhere clicked the mic twice, or clicked stop in the middle of a start, and so on. No assertion anywhere said "at most one transcriber is live". The bug lived in the gap between two correct pieces, and that's the one place nobody had written a test for.

The same codebase already had the right pattern, by the way. The cloud-save function sets its busy flag before the await and clears it in a finally. The shape that breaks is read the flag, await, then write the flag. I found one more function with that same shape while I was at it. It has a much smaller blast radius, so it's filed separately.

The fix

The fix is one small module that owns the mic session. It does two things. One is that it serializes toggles, so no two transitions ever run at the same time. Second thing is that it gives every transition a token, and a transcriber that finishes starting after a newer transition has begun is stopped instead of installed.

Stripped down, it looks like this:

export function createMicSession() {
  let tail = Promise.resolve()
  let token = 0
  return {
    // every toggle runs to completion before the next one starts
    run<T>(job: (mine: number) => Promise<T>): Promise<T> {
      const mine = ++token
      const next = tail.then(() => job(mine))
      tail = next.then(() => {}, () => {})
      return next
    },
    isCurrent: (mine: number) => mine === token,
  }
}
 
// inside the start path, after the await
await transcriber.start(language)
if (!session.isCurrent(mine)) {
  trace("mic.start.superseded")
  void transcriber.stop() // never reaches the slot, never ingests
  return
}
slot.current = transcriber
listening = true

toggleMic keeps its signature, so none of the four buttons that call it changed. The buttons also disable while a transition is in flight, but that part is cosmetic. It removes the invitation to double-click. The invariant is the queue and the token.

A review of the first version caught three things I'd missed, and they're worth saying out loud:

  • My regression test proved the wrong thing. It clicked the button twice, and it passed. But the second click was landing on a disabled button, so the test was only proving the cosmetic guard, not the ownership rule. I rewrote it to call the handler directly, the way an error path in the code does, and then deleted the token check to make sure the test actually goes red without it. It does. Same for the queue.
  • A queued stop could act like a start. The transition read listening when it ran, not when it was clicked. So start then stop, landing inside the start window, could leave the mic on. Now the intent is fixed at click time.
  • Unmounting mid-start leaked. If the component went away while a start was in flight, the start could still resolve and install itself. Now unmount invalidates the token.

The full suite is 462 passing with 6 skipped, and type checks are clean. As of now the fix is up as a PR with those tests on it.

What I deliberately didn't build

The normalizer. I wrote it down as an option and labelled it a mitigation, not a fix, for three reasons.

One is cost. The metering I'm moving to bills by minutes of active listening, and two live connections are two sets of minutes. No text dedupe downstream can give those back.

Second is the orphan. Dropping duplicate text doesn't close a WebSocket or release a microphone. The mic would still be listening after you stopped it.

Third is the 42%. The twins don't line up closely enough for any threshold to be safe. You'd be trading missed duplicates against deleting real speech.

If it ever ships, it ships behind the source fix, as a second line of defence. With the source fixed, I'm not sure there's anything left for it to catch.

What I still don't know

A few things, honestly.

As of now I don't know how often real users hit this. This session is the only captured occurrence, and the trigger is a timing race on a click. I've added a trace for superseded starts so that the next time it happens, it gets counted instead of discovered.

I also can't show you the actual duplicate text. The traces for that window had expired by the time I looked, so everything above is ids, timestamps and character counts. That doesn't weaken the root cause, because the index count doesn't depend on the text at all. But it does mean the 42% is the only evidence I have about how closely the pairs match.

And I don't know for sure whether that session started with a real double click or something else. Start, stop, start, start in 1.06 seconds looks like me clicking, seeing nothing happen, and clicking again. The fix makes it harmless either way.

The change I've made to how I file things is small. The acceptance check in my eval doc used to be about text overlap. Now it's "the highest count of any utterance index is 1". It's a number I can compute from the log in one line, and it would have caught this the day it happened.

If you've got a toggle somewhere with an await between reading a flag and writing it, go check regarding that. Count first, then theorise.