HomeProjectsPredictionsBlog
GitHubLinkedInTwitterInstagram
2026-10-20

migrating from deno deploy to cloudflare workers without leaving deno

After Cloudflare acquired the Deno team and announced that Deno Deploy is shutting down in six months (with the Deno runtime itself only guaranteed a year of maintenance), we migrated the backend of prompt2bot (api.prompt2bot.com) from Deno Deploy to Cloudflare Workers and Durable Objects.

The codebase is around 100,000 lines of TypeScript built entirely around Deno. It uses deno.json import maps, mixes jsr: and npm: specifiers, runs hundreds of integration tests via deno test -A --env, and calls Deno.env, Deno.openKv, Deno.cron, and Deno.upgradeWebSocket everywhere.

Even with Deno's future in question, rewriting the project into a Node and Wrangler repo right now was out of the question. Deno's toolchain (deno test, deno check, deno fmt, deno lint, native TypeScript and JSR support) is miles ahead of the Node ecosystem, and we still have other services on Deno Deploy that we're migrating one by one. The goal was to keep developing and testing with the deno binary exactly as before, while compiling the server into a single Worker bundle that runs on Cloudflare's edge and SQLite-backed Durable Objects.

Getting a large, stateful agent runtime to behave identically on workerd took a lot of digging into runtime internals. Here is everything that broke along the way and how we fixed it.

what a durable object actually is

Whereas a regular Cloudflare Worker is stateless (50 concurrent requests spin up 50 random isolates across the globe that share nothing), a Durable Object (DO) is a globally unique, single-threaded V8 isolate addressed by name (for example, idFromName("enam-v1")) and paired with its own private, embedded SQLite database (ctx.storage.sql) and persistent alarm timer (ctx.storage.setAlarm).

Cloudflare guarantees that anywhere on Earth, at most one instance of a given name exists at any moment. Every Worker that calls a method on that name gets routed to that exact same JavaScript object in memory, where SQLite queries are synchronous C++ calls that take microseconds instead of network round-trips, and plain JS variables (Map, Set) work as strongly consistent locks and caches. You can create one singleton DO for shared storage, or millions of per-conversation DOs on the fly that hibernate for free when idle.

1. keeping the deno binary and toolchain intact

Wrangler wants to bundle your code with esbuild, which doesn't understand Deno's jsr: specifiers or deno.json workspace resolution. Instead of fighting esbuild, we pointed wrangler.jsonc at a custom build script (deno run -A scripts/buildWorker.ts) that shells out to deno bundle --minify first and then patches the emitted dist/worker.js for workerd.

The first hurdle was importing DurableObject from "cloudflare:workers". That module only exists inside Cloudflare's runtime, so importing it directly breaks deno check and deno test.

We solved this in deno.json by mapping "cloudflare:workers" to a 10-line local stub:

// server/src/cloudflareStub.ts
export class DurableObject {
  ctx: unknown;
  env: unknown;
  constructor(ctx: unknown, env: unknown) {
    this.ctx = ctx;
    this.env = env;
  }
}

export const env: Record<string, unknown> = {};

In Deno, ConversationDO extends DurableObject inherits from the stub, so all our Durable Object unit and integration tests run natively under deno test. During buildWorker.ts, after deno bundle inlines the stub class into dist/worker.js, a regex swap replaces the inlined stub class with the real DurableObject imported from "cloudflare:workers" in a prepended header.

The prepended header also shims globalThis.Deno on top of Cloudflare's globals (env from "cloudflare:workers", process.env, and WebSocketPair), and buildWorker.ts inlines static markdown files that the server reads at startup via Deno.readTextFileSync(new URL(...)).

workerd's top-level execution traps

Once the bundle compiled, deploying it to Cloudflare immediately crashed at module initialization before a single request arrived:

  1. No eval or new Function. Zod v4 compiles fast validators with new Function by default. On Cloudflare Workers, that throws at runtime unless you call z.config({ jitless: true }) at startup.
  2. No entropy or timers in the global scope. Unlike Deno and Node, workerd forbids calling crypto.randomUUID(), crypto.getRandomValues(), setTimeout(), or setInterval() during top-level module evaluation (outside an active request or alarm I/O context). Several third-party npm packages generate a client UUID or schedule a cleanup timer when imported. Our build header wraps crypto.randomUUID, crypto.getRandomValues, setTimeout, and setInterval in try / catch blocks that fall back to deterministic counters or no-ops only when called in the global scope before a request starts.
  3. Top-level file reads and WebAssembly in npm packages. pdf-parse famously tries to read ./test/data/05-versions-space.pdf off the filesystem when imported if module.parent is undefined, which we fixed by lazy-loading it. Other bundled dependencies tried to call WebAssembly.instantiate at import time, which Cloudflare blocks on arbitrary byte arrays, so buildWorker.ts stubs those out during bundling.

