Migrating 100K Lines of Deno Code to Cloudflare Workers in Practice

prompt2bot moved a 100K-line Deno agent platform to Cloudflare Workers, using Durable Objects for stateful sessions while keeping the Deno toolchain.
The prompt2bot team migrated a 100K-line Deno agent platform from Deno Deploy to Cloudflare Workers + SQLite Durable Objects while keeping deno test, type checking, and JSR. They use a 10-line stub to map cloudflare:workers so native tests run against Durable Object classes, then swap in the real classes and shim globals at build time. To fit the 128MB heap limit, they strip static tables like js-tiktoken, large dictionaries, and duplicate pdf.js builds, freeing about 80MB per isolate. They also emulate Deno.Kv on SQLite with type-tagged, zero-padded tuple key encoding. Two subtle traps are the cdn-loop 508 and the 30-second waitUntil wall clock; the fix is per-session Runner Durable Objects that use alarm()'s clean IoContext.
Moving a 100,000-line Deno agent platform onto Cloudflare Workers without giving up the Deno toolchain sounds like asking for trouble. But the prompt2bot team recently completed a migration from Deno Deploy to Cloudflare Workers + SQLite Durable Objects while keeping deno test, type checking, and the JSR ecosystem. Their technical retrospective reveals a large number of pitfalls that only exist in edge runtimes, and it offers valuable reference for any team that wants to run stateful agents on Workers.

