Everything worked on my laptop. That sentence is the whole post.
I build explainvids.com — you give it a URL or a brief, and it writes and renders a narrated explainer video with motion graphics. Solo, bootstrapped, live on a real domain with real payments. And for about a week, one of the two entry points into the product was broken for everyone except me.
Here is what a user saw. They pasted a website, hit generate, waited, and got a grey box that said "Generation failed." No error code, no detail, nothing to act on. Here is what I saw when I ran the identical brief locally: a finished video, about two minutes later, exactly as designed.
That gap is the most expensive kind of bug a solo founder can have, because your instinct — reproduce it locally — is the one move guaranteed not to work.
We'd shipped a toggle around the same time: pull images from the site being described and use them in the video. Two of the failures came in right after. Obvious culprit, right? Correlation in time, new code, touches the same request.
I spent real hours there. It was wrong, and it was wrong in a way worth describing, because "the new thing broke it" is such a natural assumption.
When I actually traced the payload, the toggle added exactly one field, site.shots — and the function that builds the prompt never read it. The prompt sent to the model was byte-identical whether the box was ticked or not. Worse, when I measured what the image scraper had collected on the two sites people reported, the answer both times was zero images. One site offered a favicon and one undersized PNG; the other had six candidates and all six were rejected by the size and squareness checks. So the feature I suspected was not merely innocent, it hadn't done anything at all on either failing request.
Lesson one, and it cost me a day: the thing you shipped most recently is not evidence. Measure the request, don't reason about the diff.
Once I stopped guessing and instrumented the live route, it took one run to see it. On one of the reported sites, the model call alone took 118.7 seconds.
My hosting cuts a request off at around 60.
That's it. That's the bug. The route never returned, the edge severed the connection, and the browser got a 500 with an empty body. The client tried to parse the response as JSON, failed, and fell back to its own hardcoded string — "Generation failed." Which is why the message named nothing. Every failure my code actually handles returns a proper status and a JSON error with a human sentence in it. That grey box wasn't a coded path failing. It was the absence of any coded path running at all.
If your error message is generic, check whether it's your fallback rather than your handler. A useless error message is often a message you never sent.
My first move was the obvious one. There's a Next.js export, maxDuration, that raises the ceiling on a route. It was already set to 120 in the file. Right there in the code. Surely just bump it?
maxDuration is a Vercel convention. I'm on Firebase App Hosting, which runs on Cloud Run, and it does not read that export. It had been buying me nothing the entire time it sat in the file, quietly looking like a solution. The real ceiling is the Cloud Run service timeout with the Firebase edge in front of it — and the config block App Hosting exposes has no field for raising it. Not "hard to raise." Not there.
So the budget wasn't negotiable. The work had to fit inside it, or leave the request.
Before rewriting anything, I looked at what the model was billing. That call produced 10,751 output tokens — of which 10,212 were thinking tokens. Ninety-five percent of the output, and therefore most of the wall clock, was the model reasoning before it wrote a single line of the actual video.
My own notes estimated ~1,600 output tokens per video. That estimate predated adaptive thinking. It was off by about 6×, and I'd been making decisions against it for weeks.
Then the part that made me wince: I had retries set to 1. If a generated document failed validation, the route made a second full call. On a brief that already takes 118 seconds, the retry doesn't rescue the request — it's what guarantees the timeout. My resilience feature was a reliability feature pointed backwards.
The fix is the boring correct one: get the work off the HTTP request. Kick off a job, write a document, return immediately, let the client poll for progress.
And then production taught me something I would never have learned locally, and honestly would have gotten wrong if I'd only read my own code.
I'd written the background work as an unawaited promise — fire it off, let it run, respond to the request. On my laptop, that runs in the background, because my laptop has a CPU that's always on.
Cloud Run does not. Checking the docs rather than assuming: the config surface supports CPU, memory, max instances, min instances, and concurrency. There is no always-allocated-CPU option and no way to ask for one. Between requests, the instance is throttled to near zero.
So my background job wasn't running in the background. It was running in the slivers of CPU the instance got while serving something else — and the only thing it was serving was the client's own two-second poll. It worked at all because the model call is network-bound, not CPU-bound: the socket stays open while throttled, and each poll hands over just enough CPU to process what arrived.
Which means: close the tab and the generation stalls. No polls, no CPU, no progress. The user's browser isn't watching the work. The user's browser is powering the work.
That reframed the timeouts. My client used to give up before the server's deadline — so a tab that quit early killed a job that would have finished, and the poll refunded the credit while the worker carried on writing a film nobody would ever see. Now the client's patience sits above the server's ceiling, deliberately.
And the deadline itself stopped being flat. A 120-second film is 26 scenes, and I measured that at 187.7 seconds to write — a flat six-minute budget put a first pass past halfway, making the retry guard a coin toss. It's now 180 seconds plus 12 per scene, floored at six minutes, capped at twelve, and the number rides on the job record so the poller and the retry guard can't disagree about it.
Four things, in order of how much they cost me:
Test on the deployed URL. Your laptop has no request timeout, no cold start, no edge in front of it, and infinite CPU between requests. It is not a small version of production — it's a different machine with different physics.
A generic error is usually your fallback, not your handler. Go find out which.
Measure the request before you blame the diff. My suspect had literally never executed.
Check that your framework's escape hatch is one your host implements. maxDuration = 120 looked like a fix for weeks and did nothing.
None of this was visible from my machine. All of it was one measurement away on the live one.