<?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>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>
