Going Cloudflare-native
A browser-native dev environment should run on the edge, not a pet server. For a while, Contrast didn’t — the web app, the data layer, and every user-app build all lived on a single self-hosted box. One machine to fall over, one machine to scale, one machine standing between an idea and a deployed app.
This week we finished moving all of it onto Cloudflare. Here’s the architecture we landed on, the 128-megabyte wall we hit on the way, and the expensive lesson that turned into our spend guardrails.
The target architecture
The end state is three workers wired by service bindings: an app worker serving contrast.dev, a data worker owning the Zero SQL and cache Durable Objects, and a build runner in a Cloudflare Container that runs the full bundler on every deploy.
Splitting these apart wasn’t an aesthetic choice. It was forced on us by the wall below.
The wall: 128 MiB and an out-of-memory loop
Our first attempt bundled the React app and the data tier into one Durable Object isolate. Durable Objects cap at 128 MiB, and that single shim blew straight through it — every anonymous bootstrap timed out with an out-of-memory crash after 36 seconds.
The split fixed it: a lean data worker that bundles only the Zero SQL/cache Durable Objects (about 4.5 MB), and an app worker that calls it over a service binding. Each tier got its own clean 128 MiB of headroom. We added a 1-minute cron to keep the auth Durable Object resident instead of cold-starting on real requests, and batched the /api/auth/me reads into a single data-tier hop.
The expensive lesson: a runaway replication loop
While we were stabilizing the data tier, a different problem was quietly running up a bill.
A synthetic 5-second replication heartbeat in the Zero cache Durable Object was re-streaming the retained change set on a timer — even when nothing was being written. The standby feedback that should have told it “you’re caught up” never converged, so the same 2.3 GB of rows were rewritten in an infinite loop. Over a 24 hour period this accumulated about 8.46 billion rows-written, a somewhat costly mistake.
The root fix was to make replication entirely write-driven — matching the upstream event-driven shape — so it only fires after a real app write. But a root fix isn’t enough when the failure mode is “silently spends money.” So we built defense-in-depth:
A persistent write-rate circuit breaker now trips at sustained excess and refuses further writes loudly; an hourly cost-watch emits tiered email alerts; and at $200/day an auto-shutdown force-deletes the top offending worker. Steady-state write rate post-fix is about 70k rows/day.
Folding it into one deploy path
The last migration piece was the build runner. We moved it into a Cloudflare Container bound to the apex worker, verified byte-identical build output against the old path, and folded the Cloudflare deploy into the main pipeline — running in parallel with end-to-end validation and flipping live only on green.
Then we deleted roughly 1,850 lines of deploy, health-check, and SSH machinery. The principle we hold ourselves to: after a change there should be exactly one way the code runs. No feature-detection forks, no “try the old path if the new one fails.” One Cloudflare-native deploy path now, and the old box can be powered off.
What it unlocked, and what’s next
The payoff isn’t just a tidier diagram. Sub-second auth made signup feel instant. The same week, we ran our first real multi-user load tests against Cloudflare — collaborative invites, real-time fan-out to live browser clients, sync under churn — and stayed green at 3–5 concurrent users. And the agent factory can now run entirely in a hosted Cloudflare session instead of needing a local browser, a real step toward “describe an app, get a deployed app” with no machine of your own.
Pushing the load test to 50 users surfaced exactly the next bottleneck we needed to find: a control-plane memory limit in the cache Durable Object. That’s the next wall. Onward.