<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nichola Chaus</title>
    <description>The latest articles on DEV Community by Nichola Chaus (@nicholachaus).</description>
    <link>https://dev.to/nicholachaus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4174765%2F0be44f2b-125f-45cb-862c-1e5e2366cbc4.png</url>
      <title>DEV Community: Nichola Chaus</title>
      <link>https://dev.to/nicholachaus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nicholachaus"/>
    <language>en</language>
    <item>
      <title>The host's question kept landing under the guest's name: fixing speaker labels in a live tab transcriber</title>
      <dc:creator>Nichola Chaus</dc:creator>
      <pubDate>Sun, 25 Oct 2026 02:46:47 +0000</pubDate>
      <link>https://dev.to/nicholachaus/the-hosts-question-kept-landing-under-the-guests-name-fixing-speaker-labels-in-a-live-tab-46bm</link>
      <guid>https://dev.to/nicholachaus/the-hosts-question-kept-landing-under-the-guests-name-fixing-speaker-labels-in-a-live-tab-46bm</guid>
      <description>&lt;p&gt;I build Transcribe Video to Text, a Chrome extension that turns any video playing in a tab into text while it plays: YouTube, course platforms, webinars, a local file opened in Chrome. For interviews and calls it also labels who is speaking.&lt;/p&gt;

&lt;p&gt;Last week I recorded a short demo interview for a YouTube video about the extension. Two synthetic voices: Maya, the host, asks questions, and Daniel, the guest, answers. I exported the transcript to Word and the host's questions sat under the guest's label. Not once, again and again.&lt;/p&gt;

&lt;p&gt;It turned out to be two separate bugs, one on the server and one in the side panel, plus a limit of live diarization that I could not fix. Here is the whole path, from capturing tab audio in Manifest V3 to the labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  How tab audio gets to the speech model in MV3
&lt;/h2&gt;

&lt;p&gt;There is no background page in MV3, and the service worker can't hold a media stream. So the capture is split across three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The service worker gets a stream ID.&lt;/strong&gt; When the user clicks Transcribe in the side panel, the worker calls &lt;code&gt;chrome.tabCapture.getMediaStreamId({ targetTabId })&lt;/code&gt;. This only works for a tab where the user actually invoked the extension. If Chrome refuses, the panel doesn't show the raw error. It says: "To transcribe this tab, click the Transcribe icon in the toolbar above. This grants audio access for the current tab."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. An offscreen document turns the ID into audio.&lt;/strong&gt; The worker creates it with &lt;code&gt;chrome.offscreen.createDocument({ reasons: ['USER_MEDIA'], ... })&lt;/code&gt; and passes the stream ID. The offscreen page calls &lt;code&gt;getUserMedia&lt;/code&gt; with &lt;code&gt;chromeMediaSource: 'tab'&lt;/code&gt; and that ID.&lt;/p&gt;

&lt;p&gt;One thing you notice in the first minute of testing: capturing a tab mutes it for the user. The fix is to route the captured stream back to the speakers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;audioCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaStream&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. MediaRecorder sends small chunks over a WebSocket.&lt;/strong&gt; Opus in WebM at 32 kbps, a chunk every 250 ms. The recorder starts only after my server answers &lt;code&gt;ready&lt;/code&gt;, which means the connection to the speech model is already open, so the first words have somewhere to go.&lt;/p&gt;

&lt;p&gt;A small trap here: the first chunk is the WebM header, about 220 bytes, and it arrives even when the tab is completely silent. I used "first chunk arrived" to switch a "sound is coming in" indicator on, and it lit up on a video with no sound at all. In that session the server got 233 bytes in 37 seconds. Now the first chunk is ignored, and the indicator waits for a later chunk that actually carries audio.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The server relays audio to Deepgram.&lt;/strong&gt; A live connection with nova-3, &lt;code&gt;interim_results&lt;/code&gt;, &lt;code&gt;smart_format&lt;/code&gt; and &lt;code&gt;diarize: true&lt;/code&gt;. Deepgram sends back messages with the text, an &lt;code&gt;is_final&lt;/code&gt; flag, a &lt;code&gt;speech_final&lt;/code&gt; flag at the end of an utterance, and a list of words. With diarization on, every word carries a &lt;code&gt;speaker&lt;/code&gt; number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: one label for the whole message
&lt;/h2&gt;

