Skip to content

Build1 publisher3 min readPublished Updated

A dev-server alias kept a cron handler out of the deployed Cloudflare Worker

A week building an image SaaS on Cloudflare Workers produced nine failures. In the five shown in code, two come from the TanStack Start plugin's build behaviour and three from React timing and captcha logic that follow the code anywhere.

The Engineer · Build desk

Illustration accompanying A dev-server alias kept a cron handler out of the deployed Cloudflare Worker

What happened

  • A developer spent a week building an album-cover tool on Cloudflare Workers with TanStack Start, and wrote up nine things that broke, each costing an hour to a day to fix.
  • A cron trigger added to wrangler.jsonc with a matching scheduled export deployed without complaint and then never ran, and Cloudflare did not return an error to explain it.
  • On the first day in production, the first render after the concept step returned 403 for every anonymous user on the free tier.
  • Buttons that spend a credit were disabled while a busy flag was true and still double-charged, queueing a dozen renders on the one-cover page.
  • A deployment whose Turnstile secret was unset had been allowing anonymous generation anyway. That made the free tier an open image-generation API.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Anyone choosing between OpenNext and the first-party Vite plugin is choosing which layer to debug: a translation shim with a known bug list, or an RC plugin whose dev entry and deployed entry are different files.
  • exposure An unset environment variable put the image models in front of unauthenticated callers, and the bill for that failure mode lands on the operator.
  • constraint With no community answers for an RC framework, the debugging budget is whatever it costs to read the plugin's build behaviour yourself, and the version pins turn every future upgrade into a re-verification job.
  • contradiction The write-up frames the pain as the price of RC immaturity, but the failures that reached paying users came from render timing and captcha token lifetime. No runtime choice would have prevented either.

The deployed worker's default export came from the TanStack Start package's own server entry, an object with only `fetch` on it, because `main` in wrangler.jsonc still pointed at the package [9]. Under `vite dev` the framework plugin aliases a virtual module to `src/server.ts`, so locally the entry looked complete and the `scheduled` export was live [9]. The export worked everywhere except production. The fix is to own the entry: `src/server.ts` re-exports `handler.fetch` from `@tanstack/react-start/server-entry`, adds `scheduled`, and `main` points at that file [10]. The schedule runs two jobs: an hourly cleanup on `17 * * * *`, and a two-minute job that finishes jobs the browser stopped polling, retries failed cancels and reconciles refunds [11]. Miniflare fires it locally against `/cdn-cgi/local/scheduled?cron=*/2+*+*+*+*` [12].

The post's author is explicit about the cost of being early. "The trade is maturity: it is still an RC, so when something breaks there is no Stack Overflow answer waiting," he wrote [5]. The response was four pins: `@tanstack/react-start` 1.168.49, Vite 8.2.2, `@cloudflare/vite-plugin` 1.54.4, wrangler 4.129.0 [6]. Plugin order is the other build-level trap, and it has to be `cloudflare({ viteEnvironment: { name: 'ssr' } })` first, `tanstackStart()` second, `react()` third [7].

Turnstile's siteverify answers `timeout-or-duplicate` when the same token comes back, and one anonymous session makes several guarded calls: concepts, render, try again, switch direction [15]. The widget mints one token, and every protected call was verifying it server-side [13]. Now the first successful verification mints a signed pass, `<expiry unix seconds>.<hmac(fingerprint.expiry)>`, valid 24 hours, sent as an httpOnly, secure, SameSite=Lax cookie, bound to the browser fingerprint and compared in constant time; later calls skip siteverify [16].

The double charge is application logic too. `setBusy(true)` does not take effect until the next render, so two clicks in the same frame both read `busy === false` and both fire [18]. A ref flips synchronously, so every paid action now calls `takeLock()` first and releases in `finally`, with the visual `busy` state left as decoration [19].

Sort the five failures shown in code by cause and two land on the build configuration, the plugin order and the deployed entry point, while three are application logic that travels with the code to any host [1]. Under OpenNext the same developer's Workers bug list was different in kind: 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 [4]. Cron needed manual work in both stacks, a custom worker in one and a hand-owned server entry in the other [2].

Anonymous users get three free images before payment [13]. A deployment with no Turnstile secret at all now refuses anonymous generation [17].

What to watch

  • Whether TanStack Start leaves RC, and whether the Cloudflare plugin ever registers a scheduled handler without the developer owning the entry file.
  • Whether an upgrade past the pinned @cloudflare/vite-plugin 1.54.4 and wrangler 4.129.0 changes the virtual-module aliasing that hid the missing export.
  • Whether the plugin or wrangler starts warning when wrangler.jsonc declares triggers.crons but the deployed module exports no scheduled handler.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories