I maintain a browser extension that shows live captions (and translated captions) on top of any tab: a video, a livestream, a web meeting. Audio is captured in the browser, streamed over a WebSocket to a server, and recognised text comes back sentence by sentence.

Starting a session got all the attention. Stopping was one line: close the socket. That line was the reason a lot of first sessions ended with an empty screen. This post is about what "Stop" has to mean in a streaming pipeline, and the extra wrinkles a Manifest V3 extension adds.

Why the last sentence disappears

A streaming recogniser sends two kinds of results:

  • partial results: fast guesses that keep changing, good for showing "the app is listening";
  • final results: settled text. Only finals are split into sentences, translated and saved.

Finals always lag behind the audio. The recogniser waits for a little right-hand context before it commits a word. So at the moment the user presses Stop, the sentence they just said exists only as a partial.

If Stop closes the socket, the server tells the recogniser "end of audio" after the client is already gone. The recogniser dutifully sends the final for the last sentence… to nobody. Someone who says one short sentence to test the tool and presses Stop sees nothing at all, and decides the tool doesn't work.

Stop is a process: the handshake

The fix is to separate "stop sending audio" from "close the connection":

extension server recogniser │ stop capture │ │ │── {type:'stop'} ──────────► │ stop accepting audio + billing │ │ │── end of stream ──────────────► │ │ │ ◄────────── final of last sentence │ ◄── last sentence + translation │ │ │ ◄────────────── end of transcript │ │ wait for in-flight translations │ │ ◄──── {type:'stopped'} ──── │ │ │ close socket │ │ 

On the server that means two flags instead of one: stopping (no new audio, but results are still processed) and stopped (ignore everything). With a single flag there is no "draining" window, and the last sentence falls exactly into the gap. One more trap from my own code: the server ignored all text frames so that control JSON could never leak into the audio stream — which also swallowed the stop message. Parse text frames and handle the few control messages you support; keep binary frames as audio.

The rest of this post is the client side, because in an extension "the client" is three separate contexts.

Where things live in an MV3 extension

In Chrome and Edge, a Manifest V3 extension has no persistent background page. My layout:

  • service worker (background): handles the toolbar button and keyboard shortcut, gets a tab-capture stream id, creates and closes the offscreen document, stores session state;
  • offscreen document: the only place that can hold a MediaStream and an AudioContext; it captures the tab audio, plays it back (capturing a tab mutes it) and runs the caption engine with the WebSocket;
  • overlay / popup / side panel: show the captions and the state.

The old Stop path in the offscreen document was: stop the audio, close the socket, report stopped. The service worker reacted to stopped by closing the offscreen document. Every step was correct on its own, and together they guaranteed that nothing arriving after Stop could ever be shown.

A stopping state in the engine

The engine now has a state between listening and stopped:

const STOP_WAIT_MS = 9000; // longer than the server's own waits added together function stop(error = null, silent = false) { const sock = ws; ws = null; // pushAudio() checks ws, so no more audio is sent if (sock && sock.readyState === WebSocket.OPEN && !error && !silent && !draining) { sock.send(JSON.stringify({ type: 'stop' })); draining = { sock, timer: setTimeout(() => finish(null), STOP_WAIT_MS) }; setState({ status: 'stopping' }); return; // keep the socket open } if (sock) sock.close(1000); finish(error, silent); } function finish(error, silent = false) { if (draining) { clearTimeout(draining.timer); const d = draining; draining = null; try { d.sock.close(1000); } catch {} } if (!silent) { persistTranscript(); setState({ status: 'stopped', error }); } } 

Only a normal Stop drains. Errors (quota used up, network gone, capture failed) and a session being replaced still close at once — there is nothing useful to wait for.

The message handler accepts results from the draining socket, and stopped (or the socket closing) ends the drain:

sock.onmessage = (e) => { const mine = ws === sock || (draining && draining.sock === sock); if (!mine || typeof e.data !== 'string') return; const msg = JSON.parse(e.data); if (msg.type === 'stopped') { if (draining?.sock === sock) finish(null); return; } onMessage(msg); // last sentence + its translation still render }; sock.onclose = () => { if (draining && draining.sock === sock) return finish(null); if (ws !== sock) return; // ...unexpected close: reconnect with backoff }; 

Note the identity checks against sock, not the global ws. Users press Stop and then Start again within a second. The new session gets a new socket; the old one may still be draining. Without the comparison, the old socket's stopped would end the new session, or its fallback timer would close the new connection.

Audio stops immediately, the socket doesn't

In the offscreen document, Stop still tears down capture right away:

if (msg.cmd === 'stop') { stopAudio(); engine.stop(); } 

stopAudio() stops the tracks and closes the AudioContext, so the tab is no longer captured (and it is audible again in the normal way), and no more audio is billed. Only the WebSocket lives on for a few seconds. Users see "Stopping…" and then the last sentence appears.

The service worker must not kill the offscreen document early

The background now closes the offscreen document only on the final state:

async function onEngineState(state) { await chrome.storage.session.set({ run: state }); if (state.status === 'stopped') setTimeout(closeOffscreen, 800); // let the overlay get the final state } async function closeOffscreen() { const { run } = await chrome.storage.session.get('run'); if (run && run.status !== 'stopped' && run.status !== 'idle') return; // a new session started meanwhile if (await hasOffscreen()) await chrome.offscreen.closeDocument(); } 

The re-check inside closeOffscreen() matters for the same Stop-then-Start case: a late stopped from the old session must not close the document that the new session is already using. When a new session starts while one exists, the background first sends a quiet release (stop without a report) instead of a normal stop.

Firefox: the audio port disconnect

Firefox MV3 has no offscreen documents; the background page runs the engine and a content script captures the media element and posts PCM through a runtime port. When the page closes or the capture ends, the port disconnects and the background stops the session. During stopping that disconnect is expected — capture already ended because the user pressed Stop — so it must not turn into a second, immediate stop:

port.onDisconnect.addListener(() => { const s = engine.getState().status; if (s !== 'idle' && s !== 'stopped' && s !== 'stopping') engine.stop(); }); 

Bonus: telling "silent tab" from "quiet speaker"

The other cause of empty first sessions wasn't the stop path at all: the user started captions on a tab that was paused or muted, or picked "tab audio" while talking into the microphone.

A muted or paused tab doesn't produce quiet audio — it produces exact zeros. Real silence from a microphone or a video still has a noise floor. So the engine tracks two clocks from the RMS of each audio frame:

const DIGITAL_SILENCE = 1e-5; // below this, the samples are (practically) all zero const NO_SOUND_HINT_MS = 8000; function pushAudio(pcm, rms) { const now = Date.now(); if (rms > DIGITAL_SILENCE) { lastSignalAt = now; if (state.notice) setState({ notice: null }); // sound came back: clear the hint } else if (!state.notice && segments.length === 0 && now - lastSignalAt > NO_SOUND_HINT_MS) { setState({ notice: settings.audio === 'mic' ? 'nosound-mic' : 'nosound-tab' }); } ws?.send(pcm.buffer); } 

The hint only appears while there are no captions yet, so it never nags in the middle of a long pause, and it names the fix for the chosen source ("play or unmute the tab, or switch to the microphone"). A separate, much longer timer with a higher threshold ends sessions where nothing has been heard for minutes, so a forgotten tab doesn't keep running.

What to test whenever you touch Stop

  • Say one short sentence and press Stop immediately → the sentence and its translation appear.
  • Press Stop while the socket is still connecting → no error, no stuck button.
  • Stop and Start repeatedly → no session closes another one.
  • Close the captured tab during stopping → the session ends cleanly.
  • Throttle the network in DevTools → stopped still arrives before the fallback timer.
  • Start on a muted tab → the hint appears; unmute → it disappears.

Logging why each session ended on the server (end of transcript, timeout, recogniser closed) tells you whether the waits are too short for real recogniser latency.

Takeaway

In a streaming system, Stop is not an event but a short protocol: stop the input, tell the next layer, wait for it to finish, report back, then close. In an MV3 extension that protocol spans a socket, an offscreen document and a service worker — and every one of them has to know that "stopping" is not "stopped".

The extension is part of LiveToka, which does live captions and translation in the browser: https://livetoka.com/extension/