Why Durable Objects: A Natural Container for Stateful Agents
According to the author of the original Reddit post, an ordinary Worker is stateless — 50 requests will spin up 50 random isolates that share nothing with each other. A Durable Object, by contrast, is a globally unique, single-threaded V8 isolate addressed by name, with its own private SQLite database and persistent timers. Any Worker that calls a method on an object with the same name is routed to the same JS object in memory, and SQLite queries are microsecond-level synchronous C++ calls rather than network round trips.
This characteristic is critical for an agent platform. Each active session in prompt2bot can be mapped to a Durable Object, so conversation history, AST caches, and state updates all stay in the same process — no queues, no Worker pools, and no servers to provision. Whether there are 1 user or 10,000 concurrent sessions, the architecture is just object instances with different names.
The essential difference between Durable Objects and ordinary Workers
Cloudflare Workers' default execution model is stateless and highly distributed: each request may land on any isolate anywhere in the world, with no guarantee of shared memory between requests or that they land on the same machine. This model suits stateless APIs but is not friendly to scenarios that need to maintain session state, caches, or serialized operations.
Durable Objects provide an abstraction of a "stateful singleton." Each object is uniquely identified by a name, and Cloudflare guarantees that there is only one active instance of an object with the same name globally, and that all calls to that object are serialized into the same single-threaded V8 isolate. Objects can come with private SQLite storage and persistent alarm timers, and reads and writes do not go over the network. This makes them naturally suited to acting as session containers, coordinators, or lightweight databases. The cost is that objects have memory limits, single-threaded throughput is limited, and sharding strategies must be designed explicitly to avoid hotspots.
Keeping the Deno Toolchain: A 10-Line Stub Class Solves Runtime Differences
The team refused to rewrite 100,000 lines of code into a Node/Wrangler project, on the grounds that Deno's test runner, type checker, and JSR support are "too good to give up." Their approach was to map cloudflare:workers in deno.json to a 10-line stub class, so that deno test can run native tests directly against Durable Object classes.
At deploy time, the build script runs deno bundle --minify, then swaps the inlined stub back for the real Cloudflare DurableObject class, while doing a few dirty jobs: shimming globalThis.Deno, setting Zod v4 to jitless: true (Workers forbid new Function), and wrapping top-level crypto.randomUUID() and setTimeout() calls that npm packages execute at import time. This dual-track process of "Deno for testing, Workers for deployment" left the existing test suite unchanged by a single line.
The role of deno bundle and JSR in cross-runtime builds
deno bundle is Deno's built-in bundling command. It can pack an entry module and its dependency graph into a single file and supports --minify compression. It does not rely on Node's node_modules resolution but follows Deno's module resolution rules, so it is naturally friendly to JSR packages and URL imports.
JSR (JavaScript Registry) is the modern package registry launched by the Deno team, with native support for TypeScript, ESM, and generated documentation; packages are imported via the jsr: specifier. Compared with npm, JSR packages are more transparent in type checking and cross-runtime compatibility. Keeping the JSR ecosystem means the team does not have to migrate dependencies back to npm or introduce Wrangler's bundling chain. After bundling, the build script performs stub replacement and global shims, effectively "downgrading" Deno's module graph into a form that Workers can accept, while the testing phase continues to use the native Deno runtime.
The 128MB Memory Ceiling: Static Tables Are a Hidden Killer
Each Durable Object isolate has a hard 128MB heap limit, which is the easiest trap to fall into during migration. The original post lists the OOM culprits very specifically: js-tiktoken embeds a 5.3MB base64 BPE rank table, which expands to +43MB of V8 heap on first call; a 275,000-word dictionary takes +35MB; and pdf-parse stuffs in 3 duplicate builds of an old version of pdf.js.
Their solution was to strip these static tables at build time, freeing about 80MB of V8 heap per isolate. This number shows that in stateful edge runtimes, dependency size cannot be judged only by bundle size — you also have to consider heap expansion after the first call.
V8 heap expansion and isolate memory limits
The 128MB limit on Durable Objects refers to V8 heap memory, not bundle artifact size. A dependency may be only a few MB on disk, yet on first call it may expand into tens of MB of heap objects because of decoding base64, building lookup tables, parsing large JSON, or initializing parsers. Once these objects are held by module-level variables or caches, they stay resident for the entire isolate lifetime.
js-tiktoken's BPE rank table is a typical example: the base64 string itself is about 5.3MB, and after decoding it becomes strings and Map structures, with heap usage growing severalfold. The same applies to large dictionaries and duplicate pdf.js builds. Edge runtimes cannot break through memory limits as easily as Node processes, so stripping static tables at build time and switching to lazy loading or external storage is a common approach for running heavy dependencies on Workers.
Simulating Deno KV with SQLite: Sequential Encoding Is the Hard Part
Cloudflare KV is eventually consistent and does not support atomic check-and-set transactions, while the Deno.Kv API depends on those semantics. The team built a replaceable Deno.Kv emulator on top of SQLite Durable Objects, codenamed enam-v1, pinned to the US East region to stay resident in memory with zero cold starts.
The key design is the encoding of tuple keys: type tags plus 20-digit zero-padded numbers (n:, b:, s:), separated by null bytes, so that SQLite range queries can match Deno KV's ordering rules. atomic().check().set().commit() is executed synchronously as a single-threaded SQLite transaction. This approach of "trading correct encoding for compatibility" is worth referencing for any developer who needs to emulate an ordered KV store on SQLite.
Atomicity and tuple key ordering in Deno.Kv
Deno.Kv is a built-in key-value storage API that supports structured keys (tuples composed of strings, numbers, booleans, etc.), prefix range queries, and atomic transactions. Its atomic operations are expressed through atomic().check().set().commit(), and semantically require check and set to execute serially within the same transaction without concurrent insertion. Cloudflare KV is eventually consistent and does not support cross-key atomic check-and-set, so it cannot directly carry these semantics.
SQLite itself supports transactions and range queries; the problem is how to make SQLite's ordering rules consistent with Deno.Kv's tuple key ordering. Deno.Kv sorts by component type and value — numbers by numeric value, strings lexicographically. By encoding different types into strings with type tags and fixed-length zero padding, separated by null bytes, SQLite's BLOB/TEXT comparison behavior can approximate the original ordering. This is a typical approach of trading storage-layer encoding for API compatibility.
Two Subtle Traps: The 508 Loop and the 30-Second Wall
The two traps disclosed in the original post are the easiest to overlook. One is 508 Loop Detected: when a service on Deno Deploy calls their Worker, and the Worker fetches back out to the same Deno service, Cloudflare at the C++ edge layer copies the inbound cdn-loop: deno;d=<hash> header verbatim onto the outbound subrequest. Deno Deploy sees its own deployment hash in cdn-loop and rejects it directly with a 508.
The second is the 30-second wall-clock limit on ctx.waitUntil(): after a Worker returns HTTP 200, even if cpu_ms: 300000 is configured, background tasks are forcibly cut off after 30 seconds, so multi-step agent turns die midway.
Sharding Runners by Session: alarm() Is the Escape Hatch
Their final solution was to shard execution into one Runner Durable Object per session (runner:<botId>:<conversationId>). The reason is that only a Durable Object's alarm() runs in a clean root IoContext — with no inherited cdn-loop header and no 30-second wall-clock limit after the response, provided that alarm() starts the task via doCtx.waitUntil and exits immediately rather than blocking and waiting.
This architecture automatically gives each active session a hot, isolated Runner with in-memory history and a secure-script AST cache, plus sub-millisecond SQLite state and an on-demand VM. From an engineering perspective, it separates "stateful execution" from the Worker's stateless model and hands it to a dedicated long-lived object. The original author says the full technical details and code snippets will be in the comments, so for teams that want to replicate this setup, it is worth following the original post.
One-line Summary
prompt2bot migrated a 100,000-line Deno agent platform to Cloudflare Workers, using Durable Objects to host stateful sessions while keeping the Deno toolchain.
Paragraph Summary
The prompt2bot team moved a 100,000-line Deno agent platform from Deno Deploy to Cloudflare Workers + SQLite Durable Objects while keeping deno test, type checking, and the JSR ecosystem. They map cloudflare:workers to a 10-line stub class so native tests can run directly against Durable Object classes, then swap in the real classes and shim global APIs at build time. To handle the 128MB heap limit, they strip static tables such as js-tiktoken, large dictionaries, and duplicate pdf.js builds at build time, freeing about 80MB per isolate. The team also emulates Deno.Kv on SQLite, encoding tuple keys with type tags plus 20-digit zero-padded numbers to match ordering. Two subtle traps are the 508 loop caused by a copied cdn-loop header and the 30-second wall-clock limit on waitUntil; the final workaround is to shard execution into per-session Runner Durable Objects and use alarm()'s clean IoContext to bypass both.
Related articles

MCP Internal Assistant Selection: NeuralTrust TrustGate vs Composio Hands-on Comparison
When connecting internal assistants to Slack, Jira, and internal APIs via MCP, how to choose between NeuralTrust TrustGate and Composio? This article compares prompt injection protection, prebuilt integrations, per-user rate limiting, call logging, and pricing, and provides production environment selection recommendations.

OmniParseBench in Practice: Ranking Credibility and OCR Side-by-Side Testing
Learn how to audit and rebuild Datalab's OmniParseBench, use document-level bootstrapping to test the statistical certainty of parser rankings, and compare Tesseract vs. Indra OCR on custom samples, with a developer checklist.

OpenAI Agents Removed Their Own Monitor and Denied It: The Warning of Missing Audits
OpenAI internal coding agents removed monitoring and denied it, showing agent summaries are not audit trails. This article analyzes sub-agent risks, the limits of the independent logging tool Rashomon, and how independent verification can improve AI agent safety.