&lt;p&gt;Note where the speaker lives: on each word, not on the message. My server took the speaker of the first word that had one and put that label on the whole message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of the time a final message is one person, so this looked fine. But when the host asks a short question and the guest answers right away, one final message can hold both voices. The whole thing went out under the label of whoever spoke first.&lt;/p&gt;

&lt;p&gt;The fix splits every final message into runs of consecutive words by the same speaker. A word without a speaker number joins the current run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;splitBySpeaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;curSpk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;curSpk&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;curSpk&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there is more than one run, each run goes to the client as its own final segment with its own start time, duration and speaker. Interim messages are left alone: they get replaced a moment later anyway.&lt;/p&gt;

&lt;p&gt;The test has five cases: question and answer in one message, a single speaker, an interim message, a word without a speaker number, and a response with no diarization at all. The two-voice case fails on the old code, all five pass on the new one. The server fix went live on October 18, so transcripts saved in the web account got correct labels right away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the side panel closed the paragraph one chunk too late
&lt;/h2&gt;

&lt;p&gt;The panel doesn't print every final chunk as a separate line. It collects chunks in a buffer and closes a paragraph when the text ends a sentence and is longer than 80 characters, or grows past 250 characters, or has 4 chunks, or the speaker changes. The paragraph gets the label of its first chunk.&lt;/p&gt;

&lt;p&gt;The bug was in the order of operations. A new chunk was pushed into the buffer first, and only then did the code check whether the speaker had changed. So on a speaker change, the guest's first sentence was already inside the host's buffer and went out as part of the host's paragraph, under the host's label. The Word export copies the panel, so the document had the same mistake.&lt;/p&gt;

&lt;p&gt;The fix is boring and that's the point: if the speaker changed, close the previous speaker's paragraph first, then push the new chunk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;speakerChanged&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;deepgramChunkBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;flushBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;deepgramChunkBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ...then the usual length and sentence checks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reproduced the demo interview in a browser test: load the side panel page, push the same sequence of transcript messages into its listener, read the paragraphs from the DOM. On the old code two checks failed, exactly the way my Word file looked. On the new code they pass. This part shipped in version 1.3.5 on October 21.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bonus bug found while checking the fix
&lt;/h2&gt;

&lt;p&gt;While I was checking labels on a TED interview, I found that the server only stored messages that had &lt;code&gt;speech_final&lt;/code&gt;. A long reply can produce several &lt;code&gt;is_final&lt;/code&gt; messages before the utterance ends, and everything except the last one was lost in the saved transcript. In one recording, the stretch from 0:29 to 0:54 was saved as a single word. The live panel showed it fine, so it went unnoticed since July.&lt;/p&gt;

&lt;p&gt;Now the server keeps &lt;code&gt;is_final&lt;/code&gt; text in a buffer and writes it out on &lt;code&gt;speech_final&lt;/code&gt;, on a speaker change, or when the connection closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit I could not fix: live diarization is not stable
&lt;/h2&gt;

&lt;p&gt;The same TED interview had another complaint: the whole first minute was labeled as one speaker, and the second label showed up at 1:01 in the middle of a sentence. This time my code was not the problem. Deepgram itself sent speaker 0 for everything up to 60.6 seconds.&lt;/p&gt;