the 128MB durable object memory cliff

On Deno Deploy, an isolate has plenty of heap headroom. On Cloudflare Workers and Durable Objects, every isolate has a hard 128MB RAM limit. Exceeding it kills the isolate immediately.

During our first load tests on Durable Objects, isolates were dying with out-of-memory errors after a couple of turns. Inspecting the bundle showed three massive static allocations sitting in dependencies:

  • js-tiktoken bundled 5.34MB of base64 BPE rank dictionaries, which expanded into +43MB of V8 heap the first time token counting ran.
  • an-array-of-english-words bundled a 3.21MB array of 275,000 strings (~35MB of V8 heap).
  • pdf-parse bundled four separate historical versions of pdf.js (v1.9.426, v1.10.88, v1.10.100, v2.0.550), wasting 3.05MB of duplicate parser code.

We didn't need exact OpenAI BPE tokenization to estimate context window budgets, and we didn't need three dead copies of pdf.js. Stripping those three items in buildWorker.ts cut 11.6MB of raw JS source from the bundle and freed ~80MB of V8 heap per isolate. We also memoized our tool JSON-Schema conversions so each turn stopped re-allocating schema objects.

2. moving off deno kv to durable object sqlite

On Deno Deploy, our entire state layer lived in Deno.openKv():

  • Append-only conversation history (["history-v10", botId, conversationId, timestamp, eventId])
  • Per-conversation turn locks (atomic().check().set())
  • Webhook deduplication flags and sliding-window rate limits
  • Arbitrary key-value state with TTLs and optimistic concurrency (versionstamp checks)

Cloudflare KV is eventually consistent and has no atomic check-and-set operations, so it cannot replace Deno.Kv. Durable Objects with embedded SQLite (ctx.storage.sql) can.

