Frontend
Shipping an image SaaS on Cloudflare Workers: nine things that broke
Jack Cooper DEV Community
2 views
I spent a week building Album Art Creator, a tool that turns a song title into a release-ready cover: a picture from an image model, a title and artist name as live text layers, and a pre-flight check against what Spotify, Apple Music and the three big distributors publish.
The whole thing runs on Cloudflare Workers — TanStack Start for the app, D1 for the database, R2 for the images, a payment provider for money, and four different image models behind it. Most of it went the way the docs said. These nine did not, and each one cost me an hour to a day.
1. TanStack Start instead of Next.js, because of what sits underneath
I have shipped Next.js on Workers before, through OpenNext. Every Workers bug I had written down came from that translation layer: cron needed a hand-written custom worker, Node middleware was unsupported, auth occasionally lost its async-local-storage context, and the bundle limit was real.
TanStack Start is Vite-native and Cloudflare ships a first-party plugin for it, so there is no translation layer to debug. The trade is maturity: it is still an RC, so when something breaks there is no Stack Overflow answer waiting. I pinned every version (@tanstack/react-start 1.168.49, Vite 8.2.2, @cloudflare/vite-plugin 1.54.4, wrangler 4.129.0) and moved on.
Plugin order matters and is easy to get wrong: cloudflare({ viteEnvironment: { name: 'ssr' } }) first, tanstackStart() second, react() third.
2. Cron silently did nothing until the entry point changed
I added triggers.crons to wrangler.jsonc, exported a scheduled handler, deployed, and nothing ran. No error.
The framework plugin aliases a virtual module to your src/server.ts, so during vite dev my entry looked live. But wrangler.jsonc still had main pointing at the framework's own server entry — an object with only fetch on it. My scheduled export was never part of the deployed worker.
The fix is to own the entry:
// src/server.ts — and wrangler.jsonc main must point HERE, not at the package entry
import handler from '@tanstack/react-start/server-entry';
export default {
fetch: handler.fetch,
async scheduled(controller: ScheduledController, _env: unknown, ctx: ExecutionContext) {
if (controller.cron === '17 * * * *') {
const { runCleanup } = await import('./server/cleanup');
ctx.waitUntil(runCleanup().then((r) => console.log('[cleanup]', JSON.stringify(r))));
return;
}
// every 2 minutes: finish jobs the browser stopped polling, retry failed cancels, reconcile refunds
},
};
Locally, miniflare will trigger it for you: curl http://127.0.0.1:3100/cdn-cgi/local/scheduled?cron=*/2+*+*+*+*.
3. One bot check, several protected calls
Anonymous users get three free images, so generation is behind Turnstile. The widget sits on the form, mints one token, and I verified it server-side on every protected call.
Day one in production, the first render after the concept step returned 403 for every anonymous user. Turnstile tokens are single-use: siteverify answers timeout-or-duplicate when the same token comes back. One anonymous session makes several guarded calls — concepts, render, try again, switch direction — and the widget is long gone by then.
So the first successful verification mints a signed pass instead:
/** `<expiry unix seconds>.<hmac(fingerprint.expiry)>` — nothing secret inside, the signature is what matters. */
export async function mintHumanPass(fingerprint: string, secret: string, now = Date.now()) {
const exp = Math.floor(now / 1000) + 24 * 3600;
return `${exp}.${await hmacHex(secret, `${fingerprint}.${exp}`)}`;
}
It goes out as an httpOnly, secure, SameSite=Lax cookie, is bound to the browser fingerprint, and is compared in constant time. Later calls accept the cookie and skip siteverify.
One more guard came out of the same audit: if the deployed site has no Turnstile secret at all, anonymous generation is refused rather than waved through. A missing environment variable had quietly turned the free tier into an open image-generation API.
4. The double-charge bug that React state cannot fix
Every button that spends a credit was disabled while busy was true. It still double-charged.
setBusy(true) does not take effect until the next render. Two clicks in the same frame — a double click, or an impatient tap on mobile — both read busy === false and both fire. On the one-cover page this queued a dozen renders.
A ref flips synchronously, so the lock has to live there:
const inFlight = useRef(false);
const takeLock = () => {
if (inFlight.current) return false;
inFlight.current = true;
return true;
};
Every paid action calls takeLock() first and releases in finally. The visual busy state stays, but it is now decoration, not the lock.
5. The payment SDK does not run on Workers
The provider ships a TypeScript SDK. It imports node:crypto, which throws "not implemented" on workerd even with nodejs_compat. Signing is about thirty lines with WebCrypto, so I wrote them:
const RSA = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } as const;
// request signature
await crypto.subtle.sign(RSA, privateKey, encoder.encode(`${method}\n${path}\n${timestamp}\n${bodyHash}`));
// webhook verification: the signature header carries a timestamp `t` and a signature `v1`,
// and what was signed is `${t}.${rawBody}` — so verify the RAW body, and parse it only after
const { t, v1 } = parseSignatureHeader(header);
await crypto.subtle.verify(RSA, publicKey, base64ToBytes(v1), encoder.encode(`${t}.${rawBody}`));
Two things worth copying: read the raw body with request.text() in the route handler before anything parses it, and treat the verification public key as configuration, not a constant in your source. I keep the private key in a secret and the public key in wrangler.jsonc vars — which leads to the next one.
6. Config edited in the dashboard is config you will lose
vars in wrangler.jsonc are uploaded wholesale on every deploy. I updated a webhook public key in the Cloudflare dashboard, deployed something unrelated a day later, and the old value came back. A live top-up was charged and no credits arrived, because every webhook signature then failed.
Secrets survive deploys; plain vars do not. Anything you change in the dashboard has to be written back into the repo the same minute, and /api/health now returns the values that matter so I can check what is actually live.
7. The idempotency key was the wrong id
Purchases reach the app twice on purpose: the webhook, and the return page that polls the provider's API in case the webhook is slow or lost. Both paths credit the account, so both write a ledger row with a unique source_event_id — the database unique index is the lock, and the loser of the race reports "already credited".
That works only if both paths compute the same key. Mine used the event id, and the two paths see two different event ids for the same purchase. One top-up was credited twice.
const ledgerKey = `order:${order.orderId}`; // the order, not the event that told us about it
Pick the identifier of the thing that happened, not the identifier of the message that delivered the news.
8. Images are heavy in ways that bite Workers specifically
A 4096 px PNG from an upscaler weighed 40 MB. That is slow to store, slow to open in a canvas editor, and — the part I did not expect — above the 20 MB input limit of the Images binding, so it could not even be resized for a thumbnail.
The rules now: every provider is asked for JPEG, the originals live in R2, and every <img> goes through one route that resizes on the edge.
// /img/<key>?w=320|600|1024|2048 → WebP via the Images binding
if (wantResize && images && source && source.size <= MAX_TRANSFORM_BYTES) {
try {
const out = await (await images.input(source.body).transform({ width, height: width, fit: 'scale-down' }).output({ format: 'image/webp', quality: 82 })).response();
return new Response(out.body, { headers: { 'content-type': 'image/webp', ...cacheHeaders } });
} catch { /* fall through */ }
}
return new Response(stored.body, headers); // missing binding, oversized input, or a failed transform
Three fallbacks, one served file. History pages ask for 600, the results grid for 1024, the editor canvas for 2048, and the full-size file is fetched only at export.
9. A stylesheet in the wrong place re-downloads every font
The editor offers 22 text faces, all self-hosted. On the live site, every in-app navigation made the whole page flash: body text dropped to Helvetica, then swapped back.
The stylesheet was rendered inside the route-managed head. Route changes tear those tags down and rebuild them, and rebuilding a <style> element re-parses every @font-face in it — so the browser re-requested all of them, every time. Moving the stylesheet into the document shell, outside the route-managed head, fixed it.
Two related notes, since fonts are where the bytes are: subset to Latin with a unicode-range face per file, and if you want to inline the stylesheet in production, do it in a prebuild step. Vite's ?inline import breaks the Worker build.
What I would tell someone starting this week
Own your Worker entry from day one. Cron, fetch, and anything else you export should live in a file you wrote.
Anything that spends money needs a synchronous lock in the UI, an idempotency key derived from the thing itself, and a unique index behind it. There is also a daily spend cap booked with a conditional UPDATE ... WHERE micros + ? <= cap, as a backstop for the bugs the other two miss.
Verify webhooks over the raw body; keep signing keys in secrets and verification keys in versioned config.
Test what the platform actually does, not what the framework implies. My cron, my Turnstile flow and my image thumbnails all looked fine in vite dev.
One piece of this is now a standalone package. The pre-flight check — the part that reads a cover and compares it against what each platform publishes — is on npm as cover-art-check, MIT, no dependencies in the core:
npx cover-art-check cover.jpg --platforms distrokid,tunecore,cdbaby
The rules in it are read from each platform's own help page and dated, because they contradict each other: Apple Music wants at least 4000 px, TuneCore and CD Baby refuse anything above 3000. No single file satisfies both, and that is a product decision, not a bug.
Read original: https://dev.to/jackcooper/shipping-an-image-saas-on-cloudflare-workers-nine-things-that-broke-4j7o
← Previous
The Confluence content manager: what it does and where it stops
Next →
Stop Trusting the App: Enforcing Append-Only at the Database Layer
Related
The Need for a Modern UML and Diagram Engine (Part 2)
Frontend
2
DEV Community
[Showoff Saturday] A little SVG character that spills coffee and points at a button
Frontend
1
Reddit r/webdev
I Built a Documentation Tool in 48 Hours (While Running a Code Jam)
Frontend
5
Dev.to (EN Zone)
I’m 17 and built a cute animated “wish jar” web app for sending little wishes to someone
Frontend
0
Reddit r/webdev
Comments0
No comments yet — be the first