&lt;p&gt;I ran a small experiment with the same SDK, nova-3, diarization on, English, on an 80 second clip from that talk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live stream, run 1: speaker changes at 12.8, 27.3, 37.0, 57.9 and 78.0 seconds. Correct.&lt;/li&gt;
&lt;li&gt;Live stream, run 2, same file, same parameters: speaker 0 from 12.8 all the way to 78.0. Wrong, exactly what the user saw.&lt;/li&gt;
&lt;li&gt;The whole file sent as a prerecorded request: correct, and it even caught a short reply at 17.3 seconds that both live runs missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So in live mode, with two voices that sound alike, the labels can be perfect on one run and collapse into one speaker on the next. The fix I see is to keep the session audio in server memory and, after the user presses Stop, send it once more as a whole file with diarization and relabel the saved segments. That roughly doubles the speech model minutes per session, so it is not shipped yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;With diarization, the speaker belongs to the word, not to the message. Anything that labels a whole message by its first word will mislabel quick back-and-forth.&lt;/li&gt;
&lt;li&gt;Buffering for readability has an order of operations. Close on the boundary before you add what is past it.&lt;/li&gt;
&lt;li&gt;A synthetic two-voice interview is a cheap and repeatable test for speaker labels. It caught both bugs in one recording.&lt;/li&gt;
&lt;li&gt;Before you blame your code for wrong labels, log what the model actually sent. One of my two "label bug" reports was the live model, not my code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it on your own interviews or lectures, the extension is here: &lt;a href="https://www.devexthub.com/transcribe-video-to-text/?utm_source=devto" rel="noopener noreferrer"&gt;Transcribe Video to Text&lt;/a&gt;. YouTube videos with subtitles are free and unlimited, AI transcription of other videos has a free monthly allowance.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>extensions</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Chrome kills an MV3 service worker at 5 minutes. Here is how my AI image extension stopped losing results</title>
      <dc:creator>Nichola Chaus</dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:13:35 +0000</pubDate>
      <link>https://dev.to/nicholachaus/chrome-kills-an-mv3-service-worker-at-5-minutes-here-is-how-my-ai-image-extension-stopped-losing-3c63</link>
      <guid>https://dev.to/nicholachaus/chrome-kills-an-mv3-service-worker-at-5-minutes-here-is-how-my-ai-image-extension-stopped-losing-3c63</guid>
      <description>&lt;p&gt;I build a small Chrome extension that upscales and unblurs photos with an AI model running right on your computer. No server, the image never leaves the browser. On a laptop with a decent GPU an upscale takes seconds. On an old office PC with no GPU, the same model runs on the CPU through WebAssembly, and a 4x upscale of a big photo can take six or seven minutes.&lt;br&gt;
