<?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: Ray Mac</title>
    <description>The latest articles on DEV Community by Ray Mac (@ray_mac).</description>
    <link>https://dev.to/ray_mac</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%2F4107278%2Fb99cc365-5c7f-428d-975a-436dff513899.jpg</url>
      <title>DEV Community: Ray Mac</title>
      <link>https://dev.to/ray_mac</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ray_mac"/>
    <language>en</language>
    <item>
      <title>Subtitles Out of Sync? The 4 Causes and How to Fix Each One</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Tue, 13 Oct 2026 03:40:43 +0000</pubDate>
      <link>https://dev.to/ray_mac/subtitles-out-of-sync-the-4-causes-and-how-to-fix-each-one-3fej</link>
      <guid>https://dev.to/ray_mac/subtitles-out-of-sync-the-4-causes-and-how-to-fix-each-one-3fej</guid>
      <description>&lt;p&gt;&lt;strong&gt;Subtitles go out of sync for one of four reasons, and each needs a different fix.&lt;/strong&gt; The whole file can be early or late by the same amount (an offset). It can start right and slide further off as the video plays (a frame-rate mismatch). It can be fine until one point and then jump (a different cut of the video). Or the file can be roughly in place while individual lines start late, end late or hang over silence (bad line timing, which is what most auto-generated subtitles suffer from).&lt;/p&gt;

&lt;p&gt;You can tell which one you have in about two minutes by checking two lines, one near the start and one near the end. This guide shows how, gives the fix for each case, and then digs into the fourth kind with numbers: we built test audio where we knew the exact moment every sentence started and ended, ran it through Whisper three ways, and measured how far the subtitle timings landed from the speech.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: find out which kind of out of sync you have
&lt;/h2&gt;

&lt;p&gt;Pick a line near the beginning of the video and a line near the end. For each one, write down two times: when the line is spoken in the video, and the start time the subtitle file gives it. The difference is the offset at that point.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Offset near the start&lt;/th&gt;
&lt;th&gt;Offset near the end&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Everything is early or late by the same amount&lt;/td&gt;
&lt;td&gt;2.0 s late&lt;/td&gt;
&lt;td&gt;2.0 s late&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Constant offset&lt;/strong&gt;: a different intro, logo or lead-in&lt;/td&gt;
&lt;td&gt;Shift every line by the same amount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starts in sync, gets worse steadily&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;td&gt;2 min 34 s early&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Frame-rate drift&lt;/strong&gt;: subtitles made for a different frame rate&lt;/td&gt;
&lt;td&gt;Stretch the timings (frame-rate conversion or two-point sync)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In sync, then suddenly off from one scene onward&lt;/td&gt;
&lt;td&gt;0 s&lt;/td&gt;
&lt;td&gt;12 s late, starting from one scene&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A different cut&lt;/strong&gt;: a scene, recap or ad break added or removed&lt;/td&gt;
&lt;td&gt;Shift only the lines after the jump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On average in place, but lines pop in late, linger, or show during silence&lt;/td&gt;
&lt;td&gt;varies by line&lt;/td&gt;
&lt;td&gt;varies by line&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Bad line timing&lt;/strong&gt;, typical of auto-generated subtitles&lt;/td&gt;
&lt;td&gt;Retime the bad lines, or regenerate with word-level timing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two checks are enough to tell the first two apart: if both offsets are about equal, it's a constant offset. If the second is much bigger, divide it by how far into the video that line is. A steady ratio of about 4% is the signature of a frame-rate mismatch (more on the numbers below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: the whole file is early or late (constant offset)
&lt;/h2&gt;

&lt;p&gt;This is the most common case and the easiest fix. Common causes: the subtitle file was made for a version with a longer studio logo, a "previously on" recap, or a few seconds of black at the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To watch right now, adjust it in the player.&lt;/strong&gt; VLC has a subtitle delay setting. On Windows and Linux the default hotkeys are &lt;strong&gt;G&lt;/strong&gt; and &lt;strong&gt;H&lt;/strong&gt;, which move subtitles 50 ms earlier or later per press, according to &lt;a href="https://wiki.videolan.org/QtHotkeys/" rel="noopener noreferrer"&gt;VLC's hotkey list&lt;/a&gt;. The Mac build uses different keys, so check &lt;em&gt;Preferences → Hotkeys&lt;/em&gt; there. Most other players (mpv, PotPlayer, Plex, Kodi) have a similar subtitle offset setting. The change only lasts for that playback session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To fix the file for good, shift every timestamp.&lt;/strong&gt; Two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the browser: load the file into the free &lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT editor&lt;/a&gt;, optionally with the video, enter the shift in seconds (negative to move earlier) and apply it to all cues. Play a few lines against the video to confirm, then download. The file stays on your computer.&lt;/li&gt;
&lt;li&gt;On the command line, ffmpeg can do it: &lt;code&gt;ffmpeg -itsoffset 2.5 -i in.srt out.srt&lt;/code&gt; moves every cue 2.5 s later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One ffmpeg trap we hit while testing:&lt;/strong&gt; a negative offset bigger than the first cue's start time gets quietly shortened. On ffmpeg 8.1.1, shifting a file whose first cue starts at 1.0 s by &lt;code&gt;-itsoffset -1.5&lt;/code&gt; moved every cue only 1.0 s earlier, just enough to put the first cue at 0:00. Every later cue ended up 0.5 s late, and ffmpeg printed no warning. Our editor clamps each cue at 0:00 separately instead, so only cues that would start before the video begins get squashed, and every other cue moves by the full amount. If you use ffmpeg for negative shifts, check the second cue, not the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: subtitles drift further off as the video plays (frame rate)
&lt;/h2&gt;

&lt;p&gt;If the subtitles start in sync and are a few seconds off after half an hour and minutes off by the end, the file was timed against a version of the video that runs at a different speed. The classic case: subtitles made for a 25 fps European (PAL) release played against a 23.976 fps release of the same film. The PAL release runs about 4% faster, so every timestamp is about 4% too small.&lt;/p&gt;

&lt;p&gt;Here's how far off that gets, by how long into the video you are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subtitles made for&lt;/th&gt;
&lt;th&gt;Played on&lt;/th&gt;
&lt;th&gt;Off after 10 min&lt;/th&gt;
&lt;th&gt;30 min&lt;/th&gt;
&lt;th&gt;60 min&lt;/th&gt;
&lt;th&gt;90 min&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25 fps&lt;/td&gt;
&lt;td&gt;23.976 fps&lt;/td&gt;
&lt;td&gt;25.6 s&lt;/td&gt;
&lt;td&gt;1 min 17 s&lt;/td&gt;
&lt;td&gt;2 min 34 s&lt;/td&gt;
&lt;td&gt;3 min 51 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 fps&lt;/td&gt;
&lt;td&gt;24 fps&lt;/td&gt;
&lt;td&gt;25.0 s&lt;/td&gt;
&lt;td&gt;1 min 15 s&lt;/td&gt;
&lt;td&gt;2 min 30 s&lt;/td&gt;
&lt;td&gt;3 min 45 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 fps&lt;/td&gt;
&lt;td&gt;23.976 fps&lt;/td&gt;
&lt;td&gt;0.6 s&lt;/td&gt;
&lt;td&gt;1.8 s&lt;/td&gt;
&lt;td&gt;3.6 s&lt;/td&gt;
&lt;td&gt;5.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(Ratio 25 / 23.976 = 1.0427, so a cue at 60:00 belongs at 62:34.) The last row is the sneaky one: 0.1% looks like nothing, but it puts a feature film's last scenes 5 to 7 seconds off.&lt;/p&gt;

&lt;p&gt;Shifting can't fix this, because the error grows. You need to stretch the timings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If you know both frame rates&lt;/strong&gt;, use a frame-rate conversion. In &lt;a href="https://github.com/SubtitleEdit/subtitleedit" rel="noopener noreferrer"&gt;Subtitle Edit&lt;/a&gt; that's &lt;em&gt;Synchronization → Change frame rate&lt;/em&gt;. Getting the direction backwards doubles the problem, so test one late line before saving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you don't&lt;/strong&gt;, use two-point sync: tell the tool the correct time of one line near the start and one near the end, and it scales everything in between. Subtitle Edit calls this &lt;em&gt;Point sync&lt;/em&gt;. The math is a straight line: &lt;code&gt;new time = a × old time + b&lt;/code&gt;, where &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; come from your two corrected lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our SRT editor only shifts; it doesn't stretch. For drift, use a tool with point sync, then load the result into the editor if you want to check it against the video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: in sync, then suddenly off from one point (a different cut)
&lt;/h2&gt;

&lt;p&gt;If the first half is perfect and everything after one scene is late by the same amount, the subtitles came from a different edit: a director's cut, a TV version with a recap, or a recording with an ad break. Find the first line that's wrong, measure its offset, and shift that line and everything after it. Subtitle Edit can shift just selected lines (select from the first bad line to the end). If there are several jumps, repeat for each section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: lines start late, linger, or show during silence (bad line timing)
&lt;/h2&gt;

&lt;p&gt;The first three problems are about the whole file. This one is about individual lines, and it's the one you'll meet in auto-generated subtitles: a line that appears a beat late, stays on screen through a long pause, or shows up during the intro music before anyone speaks.&lt;/p&gt;

&lt;p&gt;What counts as "in sync" here? Netflix's &lt;a href="https://partnerhelp.netflixstudios.com/hc/en-us/articles/360051554394-Timed-Text-Style-Guide-Subtitle-Timing-Guidelines" rel="noopener noreferrer"&gt;subtitle timing guidelines&lt;/a&gt; are the strictest common reference. A subtitle should appear on the first frame of the audio, with 1 to 2 frames either way accepted (at 24 fps, a frame is about 42 ms). The out-time should ideally be at least half a second after the audio ends, provided the line doesn't visibly linger. So the target is: appear with the voice, and leave about half a second after it.&lt;/p&gt;

&lt;p&gt;We wanted to know how close AI subtitles get to that, and what goes wrong when they miss. So we measured it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The test: speech with known timings
&lt;/h3&gt;

&lt;p&gt;You can't measure timing errors without knowing the right answer, and nobody knows the exact start of every word in a real recording. So we built one where we did:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We wrote 30 sentences (a short history of a lighthouse) and had four macOS voices read them (US, UK, Australian and Irish English), cut to the exact start and end of the speech.&lt;/li&gt;
&lt;li&gt;We joined them with gaps of 0.35 to 1.4 seconds, plus three long pauses of 6, 12 and 25 seconds, the kind you get in interviews and lectures. That gave a 2 min 49 s track where we knew every sentence's start and end to the millisecond.&lt;/li&gt;
&lt;li&gt;We made four versions: &lt;strong&gt;clean&lt;/strong&gt;; with &lt;strong&gt;20 seconds of synthetic intro music&lt;/strong&gt; before the first word; with a &lt;strong&gt;music bed&lt;/strong&gt; under the whole thing; and with &lt;strong&gt;pink noise&lt;/strong&gt; under the whole thing.&lt;/li&gt;
&lt;li&gt;We transcribed each version with Whisper (&lt;code&gt;small&lt;/code&gt; model, faster-whisper 1.2.1) in three setups:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain Whisper:&lt;/strong&gt; the library defaults, timing each line from Whisper's segment timestamps. This is what you get from many basic Whisper wrappers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word timestamps:&lt;/strong&gt; the same, plus word-level timing, with each line starting at its first word and ending at its last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word timestamps + VAD:&lt;/strong&gt; word timing plus a voice activity detector that skips non-speech before Whisper sees it. These are the settings ScribeToAny's own engine uses in its fast mode.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;For every line that starts or ends a sentence, we compared its time with the true time.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Lines starting within ±0.25 s&lt;/th&gt;
&lt;th&gt;Lines ending more than 0.5 s late&lt;/th&gt;
&lt;th&gt;Longest a line stayed up over silence&lt;/th&gt;
&lt;th&gt;Sentences lost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain Whisper&lt;/td&gt;
&lt;td&gt;48–89%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;83%&lt;/strong&gt; (86 of 104)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; (clean version)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word timestamps&lt;/td&gt;
&lt;td&gt;64–72%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;5.2 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word timestamps + VAD&lt;/td&gt;
&lt;td&gt;62–90%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;1.4 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ranges cover the four versions (clean, intro music, music bed, noise). Here's what's behind them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftvwtnjr8dqopv7c3qw3d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftvwtnjr8dqopv7c3qw3d.png" alt="Timeline of the same 65 seconds of audio: the real speech, plain Whisper's lines, and word-timed lines with VAD. Plain Whisper's line stays on screen through the whole 25-second pause and the three sentences after it are missing." width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Plain Whisper's lines don't end, they wait for the next one.&lt;/strong&gt; In the clean version, all but one line ended exactly where the next one started. Whisper's segment timestamps run back to back, so a line stays on screen until the next line replaces it. Over normal gaps that means lines end about 0.9 s late (median), on the edge of Netflix's half-second allowance or past it. Over a long pause it's worse: one line, "Next to it is the keeper's logbook from the winter of 1947", was spoken in 4 seconds and then stayed on screen through another 25 seconds of silence. To a viewer, a subtitle hanging over silence looks like the subtitles are out of sync, even though every start time is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Plain Whisper lost three sentences after the long pause.&lt;/strong&gt; In the clean version, the three sentences after the 25-second pause never appeared: the transcript jumped from the line before the pause to the fourth sentence after it. The line after them, "The keeper rowed out twice in a storm", went up 11 seconds early, over the missing ones. Nothing in the output flagged the gap. The same audio with intro music in front kept all three. If a transcript gets out of step after a long silence, check whether lines are missing before you start retiming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Intro music put the first line at 0:00.&lt;/strong&gt; With 20 seconds of music before the first word, plain Whisper made the first subtitle the single word "The", from 0:00.00 to 0:20.64, and put the rest of the sentence in the next line. So a lone "The" sits on screen for the whole intro. This is the classic "first subtitle is way too early" complaint. With VAD the first line started at 0:20.08, 0.42 s before the voice, because the detector had removed the music before Whisper ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Word timestamps fix the hanging but end every line early.&lt;/strong&gt; With word-level timing no line ever hung over a pause for more than a few seconds, and with VAD no more than 1.4 s. But the ends came out early across the board: in every version, all or all but one of the line endings came before the voice stopped, by a median of 0.18 to 0.21 s. Starts were early too, by a median of 0.10 to 0.16 s. We checked this against our ground truth: setting the speech-end point with a stricter loudness threshold moved it by only about 0.03 s, so the early bias is real. Measured against Netflix's target of leaving half a second after the voice ends, word-timed lines leave about 0.7 s too soon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Noise hurts plain Whisper's starts most.&lt;/strong&gt; Under pink noise, only 48% of plain Whisper's lines started within a quarter-second of the voice, and three were more than a second off. With word timestamps it was 66%, with no line more than 0.72 s off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we didn't test:&lt;/strong&gt; synthetic voices are cleaner and more regular than people, so real recordings will likely do somewhat worse on every setup. This was one model (&lt;code&gt;small&lt;/code&gt;; our engine's other modes use larger models, which we didn't test here), English only, 30 sentences, one run per setup. The pattern is what matters here (segment timing hangs over silence, word timing ends early, VAD fixes the intro), not the exact percentages.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to do with auto-generated subtitles
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check three places before anything else:&lt;/strong&gt; the first line (is it on screen during the intro?), the line before every long pause (does it hang over the silence?), and the line after it (is anything missing?).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If lines hang over pauses, end each line at its last word.&lt;/strong&gt; A tool that times from word timestamps will do this for you. If you're stuck with segment-timed lines, retime the ones before long pauses by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push line ends out a little.&lt;/strong&gt; Word-timed lines tend to vanish just before the voice stops. Extending each end by about half a second, where the next line leaves room (Netflix wants at least 2 frames between lines), puts them close to the broadcast convention, and it also gives viewers more time to read. Our &lt;a href="https://scribetoany.com/blog/subtitle-reading-speed-cps" rel="noopener noreferrer"&gt;subtitle reading speed&lt;/a&gt; experiment found the same move cut the share of too-fast lines from 68% to 29%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip the intro before transcribing,&lt;/strong&gt; or use a tool with voice activity detection, if the video opens with music.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How ScribeToAny does it:&lt;/strong&gt; our engine uses the third setup: word-level timestamps with a voice activity detector tuned to keep quiet speech. That avoids hanging lines, the 0:00 intro line and, in this test, lost sentences. But it also had the early-ending problem: its lines ended about 0.2 s before the voice. So after this test we changed how the engine ends lines. Each line now stays up to half a second past its last word, never into the next line, and any gap left under half a second is closed to 2 frames, following Netflix's rules. Starts don't move. We ran the same word timings through the updated code. Line ends went from a median 0.2 s before the voice to 0.38 to 0.41 s after it, and no lines overlapped. As a side effect, the lines reading faster than 17 characters per second dropped from 14 to 20 of 30 per version to 1 to 4. You can still edit any line's start and end times in the transcript's QC view, or load the exported file into the &lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT editor&lt;/a&gt; with the video and adjust it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to keep subtitles in sync in the first place
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make subtitles from the exact file you'll publish.&lt;/strong&gt; Re-export after the final edit. Most offset and cut problems come from subtitling a rough cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note the frame rate&lt;/strong&gt; of the version the subtitles were made for, and keep it in the file name if you work with several releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check two points, not one.&lt;/strong&gt; One correct line proves nothing about drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer word-timed subtitles&lt;/strong&gt; for anything with pauses: interviews, lectures, podcasts. If you're generating them, the &lt;a href="https://scribetoany.com/tools/subtitle-generator" rel="noopener noreferrer"&gt;subtitle generator&lt;/a&gt; makes a timed SRT or VTT from your audio or video in about 98 languages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why are my subtitles a few seconds behind?&lt;/strong&gt; If they're behind by the same amount all the way through, the file was made for a version with a longer intro. Shift every line earlier by that amount. If they get further behind as the video goes on, it's a frame-rate mismatch and needs stretching instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should subtitles appear before the person speaks?&lt;/strong&gt; Netflix's rule is on the first frame of audio, with one or two frames of tolerance. In our test, word-timed AI subtitles appeared 0.10 to 0.16 s early (median), a few frames. In our view that matters far less than lines that appear late or stay on screen through a silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I fix subtitle sync without installing anything?&lt;/strong&gt; For a constant offset, yes: load the file and the video into the browser-based &lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT editor&lt;/a&gt;, shift all lines and download. For frame-rate drift you need a tool that can stretch timings, such as Subtitle Edit's point sync.&lt;/p&gt;