Rather than rewriting hundreds of Deno.Kv call sites across the codebase, we built two layers inside a SQLite-backed ConversationDO instance named "enam-v1" and pinned to Eastern North America (locationHint: "enam", Cloudflare's region code for US East so it sits right next to our LLM and database endpoints):

  1. A first-class ConversationStore interface backed by dedicated SQLite tables (events_v2 where all HistoryEvent rows live, flags, json_store, rate_limits, scheduled_tasks) and in-memory turn locks (tryAcquireTurn / releaseTurn) for the hot path.
  2. A drop-in Deno.Kv emulator (createDurableObjectKv) over a kv_entries SQLite table in that same "enam-v1" database for everything else.

The tricky part of emulating Deno.Kv on SQLite is key ordering and atomic transactions. Deno.Kv keys are tuples of strings, numbers, and booleans (Deno.KvKeyPart[]), and kv.list({ prefix }) or kv.list({ start, end }) returns entries ordered lexicographically by tuple element, with numbers sorted numerically.

To make SQLite's WHERE key >= ? AND key < ? ORDER BY key ASC match Deno.Kv's exact ordering, we encode each tuple part with a type tag and zero-pad numbers to 20 digits, joined by \x00 bytes:

const encodeKvPart = (part: Deno.KvKeyPart): string =>
  typeof part === "number"
    ? `n:${String(part).padStart(20, "0")}`
    : typeof part === "boolean"
    ? `b:${part ? 1 : 0}`
    : `s:${String(part)}`;

const encodeKvKey = (key: Deno.KvKey): string =>
  key.map(encodeKvPart).join("\x00");

For kv.atomic().check(...).set(...).delete(...).commit(), createDurableObjectKv collects the checks and mutations in memory and sends them in a single RPC call (kvCommitAtomic) to the Durable Object. Because a Durable Object is single-threaded and this.sql.exec is synchronous, the entire check-and-set executes atomically without race conditions:

kvCommitAtomic(
  checks: { key: string; versionstamp: string | null }[],
  mutations: {
    type: "set" | "delete";
    key: string;
    keyJson?: string;
    valueJson?: string;
    expiresAt?: number | null;
  }[],
): { ok: boolean; versionstamp?: string } {
  const now = Date.now();
  for (const check of checks) {
    const rows = [
      ...this.sql.exec(
        "SELECT versionstamp, expires_at FROM kv_entries WHERE key = ?",
        check.key,
      ),
    ];
    const row = rows[0];
    const currentVs =
      !row || (row.expires_at !== null && Number(row.expires_at) <= now)
        ? null
        : String(row.versionstamp);
    if (currentVs !== check.versionstamp) {
      return { ok: false };
    }
  }
  const vs = this.nextVersionstamp();
  for (const m of mutations) {
    if (m.type === "delete") {
      this.sql.exec("DELETE FROM kv_entries WHERE key = ?", m.key);
    } else if (m.type === "set") {
      this.sql.exec(
        "INSERT OR REPLACE INTO kv_entries (key, key_json, value, versionstamp, expires_at) VALUES (?, ?, ?, ?, ?)",
        m.key,
        m.keyJson ?? "[]",
        m.valueJson ?? "null",
        vs,
        m.expiresAt ?? null,
      );
    }
  }
  return { ok: true, versionstamp: vs };
}

To cut over with zero downtime, we wired bidirectional live mirroring (/internal/migrate-history) between Deno KV and the Cloudflare Durable Object using waitUntil, ran a backfill script over all active conversations in Deno KV, verified parity, and then switched the api.prompt2bot.com/* DNS/Worker route over to Cloudflare.

3. the 508 Loop Detected mystery (CDN-Loop propagation)

Right after we switched api.prompt2bot.com/* to the Cloudflare Worker, bots that talked to services hosted on Deno Deploy (including our own encrypted web chat backend alice-and-bot.uriva.deno.net and bots with remote tool or config servers on Deno Deploy) started failing with a bizarre HTTP error whenever the Worker called them back:

508: Loop Detected (LOOP_DETECTED)
Loop detected: the deployment is fetching itself.

The flow looked like this:

  1. A service on Deno Deploy sends a webhook or API request (POST https://api.prompt2bot.com/alice-and-bot/:botId or POST https://api.prompt2bot.com/api).
  2. Deno Deploy's egress proxy automatically stamps an RFC 8586 CDN-Loop header onto that outbound request identifying the originating Deno deployment:
    cdn-loop: deno;d=<deployment_hash>
    
  3. Our Cloudflare Worker receives the request and starts running the agent turn. During the turn, the Worker makes a completely separate outbound fetch() call back to the same Deno Deploy deployment (for example, sending a typing indicator, fetching remote bot config, or delivering the agent's reply), passing a brand-new headers object (headers: { "content-type": "application/json" }).
  4. Cloudflare's runtime silently copies the incoming request's cdn-loop header onto every outbound fetch() subrequest made within that request's execution context, and appends , cloudflare; loops=1:
    cdn-loop: deno;d=<deployment_hash>, cloudflare; loops=1
    
  5. When that outbound request arrives at Deno Deploy (deployd), Deno Deploy inspects cdn-loop, sees its own deno;d=<deployment_hash> token already in the chain, concludes the deployment is fetching itself in an infinite loop, and rejects the request with 508 Loop Detected.

Because Cloudflare attaches CDN-Loop in its C++ edge layer below the JavaScript Request and Headers objects, calling headers.delete("cdn-loop") in Worker code does nothing.

why ctx.waitUntil and our first durable object alarm still leaked cdn-loop

Our first thought was to run the agent inside ctx.waitUntil(...) after returning 200 OK to the webhook. That failed with the exact same 508: in workerd, ctx.waitUntil() shares the incoming HTTP request's IoContext, so every outbound fetch() inside waitUntil still inherits the incoming cdn-loop token.

To get a clean root IoContext with no inherited cdn-loop header, the outbound fetch() has to originate from a Durable Object alarm() invocation, which is triggered by Cloudflare's internal alarm scheduler rather than an incoming HTTP request.

So we routed incoming webhooks into ConversationDO.scheduleTask(id, Date.now(), payload), which wrote the task to SQLite and armed ctx.storage.setAlarm(Date.now()).

And then, under concurrent traffic, the 508 Loop Detected error came back!

Tracing it revealed a subtle workerd runtime detail. Our initial alarm() implementation used a while (this.activeTasks.size > 0) drain loop so that if a new task arrived while an alarm was already running, scheduleTask() could wake the running loop immediately by resolving an in-memory Promise (this.resolveNewTaskSignal()).

In workerd, resolving an in-memory Promise from inside an incoming RPC call (scheduleTask) propagates the caller's incoming HTTP IoContext (including its cdn-loop header!) into the awaiting alarm() continuation. Every task spawned by that awakened loop inherited the second webhook's cdn-loop token and 508'd again.

The fix (58d125af6) was to never signal a running alarm() from an RPC call, and never block inside alarm():

async scheduleTask(
  id: string,
  runAt: number,
  payload: unknown,
): Promise<void> {
  this.sql.exec(
    "INSERT OR REPLACE INTO scheduled_tasks (id, run_at, payload) VALUES (?, ?, ?)",
    id,
    runAt,
    JSON.stringify(payload),
  );
  await this.updateAlarm();
}

async alarm(): Promise<void> {
  this.startDueTasks();
  if (!this.doCtx?.waitUntil && this.activeTasks.size > 0) {
    await Promise.all([...this.activeTasks]);
  }
  await this.updateAlarm();
}

When alarm() fires, startDueTasks() deletes the due rows from scheduled_tasks, launches each task asynchronously into this.doCtx.waitUntil(promise), and alarm() returns immediately. Because alarm() exits right away, every subsequent scheduleTask() call arms a brand-new setAlarm(Date.now()) tick from Cloudflare's scheduler, guaranteeing that every task executes in a clean root IoContext with zero inherited headers.

We opened a support ticket with Cloudflare (#02373342) with a minimal reproduction. Cloudflare Support confirmed the runtime behavior:

"Appreciate your detailed testing. You are correct about the runtime behaviour. To prevent Deno Deploy from detecting its own hop marker, the outbound fetch must run from an independent root execution context outside the incoming HTTP request lifecycle."

When we asked whether the Workers runtime team could add a compatibility flag or avoid copying third-party CDN-Loop tokens onto fresh outbound subrequests, they forwarded the feature request to the Workers engineering team and closed the ticket since changing loop-prevention behavior requires platform roadmap review. So the Durable Object alarm hop isn't a temporary hack; it is the canonical way to break CDN-Loop inheritance on Cloudflare Workers.

4. other architectural differences that bit us

ctx.waitUntil has a 30-second wall-clock cap; durable object alarms don't

Even for webhooks that don't come from Deno Deploy (like WhatsApp, Telegram, or AgentMail), running an AI agent inside ctx.waitUntil() on a stateless Worker turned out to be a trap.

In wrangler.jsonc, we set "limits": { "cpu_ms": 300000 } (5 minutes of CPU time). What the docs don't make obvious is that cpu_ms only raises the CPU limit. Once a Worker returns an HTTP response to the webhook caller, ctx.waitUntil() still enforces a hard 30-second wall-clock timeout.

An AI agent turn is almost entirely network I/O wait time, not CPU time: streaming LLM reasoning tokens, executing remote tools, waiting on web search or image generation, and sending replies. A complex turn easily takes 35 to 90 seconds of wall time while burning less than 200ms of CPU. In ctx.waitUntil(), Cloudflare silently terminated the isolate at the 30-second mark mid-turn.

Durable Object alarm() handlers don't have that 30-second post-response wall-clock cutoff, and this.doCtx.waitUntil() keeps the Durable Object alive while its promises are in flight. Moving all channel webhooks onto Durable Object alarms (e22139f3f) solved both the 508 issue and the 30-second wall-clock truncation in one stroke.

sharding runner durable objects per conversation (f5833873c)

Once every agent turn was running inside a Durable Object, we had to think carefully about how Durable Objects are sharded:

  • Attempt 1: Run everything in the single enam-v1 storage DO. Because each Durable Object is single-threaded, heavy synchronous work in an agent turn (compiling SafeScript ASTs, projecting large conversation histories, parsing big tool payloads) contended with enam-v1's main job: serving sub-millisecond SQLite and KV RPCs for all other conversations.
  • Attempt 2: Shard a fresh Runner DO per task (runner:${taskId}). This isolated compute away from enam-v1, but because every task ID is a new UUID, every single incoming message spun up a brand-new, cold Durable Object isolate. That threw away our in-memory per-isolate caches (our versionstamp-validated conversation history cache and our compiled SafeScript AST cache).
  • Final architecture: Shard Runner DOs per conversation (runner:${botId}:${conversationId}), backed by the shared enam-v1 storage DO.
export const runnerShardKey = (
  botId: string | undefined,
  conversationId: string | undefined,
  taskId: string,
): string =>
  botId && conversationId && botId !== "global" &&
    conversationId !== "bot-tasks"
    ? `${botId}:${conversationId}`
    : taskId;

export const getRunnerDO = (
  ns: ConversationDONamespace,
  runnerKey: string,
): ConversationDO =>
  ns.get(ns.idFromName(`runner:${runnerKey}`), { locationHint: "enam" });

When a webhook arrives at the stateless edge Worker, it hands the payload (along with any pre-warmed bot config from the Worker isolate's memory) to runner:${botId}:${conversationId} via scheduleTask. The conversation's Runner DO fires its alarm in ~10ms in a clean root IoContext, already warm from the previous turn of that conversation, with its history and SafeScript AST caches hot in RAM, while enam-v1 stays a pure, uncontended SQLite storage engine.

websockets and AsyncLocalStorage context loss (727b0d360)

Our codebase uses @uri/inject (built on node:async_hooks AsyncLocalStorage) to pass database connections, KV stores, API tokens, and analytics loggers down the call stack without threading parameters through every function.

On Deno Deploy, Deno.upgradeWebSocket(req) and all ws.addEventListener("message", ...) callbacks naturally preserve the AsyncLocalStorage context that was active when the WebSocket was upgraded.

On Cloudflare Workers (using new WebSocketPair()), we discovered during live Twilio voice calls (/twilio-voice/stream) that AsyncLocalStorage context was getting dropped inside WebSocket message and close event listeners and setInterval billing ticks. Calls would connect and then fail deep inside the agent runner with missing dependency context errors.

Capturing const runInDepsContext = AsyncLocalStorage.snapshot() right after initializing the dependencies and wrapping the WebSocket event listeners and setInterval callbacks in runInDepsContext(() => ...) restored full context propagation across the lifetime of the voice stream.

node SDKs that assume node sockets (d7167bb3c, e22139f3f)

Even with "compatibility_flags": ["nodejs_compat"], a few APIs don't translate:

  • @google-cloud/storage uploads: Reading metadata worked, but calling file.save(Buffer.from(bytes)) to upload chat media attachments hung or failed inside @google-cloud/storage's Node stream/HTTP stack on Workers. Replacing file.exists() and file.save() with two direct fetch() calls against Google Cloud Storage's JSON REST API (https://storage.googleapis.com/upload/storage/v1/b/.../o?uploadType=media), authenticated with a Web Crypto JWT bearer token, made uploads faster and eliminated a huge dependency path.
  • Deno.resolveDns: Our SSRF guard (ssrfGuard.ts) calls Deno.resolveDns(host, "A") and "AAAA" to block user-supplied URLs that resolve to private IP ranges (127.0.0.0/8, 10.0.0.0/8, 169.254.169.254, etc.). Workers doesn't expose raw DNS resolution APIs (and Cloudflare's egress network already blocks fetches to private/loopback IPs at the edge), so we guarded Deno.resolveDns behind a feature check when running on Workers while keeping the hostname rules and Deno test coverage intact.

where we landed

We still write 100% Deno code. Every test runs with deno test -A --env, every package comes from deno.json, and pushing to main bundles the server with deno bundle and deploys it to Cloudflare Workers in under a minute.

The migration forced us to separate stateless edge intake, per-conversation compute isolates (runner:${botId}:${conversationId}), and always-warm SQLite storage (enam-v1), which ended up making the system both faster and more predictable than what we had before.

This architecture is what makes prompt2bot able to scale agents so easily. You describe an agent in a plain prompt, connect it to WhatsApp, Telegram, email, voice, or web chat, and the platform gives every active conversation its own isolated, warm Durable Object runner with sub-ms state, in-process SafeScript execution, and on-demand VMs. Whether your bot has one user or ten thousand concurrent conversations, there are no queues, worker pools, or servers to provision.

← all posts