For two weeks those slow machines kept losing their results at the very end. Here is what was going on and the fix that took the error count to zero.&lt;br&gt;
The symptom&lt;br&gt;
Users on slow PCs pressed Enhance, watched the progress bar crawl for several minutes, and then got an error instead of a picture. Under the hood it was Chrome's "The message port closed before a response was received" (sometimes worded as "message channel closed"). In our anonymous error stats it showed up 6 times from 5 users between September 27 and October 6, including Japan, Mexico and Canada. Every case had the same shape: no GPU, and a run longer than about five minutes. One user saw the raw English error text right before uninstalling.&lt;br&gt;
The architecture that caused it&lt;br&gt;
In Manifest V3 there is no background page. The model can't live in the service worker, which Chrome stops whenever it likes, so it runs in an offscreen document. The original message flow looked like this:&lt;br&gt;
The editor page sends INFER to the service worker.&lt;br&gt;
The service worker forwards it to the offscreen document and keeps sendResponse open with return true.&lt;br&gt;
The offscreen document runs the model and answers.&lt;br&gt;
The service worker passes the answer back to the page.&lt;br&gt;
It worked perfectly in testing, because on my machine step 3 took seconds.&lt;br&gt;
The 5-minute rule&lt;br&gt;
Chrome's own docs on the extension service worker lifecycle say it plainly: the worker is terminated when a single request, like an event or an API call, takes longer than 5 minutes to process. A message the worker is still waiting to answer counts as that request. So on a slow PC Chrome killed the service worker in the middle of step 2, the port to the page died with it, and the result the offscreen document produced a minute later had nowhere to go.&lt;br&gt;
The offscreen document itself was fine. It kept computing. The middleman was the problem.&lt;br&gt;
The fix: take the service worker out of the long path&lt;br&gt;
In version 1.1.1 the service worker does only the fast part: it creates the offscreen document if it isn't there yet and replies right away. The long INFER message goes from the page straight to the offscreen document.&lt;br&gt;
On the page side it is one small helper. First chrome.runtime.sendMessage({ type: 'ENSURE_OFFSCREEN' }), which the worker answers in milliseconds. Then chrome.runtime.sendMessage({ type: 'INFER', target: 'offscreen', ...payload }), which the offscreen document answers when the model is done. The batch mode uses the same helper, so a folder of 30 photos goes through the same safe path.&lt;br&gt;
One gotcha: runtime.sendMessage is delivered to every listener in the extension, the service worker included. If the worker's listener answers anything it doesn't recognise with an "unknown message" error, that answer wins the race and the page gets an error in a millisecond. So the first line of the worker's listener is now if (request.target === 'offscreen') return false; and the offscreen listener does the mirror check.&lt;br&gt;
How I tested it without waiting 5 minutes&lt;br&gt;
You don't need a slow PC to reproduce this. With the Chrome DevTools Protocol you can stop all service workers on demand: ServiceWorker.stopAllWorkers. The end-to-end test starts an upscale, kills the worker in the middle of the run, and waits for the result. On the old code the test fails with exactly the error users saw. On 1.1.1 the picture arrives.&lt;br&gt;
The result&lt;br&gt;
Version 1.1.1 went live on October 7. Since then the port error count is zero, on 1.1.1 and on 1.1.2 after it, while dozens of users have run enhancements. It is not the end of long-run problems (a busy model still makes a second tab wait), but no one loses a finished result anymore.&lt;br&gt;
Takeaways for MV3 extensions&lt;br&gt;
Never keep a service worker waiting on work that can take minutes. Let it start things, not carry them.&lt;br&gt;
Talk to the offscreen document directly with a target field, and make every other listener ignore messages that aren't addressed to it.&lt;br&gt;
Kill the worker in your tests with ServiceWorker.stopAllWorkers. If a run survives that, it survives a slow PC.&lt;br&gt;
Map Chrome's channel errors to a human message in the user's language. A raw "message port closed" is an uninstall.&lt;br&gt;
If you want to see the extension this happened in, it is Image Enhancer, a free AI photo upscaler that runs in your browser (&lt;a href="https://www.devexthub.com/image-enhancer/?utm_source=devto" rel="noopener noreferrer"&gt;https://www.devexthub.com/image-enhancer/?utm_source=devto&lt;/a&gt;). Happy to answer questions about running ONNX models in an offscreen document.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>extensions</category>
    </item>
    <item>
      <title>I built a bank statement to Excel converter that never uploads your file</title>
      <dc:creator>Nichola Chaus</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:06:33 +0000</pubDate>
      <link>https://dev.to/nicholachaus/i-built-a-bank-statement-to-excel-converter-that-never-uploads-your-file-kji</link>
      <guid>https://dev.to/nicholachaus/i-built-a-bank-statement-to-excel-converter-that-never-uploads-your-file-kji</guid>
      <description>&lt;p&gt;Most "bank statement to Excel" sites ask you to upload the PDF to their server. For a document with your account number, balance and every purchase you made this month, that always felt like the wrong trade.&lt;/p&gt;

&lt;p&gt;So the converter I built runs entirely in the browser. Here is how it works and where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the PDF without a server
&lt;/h2&gt;

&lt;p&gt;A statement downloaded from online banking is a text PDF: every date, description and amount is a positioned text item. pdf.js (Mozilla's PDF engine, the one inside Firefox) gives you those items with x/y coordinates via page.getTextContent(). No upload, no OCR, it all happens on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning text items into a table
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Cluster items into rows by their y coordinate, with a tolerance of about half the median text height.&lt;/li&gt;
&lt;li&gt;Find column anchors by clustering x positions across rows.&lt;/li&gt;
&lt;li&gt;Amounts are right-aligned, so their left edges drift (-4.75 vs -1,450.00). Before clustering I snap numbers that share a right edge to one column. Without this, debit and credit columns fall apart.&lt;/li&gt;
&lt;li&gt;Merge sparse columns, cap the width, glue wrapped lines back to their row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step bit me this week: a receipt with amounts like RUB 5,476.82 was not recognised as numbers, so the bigger bold total drifted left and got glued to the line above. Currency codes are now treated like currency symbols.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a real .xlsx
&lt;/h2&gt;

&lt;p&gt;An .xlsx is a zip of XML files, so JSZip plus a small writer is enough. The first version wrote every cell as text, and SUM over the amount column returned 0. Now amounts like 1,488.00, (25.40) or 1.488,00 are written as numbers, while dates and account numbers with leading zeros stay text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;[x] Scanned statements are pictures, there is no text layer. The page detects this and says so instead of producing an empty sheet.&lt;br&gt;
[x] The whole page lands in the sheet: bank name, address, footers. You delete those rows.&lt;br&gt;
[x] Very wide statements (more than 6 columns) get neighbouring columns merged.&lt;/p&gt;

&lt;p&gt;You can try it here, the file never leaves your machine: &lt;a href="https://www.devexthub.com/pdf-to-excel/bank-statement/" rel="noopener noreferrer"&gt;https://www.devexthub.com/pdf-to-excel/bank-statement/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have a statement layout that comes out wrong, I'd like to hear about it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