&lt;p&gt;Out-of-sync subtitles feel like one problem, but it's four, and the two-minute check tells you which one you've got. Offsets need a shift, drift needs a stretch, cuts need a partial shift, and auto-generated subtitles need their line ends looked at, especially around silence. We hadn't expected the silence to matter more than the noise, but in our test it did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://scribetoany.com/blog/subtitles-out-of-sync" rel="noopener noreferrer"&gt;ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>video</category>
      <category>ai</category>
      <category>whisper</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Can ChatGPT Transcribe Audio? We Uploaded a Real Recording to Find Out</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Mon, 05 Oct 2026 17:00:16 +0000</pubDate>
      <link>https://dev.to/ray_mac/can-chatgpt-transcribe-audio-we-uploaded-a-real-recording-to-find-out-346i</link>
      <guid>https://dev.to/ray_mac/can-chatgpt-transcribe-audio-we-uploaded-a-real-recording-to-find-out-346i</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer: not from an uploaded file in a normal chat, at least in our test.&lt;/strong&gt; On 4 October 2026 we uploaded a one-minute MP3 to ChatGPT's free web app and asked for a word-for-word transcript. Both the default mode and Think mode recognised the file and its length, then declined. ChatGPT &lt;em&gt;can&lt;/em&gt; turn speech into text in three other ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dictation&lt;/strong&gt; types what you say into the message box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record mode&lt;/strong&gt; transcribes live audio. It's for paid plans, in the macOS app only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI's API&lt;/strong&gt; transcribes files, but you need to write code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the test, what each option actually does, and when a dedicated transcription tool is the better fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: one MP3, one prompt
&lt;/h2&gt;

&lt;p&gt;We used the same recording as in our &lt;a href="https://scribetoany.com/blog/transcript-examples" rel="noopener noreferrer"&gt;transcript examples&lt;/a&gt; guide. It's a &lt;strong&gt;64-second interview between two people&lt;/strong&gt;: a podcast host and a baker. We made it for our product tutorial from a written script, with two text-to-speech voices. Because we have the script, any transcript of it can be scored word for word.&lt;/p&gt;

&lt;p&gt;We uploaded &lt;code&gt;podcast-bakery-interview.mp3&lt;/code&gt; (about 1 MB) to a new chat and sent this prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transcribe this audio word for word. Label each speaker (Speaker 1,
Speaker 2) and give a timestamp at the start of each speaker turn.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Default mode&lt;/strong&gt; (the page reported the model as &lt;code&gt;gpt-5-6&lt;/code&gt;) answered after about 8 seconds. It said it could see the file and that it was about a minute long, but that it didn't currently have an audio transcription capability in that chat session. It offered to format a transcript if we supplied one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Think mode&lt;/strong&gt; (&lt;code&gt;gpt-5-6-t-mini&lt;/code&gt;) worked for 12 seconds. It measured the file precisely as about 1 minute 3.6 seconds; the real length is 63.6 seconds. Then it said it couldn't hear or decode the audio in that environment, and it wouldn't produce a transcript "without risking making up text". Again it offered a template with &lt;code&gt;Speaker 1&lt;/code&gt; and &lt;code&gt;[00:00]&lt;/code&gt; placeholders.&lt;/p&gt;

&lt;p&gt;Two things are worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It didn't invent a transcript.&lt;/strong&gt; That's the right behaviour. A confident fake transcript would have been worse than a refusal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It could read the file's length but not its words.&lt;/strong&gt; That suggests the chat could inspect the file's data, but had no speech recognition to apply to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What we didn't test:&lt;/strong&gt; this was the free plan, on the web app, on one day. ChatGPT's features change often and differ by plan. A paid plan or a different app may behave differently, so try your own file before you rely on either answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ways ChatGPT can turn speech into text
&lt;/h2&gt;

&lt;p&gt;ChatGPT isn't one product here. OpenAI offers several speech-to-text routes, and they do different jobs. From OpenAI's own help pages and API documentation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it transcribes&lt;/th&gt;
&lt;th&gt;Who can use it&lt;/th&gt;
&lt;th&gt;Speaker labels&lt;/th&gt;
&lt;th&gt;Timestamps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File upload in a chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nothing, in our free-plan test&lt;/td&gt;
&lt;td&gt;Everyone&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Dictation&lt;/strong&gt; (microphone icon)&lt;/td&gt;
&lt;td&gt;What you say, typed into the message box for you to edit&lt;/td&gt;
&lt;td&gt;ChatGPT users; the help article lists no plan limits&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Record mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A live recording of a meeting or voice note, then notes. Up to 4 hours per session&lt;/td&gt;
&lt;td&gt;Plus, Pro, Business, Enterprise and Edu, in the &lt;strong&gt;macOS&lt;/strong&gt; desktop app only&lt;/td&gt;
&lt;td&gt;Yes: generic labels such as "Speaker 1", which you can rename&lt;/td&gt;
&lt;td&gt;Not described in the help article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;OpenAI API&lt;/strong&gt; (&lt;code&gt;gpt-transcribe&lt;/code&gt;, &lt;code&gt;gpt-4o-transcribe&lt;/code&gt;, &lt;code&gt;whisper-1&lt;/code&gt; and others)&lt;/td&gt;
&lt;td&gt;An audio or video file you send from your own code&lt;/td&gt;
&lt;td&gt;Developers with an API account (pay per use)&lt;/td&gt;
&lt;td&gt;With &lt;code&gt;gpt-4o-transcribe-diarize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;With &lt;code&gt;whisper-1&lt;/code&gt; (word or segment level)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few details from those docs matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Record mode records; it doesn't import.&lt;/strong&gt; The help article describes pressing Record and transcribing as you talk. It doesn't describe feeding it a file you already have. OpenAI also says it "works best in English today".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The API caps files at 25 MB.&lt;/strong&gt; It accepts mp3, mp4, mpeg, mpga, m4a, wav and webm. Longer recordings have to be compressed or split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the API, timestamps and speaker labels come from different models.&lt;/strong&gt; Per the docs, timestamps are only available with &lt;code&gt;whisper-1&lt;/code&gt;, and speaker labels need the separate diarization model. A subtitle file with both takes more than one call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When ChatGPT is the right tool, and when it isn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use ChatGPT's Record mode if&lt;/strong&gt; you're on a paid plan, you use a Mac, and the audio is happening &lt;em&gt;now&lt;/em&gt;: a meeting, a brainstorm, a voice note. You get a transcript and a summary in the same place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use dictation if&lt;/strong&gt; you just want to talk instead of type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use something else if&lt;/strong&gt; you have a recording &lt;em&gt;already&lt;/em&gt;: an interview, a lecture, a podcast episode or a video file. You'll also want something else if you need any of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timestamps on every line,&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;.srt&lt;/code&gt; or &lt;code&gt;.vtt&lt;/code&gt; subtitle file,&lt;/li&gt;
&lt;li&gt;a language other than English that you need to rely on,&lt;/li&gt;
&lt;li&gt;a transcript you can check line by line against the audio before quoting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's what dedicated transcription tools are built for. We ran the same 64-second file through ScribeToAny's engine. It returned the whole transcript with speaker labels and timestamps. &lt;strong&gt;14 of 14 speaker turns were labelled correctly, and 4 of 189 words differed from the script.&lt;/strong&gt; Two of those were real errors and two were "eighty" written as "80". The full scoring, mistakes included, is in our &lt;a href="https://scribetoany.com/blog/transcript-examples" rel="noopener noreferrer"&gt;transcript examples&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow that uses both
&lt;/h2&gt;

&lt;p&gt;ChatGPT is very good at what comes &lt;em&gt;after&lt;/em&gt; the transcript: summarising it, pulling out action items, writing chapter titles, drafting show notes. So split the job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transcribe&lt;/strong&gt; the recording with a tool built for it. Try the &lt;a href="https://scribetoany.com/tools/audio-to-text" rel="noopener noreferrer"&gt;audio to text&lt;/a&gt; or &lt;a href="https://scribetoany.com/tools/video-to-text" rel="noopener noreferrer"&gt;video to text&lt;/a&gt; tools. Turn on speaker detection for interviews and meetings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the transcript&lt;/strong&gt; against the audio, especially names, numbers and anything you'll quote. (See &lt;a href="https://scribetoany.com/blog/how-accurate-is-ai-transcription" rel="noopener noreferrer"&gt;how accurate AI transcription is&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paste it into ChatGPT&lt;/strong&gt; and ask for the summary, action items or chapters. In ScribeToAny, the &lt;strong&gt;AI summary &amp;amp; chat&lt;/strong&gt; button builds a ready-to-paste prompt with your transcript in it. It opens Claude by default, but the prompt works in ChatGPT too. ScribeToAny itself doesn't send your transcript to any AI service.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You get the accuracy and timing of a transcription engine, plus the writing help of a chat assistant, and you can check every step.&lt;/p&gt;




&lt;p&gt;So, can ChatGPT transcribe audio? It can transcribe &lt;em&gt;you&lt;/em&gt;, live, through dictation and Record mode, and developers can use OpenAI's API. But in our test, dropping an existing recording into a chat didn't produce a transcript. For a file you already have, transcribe it first, then bring the text to ChatGPT. Start with the &lt;a href="https://scribetoany.com/tools/interview-transcription" rel="noopener noreferrer"&gt;interview transcription&lt;/a&gt; tool if there's more than one voice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://scribetoany.com/blog/can-chatgpt-transcribe-audio" rel="noopener noreferrer"&gt;ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chatgpt</category>
      <category>ai</category>
      <category>transcription</category>
      <category>productivity</category>
    </item>
    <item>
      <title>What Is an SRT File? The Format, an Example, and the Mistakes That Break It</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Mon, 05 Oct 2026 16:54:01 +0000</pubDate>
      <link>https://dev.to/ray_mac/what-is-an-srt-file-the-format-an-example-and-the-mistakes-that-break-it-54o6</link>
      <guid>https://dev.to/ray_mac/what-is-an-srt-file-the-format-an-example-and-the-mistakes-that-break-it-54o6</guid>
      <description>&lt;p&gt;&lt;strong&gt;An SRT file (&lt;code&gt;.srt&lt;/code&gt;, short for SubRip Subtitle) is a plain-text subtitle file.&lt;/strong&gt; It holds a list of numbered cues. Each cue has a start time, an end time and the words to show on screen between them. Almost every video player, editor and video platform can read one. That's why it's the default answer to "what subtitle file do I need?"&lt;/p&gt;

&lt;p&gt;The name comes from SubRip, a free Windows program that pulled subtitles off DVDs and saved them as text. The program is long gone, but its output format became the lingua franca of subtitles. SRT never got a formal specification, though. Every tool reads it its own way, and that's where the trouble starts. Below is the format, a real example, and what happened when we fed 16 slightly broken SRT files to ffmpeg and to our own converter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an SRT file looks like
&lt;/h2&gt;

&lt;p&gt;Here are the first three cues of a real file: the subtitles ScribeToAny made for a 64-second interview we use in our tutorial.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1
00:00:00,000 --&amp;gt; 00:00:01,680
Welcome back to Small Shop Stories.

2
00:00:02,160 --&amp;gt; 00:00:05,320
Today I'm talking to Maya, who runs a tiny bakery on the

3
00:00:05,320 --&amp;gt; 00:00:06,320
corner of Mill Street.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each cue is four parts, always in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A number.&lt;/strong&gt; The cue's position in the file, counting from 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timing line.&lt;/strong&gt; The start and end time, separated by &lt;code&gt;--&amp;gt;&lt;/code&gt; (space, two hyphens, a greater-than sign, space). The format is &lt;code&gt;hours:minutes:seconds,milliseconds&lt;/code&gt;, so &lt;code&gt;00:00:05,320&lt;/code&gt; is 5.32 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The text.&lt;/strong&gt; One or two lines is the norm. Most style guides cap a line at about 42 characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A blank line.&lt;/strong&gt; It marks the end of the cue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two details catch people out. The milliseconds come after a &lt;strong&gt;comma&lt;/strong&gt;, not a dot; the dot is WebVTT's habit. And the file should be saved as &lt;strong&gt;UTF-8&lt;/strong&gt;, or accented letters turn into garbage in some players.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SRT can and can't do
&lt;/h2&gt;

&lt;p&gt;SRT is deliberately simple. That's its strength and its limit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Basic styling works, mostly.&lt;/strong&gt; Many players honour &lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;u&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;font color="…"&amp;gt;&lt;/code&gt;. Support varies, so don't rely on colour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Positioning isn't part of the format.&lt;/strong&gt; Some files put &lt;code&gt;{\an8}&lt;/code&gt; at the start of a line to move it to the top of the screen. That's borrowed from the ASS format. Players that don't understand it show it as literal text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no language field.&lt;/strong&gt; The convention is to put the language in the filename, such as &lt;code&gt;interview.en.srt&lt;/code&gt; and &lt;code&gt;interview.es.srt&lt;/code&gt;. Media servers and players use that to label the tracks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web browsers can't play it.&lt;/strong&gt; The HTML5 &lt;code&gt;&amp;lt;track&amp;gt;&lt;/code&gt; element only accepts WebVTT. We checked in Chrome 152: an SRT file in a &lt;code&gt;&amp;lt;track&amp;gt;&lt;/code&gt; failed to load and showed no cues, and the same cues saved as &lt;code&gt;.vtt&lt;/code&gt; loaded fine. For web video, convert with &lt;a href="https://scribetoany.com/tools/srt-to-vtt" rel="noopener noreferrer"&gt;SRT to VTT&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For how SRT compares with VTT, TXT, JSON, ASS and the rest, see &lt;a href="https://scribetoany.com/blog/subtitle-and-transcript-file-formats" rel="noopener noreferrer"&gt;subtitle and transcript file formats&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  We fed 16 broken SRT files to ffmpeg, and to our own converter
&lt;/h2&gt;

