Building ~/tools/paper-revision was the easy part. Getting it to behave the
same in production as it did on localhost took a lot longer, and most of the
bugs weren't in the AI part at all — they were in the plumbing around it.
Here's what actually broke.
The silent 502: SSE connections need a heartbeat
Long reviews using a BYOK Anthropic key would occasionally fail after ~100 seconds — no error message, just a dead connection. Vercel's logs showed a clean 502 with nothing pointing at the actual cause.
The real problem: a model can go quiet for a while mid-generation (a long internal "thinking" stretch before the first visible token), and if the response stream sends no bytes for too long, some intermediary between the client and the model decides the connection is idle and kills it. The fix was a heartbeat — an invisible character written into the stream on a fixed interval so bytes never stop flowing:
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // zero-width space, invisible in rendered markdown
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
continue
}Chasing this down also surfaced a worse bug hiding behind it: the abort
handler was catching every exception named AbortError and treating it as
a deliberate user cancel — including ones that came from the network dying
on its own. Real failures were being silently swallowed. Fixing that meant
tracking whether the user had actually clicked "abort" before trusting the
error's name.
Bring-your-own-key, without the footguns
The free tier runs on a hosted model with a rate limit; BYOK users pay with their own key and skip it entirely — except the first version didn't skip it, and BYOK requests were burning through the free-tier quota too. An honest bug report ("but I used my own key and still got rate limited") is how that one surfaced.
The other BYOK lesson was proactive rather than reactive: cap the model allowlist to reasonable price tiers so a request can't accidentally land on the most expensive model available, and cap output tokens regardless of provider. None of that stops a user from spending their own money badly, but it stops our code from being the reason they do.
A favicon Google Search refused to show
The site's favicon rendered fine in every browser tab and just never showed
up next to the URL in Google Search results. The cause was almost
embarrassingly indirect: Next.js's generic icon convention appends a content
hash to the file's URL — /icon.png?d72260d... — and that hash changes
every time the file's bytes change. Google's own guidelines are explicit
about this exact failure mode: "the favicon URL must be stable — don't
change the URL frequently." Every edit to that icon was quietly resetting
whatever trust Google had built up in the old URL.
Next.js has a special case for exactly one filename — favicon.ico skips
the content hash entirely and gets a permanently stable URL. Wrapping the
same artwork in a minimal ICO container and switching to that filename was
the whole fix. Worth checking the domain setup at the same time: Google
treats different hostnames as entirely separate "sites" for favicon
purposes, so www. and the default *.vercel.app domain both got permanent
redirects to the one canonical hostname.
Cloudflare Turnstile only renders once
Turnstile's implicit rendering mode scans the page for .cf-turnstile
elements exactly once, when its script first loads. That's invisible on a
traditional multi-page site where every navigation reloads the script fresh
— but this is an SPA, and the review form's state deliberately survives
client-side navigation so an in-flight review doesn't get abandoned.
Navigate away and back, and the widget's mount point is a brand-new DOM node
the already-loaded script never re-scans. The token silently becomes
undefined, which — after adding server-side verification to the sharing
endpoint — meant every share attempt after a nav-away-and-back failed with
no visible explanation.
The fix is Turnstile's explicit rendering API: load the script once, then
call turnstile.render() yourself on every mount and turnstile.remove()
on every unmount, instead of relying on its one-time automatic scan.
Windows dev, Linux prod
Two separate bugs, same root cause: developing on Windows and deploying to
Vercel's Linux runtime aren't the same environment, and PDF/image libraries
are exactly where that gap shows up. pdf-parse crashed with a DOMMatrix
error in production that never reproduced locally — switching to a
serverless-safe PDF library (unpdf) fixed it outright. Separately,
next/og's image generation crashed on font loading, traced to a Windows
file path containing spaces that the library's URL parsing choked on.
Neither bug existed as far as local npm run dev was concerned; both were
100% reproducible on every single deploy.
What a security review actually catches
A full pass over the codebase turned up a handful of real, if quiet, issues: a snapshot-sharing endpoint with no bot verification at all — technically open for anyone to publish arbitrary text to a public URL; a rate limit charged before checking whether the uploaded file could even be parsed, so a malformed upload burned a user's quota for a review that was never going to happen; a zip handler that decompressed an archive entry before checking its declared size, which is exactly the shape of a zip-bomb vulnerability; and a share button that silently created a duplicate snapshot if you navigated away and clicked it again, because the "do we already have a link" state lived in a component that had just been thrown away and recreated.
None of these were exotic. They were the kind of bug that only shows up when someone actually reads the code looking for what could go wrong, rather than just checking that the happy path works.
Closing thought
Every bug on this list shipped, worked, and looked done — right up until a
specific input, a specific navigation, or a specific hosting environment
proved otherwise. Boring infrastructure, checked carefully, beats clever
infrastructure that's never been tried against production. exit 0.