◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/AI Models
◉ AI Models/2026-07-17Advanced

The Three-Minute Video That Kept Failing — Moving Rork's Gemini Uploads Off the Worker

How I rerouted video uploads to the Gemini Files API from a Cloudflare Workers relay to a direct device-to-Google path — why the 128MB isolate limit breaks the relay, how to hand out a resumable upload URL without leaking your API key, what Expo SDK 54 broke on the device side, and how the 20GB project ceiling and media resolution decide the bill.

Rork583Gemini8Files APIVideo AnalysisCloudflare Workers25Expo219Indie Dev39

✦ Premium Article

A 28-second clip shot on my iPhone went through every time. The analysis came back looking exactly like I wanted.

Then I asked a friend to record three minutes of footage, and the upload simply stopped coming back. No useful error. Just a long pause and eventually a 500.

I spent the first hour suspecting the model. Wrong format, maybe, or a video too long for the request. The logs said otherwise: the thing was dying well before it ever reached Gemini. What had broken was not the inference. It was the pipe.

Twenty-eight seconds passes, three minutes stalls

The setup was reasonable enough, as these things go.

An Expo app picks a video, posts it as multipart to a Cloudflare Worker, and the Worker relays it to the Gemini Files API. I did not want the API key sitting inside the app bundle, so I put a server in the middle. As an indie developer who would rather not run a backend at all, I found Workers a tempting place to land.

The Worker looked like this.

// ❌ The original — the whole video lands in the Worker's heap
async function handleVideoUpload(request: Request, env: Env): Promise<Response> {
  const formData = await request.formData();
  const videoFile = formData.get('video') as File;
 
  // Every byte of the video is materialized here
  const arrayBuffer = await videoFile.arrayBuffer();
 
  const initResponse = await fetch(`${UPLOAD_URL}?key=${env.GEMINI_API_KEY}`, {
    method: 'POST',
    headers: {
      'X-Goog-Upload-Protocol': 'resumable',
      'X-Goog-Upload-Command': 'start',
      'X-Goog-Upload-Header-Content-Length': arrayBuffer.byteLength.toString(),
      'X-Goog-Upload-Header-Content-Type': videoFile.type,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ file: { display_name: videoFile.name } }),
  });
 
  const uploadUri = initResponse.headers.get('X-Goog-Upload-URL');
 
  // ...and then every byte gets shipped a second time
  return fetch(uploadUri!, {
    method: 'POST',
    headers: {
      'Content-Length': arrayBuffer.byteLength.toString(),
      'X-Goog-Upload-Offset': '0',
      'X-Goog-Upload-Command': 'upload, finalize',
    },
    body: arrayBuffer,
  });
}

One line did all the damage: await videoFile.arrayBuffer().

The video was passing straight through the Worker

A Cloudflare Worker isolate is capped at 128MB of memory, covering the JavaScript heap and any WebAssembly allocations. Paying for the Workers plan does not raise it. And the cap applies per isolate, not per invocation — which matters more than it first sounds.

Meanwhile the request body limit on the Free plan is 100MB. So the platform will happily accept a video it has no room to hold.

Here is what my phone actually produces.

Capture settingLengthMeasured sizeResult after arrayBuffer
1080p / 30fps28s~52MBPasses
1080p / 30fps1m 10s~130MB413
720p / 30fps3m~96MBIntermittent 500
4K / 60fps30s~190MB413

The three-minute 720p clip was the nastiest of the four. At 96MB it slips under the body limit and gets accepted. But once those 96MB are on the heap, a single video owns most of the 128MB budget. Add one concurrent request sharing that isolate and you are over.

That is why it failed intermittently. Testing alone, it worked. With a few people touching it at once, it fell over. I could not reproduce it on demand because the bug needed company.

And even on the runs that succeeded, the shape was wasteful. The bytes climb from the phone to Cloudflare, then climb again from Cloudflare to Google. The same payload crosses the network twice while the Worker burns CPU time doing nothing but holding it. The only thing that relay bought me was hiding the API key.

Hiding a key does not require moving the video.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Why loading a video into a Worker's arrayBuffer collides with the 128MB isolate ceiling, and the upload path that removes the problem instead of tuning around it
✦Working Worker and Expo code that keeps the API key server-side while the video travels straight from the device to Google
✦What Expo SDK 54 did to createUploadTask, the expo/fetch rewrite that replaces it, and the upload progress you give up by moving
✦Keeping the 20GB project-wide Files API ceiling visible once the bytes stop passing through your server, plus fps and media resolution as billing controls
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◉ AI Models2026-09-17
The recommended replacement was already gone — auditing all 74 rows of Gemini's deprecation table
Cross-checking Gemini's deprecation table turned up five rows where the recommended replacement had itself already shut down, including gemini-2.5-flash-image, which goes away on October 2. Here is how to verify a migration target, and how to catch the 404 before your users do.
◉ AI Models2026-09-12
Ask Rork for an iOS 27 feature and you may get last year's code back, with no error at all
iOS 27 ships on September 14. When a model is asked for an API it has never seen, there are two kinds of failure: the kind that stops your build, and the kind that does not. Here is why standard Rork and Rork Max differ, and the order I check generated code in.
◉ AI Models2026-08-07
Cutting MCP Tools Didn't Make Anything Lighter — 2,377 Bytes of Definitions vs 110,298 Bytes of Response
I wrote a minimal MCP server for a wallpaper catalog and measured the byte cost of tool definitions against the byte cost of responses. Here is which side actually matters, and the real reason to merge tools.