&lt;p&gt;With no official spec, "valid SRT" means "whatever the tool you're using accepts". To see what that means in practice, we took the three cues above and broke them in 16 different ways, the kinds of mistakes that come from hand editing, old Windows tools and copy-paste. Then we ran all 17 files (the valid one plus the 16 broken ones) through two readers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg 8.1.1.&lt;/strong&gt; A huge number of video tools use it under the hood. We converted each file to WebVTT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ScribeToAny's subtitle converter&lt;/strong&gt;, the parser behind our free &lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT tools&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what happened. "Silent" means the conversion reported success and the output looked fine at a glance. At most, a warning was printed in the log.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What's wrong with the file&lt;/th&gt;
&lt;th&gt;ffmpeg 8.1.1&lt;/th&gt;
&lt;th&gt;ScribeToAny (before our fix)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing (valid file)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dot instead of comma (&lt;code&gt;00:00:01.680&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Byte-order mark at the start (Windows Notepad)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows line endings (CRLF)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cue numbers out of order or skipped (5, 2, 9)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-digit hours (&lt;code&gt;0:00:01,680&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra coordinates after the times (&lt;code&gt;X1:100 X2:600 …&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✅ Ignored&lt;/td&gt;
&lt;td&gt;✅ Ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No cue numbers at all&lt;/td&gt;
&lt;td&gt;⚠️ Rejected when ffmpeg guesses the format; read fine when told it's SRT&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No spaces around the arrow (&lt;code&gt;--&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;⚠️ Same as above&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short arrow (&lt;code&gt;-&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;❌ Nothing read&lt;/td&gt;
&lt;td&gt;❌ Nothing read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two-digit milliseconds (&lt;code&gt;,68&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Silent&lt;/strong&gt;: read as 68 ms instead of 680, so times moved by up to 0.6 s&lt;/td&gt;
&lt;td&gt;✅ Read as 680 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End time before start time&lt;/td&gt;
&lt;td&gt;⚠️ &lt;strong&gt;Silent&lt;/strong&gt;: cue shrunk to zero length, so it never shows&lt;/td&gt;
&lt;td&gt;⚠️ Kept as is; flagged by the editor's quality check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overlapping cues&lt;/td&gt;
&lt;td&gt;Kept as is&lt;/td&gt;
&lt;td&gt;Kept as is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt; tags&lt;/td&gt;
&lt;td&gt;✅ Kept&lt;/td&gt;
&lt;td&gt;✅ Kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;font color&amp;gt;&lt;/code&gt; tag&lt;/td&gt;
&lt;td&gt;Colour dropped&lt;/td&gt;
&lt;td&gt;✅ Kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{\an8}&lt;/code&gt; position tag&lt;/td&gt;
&lt;td&gt;Tag removed&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Shown as text&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No blank lines between cues&lt;/td&gt;
&lt;td&gt;✅ Three cues&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Merged into one cue&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saved as Latin-1 / Windows-1252 ("café")&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Silent&lt;/strong&gt;: that cue dropped&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Garbled&lt;/strong&gt; ("caf�")&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What the test shows
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The dangerous failures are the silent ones.&lt;/strong&gt; A file that won't open gets noticed. A file that opens with one cue missing, or with its timings shifted, ships. The two-digit-millisecond case is the nastiest: ffmpeg read &lt;code&gt;,68&lt;/code&gt; as 68 milliseconds rather than 680, so that time moved 0.6 seconds, and nothing warned about it. Always write three digits: &lt;code&gt;,680&lt;/code&gt; for 680 ms, &lt;code&gt;,068&lt;/code&gt; for 68 ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Being "lenient" isn't the same as being right.&lt;/strong&gt; ffmpeg rescued the file with no blank lines, but dropped the Latin-1 cue with only a log warning. Our converter read the Latin-1 cue, but showed the accents as garbage. Neither stopped with an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of these broke our own tool.&lt;/strong&gt; Files with no blank lines between cues collapsed into a single cue. &lt;code&gt;{\an8}&lt;/code&gt; tags showed up as text in the converted file. Legacy-encoded files came out garbled. We fixed all three the same day. Our parser now finds cues by their timing lines instead of by blank lines. It removes ASS position tags. And it detects the file's encoding (UTF-8, UTF-16 or Windows-1252) instead of assuming UTF-8. We added each case to our regression checks so they stay fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we didn't test:&lt;/strong&gt; this was one ffmpeg build and one converter, with small synthetic files. Players such as VLC, editing apps and platforms like YouTube each have their own parser, and may behave differently on the same file.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check and fix an SRT file
&lt;/h2&gt;

&lt;p&gt;If a platform rejects your SRT, or the subtitles drift, open the file in a plain-text editor and go down this list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Encoding.&lt;/strong&gt; Re-save it as UTF-8. This one fix clears up most garbled accents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing lines.&lt;/strong&gt; Each one should look exactly like &lt;code&gt;00:00:05,320 --&amp;gt; 00:00:06,320&lt;/code&gt;: two digits for hours, minutes and seconds, three for milliseconds, a comma, and &lt;code&gt;--&amp;gt;&lt;/code&gt; with spaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blank lines.&lt;/strong&gt; There must be one empty line between cues and none inside a cue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers.&lt;/strong&gt; Number the cues 1, 2, 3… in order. Most readers don't care, but some strict validators do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Times that make sense.&lt;/strong&gt; Each end time must come after its start time. Ideally, each cue ends before the next one starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags.&lt;/strong&gt; Remove &lt;code&gt;{\an8}&lt;/code&gt;-style tags unless you know the destination player supports them. Keep &lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt; for italics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Faster: load the file into the &lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT editor&lt;/a&gt;. It runs in your browser, so the file isn't uploaded anywhere. It reads every variation above except the short arrow, and its quality check flags cues that end before they start, overlap, or go by too fast to read. Then save a clean copy. (Too-fast cues are their own topic: see &lt;a href="https://scribetoany.com/blog/subtitle-reading-speed-cps" rel="noopener noreferrer"&gt;subtitle reading speed&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How to make an SRT file
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;From a video or audio file:&lt;/strong&gt; upload it to the &lt;a href="https://scribetoany.com/tools/subtitle-generator" rel="noopener noreferrer"&gt;subtitle generator&lt;/a&gt;. ScribeToAny transcribes it in about 98 languages and exports a timed &lt;code&gt;.srt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From a script you already have:&lt;/strong&gt; &lt;a href="https://scribetoany.com/tools/text-to-srt" rel="noopener noreferrer"&gt;text to SRT&lt;/a&gt; turns plain text into timed cues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From another subtitle format:&lt;/strong&gt; convert with &lt;a href="https://scribetoany.com/tools/vtt-to-srt" rel="noopener noreferrer"&gt;VTT to SRT&lt;/a&gt; or &lt;a href="https://scribetoany.com/tools/ass-to-srt" rel="noopener noreferrer"&gt;ASS to SRT&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By hand:&lt;/strong&gt; any plain-text editor works. Follow the four-part pattern above and save as UTF-8 with the &lt;code&gt;.srt&lt;/code&gt; extension.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;An SRT file is the simplest thing in video: numbered cues, a timestamp pair and a line of text. The trouble is that without a standard, every tool fills the gaps differently, often without saying so. Keep your files strictly to the pattern (three-digit milliseconds, blank lines, UTF-8) and they'll read the same everywhere. When one doesn't, check it in the &lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT editor&lt;/a&gt; before it ships.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://scribetoany.com/blog/what-is-an-srt-file" rel="noopener noreferrer"&gt;ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>subtitles</category>
      <category>video</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Transcript Examples: Timestamps, Speaker Labels and Formatting, From a Real AI Transcript</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Sun, 04 Oct 2026 09:13:34 +0000</pubDate>
      <link>https://dev.to/ray_mac/transcript-examples-timestamps-speaker-labels-and-formatting-from-a-real-ai-transcript-ep</link>
      <guid>https://dev.to/ray_mac/transcript-examples-timestamps-speaker-labels-and-formatting-from-a-real-ai-transcript-ep</guid>
      <description>&lt;p&gt;Most transcript examples online show one tidy, hand-typed sample. That doesn't help when your transcript came out of an audio-to-text engine and looks nothing like it. This guide works the other way round. It takes &lt;strong&gt;one real machine transcript&lt;/strong&gt; and shows it in every common layout: the raw output, speaker-labelled dialogue, three timestamp styles, editing timecode and a video transcript. It also counts what the AI got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recording behind every example
&lt;/h2&gt;

&lt;p&gt;All the examples use the same &lt;strong&gt;64-second, two-person interview&lt;/strong&gt;. A podcast host talks to Maya, who runs a small bakery. We made it for our product tutorial from a written script, with two text-to-speech voices. The people are invented. For some of Maya's answers we sped her voice up by 32%, so the recording has a fast talker in it as well.&lt;/p&gt;

&lt;p&gt;Because we wrote the script, we know exactly what was said. That's what lets us score the transcript below instead of eyeballing it. We ran the file through ScribeToAny with the language on auto-detect, the Accurate mode and &lt;strong&gt;Detect speakers&lt;/strong&gt; set to two. Everything in the code blocks below is that output, mistakes included. We only changed the layout, never the words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 1: a raw audio-to-text transcript
&lt;/h2&gt;

&lt;p&gt;This is what you get when you copy the transcript with speakers and timestamps switched on. Here are the first ten of its 30 segments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SPEAKER_00 (0:00) Welcome back to Small Shop Stories.
SPEAKER_00 (0:02) Today I'm talking to Maya, who runs a tiny bakery on the
SPEAKER_00 (0:05) corner of Mill Street.
SPEAKER_01 (0:07) Thanks for having me.
SPEAKER_01 (0:08) It's really small, just two ovens and a counter.
SPEAKER_00 (0:12) So how does a normal day start for you?
SPEAKER_01 (0:15) Honestly, it starts at four in the morning.
SPEAKER_01 (0:17) I switch the ovens on, mix the sourdough, shape the loaves,
SPEAKER_01 (0:19) and by six there's already a queue
SPEAKER_01 (0:21) outside the door.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is how the same transcript looks in the ScribeToAny editor, where each timestamp replays that moment of the audio:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz08gh4pi0pdhaf820fx9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz08gh4pi0pdhaf820fx9.png" alt="ScribeToAny transcript editor showing the interview as segments, each with a SPEAKER_00 or SPEAKER_01 label and a timestamp; the 0:22 line reads " width="800" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things stand out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The speakers have no names.&lt;/strong&gt; The engine tells voices apart, but it doesn't know who anyone is. &lt;code&gt;SPEAKER_00&lt;/code&gt; is just "the first voice it heard." Working out &lt;em&gt;who spoke when&lt;/em&gt; is called &lt;a href="https://scribetoany.com/blog/what-is-speaker-diarization" rel="noopener noreferrer"&gt;speaker diarization&lt;/a&gt;. Putting names on the voices is your job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segments are not sentences.&lt;/strong&gt; The recording has 14 speaker turns, but the transcript has 30 segments. They're short, between 0.5 and 3.2 seconds (half are under 1.5 seconds), and &lt;strong&gt;9 of the 30 stop in the middle of a sentence&lt;/strong&gt;: "…a tiny bakery on the" / "corner of Mill Street." Segments are cut to fit on screen as subtitles, not to read well as a document. That suits an SRT file. For reading, you'll want Example 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the AI got right, and what it got wrong
&lt;/h2&gt;

&lt;p&gt;Against the script, the transcript scored like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Words in the script&lt;/td&gt;
&lt;td&gt;189&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Words that differ from the script&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real recognition errors&lt;/td&gt;
&lt;td&gt;2: "&lt;strong&gt;Four&lt;/strong&gt; in the morning" came out as "For in the morning", and "the one thing &lt;strong&gt;you'd&lt;/strong&gt; tell" as "you tell"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Style differences, not errors&lt;/td&gt;
&lt;td&gt;2: "eighty" was written as "80", twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speaker turns labelled correctly&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;14 of 14&lt;/strong&gt;, including the sped-up answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Segments that end mid-sentence&lt;/td&gt;
&lt;td&gt;9 of 30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scored strictly, that's a 2.1% word error rate. If you count "80" as a correct way to write "eighty", it's 1.1%. Don't read too much into this. It's one minute of clean synthetic speech, with no background noise and nobody talking over anyone else. Real recordings do worse, sometimes much worse. See &lt;a href="https://scribetoany.com/blog/how-accurate-is-ai-transcription" rel="noopener noreferrer"&gt;how accurate AI transcription really is&lt;/a&gt;. What the test does show is &lt;em&gt;the kind&lt;/em&gt; of mistake to look for. Homophones like "for" and "four" sound identical, so only the context gives them away. Small words like "you'd" are easy to miss on a quick read. And style choices, such as digits versus words, are made for you by the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 2: a speaker-labelled transcript (dialogue format)
&lt;/h2&gt;

&lt;p&gt;This is the layout most people mean by "transcript". Each speaker turn is one paragraph, and each paragraph starts with a speaker label. To get it from the raw output we did three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Merged&lt;/strong&gt; each run of segments from the same speaker into one turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renamed&lt;/strong&gt; &lt;code&gt;SPEAKER_00&lt;/code&gt; to HOST and &lt;code&gt;SPEAKER_01&lt;/code&gt; to MAYA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kept&lt;/strong&gt; one timestamp per turn: the start of its first segment.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[00:00:00] HOST: Welcome back to Small Shop Stories. Today I'm talking to
Maya, who runs a tiny bakery on the corner of Mill Street.

[00:00:07] MAYA: Thanks for having me. It's really small, just two ovens and
a counter.

[00:00:12] HOST: So how does a normal day start for you?

[00:00:15] MAYA: Honestly, it starts at four in the morning. I switch the
ovens on, mix the sourdough, shape the loaves, and by six there's already a
queue outside the door.

[00:00:22] HOST: For in the morning, every single day?

[00:00:25] MAYA: Six days a week. Sunday is for sleeping and for testing new
recipes.

[00:00:30] HOST: What sells out first?

[00:00:33] MAYA: The cardamom buns. We bake about 80 and they're usually
gone before nine.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The words are exactly what the engine wrote, including "For in the morning", which you would correct before using it. Only the line breaks and labels changed. The 30 fragments become 14 readable turns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Speaker labels in a transcription: which style to use
&lt;/h3&gt;

&lt;p&gt;There's no single standard. What matters is choosing one style and using it the whole way through. Common choices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label style&lt;/th&gt;
&lt;th&gt;Looks like&lt;/th&gt;
&lt;th&gt;Use it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Speaker 1:&lt;/code&gt; &lt;code&gt;Speaker 2:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You don't know the names, or don't need them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full name&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MAYA:&lt;/code&gt; or &lt;code&gt;Maya Patel:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Podcasts, panels, published interviews&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Interviewer:&lt;/code&gt; &lt;code&gt;Participant:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Research and HR interviews, where roles matter more than names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anonymised code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;I:&lt;/code&gt; &lt;code&gt;P1:&lt;/code&gt; &lt;code&gt;P2:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Qualitative research that must not identify participants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q and A&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Q:&lt;/code&gt; &lt;code&gt;A:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Two people, strictly question then answer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few rules hold for all of them. Use the full label the first time someone speaks if you'll shorten it later. Never reuse a label for two people. When you can't tell who's talking, write &lt;code&gt;[unclear speaker]&lt;/code&gt; instead of guessing. In research, the anonymised code &lt;em&gt;is&lt;/em&gt; the privacy measure. Keep the key that maps &lt;code&gt;P1&lt;/code&gt; to a real person separate from the transcripts. For a full research workflow, see &lt;a href="https://scribetoany.com/blog/transcribe-research-interviews" rel="noopener noreferrer"&gt;how to transcribe research interviews&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 3: timestamping a transcription, three styles
&lt;/h2&gt;

&lt;p&gt;Timestamps answer the question &lt;em&gt;where in the recording is this?&lt;/em&gt; How many you need depends on what you'll do with the transcript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per segment.&lt;/strong&gt; One time for every line, as in Example 1. It's the densest style. It's good for checking the transcript against the audio line by line, and it's what subtitle files need. As a document, it's too busy to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per speaker turn.&lt;/strong&gt; One time at the start of each turn, as in Example 2. This is the usual choice for interviews, meetings and podcasts. When you want to quote someone, you can still find the exact moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At fixed intervals.&lt;/strong&gt; A marker every 30 seconds, every minute or every five minutes, dropped into continuous text. This suits long, mostly one-speaker recordings such as lectures and sermons. Here's our interview with a marker every 30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[00:00:00] HOST: Welcome back to Small Shop Stories. Today I'm talking to
Maya, who runs a tiny bakery on the corner of Mill Street. MAYA: Thanks for
having me. It's really small, just two ovens and a counter. HOST: So how
does a normal day start for you? MAYA: Honestly, it starts at four in the
morning. […] HOST: For in the morning, every single day? MAYA: Six days a
week. Sunday is for sleeping and for testing new recipes.

[00:00:30] HOST: What sells out first? MAYA: The cardamom buns. […]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes. A transcript timestamp points to the &lt;strong&gt;start&lt;/strong&gt; of the words it's attached to, so seeking there plays them from the beginning. And &lt;code&gt;[00:00:30]&lt;/code&gt; (hours:minutes:seconds) is clearer than &lt;code&gt;[0:30]&lt;/code&gt; once a recording runs past an hour. Choose the long form if there's any chance it will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 4: timecode, from transcript to subtitles and video editing
&lt;/h2&gt;

&lt;p&gt;"Timecode" means the same moment written to a different precision. Here's the start of Maya's first line, which the engine placed at 7.27 seconds, in each format you're likely to meet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where it's used&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;The 7.27-second mark&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transcript, reading&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HH:MM:SS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;00:00:07&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SRT subtitles&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HH:MM:SS,mmm&lt;/code&gt; (comma)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;00:00:07,270&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebVTT subtitles&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HH:MM:SS.mmm&lt;/code&gt; (dot)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;00:00:07.270&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video editor, 25 fps&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HH:MM:SS:FF&lt;/code&gt; (frames)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;00:00:07:06&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video editor, 30 fps&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HH:MM:SS:FF&lt;/code&gt; (frames)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;00:00:07:08&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three count milliseconds. The last two count &lt;strong&gt;frames&lt;/strong&gt;, and that's where hand-made conversions go wrong. At 25 frames per second, 0.27 seconds is frame 6 (0.27 × 25 = 6.75, rounded down). At 30 fps, it's frame 8. Editors working at 29.97 fps often use &lt;em&gt;drop-frame&lt;/em&gt; timecode, written with a semicolon before the frames. It skips some frame &lt;em&gt;numbers&lt;/em&gt; so that the clock stays in step with real time: the frame after &lt;code&gt;00:00:59;29&lt;/code&gt; is &lt;code&gt;00:01:00;02&lt;/code&gt;. If the timecode is for an editor, ask which frame rate they're using. Don't assume.&lt;/p&gt;

&lt;p&gt;Here are the same opening seconds as an SRT file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1
00:00:00,000 --&amp;gt; 00:00:01,680
[SPEAKER_00] Welcome back to Small Shop Stories.

2
00:00:02,160 --&amp;gt; 00:00:05,320
[SPEAKER_00] Today I'm talking to Maya, who runs a tiny bakery on the

3
00:00:05,320 --&amp;gt; 00:00:06,320
[SPEAKER_00] corner of Mill Street.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the comma before the milliseconds. That's SRT. WebVTT uses a dot. For the full format reference, see &lt;a href="https://scribetoany.com/blog/subtitle-and-transcript-file-formats" rel="noopener noreferrer"&gt;subtitle and transcript file formats&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 5: a video transcript
&lt;/h2&gt;

&lt;p&gt;An audio transcript records what was &lt;em&gt;said&lt;/em&gt;. A video transcript should also record what was &lt;em&gt;shown&lt;/em&gt;, whenever the picture carries meaning the words don't. Think of a title card, a chart, or a click the narrator doesn't describe. The W3C accessibility guidelines call this a &lt;a href="https://www.w3.org/WAI/media/av/transcripts/" rel="noopener noreferrer"&gt;descriptive transcript&lt;/a&gt;. It's what a deaf-blind user reads on a braille display, and it's what search engines and AI tools can quote, because they can't watch the video.&lt;/p&gt;

&lt;p&gt;Here are the first 15 seconds of our own product tutorial. The narration comes from its subtitle file. We wrote the bracketed notes from the video's frames.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[00:00:00] [Title card: "How to use ScribeToAny. Transcript · subtitles ·
translation, step by step", over the dimmed homepage]

NARRATOR: Here's how to use ScribeToAny, step by step: from a recording to a
transcript, subtitles and a translation.

[00:00:05] [The homepage. Headline: "Turn any audio or video into text, then
into 134+ languages"]

[00:00:08] NARRATOR: Go to scribetoany.com and click "Start transcribing
free".

[00:00:13] [The "Create an account" form, with name, email and password
fields. The cursor rests on "Sign in with Google".]

NARRATOR: Sign up with Google, or with your email.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How to write the descriptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Describe only what matters for understanding.&lt;/strong&gt; "The cursor rests on 'Sign in with Google'" helps. "The background is dark grey" doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy on-screen text word for word&lt;/strong&gt;, in quotes. Viewers may search for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark sounds that carry meaning:&lt;/strong&gt; &lt;code&gt;[music]&lt;/code&gt;, &lt;code&gt;[laughter]&lt;/code&gt;, &lt;code&gt;[phone rings]&lt;/code&gt;. This is also what turns subtitles into captions. See &lt;a href="https://scribetoany.com/blog/captions-vs-subtitles" rel="noopener noreferrer"&gt;captions vs. subtitles&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Speech recognition only hears the soundtrack. In a video transcript the narration can be automatic, but the bracketed descriptions are always written by a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verbatim vs. clean verbatim
&lt;/h2&gt;

&lt;p&gt;How faithfully should a transcript keep "um", false starts and repeats? There are two standard answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verbatim&lt;/strong&gt; keeps everything: fillers, stutters, false starts, &lt;code&gt;[pause]&lt;/code&gt; marks. Use it for legal and evidence work, and for research where &lt;em&gt;how&lt;/em&gt; something was said matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean verbatim&lt;/strong&gt; removes the noise but keeps the meaning and the speaker's own words. It's the default for interviews, podcasts, meetings and anything you'll publish.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our test recording can't show the difference, because text-to-speech voices don't hesitate. So here's a made-up line for illustration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verbatim:        So, um, I— I switch the, uh, the ovens on at, like, four.
Clean verbatim:  So I switch the ovens on at four.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whisper-family engines, ours included, tend to write something close to clean verbatim. Many fillers and false starts never reach the text. If you need true verbatim, plan to add them back by hand while listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Style decisions the AI makes for you
&lt;/h2&gt;

&lt;p&gt;Look again at "We bake about &lt;strong&gt;80&lt;/strong&gt;". The script said "eighty", and the engine chose digits. That's not a mistake, but it is a style decision, and you'll find a lot of them in an AI transcript:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbers:&lt;/strong&gt; digits or words ("80" or "eighty"), and times ("four in the morning" or "4 a.m.").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Punctuation:&lt;/strong&gt; where sentences end, which affects how quotes read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spelling:&lt;/strong&gt; US or UK English ("color" or "colour"), and how names and brands are written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No rule is right everywhere. Publications and research teams each have their own style guides. What matters is consistency: decide once, then fix the exceptions with find-and-replace. Don't fix them one by one as you go.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get these formats from your own recording
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Upload the audio or video to the &lt;a href="https://scribetoany.com/tools/audio-to-text" rel="noopener noreferrer"&gt;audio-to-text&lt;/a&gt; or &lt;a href="https://scribetoany.com/tools/video-to-text" rel="noopener noreferrer"&gt;video-to-text&lt;/a&gt; tool. For more than one voice, open the extra settings and tick &lt;strong&gt;Detect speakers&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Check the transcript against the audio. Clicking a timestamp replays that moment, and clicking a line lets you fix a word. That's how you'd catch "For in the morning" and correct it to "Four".&lt;/li&gt;
&lt;li&gt;Copy or export with speakers and timestamps on or off. Use &lt;strong&gt;TXT, DOCX or PDF&lt;/strong&gt; to read, &lt;strong&gt;SRT or VTT&lt;/strong&gt; for subtitles, and &lt;strong&gt;TSV, CSV or JSON&lt;/strong&gt; for analysis.&lt;/li&gt;
&lt;li&gt;In your document, replace &lt;code&gt;SPEAKER_00&lt;/code&gt; and &lt;code&gt;SPEAKER_01&lt;/code&gt; with real names or codes, and merge the short segments into turns where you want the dialogue layout from Example 2.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;A transcript is the same timed words laid out for different jobs. You can play it back segment by segment, read it as dialogue, scan it by timestamp, cut video to it, or read it as text in place of the video. Start from the raw output, choose the layout your reader needs, and check the small words. Try it on your own recording with the &lt;a href="https://scribetoany.com/tools/interview-transcription" rel="noopener noreferrer"&gt;interview transcription&lt;/a&gt; tool.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://scribetoany.com/blog/transcript-examples" rel="noopener noreferrer"&gt;ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>transcription</category>
      <category>ai</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Transcription Pricing Compared (2026): ScribeToAny vs Otter, Rev, Sonix and HappyScribe</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Sun, 27 Sep 2026 14:42:27 +0000</pubDate>
      <link>https://dev.to/ray_mac/transcription-pricing-compared-2026-scribetoany-vs-otter-rev-sonix-and-happyscribe-1bb5</link>
      <guid>https://dev.to/ray_mac/transcription-pricing-compared-2026-scribetoany-vs-otter-rev-sonix-and-happyscribe-1bb5</guid>
      <description>&lt;p&gt;Transcription tools love to advertise a monthly price, but the monthly price&lt;br&gt;
tells you very little. What matters is &lt;strong&gt;how many hours of audio that price&lt;br&gt;
covers&lt;/strong&gt;, and whether the plan lets you upload the files you actually have.&lt;br&gt;
Two tools at the same $20 can differ tenfold in what you get.&lt;/p&gt;

&lt;p&gt;So instead of comparing sticker prices, this post works out what a real&lt;br&gt;
workload costs on five tools: &lt;strong&gt;Otter.ai, Rev, Sonix, HappyScribe and&lt;br&gt;
ScribeToAny&lt;/strong&gt; (ours — we'll be upfront about where the others are the better&lt;br&gt;
choice). Every number comes from each vendor's public pricing page, checked on&lt;br&gt;
&lt;strong&gt;September 23, 2026&lt;/strong&gt;; links are at the end. Prices change, so treat this as a&lt;br&gt;
snapshot and check the vendor's page before you buy.&lt;/p&gt;

&lt;p&gt;One scoping note: this is about &lt;strong&gt;transcribing recorded files&lt;/strong&gt; — interviews,&lt;br&gt;
lectures, podcasts, videos. Live meeting bots are a different job, covered&lt;br&gt;
under "When another tool is the better choice" below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost per hour of audio
&lt;/h2&gt;

&lt;p&gt;Each row is the cheapest plan that includes file uploads, divided by the hours&lt;br&gt;
of transcription it includes each month.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool and plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Included per month&lt;/th&gt;
&lt;th&gt;Cost per hour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScribeToAny Plus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$10/mo · $88/yr&lt;/td&gt;
&lt;td&gt;25 h (1,500 min)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.40&lt;/strong&gt; · $0.29 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScribeToAny Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$20/mo · $120/yr&lt;/td&gt;
&lt;td&gt;50 h (3,000 min)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.40&lt;/strong&gt; · $0.20 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rev Essentials&lt;/td&gt;
&lt;td&gt;$29.99/mo · $305.90/yr&lt;/td&gt;
&lt;td&gt;83 h (5,000 min)&lt;/td&gt;
&lt;td&gt;$0.36 · $0.31 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Otter.ai Pro&lt;/td&gt;
&lt;td&gt;$16.99/mo · $99.96/yr&lt;/td&gt;
&lt;td&gt;20 h (1,200 min), &lt;strong&gt;max 10 file imports&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;$0.85 · $0.42 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HappyScribe Pro&lt;/td&gt;
&lt;td&gt;€29/mo · €228/yr&lt;/td&gt;
&lt;td&gt;10 h (600 min)&lt;/td&gt;
&lt;td&gt;€2.90 · €1.90 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HappyScribe Basic&lt;/td&gt;
&lt;td&gt;€17/mo · €102/yr&lt;/td&gt;
&lt;td&gt;2 h (120 min)&lt;/td&gt;
&lt;td&gt;€8.50 · €4.25 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonix Core&lt;/td&gt;
&lt;td&gt;$25/mo · $275/yr&lt;/td&gt;
&lt;td&gt;5 h&lt;/td&gt;
&lt;td&gt;$5.00 · $4.58 yearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonix pay-as-you-go&lt;/td&gt;
&lt;td&gt;$10 per hour&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out. Per hour, &lt;strong&gt;Rev Essentials is slightly cheaper than&lt;br&gt;
ScribeToAny on monthly billing&lt;/strong&gt; ($0.36 vs $0.40; on yearly billing ScribeToAny&lt;br&gt;
is cheaper) — but it starts at $29.99 and covers English and Spanish only;&lt;br&gt;
you need Rev Pro ($59.99/mo) for its 37+ languages. And &lt;strong&gt;Otter's 1,200&lt;br&gt;
minutes are capped at 10 imported files a month&lt;/strong&gt; on Pro, so if your work is&lt;br&gt;
uploading recordings rather than recording meetings, the file cap runs out long&lt;br&gt;
before the minutes do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real month costs
&lt;/h2&gt;

&lt;p&gt;Cost per hour hides the jumps between plans, so here are two concrete&lt;br&gt;
workloads, priced on monthly billing (yearly in brackets).&lt;/p&gt;

&lt;h3&gt;
  
  
  10 hours of recordings a month
&lt;/h3&gt;

&lt;p&gt;Ten one-hour interviews, or twenty 30-minute lectures.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Plan you'd need&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScribeToAny&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Plus (1,500 min)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$10&lt;/strong&gt; ($7.33)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Otter.ai&lt;/td&gt;
&lt;td&gt;Pro — fits only if it's ≤ 10 files&lt;/td&gt;
&lt;td&gt;$16.99 ($8.33)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rev&lt;/td&gt;
&lt;td&gt;Essentials&lt;/td&gt;
&lt;td&gt;$29.99 ($25.49)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HappyScribe&lt;/td&gt;
&lt;td&gt;Pro (600 min)&lt;/td&gt;
&lt;td&gt;€29 (€19)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonix&lt;/td&gt;
&lt;td&gt;Core 5 h + 5 h at $10/h&lt;/td&gt;
&lt;td&gt;$75&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  30 hours of recordings a month
&lt;/h3&gt;

&lt;p&gt;A research project, a weekly podcast back catalogue, or a course's lectures.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Plan you'd need&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScribeToAny&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pro (3,000 min)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$20&lt;/strong&gt; ($10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rev&lt;/td&gt;
&lt;td&gt;Essentials (5,000 min)&lt;/td&gt;
&lt;td&gt;$29.99 ($25.49)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Otter.ai&lt;/td&gt;
&lt;td&gt;Business — Pro stops at 1,200 min and 10 files&lt;/td&gt;
&lt;td&gt;$30 ($19.99)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HappyScribe&lt;/td&gt;
&lt;td&gt;Business (6,000 min)&lt;/td&gt;
&lt;td&gt;€89 (€59)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonix&lt;/td&gt;
&lt;td&gt;Pro (40 h)&lt;/td&gt;
&lt;td&gt;$80 ($73.33)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 30 hours, the gap with Sonix and HappyScribe is several-fold. Rev and Otter&lt;br&gt;
Business come closest, at roughly $20–30 a month against ScribeToAny's $10–20 —&lt;br&gt;
and Otter Business is a per-user plan built for meetings rather than file&lt;br&gt;
uploads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free plans: what you can actually do before paying
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Free allowance&lt;/th&gt;
&lt;th&gt;Longest file&lt;/th&gt;
&lt;th&gt;Exports on free&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScribeToAny&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100 min every month, 2 files/day&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;td&gt;All 8: TXT, SRT, VTT, TSV, CSV, JSON, PDF, DOCX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Otter.ai&lt;/td&gt;
&lt;td&gt;300 min/month, but &lt;strong&gt;3 file imports in total&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;td&gt;TXT, MP3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rev&lt;/td&gt;
&lt;td&gt;45 min/month, English only&lt;/td&gt;
&lt;td&gt;not listed&lt;/td&gt;
&lt;td&gt;not listed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HappyScribe&lt;/td&gt;
&lt;td&gt;10-minute one-time trial&lt;/td&gt;
&lt;td&gt;45 min&lt;/td&gt;
&lt;td&gt;TXT, SRT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonix&lt;/td&gt;
&lt;td&gt;30-minute one-time trial&lt;/td&gt;
&lt;td&gt;not listed&lt;/td&gt;
&lt;td&gt;not listed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ScribeToAny's free plan is smaller than Otter's on paper, but it renews every&lt;br&gt;
month, has no lifetime import cap, and includes speaker labels and every export&lt;br&gt;
format — so you can test your real files, in the format you need, before paying&lt;br&gt;
anything. No credit card is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits that change the math
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File length.&lt;/strong&gt; ScribeToAny paid plans take files up to &lt;strong&gt;3 hours&lt;/strong&gt; (5 GB).
Otter Pro stops at 90 minutes per conversation and HappyScribe Basic at 90
minutes per file; long recordings would need splitting there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Languages.&lt;/strong&gt; ScribeToAny transcribes about &lt;strong&gt;98 languages&lt;/strong&gt; on every plan,
free included, and can transcribe non-English audio straight into English.
Otter supports 6 languages; Rev Essentials 2 (37+ on Pro); Sonix 54+;
HappyScribe 150+ (60+ on its free trial).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel work.&lt;/strong&gt; ScribeToAny paid plans transcribe 6 files at once, with no
daily upload limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translation.&lt;/strong&gt; Finished ScribeToAny transcripts can be translated into 134+
languages for free (via Google Translate), keeping the original subtitle
timings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When another tool is the better choice
&lt;/h2&gt;

&lt;p&gt;Cheaper isn't better if the tool doesn't do the job. Pick one of the others if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need live meeting notes.&lt;/strong&gt; Otter joins Zoom, Teams and Google Meet and
transcribes in real time. ScribeToAny transcribes recordings you upload; it
doesn't join calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need human-verified transcripts.&lt;/strong&gt; Rev offers human transcription at
$1.99/min with 99%+ accuracy, and legal transcript formats — useful for court
and compliance work. ScribeToAny is machine transcription only; see
&lt;a href="https://scribetoany.com/blog/how-accurate-is-ai-transcription" rel="noopener noreferrer"&gt;how accurate AI transcription really is&lt;/a&gt;
for what to expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a rare language or professional subtitling.&lt;/strong&gt; HappyScribe covers
150+ languages, exports 15+ formats and sells human subtitling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're a team that lives in a shared workspace.&lt;/strong&gt; Sonix is built around
multi-seat workspaces and AI analysis of transcripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You transcribe 80+ hours a month.&lt;/strong&gt; Rev's larger allowances (5,000 minutes
on Essentials, 10,000 on Pro) fit very high volumes; ScribeToAny Pro tops out
at 3,000 minutes a month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If your work is &lt;strong&gt;uploading recordings&lt;/strong&gt; — interviews, lectures, podcasts,&lt;br&gt;
videos — and you transcribe somewhere between a few hours and 50 hours a&lt;br&gt;
month, ScribeToAny is the lowest-cost way to do it among these five: $10 a&lt;br&gt;
month covers 25 hours, $20 covers 50, with 3-hour files, ~98 languages and&lt;br&gt;
every export format. You can try it on your own files with the&lt;br&gt;
&lt;a href="https://scribetoany.com/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt;, no card needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;Prices and limits as published on each vendor's pricing page on September 23,&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;EUR prices are shown as published, not converted. Yearly cost per hour
divides the yearly price by 12 months of included time.&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Otter.ai — &lt;a href="https://otter.ai/pricing" rel="noopener noreferrer"&gt;otter.ai/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rev — &lt;a href="https://www.rev.com/pricing" rel="noopener noreferrer"&gt;rev.com/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sonix — &lt;a href="https://sonix.ai/pricing" rel="noopener noreferrer"&gt;sonix.ai/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HappyScribe — &lt;a href="https://www.happyscribe.com/pricing" rel="noopener noreferrer"&gt;happyscribe.com/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ScribeToAny — &lt;a href="https://scribetoany.com/pricing" rel="noopener noreferrer"&gt;scribetoany.com/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otter.ai, Rev, Sonix and HappyScribe are trademarks of their respective owners.&lt;br&gt;
This comparison is independent and not endorsed by any of them. Spotted a&lt;br&gt;
number that's changed? &lt;a href="https://scribetoany.com/contact" rel="noopener noreferrer"&gt;Tell us&lt;/a&gt; and we'll update it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://scribetoany.com/blog/transcription-pricing-compared" rel="noopener noreferrer"&gt;ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>ai</category>
      <category>tools</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Subtitle Reading Speed (CPS): The Limits, and Why AI Subtitles Break Them</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Sat, 26 Sep 2026 04:35:58 +0000</pubDate>
      <link>https://dev.to/ray_mac/subtitle-reading-speed-cps-the-limits-and-why-ai-subtitles-break-them-892</link>
      <guid>https://dev.to/ray_mac/subtitle-reading-speed-cps-the-limits-and-why-ai-subtitles-break-them-892</guid>
      <description>&lt;p&gt;A subtitle can be perfectly accurate and still fail. If a line leaves the&lt;br&gt;
screen before the viewer has finished reading it, the viewer has missed it.&lt;br&gt;
&lt;strong&gt;Reading speed&lt;/strong&gt;, measured in &lt;strong&gt;characters per second (CPS)&lt;/strong&gt;, is how&lt;br&gt;
subtitlers catch that before anyone watches the video.&lt;/p&gt;

&lt;p&gt;This post covers the limits the big style guides use and how to convert&lt;br&gt;
between CPS and words per minute. It then runs a small experiment on&lt;br&gt;
machine-made subtitles. The finding surprised us: &lt;strong&gt;most "too fast" subtitles&lt;br&gt;
from AI transcription aren't too wordy. They're timed too tightly&lt;/strong&gt;, and you&lt;br&gt;
can fix most of them without deleting a single word.&lt;/p&gt;
&lt;h2&gt;
  
  
  What CPS measures
&lt;/h2&gt;

&lt;p&gt;CPS is the number of characters in a subtitle divided by how long it stays on&lt;br&gt;
screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CPS = characters in the cue ÷ seconds on screen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 42-character line shown for 2 seconds runs at 21 CPS. The same line shown for&lt;br&gt;
2.5 seconds runs at 16.8 CPS.&lt;/p&gt;

&lt;p&gt;Two details change the result, so check them before you compare numbers&lt;br&gt;
between tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What counts as a character.&lt;/strong&gt; We count every character the eye reads,
including spaces and punctuation, and leave out only the line break between
two rows. Some tools skip spaces or punctuation. Their numbers come out lower
for the same subtitle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which duration counts.&lt;/strong&gt; The time on screen is the cue's end time minus its
start time. It is not how long the person took to say the words. That
difference is the whole story of the experiment below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The limits in the major style guides
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guideline&lt;/th&gt;
&lt;th&gt;Reading speed&lt;/th&gt;
&lt;th&gt;Line length&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Netflix, English (adult)&lt;/td&gt;
&lt;td&gt;up to 20 CPS&lt;/td&gt;
&lt;td&gt;42 characters&lt;/td&gt;
&lt;td&gt;2 max&lt;/td&gt;
&lt;td&gt;5/6 s to 7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netflix, English (children's)&lt;/td&gt;
&lt;td&gt;up to 17 CPS&lt;/td&gt;
&lt;td&gt;42 characters&lt;/td&gt;
&lt;td&gt;2 max&lt;/td&gt;
&lt;td&gt;5/6 s to 7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netflix, Simplified Chinese (adult)&lt;/td&gt;
&lt;td&gt;up to 9 CPS&lt;/td&gt;
&lt;td&gt;16 characters&lt;/td&gt;
&lt;td&gt;2 max&lt;/td&gt;
&lt;td&gt;5/6 s to 7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netflix, Japanese&lt;/td&gt;
&lt;td&gt;up to 4 CPS&lt;/td&gt;
&lt;td&gt;13 full-width characters&lt;/td&gt;
&lt;td&gt;2 max&lt;/td&gt;
&lt;td&gt;5/6 s to 7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BBC&lt;/td&gt;
&lt;td&gt;160–180 words per minute&lt;/td&gt;
&lt;td&gt;about 68% of a 16:9 frame width&lt;/td&gt;
&lt;td&gt;2 (3 if nothing important is covered)&lt;/td&gt;
&lt;td&gt;about 0.3 s per word minimum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chinese and Japanese limits are far lower&lt;/strong&gt;, because each character carries
much more meaning than a Latin letter. A Latin-script limit would never flag
anything in Chinese text. You need a separate threshold for each script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The maximum is not a target.&lt;/strong&gt; Netflix drops to 17 CPS for children's
programs, and the BBC's 160–180 words per minute works out to about 14–16
CPS (see below). Aiming below the ceiling leaves room for viewers who read
slowly or are reading in a second language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum duration matters as much as the speed limit.&lt;/strong&gt; Netflix's 5/6 of a
second (20 frames at 24 fps) exists because a flash of text is hard to catch
even when it is short.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Converting words per minute to CPS
&lt;/h2&gt;

&lt;p&gt;Speech and subtitle guidelines often use words per minute (WPM). To convert&lt;br&gt;
between the two, you need the average characters per word, including the space&lt;br&gt;
after each word.&lt;/p&gt;

&lt;p&gt;In the English sample we measured below (447 words, 2,413 characters), that&lt;br&gt;
came to &lt;strong&gt;5.4 characters per word&lt;/strong&gt;. So, for English:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Words per minute&lt;/th&gt;
&lt;th&gt;≈ CPS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;12.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;td&gt;14.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;16.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;18.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;220&lt;/td&gt;
&lt;td&gt;19.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the BBC's 160–180 WPM works out to about &lt;strong&gt;14–16 CPS&lt;/strong&gt; in our counting,&lt;br&gt;
noticeably below Netflix's 20 CPS adult ceiling. Audiobooks are commonly&lt;br&gt;
recommended at 150–160 WPM; the explainer voice in our test below ran at 189. A&lt;br&gt;
word-for-word subtitle of a fast talker can therefore break a 17 CPS limit on&lt;br&gt;
text alone. But as the experiment shows, text is usually not the main cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  An experiment: where AI subtitles break the limit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The audio.&lt;/strong&gt; We used the English voiceover from our own product video: 2&lt;br&gt;
minutes 48 seconds, 447 words, one clear synthetic voice with natural pauses&lt;br&gt;
between sentences. The words take up 142 seconds of that time, and within those&lt;br&gt;
seconds the voice runs at &lt;strong&gt;189 WPM (17.0 CPS)&lt;/strong&gt;. That's a brisk but normal&lt;br&gt;
explainer-video pace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The subtitles.&lt;/strong&gt; We transcribed it with Whisper (the open-source &lt;code&gt;base&lt;/code&gt;&lt;br&gt;
model, run locally). We then cut the result into subtitle cues with the same&lt;br&gt;
splitting rules ScribeToAny's engine uses for exported SRT files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at most 84 characters per cue (two lines of 42);&lt;/li&gt;
&lt;li&gt;at most 7 seconds per cue;&lt;/li&gt;
&lt;li&gt;break at the end of a sentence first, then at a clause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each cue starts at its first word and ends at its last word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check.&lt;/strong&gt; We counted every cue against 17 and 20 CPS, the two Netflix&lt;br&gt;
English limits, and flagged cues shorter than 5/6 of a second. We then applied&lt;br&gt;
two standard fixes, one at a time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Cues&lt;/th&gt;
&lt;th&gt;Median CPS&lt;/th&gt;
&lt;th&gt;Over 17 CPS&lt;/th&gt;
&lt;th&gt;Over 20 CPS&lt;/th&gt;
&lt;th&gt;Shorter than 5/6 s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A. Cues timed to the words&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;19.3&lt;/td&gt;
&lt;td&gt;47 (68%)&lt;/td&gt;
&lt;td&gt;29 (42%)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B. + end extended into the following pause&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;15.5&lt;/td&gt;
&lt;td&gt;20 (29%)&lt;/td&gt;
&lt;td&gt;9 (13%)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C. + short neighbours merged&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;15.5&lt;/td&gt;
&lt;td&gt;10 (24%)&lt;/td&gt;
&lt;td&gt;3 (7%)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What the numbers say
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Timed to the words, two thirds of the cues were "too fast."&lt;/strong&gt; Yet the audio&lt;br&gt;
averages exactly 17.0 CPS while someone is talking. The average cue was not&lt;br&gt;
too wordy. Cues that start and end exactly with the speech leave no reading&lt;br&gt;
time, and the short ones suffer most. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Need more?" was on screen for 0.36 s, which works out to 27.8 CPS.&lt;/li&gt;
&lt;li&gt;"Fix it right there." lasted 0.6 s, which works out to 31.7 CPS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Using the pauses fixed more than half of the failures.&lt;/strong&gt; For step B, each&lt;br&gt;
cue's end time moves forward into the silence before the next line starts. We&lt;br&gt;
left an 80 ms gap (about 2 frames at 25 fps) and capped each cue at 7 seconds.&lt;br&gt;
That alone took the cues over 17 CPS from 47 to 20, without changing a word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merging short neighbours fixed most of the rest.&lt;/strong&gt; For step C, a cue that&lt;br&gt;
was still too fast merged with the next one when the result stayed within 84&lt;br&gt;
characters and 7 seconds. We only merged where the combined text ended at&lt;br&gt;
punctuation, so no subtitle ends mid-phrase.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 0.44-second "Turn on" fragment rejoined the rest of its sentence, "detect
speakers, and every line gets a speaker label." The merged cue runs at
15.7 CPS.&lt;/li&gt;
&lt;li&gt;Cues over 20 CPS fell to 3 of 41.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's left is a small editing job.&lt;/strong&gt; To bring every remaining cue to 17 CPS,&lt;br&gt;
you would need to cut &lt;strong&gt;40 characters out of 2,399, about 1.7% of the text&lt;/strong&gt;.&lt;br&gt;
To reach 20 CPS, you would cut 5 characters. That is where a human makes&lt;br&gt;
choices: dropping a filler word, or shortening "Then take it out in the format&lt;br&gt;
you need" to "Then export it."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The caveats.&lt;/strong&gt; This is one recording, with one voice, and with clean pauses&lt;br&gt;
between sentences. A real conversation has fewer pauses, overlapping speakers&lt;br&gt;
and faster bursts, so steps B and C buy less there and condensing matters&lt;br&gt;
more. But the ordering holds in general: &lt;strong&gt;fix the timing before you cut&lt;br&gt;
words.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing a fast subtitle, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extend the end time into silence.&lt;/strong&gt; If the next line doesn't start
immediately, let the current cue stay on screen longer. Keep a small gap of
about 2 frames before the next cue, so the viewer can see that the subtitle
changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the minimum duration.&lt;/strong&gt; Nothing should flash on screen for less
than about 5/6 of a second, even a one-word "Yes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge short neighbours.&lt;/strong&gt; Two quick lines from the same speaker often
read better as one two-line subtitle. Stay within your line and duration
limits, and don't merge across a change of speaker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only then, condense.&lt;/strong&gt; Remove fillers ("you know," "basically"), false
starts and repeated words. Keep the meaning, names and numbers. For a
verbatim or legal transcript you wouldn't do this, but subtitles are a
reading aid, not a record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break lines in sensible places.&lt;/strong&gt; When a subtitle needs two lines, break
after punctuation, or before a conjunction or preposition. Netflix's style
guide asks you not to split an article from its noun or a first name from a
last name. It also prefers a bottom-heavy shape, with the longer line
underneath.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Chinese, Japanese and Korean
&lt;/h2&gt;

&lt;p&gt;For these scripts, character counts behave differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Netflix's Simplified Chinese guide allows &lt;strong&gt;9 CPS and 16 characters a line&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Its Japanese guide allows &lt;strong&gt;4 CPS and 13 full-width characters&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Latin 17 CPS threshold would pass almost any Chinese subtitle, however&lt;br&gt;
cramped. Choose the threshold by the script on screen, not by the source&lt;br&gt;
language. An English translation of a Chinese video is checked as English.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking reading speed in ScribeToAny
&lt;/h2&gt;

&lt;p&gt;ScribeToAny's transcript editor has a &lt;strong&gt;QC mode&lt;/strong&gt; on every plan, free&lt;br&gt;
included. Turn on &lt;strong&gt;Reading-speed &amp;amp; line checks&lt;/strong&gt; and each cue shows the&lt;br&gt;
following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its CPS, amber when it gets close to your limit and red when it goes over;&lt;/li&gt;
&lt;li&gt;the character count of each line, with lines over the limit flagged;&lt;/li&gt;
&lt;li&gt;whether a cue is under 5/6 of a second or over 7 seconds, or too close to
the previous cue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The defaults are the conservative ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;17 CPS and 42 characters a line&lt;/strong&gt; for Latin scripts;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 CPS and 16 characters&lt;/strong&gt; for Chinese, Japanese and Korean, picked
automatically from the text on screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can change both numbers, for example to 20 CPS for Netflix-style adult&lt;br&gt;
English. You can edit start and end times directly, split a cue or merge it&lt;br&gt;
with the next one, and roll back to an earlier version. Then export SRT or VTT&lt;br&gt;
with the same timings.&lt;/p&gt;

&lt;p&gt;To be clear about what it doesn't do: &lt;strong&gt;QC mode flags problems. It doesn't&lt;br&gt;
retime cues for you.&lt;/strong&gt; Steps 1 to 4 above are still your call. That's&lt;br&gt;
deliberate. A tool that silently stretches or rewrites subtitles makes&lt;br&gt;
decisions only a person watching the video can make.&lt;/p&gt;

&lt;p&gt;If you only need to adjust an existing SRT file, the free&lt;br&gt;
&lt;a href="https://scribetoany.com/tools/srt-editor" rel="noopener noreferrer"&gt;SRT editor&lt;/a&gt; runs in your browser without an account. It&lt;br&gt;
doesn't show reading-speed checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://scribetoany.com/blog/captions-vs-subtitles" rel="noopener noreferrer"&gt;Captions vs. subtitles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scribetoany.com/blog/subtitle-and-transcript-file-formats" rel="noopener noreferrer"&gt;Subtitle and transcript file formats&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scribetoany.com/blog/translate-subtitles-step-by-step" rel="noopener noreferrer"&gt;How to translate subtitles step by step&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Netflix, &lt;a href="https://partnerhelp.netflixstudios.com/hc/en-us/articles/217350977-English-USA-Timed-Text-Style-Guide" rel="noopener noreferrer"&gt;English (USA) Timed Text Style Guide&lt;/a&gt;:
42 characters a line, up to 20 CPS for adult programs and 17 for children's,
and line-break rules.&lt;/li&gt;
&lt;li&gt;Netflix, &lt;a href="https://partnerhelp.netflixstudios.com/hc/en-us/articles/215758617-Timed-Text-Style-Guide-General-Requirements" rel="noopener noreferrer"&gt;Timed Text Style Guide: General Requirements&lt;/a&gt;:
5/6-second minimum, 7-second maximum, 2 lines.&lt;/li&gt;
&lt;li&gt;Netflix, &lt;a href="https://partnerhelp.netflixstudios.com/hc/en-us/articles/215986007-Chinese-Simplified-Timed-Text-Style-Guide" rel="noopener noreferrer"&gt;Chinese (Simplified) Timed Text Style Guide&lt;/a&gt;
and &lt;a href="https://partnerhelp.netflixstudios.com/hc/en-us/articles/215767517-Japanese-Timed-Text-Style-Guide" rel="noopener noreferrer"&gt;Japanese Timed Text Style Guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Wikipedia, &lt;a href="https://en.wikipedia.org/wiki/Words_per_minute" rel="noopener noreferrer"&gt;Words per minute&lt;/a&gt;:
150–160 WPM recommended for audiobooks.&lt;/li&gt;
&lt;li&gt;BBC, &lt;a href="https://www.bbc.co.uk/accessibility/forproducts/guides/subtitles/" rel="noopener noreferrer"&gt;Subtitle Guidelines&lt;/a&gt;:
160–180 words per minute, line width and line count.&lt;/li&gt;
&lt;li&gt;Experiment: our own measurement on September 24, 2026, as described above.
The cue-splitting rules are the ones our transcription engine uses. Our
production service runs larger Whisper models than the &lt;code&gt;base&lt;/code&gt; model used
here, which mainly affects word accuracy rather than timing.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://scribetoany.com/blog/subtitle-reading-speed-cps" rel="noopener noreferrer"&gt;ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>subtitles</category>
      <category>whisper</category>
      <category>video</category>
      <category>a11y</category>
    </item>
    <item>
      <title>How We Optimized ScribeToAny: From 3.5s Cloudflare Cold Starts to a 95+ Lighthouse Score</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Tue, 08 Sep 2026 04:59:18 +0000</pubDate>
      <link>https://dev.to/ray_mac/how-we-optimized-scribetoany-from-35s-cloudflare-cold-starts-to-a-95-lighthouse-score-be</link>
      <guid>https://dev.to/ray_mac/how-we-optimized-scribetoany-from-35s-cloudflare-cold-starts-to-a-95-lighthouse-score-be</guid>
      <description>&lt;p&gt;&lt;a href="https://scribetoany.com" rel="noopener noreferrer"&gt;ScribeToAny&lt;/a&gt; is a full-stack audio and video transcription platform built on React 19 and deployed to Cloudflare Workers at the edge. Heavy GPU workloads (Whisper transcription, diarization, translation) run asynchronously on &lt;a href="https://modal.com" rel="noopener noreferrer"&gt;Modal&lt;/a&gt;, while our web application, authentication, database queries, and SSR are powered by Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;While our GPU pipeline was fast and asynchronous, our front-end performance audit delivered a harsh wake-up call: real-world user monitoring (Cloudflare Observatory) showed our &lt;strong&gt;75th-percentile TTFB was 3,128 ms&lt;/strong&gt; — with over 57% of hits rated "poor". Direct &lt;code&gt;curl&lt;/code&gt; tests hitting cold edge nodes clocked TTFB between &lt;strong&gt;3.4s and 3.6s&lt;/strong&gt; for the homepage and SEO tool pages.&lt;/p&gt;

&lt;p&gt;Yet, Cloudflare's internal Worker metrics showed a median CPU wall time of &lt;strong&gt;just 5 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;How could a Worker that finishes its work in 5 milliseconds take 3.5 seconds to return a response?&lt;/p&gt;

&lt;p&gt;Here is the complete engineering breakdown of how we diagnosed the cold-start bottleneck, implemented zero-staleness edge HTML caching, decoupled our client bundles, slashed mobile hydration TBT, and took ScribeToAny to a &lt;strong&gt;95+ Lighthouse performance score&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Paradox: Why 5ms CPU Took 3.5s TTFB
&lt;/h2&gt;

&lt;p&gt;Cloudflare Worker CPU metrics only record active handler execution. They do &lt;strong&gt;not&lt;/strong&gt; account for isolate creation, bundle downloading, and V8 script compilation.&lt;/p&gt;

&lt;p&gt;When we inspected our deployment pipeline, two factors collided:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An 11MB Worker Bundle&lt;/strong&gt;: Because ScribeToAny includes rich SEO tool routes (over 80 &lt;a href="https://scribetoany.com/tools" rel="noopener noreferrer"&gt;audio/video conversion and transcription tools&lt;/a&gt;), markdown renderers, and format converter utilities, the bundled worker script reached ~11MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low Baseline Traffic Density (~0.1 req/s)&lt;/strong&gt;: With sparse initial traffic, Cloudflare edge PoPs frequently evict idle V8 isolates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Almost every new visitor — especially organic search traffic arriving from Google — hit a brand-new, cold isolate. Before running our 5ms SSR handler, V8 had to load and compile an 11MB JavaScript payload. The result was a 3-second cold-start penalty on first visit, completely undermining the user experience and SEO ranking potential.&lt;/p&gt;

&lt;p&gt;Furthermore, on mobile devices, initial Lighthouse runs flagged heavy Total Blocking Time (TBT): third-party authentication scripts and oversized vendor chunks were monopolizing the main thread during hydration.&lt;/p&gt;

&lt;p&gt;We solved this through a systematic four-layer optimization strategy.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Layer 1: Edge HTML Caching with Zero-Staleness Build Invalidation
&lt;/h2&gt;

&lt;p&gt;Cloudflare Workers do not automatically cache dynamic SSR responses. Every incoming &lt;code&gt;GET&lt;/code&gt; request was hitting our Worker, forcing a cold-start compilation.&lt;/p&gt;

&lt;p&gt;Because our marketing pages, blog, and SEO tools are public and identical for anonymous visitors of the same URL, we implemented edge-level HTML caching directly in &lt;code&gt;src/server.ts&lt;/code&gt; using the Workers Cache API (&lt;code&gt;caches.default&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Strict Isolation &amp;amp; Bypass Rules
&lt;/h3&gt;

&lt;p&gt;Edge caching dynamic web apps requires strict safety boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session isolation&lt;/strong&gt;: If the incoming request has a &lt;code&gt;better-auth.session_token&lt;/code&gt; cookie, cache reading and writing are completely bypassed. Logged-in users always receive fresh, personalized SSR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict allowlist&lt;/strong&gt;: Caching is restricted strictly to anonymous public pages (&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/pricing&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/changelog&lt;/code&gt;, &lt;code&gt;/tools/*&lt;/code&gt;, &lt;code&gt;/blog/*&lt;/code&gt;, and legal pages). Dynamic routes (&lt;code&gt;/dashboard&lt;/code&gt;, &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/settings&lt;/code&gt;) are never cached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache write condition&lt;/strong&gt;: Only responses with HTTP status &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;Content-Type: text/html&lt;/code&gt;, and no &lt;code&gt;Set-Cookie&lt;/code&gt; header are stored.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/server.ts (abridged)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasSessionCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nf"&gt;isCacheablePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;deLocalizeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caches&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cache&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;default&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canCache&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;edgeCacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&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;canCache&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;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&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;hit&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;headers&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;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Edge-Cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&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;h3&gt;
  
  
  Solving the Stale Content Problem
&lt;/h3&gt;

&lt;p&gt;The biggest danger of &lt;code&gt;caches.default&lt;/code&gt; on Cloudflare Workers is that &lt;strong&gt;a new Worker deployment does not purge the cache&lt;/strong&gt;. With a standard URL key, publishing a new blog post or fixing a bug would leave stale HTML served to users for days.&lt;/p&gt;

&lt;p&gt;We solved this by injecting a compile-time build identifier into the cache key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;define&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;__EDGE_BUILD_ID__&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;src/server.ts&lt;/code&gt;, we construct an internal cache key request tagged with &lt;code&gt;__EDGE_BUILD_ID__&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;edgeCacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Request&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;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__ev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;__EDGE_BUILD_ID__&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;request&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;This internal query parameter is only passed to &lt;code&gt;cache.match&lt;/code&gt; and &lt;code&gt;cache.put&lt;/code&gt;; it is never exposed to the client or upstream origin.&lt;/p&gt;

&lt;p&gt;When a new version deploys, &lt;code&gt;__EDGE_BUILD_ID__&lt;/code&gt; changes. Old cache entries become instantly unreachable and expire naturally on their TTL, while the new release begins populating immediately. We can safely set &lt;code&gt;s-maxage=86400&lt;/code&gt; (24h) and &lt;code&gt;stale-while-revalidate=604800&lt;/code&gt; (7 days) without ever worrying about stale HTML after a release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: Edge cache hits now return in &lt;strong&gt;under 45 milliseconds&lt;/strong&gt; directly from the nearest Cloudflare edge PoP, completely bypassing Worker isolate cold starts.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Layer 2: Main Bundle Decoupling &amp;amp; Vendor Splitting
&lt;/h2&gt;

&lt;p&gt;Serving HTML quickly is only half the battle; if the browser has to parse hundreds of kilobytes of unoptimized JavaScript before becoming interactive, the experience still stumbles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decoupling Global Configs
&lt;/h3&gt;

&lt;p&gt;Our site configuration (&lt;code&gt;src/config/website.ts&lt;/code&gt;) originally bundled metadata, navigation menus, multilingual pricing matrices, and 84 tool route names into one giant object. Because the root layout imported &lt;code&gt;websiteConfig&lt;/code&gt;, every visitor downloaded definitions for 84 tools they hadn't visited.&lt;/p&gt;

&lt;p&gt;We extracted tool metadata into &lt;code&gt;src/config/navbar-tool-names.ts&lt;/code&gt; and pricing calculations into &lt;code&gt;src/config/price-plans.ts&lt;/code&gt;, ensuring tool pages only load their own definitions on demand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vite Manual Chunks
&lt;/h3&gt;

&lt;p&gt;By default, Vite bundled React, TanStack Query, and Zod into monolithic client bundles. In &lt;code&gt;vite.config.ts&lt;/code&gt;, we configured explicit vendor chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;manualChunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&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;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tabler/icons-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendor-icons&lt;/span&gt;&lt;span class="dl"&gt;'&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;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/react/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/react-dom/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/scheduler/&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendor-react&lt;/span&gt;&lt;span class="dl"&gt;'&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;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/@tanstack/react-query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/@tanstack/query-core&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendor-query&lt;/span&gt;&lt;span class="dl"&gt;'&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;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/zod/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendor-zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&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;This isolates shared dependencies, improving long-term browser caching across route transitions and preventing small code changes from invalidating vendor libraries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scoping Global Providers
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;src/routes/__root.tsx&lt;/code&gt;, Radix &lt;code&gt;TooltipProvider&lt;/code&gt; originally wrapped the entire application tree. This forced React to initialize tooltip context on every public landing page even though tooltips were only used inside the logged-in dashboard and transcription editor. We removed &lt;code&gt;TooltipProvider&lt;/code&gt; from the root route and scoped it exclusively to the editor and dashboard components.&lt;/p&gt;

&lt;p&gt;Similarly, we extracted &lt;code&gt;prose.css&lt;/code&gt; from the global &lt;code&gt;styles.css&lt;/code&gt;. Markdown typography styling is now loaded strictly on blog, legal, and documentation routes, eliminating unused CSS overhead on marketing pages.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Layer 3: Offscreen Lazy-Loading &amp;amp; Slashing Hydration TBT
&lt;/h2&gt;

&lt;p&gt;On mobile devices, client-side hydration was choking the CPU. When a browser downloads a page, hydrating every single DOM node on a long marketing page blocks user taps and scrolls (high Total Blocking Time).&lt;/p&gt;

&lt;h3&gt;
  
  
  Synchronous Above-the-Fold, Lazy Below-the-Fold
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;src/components/blocks/homepage.tsx&lt;/code&gt;, we split the landing page into two categories:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Above-the-Fold (Synchronous)&lt;/strong&gt;: &lt;code&gt;HeroSection&lt;/code&gt;, &lt;code&gt;TrustStrip&lt;/code&gt;, and &lt;code&gt;WhisperTechSection&lt;/code&gt; are imported synchronously. They render immediately during SSR and hydrate on frame 1 for instant First Contentful Paint (FCP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Below-the-Fold (Lazy Loaded)&lt;/strong&gt;: &lt;code&gt;FeaturesSection&lt;/code&gt;, &lt;code&gt;Features2Section&lt;/code&gt;, &lt;code&gt;StatsSection&lt;/code&gt;, &lt;code&gt;CallToActionSection&lt;/code&gt;, &lt;code&gt;PricingSection&lt;/code&gt;, &lt;code&gt;FaqSection&lt;/code&gt;, and &lt;code&gt;NewsletterCard&lt;/code&gt; are wrapped in &lt;code&gt;React.lazy()&lt;/code&gt; and &lt;code&gt;Suspense&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To prevent Cumulative Layout Shift (CLS) when these components resolve, each &lt;code&gt;Suspense&lt;/code&gt; boundary has an explicit minimum-height skeleton fallback matching the component's rendered height:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/blocks/homepage.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;HomePage&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HeroSection&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TrustStrip&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WhisperTechSection&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-[650px]"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeaturesSection&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-[550px]"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Features2Section&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-[300px]"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CallToActionSection&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&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;This guaranteed &lt;strong&gt;0.00 CLS&lt;/strong&gt; while cutting the initial hydration JavaScript payload by over 40%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deferring Google One Tap to Idle
&lt;/h3&gt;

&lt;p&gt;One of the largest contributors to mobile Total Blocking Time was Google One Tap (&lt;code&gt;authClient.oneTap()&lt;/code&gt;). Previously, the Google Identity Services script executed during initial React hydration, spinning up iframe bridges and network checks while the user was trying to interact with the hero section.&lt;/p&gt;

&lt;p&gt;We moved Google One Tap out of the critical rendering path by wrapping it in &lt;code&gt;requestIdleCallback&lt;/code&gt; (with an 8-second fallback timeout) and attaching one-time event listeners to user interactions (&lt;code&gt;pointerdown&lt;/code&gt;, &lt;code&gt;touchstart&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/routes/__root.tsx (abridged)&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isOneTapEnabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;executed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;trigger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;executed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;executed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;authClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneTap&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;interactionEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointerdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;evt&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;interactionEvents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestIdleCallback&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: zero main-thread interference during initial paint.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Layer 4: LCP &amp;amp; Accessibility Polish
&lt;/h2&gt;

&lt;p&gt;With TTFB and hydration fixed, we addressed visual rendering timing and audit metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Critical CSS Preloading&lt;/strong&gt;: Added &lt;code&gt;&amp;lt;link rel="preload" as="style" href={appCss} /&amp;gt;&lt;/code&gt; in &lt;code&gt;src/routes/__root.tsx&lt;/code&gt; to eliminate stylesheet render blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hero H1 Animation Delay Removal&lt;/strong&gt;: In &lt;code&gt;src/components/blocks/hero.tsx&lt;/code&gt;, our primary H1 heading previously had a subtle entrance fade-in animation delay. Removing the delay allowed Lighthouse to record Largest Contentful Paint (LCP) the instant the first paint completed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility &amp;amp; Contrast&lt;/strong&gt;: Fixed &lt;code&gt;aria-orientation="horizontal"&lt;/code&gt; on button toggle groups, added descriptive &lt;code&gt;aria-label&lt;/code&gt; tags, and increased primary button color contrast to meet WCAG AA standards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Descriptive Anchor Text&lt;/strong&gt;: Replaced ambiguous "Learn more" link texts with descriptive link destinations ("Security →"), satisfying Lighthouse SEO crawlability checks.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  6. The Results: Lighthouse 95+ &amp;amp; Core Web Vitals
&lt;/h2&gt;

&lt;p&gt;After deploying these four layers, we ran Google PageSpeed Insights on &lt;a href="https://scribetoany.com" rel="noopener noreferrer"&gt;scribetoany.com&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Desktop Performance: 95 / 100
&lt;/h3&gt;

&lt;p&gt;On desktop devices, our score surged to &lt;strong&gt;95 Performance&lt;/strong&gt;, &lt;strong&gt;100 Accessibility&lt;/strong&gt;, &lt;strong&gt;96 Best Practices&lt;/strong&gt;, and &lt;strong&gt;100 SEO&lt;/strong&gt;, with a 3/3 score on Agentic Browsing audits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftpq7113fulc8bjy4poxi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftpq7113fulc8bjy4poxi.png" alt="Google PageSpeed Insights Desktop Report for ScribeToAny" width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Score / Value&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🟢 Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accessibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🟢 Perfect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best Practices&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🟢 Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🟢 Perfect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agentic Browsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 / 3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🟢 Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cumulative Layout Shift (CLS)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🟢 Zero Shift&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Mobile Performance: 78 / 100
&lt;/h3&gt;

&lt;p&gt;On simulated mobile networks with aggressive 4G throttling and restricted mobile CPU profiles, our performance jumped to &lt;strong&gt;78 Performance&lt;/strong&gt;, alongside flawless &lt;strong&gt;100 Accessibility&lt;/strong&gt; and &lt;strong&gt;100 SEO&lt;/strong&gt; scores:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1w3h08bhhpybq2j2yg64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1w3h08bhhpybq2j2yg64.png" alt="Google PageSpeed Insights Mobile Report for ScribeToAny" width="800" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before Optimization&lt;/th&gt;
&lt;th&gt;After Optimization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P75 Edge TTFB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3,128 ms (57% Poor)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 50 ms (Edge HIT)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cold Worker TTFB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3,500 ms&lt;/td&gt;
&lt;td&gt;Bypassed for all anon visitors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Desktop Performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~68&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;95&lt;/strong&gt; 🟢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mobile Performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~42&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;78&lt;/strong&gt; 🟡&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cumulative Layout Shift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.03&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.00&lt;/strong&gt; 🟢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Accessibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100&lt;/strong&gt; 🟢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100&lt;/strong&gt; 🟢&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Key Lessons for Full-Stack Edge Applications
&lt;/h2&gt;

&lt;p&gt;Building on Cloudflare Workers and a modern full-stack React framework gives you unprecedented global reach, but edge runtimes operate under different rules than traditional Node servers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Beware the "Fast Handler, Slow Isolate" trap&lt;/strong&gt;: If your Worker takes 5ms of CPU time but bundle size is 10MB+, your users are waiting on V8 compilation, not code execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Cache is mandatory for SSR&lt;/strong&gt;: You don't need a static site generator (SSG) to get static speeds. Using Cloudflare's &lt;code&gt;caches.default&lt;/code&gt; with build-ID key invalidation gives you static speed with all the flexibility of SSR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard your cache keys with build tags&lt;/strong&gt;: Never deploy edge caches without an automated invalidation strategy. Folding compile-time hashes into internal cache keys guarantees zero-downtime freshness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hydrate lazily, paint immediately&lt;/strong&gt;: Render above-the-fold components synchronously, defer offscreen blocks with explicit height skeletons, and push heavy third-party scripts (like Google Identity Services) to &lt;code&gt;requestIdleCallback&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Speed is a feature. By aligning our edge architecture with browser execution priorities, ScribeToAny now delivers an instantaneous experience to users worldwide. &lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>performance</category>
      <category>react</category>
    </item>
    <item>
      <title>Running Whisper on Modal from Cloudflare Workers</title>
      <dc:creator>Ray Mac</dc:creator>
      <pubDate>Thu, 03 Sep 2026 05:32:39 +0000</pubDate>
      <link>https://dev.to/ray_mac/running-whisper-on-modal-from-cloudflare-workers-2hla</link>
      <guid>https://dev.to/ray_mac/running-whisper-on-modal-from-cloudflare-workers-2hla</guid>
      <description>&lt;p&gt;A Cloudflare Worker can't run a multi-minute GPU job, and it can't open the gRPC connection Modal's Python SDK uses to &lt;code&gt;spawn()&lt;/code&gt; one. Two hard "no"s — and yet the transcription in &lt;a href="https://scribetoany.com" rel="noopener noreferrer"&gt;ScribeToAny&lt;/a&gt; runs on Modal GPUs while the whole web app runs on Workers. The trick is to stop treating Modal as an SDK and start treating it as an HTTP endpoint you fire-and-forget, with the GPU box calling back over a signed webhook. Here's the whole design, with the edges that actually bit.&lt;/p&gt;

&lt;p&gt;The web app runs entirely on Cloudflare Workers; Whisper (plus an optional translation pass) runs on GPUs on &lt;a href="https://modal.com" rel="noopener noreferrer"&gt;Modal&lt;/a&gt;. Getting two runtimes with opposite shapes to cooperate was the most interesting part of the build — because the obvious way is impossible on Workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint
&lt;/h2&gt;

&lt;p&gt;A Worker is not a server. It wakes up on a request, gets a small CPU budget, and is expected to return quickly. It has no long-lived process to babysit a job that takes minutes, and it can't open the gRPC connection Modal's Python SDK uses to call &lt;code&gt;Function.spawn()&lt;/code&gt;. Two non-starters, same conclusion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You cannot run the transcription &lt;em&gt;in&lt;/em&gt; the Worker. A ten-minute podcast is not a request-scoped workload.&lt;/li&gt;
&lt;li&gt;You cannot even use Modal's normal client to &lt;em&gt;start&lt;/em&gt; the job. There's no gRPC, no Python, no persistent socket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The naive version — "await the transcription and return the transcript" — dies on the first point. So the design has to be asynchronous from the very first line, and the Worker's entire job shrinks to a handful of sub-second HTTP calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the answer
&lt;/h2&gt;

&lt;p&gt;Treat Modal not as an SDK but as an HTTP endpoint. Modal lets you expose a web endpoint that, when hit, &lt;em&gt;spawns&lt;/em&gt; the real GPU function and returns immediately. So the flow becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser uploads the media straight to R2 (never through the Worker).&lt;/li&gt;
&lt;li&gt;The Worker presigns a read URL, writes a &lt;code&gt;queued&lt;/code&gt; job row, and fires one POST at Modal's web endpoint. Modal acks with a call id and starts the GPU work in the background.&lt;/li&gt;
&lt;li&gt;When the engine finishes (or fails, or just wants to report progress), it POSTs back to a webhook on the Worker, signed with a shared secret.&lt;/li&gt;
&lt;li&gt;The frontend polls the job row and lights up when it flips to &lt;code&gt;done&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Worker only ever does three quick things: presign, spawn, apply-callback. None of them wait on a GPU. That's the whole trick — and the rest of the work is making it survive the real world, where webhooks get lost and callbacks arrive twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uploading without touching the Worker
&lt;/h2&gt;

&lt;p&gt;Media never streams through the Worker — that would blow the CPU budget and buy nothing. The browser gets a presigned R2 &lt;code&gt;PUT&lt;/code&gt; and uploads directly. We issue it &lt;em&gt;intent-first&lt;/em&gt;: a &lt;code&gt;pending&lt;/code&gt; row is written &lt;strong&gt;before&lt;/strong&gt; the URL is signed, so an upload that's abandoned mid-flight still leaves a trace we can sweep later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// createUploadUrl (server function) — abridged&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userFiles&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r2Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="cm"&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;uploadUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;presignR2Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r2Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PRESIGN_PUT_TTL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fileId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uploadUrl&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the &lt;code&gt;PUT&lt;/code&gt; succeeds the client calls &lt;code&gt;finalizeUpload&lt;/code&gt;, which flips &lt;code&gt;pending → uploaded&lt;/code&gt; and corrects the size from R2's &lt;code&gt;HEAD&lt;/code&gt; (never trust a client-reported byte count). Rows that never reach &lt;code&gt;uploaded&lt;/code&gt; are garbage-collected by a cron sweep — more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firing the job
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;transcribeFile&lt;/code&gt; is where the async handoff happens. It checks quota, presigns a &lt;strong&gt;GET&lt;/strong&gt; so the engine can read the audio back out of R2, inserts a &lt;code&gt;queued&lt;/code&gt; job behind an atomic concurrency guard, and spawns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;presignR2Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r2Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PRESIGN_GET_TTL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// … insert the queued job row (atomic guard) …&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;spawnTranscription&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audioUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* mode, language, targetLang … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spawn itself is deliberately dumb: one &lt;code&gt;fetch&lt;/code&gt;, and it resolves the moment Modal &lt;em&gt;accepts&lt;/em&gt; the job — not when transcription finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MODAL_TRANSCRIBE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Modal Proxy Auth — token id/secret, not sent in the body&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Modal-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODAL_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Modal-Secret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODAL_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;callback_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/transcripts/webhook`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;audio_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;audioUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;model_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beam_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;targetLang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// null ⇒ no translation leg at all&lt;/span&gt;
  &lt;span class="p"&gt;}),&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to start transcription (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out. The callback URL is handed to the engine in the request, so the engine never has to know our topology. And the signing secret is &lt;strong&gt;pre-shared&lt;/strong&gt; (a Modal secret that equals our &lt;code&gt;MODAL_WEBHOOK_SECRET&lt;/code&gt;) — it is never put in a request body in either direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The callback: verify, then apply idempotently
&lt;/h2&gt;

&lt;p&gt;Everything interesting now happens in the webhook. First, authenticate it. The engine signs the &lt;strong&gt;raw body&lt;/strong&gt; with HMAC-SHA256 and sends the hex digest in &lt;code&gt;X-Webhook-Signature&lt;/code&gt;. On Workers there's no Node &lt;code&gt;crypto&lt;/code&gt;, so this is WebCrypto, and the comparison is timing-safe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyWebhookSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sign&lt;/span&gt;&lt;span class="dl"&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;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^sha256=/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;You have to hash the &lt;em&gt;raw&lt;/em&gt; bytes, not a re-serialized object — &lt;code&gt;JSON.parse&lt;/code&gt; then &lt;code&gt;JSON.stringify&lt;/code&gt; will reorder keys and change whitespace, and your signature will never match. So the handler reads &lt;code&gt;await request.text()&lt;/code&gt; and verifies before it parses.&lt;/p&gt;

&lt;p&gt;Then apply the result. The single most important property here is &lt;strong&gt;idempotency&lt;/strong&gt;, because a webhook you don't 200 fast enough gets retried, and a retried callback must not double-apply. The rule is one line: once a job is terminal, ignore repeats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;applied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// already terminal — no-op&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP status codes are chosen to steer the engine's retry behaviour:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bad/missing signature&lt;/td&gt;
&lt;td&gt;&lt;code&gt;401&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reject outright&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unknown &lt;code&gt;job_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ack so the engine &lt;strong&gt;stops&lt;/strong&gt; retrying a job we'll never have&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applied (or already terminal)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Our own DB threw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;500&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ask the engine to &lt;strong&gt;retry&lt;/strong&gt; — the callback was valid, we just fumbled it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third row is the subtle one. An unknown job isn't an error to bubble up; it's a dead letter, and the kindest thing you can do is acknowledge it so the sender gives up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sharp edges
&lt;/h2&gt;

&lt;p&gt;The happy path above is maybe a third of the code. The rest is everything that goes wrong when one side of an async contract can vanish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lost webhooks.&lt;/strong&gt; If the engine crashes, or the callback is dropped, the job sits in &lt;code&gt;transcribing&lt;/code&gt; forever. So a cron job reconciles: any job that's been quiet past a timeout is marked &lt;code&gt;failed&lt;/code&gt;, and the same tick sweeps orphaned R2 uploads that never finalized. Workers cron triggers are perfect for this — it's the backstop that makes the optimistic async path safe to rely on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never kill a healthy job by accident.&lt;/strong&gt; We also have a liveness probe that can ask Modal whether a call is still running. It returns a deliberately three-valued answer — &lt;code&gt;running&lt;/code&gt;, a terminal state, or &lt;code&gt;null&lt;/code&gt; meaning &lt;em&gt;"don't know"&lt;/em&gt; (probe not configured, request failed, unparseable). Callers must treat &lt;code&gt;null&lt;/code&gt; as &lt;em&gt;no information&lt;/em&gt; and leave the job alone. Collapsing "I couldn't reach the probe" into "the job is dead" would have the reconciler executing healthy jobs the moment the probe has a bad minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Segments live in R2, not the database.&lt;/strong&gt; A terminal callback does &lt;strong&gt;not&lt;/strong&gt; ship the transcript inline. The engine writes segments to R2 as &lt;code&gt;{job_id}.tsv&lt;/code&gt;; the &lt;code&gt;done&lt;/code&gt; webhook carries only metadata (language, duration, cost, timing). The app reads the TSV on demand and generates SRT/VTT/TXT/PDF from it. Keeping thousands of cue rows out of D1 keeps the callback small and the job table narrow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two clocks in one job.&lt;/strong&gt; Add the optional translation pass and a single job now finishes two things at different times. The transcript can be &lt;code&gt;done&lt;/code&gt; while the translation is still running. So the translation update is applied &lt;em&gt;before&lt;/em&gt; the terminal-state early-return — otherwise a translation ping arriving after the transcript finished would hit the "already terminal, no-op" branch and the translation row would be stuck at &lt;code&gt;queued&lt;/code&gt; forever. Two independent legs, one job row, and the order of those two checks is load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is actually a good fit
&lt;/h2&gt;

&lt;p&gt;It's tempting to read all this as fighting the platform. It isn't. Once the transcription is off the Worker, everything the Worker &lt;em&gt;does&lt;/em&gt; keep is exactly what the Workers model is good at: short, stateless, I/O-bound HTTP handlers with a cron backstop and a durable store (D1 + R2) holding the state between them. The GPU box does GPU work; the edge does edge work; a signed webhook and an idempotent apply are the seam.&lt;/p&gt;

&lt;p&gt;The constraint that looked fatal — "you can't run the job here" — turned out to be the thing that produced a clean design. The Worker never blocks, the job survives a dropped callback, and a retried webhook is a no-op. That's the whole system.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ScribeToAny is built on TanStack Start + React on Cloudflare Workers (D1 + R2), with the transcription engine on Modal. Originally posted &lt;a href="https://scribetoany.com/blog/running-whisper-on-modal-from-cloudflare-workers" rel="noopener noreferrer"&gt;on the ScribeToAny blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
