How to move a Deno app to Cloudflare Workers

By

Keep Deno for development while moving execution to Workers. Build a SQLite-backed Durable Object queue with alarms, retries, and safe migration steps.

You can use Deno to develop an application that runs on Cloudflare Workers.

Deno handles the tasks on your computer: checking TypeScript, running tests, resolving dependencies, and producing a JavaScript bundle. Workers executes that bundle. Persistent state lives in the storage services you connect to it.

That separation gives an existing Deno application a migration path. You can keep much of your development setup while replacing the parts tied to Deno Deploy.

This tutorial was inspired by Uri’s account of migrating a large Deno application, shared in this Reddit thread. We’ll build a smaller application around the same general workflow, using a different implementation with explicit job persistence and bounded retries.

The example is a research inbox. You submit a page to a conversation. The API saves a job and returns. A background runner reads the page, extracts its title, and saves the result. You can check the result later, including after restarting the local server.

A conversation gets its own SQLite-backed Durable Object. Jobs within that conversation run one at a time. Other conversations have separate objects and separate queues.

This is also a useful starting point for an assistant that calls tools. Reading a page gives us a real asynchronous operation without requiring a model subscription. Once the persistence works, you can replace the page reader with a model call or another bounded operation.

We’ll cover the complete project, tests, local execution, deployment, and the harder part of a real migration: moving existing data and traffic without creating conflicting copies.

What is changing for Deno Deploy?

On October 9, 2026, Deno announced that its team was joining Cloudflare. The announcement says Deno Deploy will operate for another six months before shutting down. It also commits to another year of runtime bug fixes and security updates, while JSR continues operating.

That puts the Deploy migration window around April 2027. The announcement does not specify an exact shutdown day. Check the official notice for updates before choosing your cutover date.

Deno the runtime and Deno Deploy the hosting service have different roles. Moving your hosting doesn’t require replacing every command you use locally. It does require finding every place where the deployed application depends on Deno-specific APIs or storage.

If your app uses standard Request, Response, fetch(), and Web Streams, much of its request-handling logic is portable. A call to Deno.openKv() needs a new implementation. A call to Deno.env.get() needs a different source of configuration inside Workers.

Keep the development tools, change the execution environment

A migration becomes easier to reason about when you write down where each piece runs.

PieceWhere it runs in this tutorialIts job
deno checkYour computer or CICheck application types
deno testDenoTest the portable page-reading logic
deno bundleYour computer or CIProduce the Worker entry point
WranglerYour computer or CIGenerate types, run workerd locally, deploy
Worker fetch()WorkersAuthenticate and route HTTP requests
ConversationRunnerDurable ObjectsOwn a conversation’s jobs and alarm
SQLiteThe object’s attached storagePersist jobs, attempts, and results

The production bundle won’t have a Deno runtime around it. A successful Deno test cannot prove that a Workers binding or Durable Object alarm works. We’ll test those through Wrangler as well.

Fig 1 · 1/5From a Deno project to a durable job
Deno builds; Workers runs

Click the illustration, or focus it and press Enter or Space, to follow acceptance, execution, duplicate submissions, and crash recovery. The labels use the same research conversation and page-17 job we’ll submit below.

If Durable Objects are new to you, the Durable Objects tutorial introduces named objects and their SQLite storage with a smaller counter example. The free Cloudflare Workers course covers Workers request handling and bindings in more detail.

Choose the unit that owns the work

A Durable Object gives you an addressable object with private persistent storage. Calling the same object name through the same namespace reaches the same logical object.

For this application, the name comes from a tenant and a conversation:

JSON.stringify(['demo', 'research'])

JSON encoding avoids ambiguous names formed by joining user-controlled strings with a separator. The tenant comes from server configuration in this single-tenant example. A production service should derive it from the authenticated account.

Each conversation owns its queue. That gives us an obvious place to decide which job comes next and where to find its result.

A single object for the whole application would also work for a small prototype. It would put every customer’s job behind the same runner, though. Splitting by conversation keeps unrelated work independent.

This is a decision about coordination. It doesn’t mean every conversation gets a permanently running server. Cloudflare can unload an object’s JavaScript instance and construct it again later. Data you need after that belongs in storage. See the object lifecycle documentation.

Don’t store the queue only in an array, or treat a JavaScript Map as a persistent lock. Also remember that asynchronous methods can interleave while awaiting network calls. We’ll use storage transactions for acceptance and scheduling, and the alarm handler for execution.

Create the project

Install a recent Deno release, then create an empty directory:

mkdir deno-conversation-runner
cd deno-conversation-runner
mkdir src

We’ll create these files:

deno-conversation-runner/
  deno.json
  wrangler.jsonc
  .dev.vars
  .gitignore
  src/
    page.ts
    page_test.ts
    worker.ts

Wrangler generates worker-configuration.d.ts. Deno writes a lockfile as dependencies are resolved. The build creates dist/worker.js.

Commit the lockfile with your application. It records the resolved dependencies so a later build doesn’t silently select different packages.

Create deno.json with these tasks and compiler options:

{
  "compilerOptions": {
    "lib": ["esnext", "deno.ns"],
    "types": ["./worker-configuration.d.ts"],
    "skipLibCheck": true
  },
  "tasks": {
    "types": "deno run -A npm:wrangler types",
    "check": "deno check src/worker.ts src/page_test.ts",
    "test": "deno test src/page_test.ts",
    "build": "deno bundle --platform browser --external cloudflare:workers --minify src/worker.ts -o dist/worker.js",
    "dev": "deno run -A npm:wrangler dev",
    "deploy": "deno run -A npm:wrangler deploy"
  },
  "fmt": { "semiColons": false, "singleQuote": true }
}

The types task asks Wrangler to generate the runtime declarations and bindings from our configuration. We use those declarations when checking the Worker.

deno.ns makes Deno’s namespace available to the test file. Its presence in the type checker doesn’t add Deno APIs to Workers. Keep that boundary in mind when adding application code.

Wrangler runs through Deno’s npm compatibility. -A gives this development tool full local permissions. Those permissions apply to the local CLI process, including custom build commands; they aren’t the production Worker’s security model. Review the tooling and its build commands before running it with these permissions.

The bundle command targets the browser-style platform and leaves cloudflare:workers external. Workers supplies that module at runtime. Recent Deno bundlers recognize this runtime-specific prefix; listing it explicitly also makes the dependency clear. Deno’s bundler release notes describe this behavior.

The bundler is still marked experimental by the Deno release used to test this example. Keep your lockfile and test your build when upgrading tooling. Deno also documents an alternative custom build using esbuild and its Deno plugin.

Configure the Worker and its object namespace

Create wrangler.jsonc:

{
  "name": "deno-conversation-runner",
  "main": "dist/worker.js",
  "compatibility_date": "2026-10-20",
  "build": { "command": "deno task build", "watch_dir": "src" },
  "durable_objects": {
    "bindings": [{ "name": "RUNNERS", "class_name": "ConversationRunner" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ConversationRunner"] }],
  "vars": { "TENANT_ID": "demo" },
  "observability": { "enabled": true }
}

RUNNERS is the binding our request handler uses to find conversation objects.

The migration entry creates a namespace backed by SQLite. Its tag is a deployment migration identifier. When you later change the application’s tables, you still need your own schema migration code; changing this tag doesn’t alter columns for you.

main points at the output of the custom build. Wrangler runs deno task build before loading that entry point. The watch directory tells the local development server where our source files live.

The compatibility date selects Workers runtime behavior. Start with the date you have tested, then update it deliberately. It isn’t a dependency version or the date when your app will publish.

Create .gitignore:

.dev.vars*
.wrangler/
dist/
node_modules/

Then create .dev.vars for local development:

API_TOKEN=local-demo-token

That value is a local example. Choose a different, strong secret for a deployed service. .dev.vars stays out of Git.

The configuration’s TENANT_ID is public configuration. The API token is a secret. My guide to Workers secrets and environments explains how those are supplied independently.

Write the portable page reader

Before introducing a database, let’s define the operation a job performs.

src/page.ts validates a destination, reads a bounded HTML response, and returns its title. It also contains the retry delay calculation.

export function allowedPage(value: string) {
  const url = new URL(value)
  if (
    value.length > 2000 || url.protocol !== 'https:' ||
    url.username || url.password || url.port ||
    !['deno.com', 'developers.cloudflare.com'].includes(url.hostname)
  ) {
    throw new Error(
      'Use an HTTPS page on deno.com or developers.cloudflare.com',
    )
  }
  url.hash = ''
  return url.href
}

export function retryDelay(attempt: number) {
  return Math.min(1000 * 2 ** (attempt - 1), 30_000)
}

export async function readPage(url: string, send: typeof fetch = fetch) {
  const response = await send(allowedPage(url), {
    redirect: 'manual',
    signal: AbortSignal.timeout(10_000),
  })
  if (!response.ok) {
    await response.body?.cancel()
    throw new Error(`Page returned HTTP ${response.status}`)
  }
  if (!response.headers.get('content-type')?.includes('text/html')) {
    await response.body?.cancel()
    throw new Error('Expected an HTML page')
  }
  const reader = response.body?.getReader()
  if (!reader) throw new Error('The page has no body')
  const chunks: Uint8Array[] = []
  let size = 0
  try {
    while (true) {
      const part = await reader.read()
      if (part.done) break
      size += part.value.byteLength
      if (size > 1_000_000) throw new Error('The page exceeds 1 MB')
      chunks.push(part.value)
    }
  } finally {
    await reader.cancel()
  }
  const bytes = new Uint8Array(size)
  let offset = 0
  for (const chunk of chunks) {
    bytes.set(chunk, offset)
    offset += chunk.length
  }
  const html = new TextDecoder().decode(bytes)
  const title = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]
  return (title?.replace(/\s+/g, ' ').trim() || 'Untitled').slice(0, 300)
}

The destination rule is deliberately narrow. The service can fetch HTTPS pages on deno.com and developers.cloudflare.com. It rejects credentials, custom ports, and other hosts.

Accepting arbitrary URLs would turn this into a general-purpose network fetcher. That requires a more careful policy for destinations and redirects. Here, redirects are rejected, so an allowed page can’t redirect the request to a different host.

The timeout covers the fetch and its response body. We also count bytes while reading, stopping at one megabyte. A small compressed bundle tells you little about the amount of memory a response body can consume at runtime.

The title extraction is intentionally limited. It doesn’t decode HTML entities, run JavaScript, or understand a page’s content. For a larger reader, use an HTML parser and define exactly which content you want. Avoid quietly changing this into a browser automation job with very different runtime needs.

readPage() accepts a fetch implementation. Production uses the normal global fetch. Tests can pass a fake response without contacting an external service.

That small seam lets Deno test useful logic while leaving Workers-specific behavior to integration checks.

Persist jobs and run them from an alarm

Create src/worker.ts with the complete implementation below. It contains both the Durable Object class and the HTTP entry point that routes requests to it.

A job remains queued while an attempt runs. Its attempt counter is persisted before the external request starts. After a restart, unfinished work is still discoverable in SQLite.

import { DurableObject } from 'cloudflare:workers'
import { allowedPage, readPage, retryDelay } from './page.ts'

type Job = {
  id: string
  url: string
  status: string
  attempts: number
  title: string | null
  error: string | null
  created_at: number
  ready_at: number
}

export class ConversationRunner extends DurableObject<Env> {
  private sql: SqlStorage

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env)
    this.sql = ctx.storage.sql
    this.sql.exec(`CREATE TABLE IF NOT EXISTS jobs (
      id TEXT PRIMARY KEY,
      url TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'queued',
      attempts INTEGER NOT NULL DEFAULT 0,
      title TEXT,
      error TEXT,
      created_at INTEGER NOT NULL,
      ready_at INTEGER NOT NULL
    )`)
    this.sql.exec(
      'CREATE INDEX IF NOT EXISTS jobs_pending ON jobs(status, created_at, id)',
    )
  }

  getJob(id: string) {
    return this.sql.exec<Job>('SELECT * FROM jobs WHERE id = ?', id)
      .toArray()[0] ?? null
  }

  async enqueue(id: string, url: string) {
    return await this.ctx.storage.transaction(async () => {
      const existing = this.getJob(id)
      if (existing && existing.url !== url) {
        return { conflict: true, job: existing }
      }
      const now = Date.now()
      if (!existing) {
        this.sql.exec(
          'INSERT INTO jobs (id, url, created_at, ready_at) VALUES (?, ?, ?, ?)',
          id,
          url,
          now,
          now,
        )
      }
      if (
        (!existing || existing.status === 'queued') &&
        await this.ctx.storage.getAlarm() === null
      ) {
        await this.ctx.storage.setAlarm(now + 100)
      }
      return { conflict: false, job: this.getJob(id)! }
    })
  }

  override async alarm() {
    const job = this.sql.exec<Job>(
      "SELECT * FROM jobs WHERE status = 'queued' ORDER BY created_at, id LIMIT 1",
    ).toArray()[0]
    if (!job) return
    if (job.ready_at > Date.now()) {
      await this.ctx.storage.setAlarm(job.ready_at)
      return
    }
    if (job.attempts >= 3) {
      this.sql.exec(
        "UPDATE jobs SET status = 'failed', error = 'Attempt budget exhausted' WHERE id = ?",
        job.id,
      )
    } else {
      const attempt = job.attempts + 1
      await this.ctx.storage.transaction(async () => {
        this.sql.exec(
          'UPDATE jobs SET attempts = ? WHERE id = ?',
          attempt,
          job.id,
        )
        await this.ctx.storage.setAlarm(Date.now() + 30_000)
      })
      try {
        const title = await readPage(job.url)
        this.sql.exec(
          "UPDATE jobs SET status = 'done', title = ?, error = NULL WHERE id = ?",
          title,
          job.id,
        )
      } catch (error) {
        this.sql.exec(
          'UPDATE jobs SET status = ?, error = ?, ready_at = ? WHERE id = ?',
          attempt >= 3 ? 'failed' : 'queued',
          String(error).slice(0, 300),
          Date.now() + retryDelay(attempt),
          job.id,
        )
        console.log(
          JSON.stringify({
            event: 'job_attempt_failed',
            jobId: job.id,
            attempt,
          }),
        )
      }
    }
    await this.ctx.storage.transaction(async () => {
      const next = this.sql.exec<Job>(
        "SELECT * FROM jobs WHERE status = 'queued' ORDER BY created_at, id LIMIT 1",
      ).toArray()[0]
      if (next) {
        await this.ctx.storage.setAlarm(
          Math.max(Date.now() + 100, next.ready_at),
        )
      } else await this.ctx.storage.deleteAlarm()
    })
  }
}

export default {
  async fetch(request, env) {
    if (
      !env.API_TOKEN ||
      request.headers.get('Authorization') !== `Bearer ${env.API_TOKEN}`
    ) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 })
    }
    const url = new URL(request.url)
    const match = url.pathname.match(
      /^\/conversations\/([a-zA-Z0-9_-]{1,64})\/jobs\/([a-zA-Z0-9_-]{1,64})$/,
    )
    if (!match) {
      return Response.json({ error: 'Unknown route' }, { status: 404 })
    }
    if (!['PUT', 'GET'].includes(request.method)) {
      return new Response(null, { status: 405, headers: { Allow: 'PUT, GET' } })
    }
    const [, conversation, id] = match
    const runner = env.RUNNERS.getByName(
      JSON.stringify([env.TENANT_ID, conversation]),
    ) as DurableObjectStub<ConversationRunner>
    if (request.method === 'GET') {
      const job = await runner.getJob(id)
      return Response.json(job ?? { error: 'Unknown job' }, {
        status: job ? 200 : 404,
      })
    }
    let page: string
    try {
      page = allowedPage(url.searchParams.get('url') ?? '')
    } catch (error) {
      return Response.json({ error: String(error) }, { status: 400 })
    }
    const result = await runner.enqueue(id, page)
    return Response.json(result.job, {
      status: result.conflict
        ? 409
        : result.job.status === 'queued'
        ? 202
        : 200,
    })
  },
} satisfies ExportedHandler<Env>

There is quite a bit here, so let’s walk through the parts that determine whether a job survives a failure.

Save the job and its alarm together

enqueue() first checks the job’s ID. A new ID inserts a row. An existing ID with the same URL returns the existing job. Reusing the ID for a different URL returns a conflict.

The storage transaction also schedules an alarm when one isn’t already set. The API awaits this operation before returning 202 Accepted.

That matters if the process fails during acceptance. We want the saved job and its wake-up to commit together, rather than acknowledging a row that has no scheduled execution.

SQLite-backed Durable Objects support SQL operations inside the storage transaction. Queries use parameter bindings instead of inserting user input into SQL text. Cursors are consumed before asynchronous work begins. These behaviors are covered in the SQLite storage API.

The constructor only creates the table and index if needed. It doesn’t start a fresh job or replace the alarm every time Cloudflare reconstructs the object.

Use one alarm to drive the queue

An object has one scheduled alarm. We keep many jobs in the table and let that alarm select the next one.

The ordering is created_at, then id to break ties. If the oldest job is waiting for its retry time, later jobs wait behind it. That gives this example predictable per-conversation ordering.

For a conversation where one failed step must complete before the next begins, this can be useful. For independent image thumbnails, you might prefer a queue that allows later jobs to proceed while one waits.

The code processes one job attempt per alarm invocation. It then schedules another alarm for any pending work. It doesn’t attempt to empty an arbitrarily large backlog in one handler.

Await the operation inside the alarm

The handler awaits readPage(), then saves the outcome. We don’t launch the promise and return while it is still running.

Durable Object alarms use at-least-once execution. An uncaught handler exception can trigger automatic retries. Cloudflare runs only one alarm handler at a time for an object, although other events can arrive while asynchronous work is pending.

Our expected fetch errors are caught. They update the job’s error and retry time. An unexpected storage or scheduling failure can still escape and fail the handler.

The platform’s automatic retries and our application attempt counter solve different problems. One retries an alarm invocation. The other records how many times this particular job may start its external operation.

Keep a recovery alarm during an attempt

Before fetching, the handler increments attempts and schedules a recovery alarm thirty seconds ahead in the same transaction.

The page reader has a ten-second deadline. Thirty seconds leaves room for normal completion and storage work. It is an application choice, not a promise that Cloudflare will deliver an alarm at an exact millisecond.

If execution stops before the result is saved, the row remains queued and the scheduled wake-up can find it again. If the handler completes normally, it replaces that recovery alarm with the next job’s schedule, or deletes it when nothing remains.

Saving the counter before the fetch also means a crash can consume an attempt even if the HTTP request never starts. We prefer a finite attempt budget over retrying a broken job forever.

Return the result through a stable address

The request handler addresses a job with a conversation ID and job ID. It doesn’t expose a second database lookup system or a process identifier.

A GET asks the same conversation object for the saved row. The object can be freshly constructed and still answer from SQLite.

Because Wrangler generates the namespace declaration from our bundled entry point, the code explicitly specializes the returned stub as DurableObjectStub<ConversationRunner>. This keeps source-level RPC argument and result checking for the methods we call.

The binding itself still comes from generated Env declarations. We aren’t maintaining a second handwritten list of configuration bindings.

Check types and run the Deno tests

Create src/page_test.ts:

import { assertEquals, assertRejects, assertThrows } from 'jsr:@std/assert'
import { allowedPage, readPage, retryDelay } from './page.ts'

Deno.test('normalizes supported pages and rejects other destinations', () => {
  assertEquals(allowedPage('https://deno.com/#start'), 'https://deno.com/')
  for (
    const url of [
      'http://deno.com',
      'https://deno.com.evil.test',
      'https://user@deno.com',
      'https://127.0.0.1',
    ]
  ) {
    assertThrows(() => allowedPage(url))
  }
})

Deno.test('extracts a title through the same processor used by the alarm', async () => {
  const send = (() =>
    Promise.resolve(
      new Response('<title>Deno\n Deploy</title>', {
        headers: { 'content-type': 'text/html' },
      }),
    )) as typeof fetch
  assertEquals(await readPage('https://deno.com/', send), 'Deno Deploy')
  assertEquals(retryDelay(1), 1000)
  assertEquals(retryDelay(3), 4000)
})

Deno.test('rejects redirects and oversized pages', async () => {
  const redirect =
    (() => Promise.resolve(new Response(null, { status: 302 }))) as typeof fetch
  await assertRejects(
    () => readPage('https://deno.com/', redirect),
    Error,
    'HTTP 302',
  )
  const huge = (() =>
    Promise.resolve(
      new Response('x'.repeat(1_000_001), {
        headers: { 'content-type': 'text/html' },
      }),
    )) as typeof fetch
  await assertRejects(
    () => readPage('https://deno.com/', huge),
    Error,
    'exceeds 1 MB',
  )
})

The tests check destination validation, the processor’s result, redirect rejection, and the body-size limit. They use a JSR package through Deno’s normal dependency resolution.

Now generate the Worker types and run the checks:

deno task types
deno task check
deno task test
deno task build

Run the type generation after creating .dev.vars. Wrangler can include its secret names in the generated environment declaration without including their values.

The first type-generation command also runs the custom build, because Wrangler needs to inspect the entry point. Re-run it after changing bindings or configuration.

Inspect dist/worker.js. It should contain a normal module import from cloudflare:workers, along with the bundled application code.

We don’t need a fake Durable Object base class in production output or a string replacement that turns a test stub back into a runtime import.

Deno tests are still useful when you already have many of them. Keep tests for validation, business rules, serialization, and parsing. Add actual Workers tests for bindings and storage instead of treating a mocked storage object as proof that alarms survive restarts.

Start the local Workers runtime

Run:

deno task dev

Wrangler builds the bundle and starts a local server, usually at http://localhost:8787. Use the address printed by your terminal if that port is occupied.

The local Durable Objects binding uses local persistent storage. It doesn’t create a production namespace or send the jobs to a deployed Worker.

In another terminal, submit a page:

curl -i -X PUT \
  'http://localhost:8787/conversations/research/jobs/page-17?url=https%3A%2F%2Fdeno.com%2F' \
  -H 'Authorization: Bearer local-demo-token'

You should receive 202 Accepted with a row whose status is queued. The body includes the ID, URL, and attempt count.

The alarm runs separately from that HTTP response. After a short wait, request the saved job:

curl \
  'http://localhost:8787/conversations/research/jobs/page-17' \
  -H 'Authorization: Bearer local-demo-token'

A successful job has status: "done", a title, and a nonzero attempt count. The actual title depends on the current page.

The returned row includes internal timestamps and diagnostic fields to make this tutorial inspectable. A public application should expose a deliberate response type and keep unnecessary internal errors out of client responses.

Retry the submission without creating another job

Run the same PUT again with page-17 and the same URL.

If the job has finished, the response returns its saved result with status 200. If it’s still pending, it returns the existing queued row. It doesn’t insert another job.

This is idempotent submission. The client chooses an ID once and keeps it across retries.

A real client could generate that ID with crypto.randomUUID() when the user starts an operation. Save it with the pending request. Generating a new UUID for each network retry creates new jobs.

Now submit a different page using the same ID:

curl -i -X PUT \
  'http://localhost:8787/conversations/research/jobs/page-17?url=https%3A%2F%2Fdeno.com%2Fblog' \
  -H 'Authorization: Bearer local-demo-token'

The response is 409 Conflict. That prevents a retried request from silently changing the meaning of an existing job.

A job is identified within its conversation. page-17 in another conversation is a different job. In a multi-tenant service, the authenticated tenant must also participate in that identity.

Check failure and restart behavior

Happy-path output isn’t enough for this workflow. Try the cases that can lose work or duplicate it.

CheckHow to trigger itExpected behavior
Missing authenticationOmit the Authorization header401, before routing to an object
Unknown jobRead a fresh job ID404
Unsupported destinationSubmit https://127.0.0.1400, no fetch
Duplicate requestRepeat the same ID and URLReturn the same row
Conflicting requestSame ID, different URL409
Remote failureSubmit an allowed URL that returns plain textRetry, then mark failed
RestartStop and restart Wrangler without deleting statePreviously saved jobs remain readable

For a repeatable failure, submit Deno’s plain-text robots.txt file. Our page reader requires HTML:

curl -X PUT \
  'http://localhost:8787/conversations/research/jobs/bad-page?url=https%3A%2F%2Fdeno.com%2Frobots.txt' \
  -H 'Authorization: Bearer local-demo-token'

Read bad-page as you did page-17. The rejected content type should eventually leave it failed, with three attempted operations recorded.

The retry waits grow from one second to two seconds after the first two failures. The third failed attempt ends the job. Alarms can be delayed, and network requests take time, so these aren’t exact completion timings.

Stop the development server and start it again. Read page-17 using the same command. Its result should still be there as long as you preserve Wrangler’s local state directory.

A restart after completion verifies persistence. To test recovery during execution, interrupt a running attempt, restart, and check whether the scheduled work resumes. For a deterministic automated version, use a controlled upstream that pauses before responding, or an integration test in Cloudflare’s Workers test pool. Don’t depend on winning a race against a fast public page.

The local checks for this example covered type checking, the Deno tests, concurrent duplicate submissions, conflicts, successful alarm processing, three-attempt failure handling, and reading persisted results after restarting Wrangler. Production failover and provider-side effects require their own tests.

Understand the crash gap

A job may fetch its page successfully, then crash before saving the title.

On recovery, the database still says the job is unfinished. The runner can fetch the page again.

That’s acceptable for our read-only example. It can cost another request and return newer page content, but it doesn’t send another email or charge a card.

For an agent that performs external writes, the same gap becomes more serious. A database transaction in your Durable Object cannot atomically commit an action in someone else’s API.

A crash after an external actionAn external service accepts an action, then a crash prevents saving its result. Recovery must reconcile the action or retry with the same provider idempotency key.YesCrashPersist operation IDCall externalserviceService acceptsactionLocal result saved?Return saved resultRecover unfinishedoperationReconcile or reuseprovider key

For a provider that supports idempotency keys, persist one for the operation and reuse it across retries. For a provider without that support, look for a way to query the outcome using a stable operation ID.

Sometimes the outcome remains unknown. Represent that state explicitly and ask for reconciliation. Automatically repeating an ambiguous payment or email can be worse than pausing.

Don’t describe this runner as providing exactly-once external execution. It gives us persisted acceptance and a bounded retry policy. The external system determines how safely an operation can repeat.

Background work has several different limits

An HTTP response finishing doesn’t give arbitrary promises unlimited time to keep running.

In Workers, HTTP waitUntil() can extend work for up to thirty seconds after the response finishes or the client disconnects. Durable Object alarm handlers have a fifteen-minute wall-clock limit. CPU time is a separate budget. See the current Workers limits and Durable Objects limits.

Our ten-second request deadline is much smaller than the alarm limit. A production runner should also bound the total job and its individual tool calls.

WorkStarting point
Small best-effort work after an HTTP responsewaitUntil()
Short persisted work coordinated per conversationDurable Object storage and alarms
Independent jobs with delivery and retry handlingCloudflare Queues
A durable process with many steps and long waitsCloudflare Workflows

The example stays small enough to understand the mechanics. For a multi-step assistant that can run for hours, I would use a durable workflow engine and make the object own the conversation’s coordination state.

An object can still be useful alongside Queues or Workflows. You don’t need to implement every scheduling feature inside one alarm handler.

Replacing Deno KV requires a data model

Moving compute is often easier than moving storage.

Deno KV supports tuple keys, versionstamps, atomic checks, and values with structured data types. An application may depend on those semantics in places that look like ordinary reads and writes.

Workers KV has a similar name, but its consistency model is different. It isn’t a direct replacement for a transactional queue or compare-and-set operation.

Start by listing what your application actually does with Deno KV:

Existing operationQuestion to answer before replacing it
Get a conversation by IDWhich object or table owns that conversation?
List a key prefixWhich query and index reproduce the required ordering?
Atomic version checkWhat SQL transaction or conditional update preserves it?
Expiring keyHow will expiry be enforced and old rows removed?
Queue deliveryWhich service retries it, and what identifies duplicates?
Store a structured valueWhich encoding preserves its actual types?

Our example gives enqueue() and getJob() an explicit implementation. The rest of the application doesn’t need to know whether those methods use SQLite or Deno KV.

That is a useful migration boundary for an existing service. Put domain operations behind a small interface, test their behavior, then change their storage implementation.

For example, an atomic Deno KV operation that checks whether a job exists becomes a transaction around a unique SQL key and the acceptance logic. It doesn’t need a general-purpose emulation of every KV method.

Be careful with tuple-key adapters

Serializing every key with JSON.stringify() can preserve some identities, but it doesn’t reproduce Deno KV’s ordering rules.

Deno KV tuple ordering covers different key-part types and numeric edge cases. A string-padding scheme that works for positive integers may order negative or fractional values incorrectly. Separators can collide with real string contents unless escaped correctly.

Values need the same care. Plain JSON doesn’t preserve every value supported by structured cloning, including bigint, typed arrays, maps, and dates with their original types.

If you need a compatibility adapter, define the subset it supports and test it against your existing data. Compare the results of real prefix scans and atomic operations before moving production traffic.

For a small number of domain operations, explicit tables and methods are usually easier to inspect than a broad compatibility layer.

Migrate existing data before switching writers

The tutorial’s new namespace starts empty. An existing application needs a cutover plan.

I would start with a maintenance-window migration if the product can tolerate it. It has fewer moving parts than introducing bidirectional synchronization while also replacing the runtime.

The sequence is:

  1. Deploy the new code against an isolated destination. Keep production traffic on the old service.
  2. Export a representative sample and test the import, including unusual keys and values.
  3. Pause production writers and drain or record unfinished jobs.
  4. Export the final source data and import it with stable record IDs.
  5. Verify record counts and domain invariants, then switch the application to the new writer.
  6. Watch errors and pending jobs before resuming normal traffic.

A count alone isn’t enough. A migration can import the right number of rows with the wrong relationships.

Check that every pending job belongs to an existing conversation, duplicate operation IDs remain unique, timestamps retain their meaning, and completed results aren’t accidentally scheduled as new work.

If you need an import endpoint, authenticate it separately, bound its request size, and make imports resumable and idempotent. Don’t expose a public route that accepts arbitrary SQL.

If you can’t pause writes

A live migration needs a record of changes made during the copy. Depending on the application, that might be an outbox, a change stream, or a deliberately implemented dual-write path with reconciliation.

You then copy the existing data, replay later changes, verify parity, and switch ownership of writes.

Dual writing has failure cases of its own. A request can succeed against the old store and fail against the new one. You need a persistent record that lets you repair that difference. Logging an error and continuing leaves silent divergence.

Make one system authoritative at each stage. Reads from both systems can help compare behavior, but accepting independent production writes in both makes conflicts much harder to resolve.

Rollback after new writes

Changing DNS or a route back to the old service doesn’t move new data with it.

Once the destination has accepted writes, a rollback needs a way to preserve them. That can mean reverse replication, a controlled export, or temporarily pausing writes while you reconcile.

Define that path before cutover. Also decide how queued work is transferred. A copied pending job can execute on both systems if both runners remain active.

For jobs that perform external actions, stable operation IDs need to survive the migration as well as ordinary network retries.

Audit dependencies and startup work

A Deno project can import packages from JSR and npm while developing. Whether the bundled result runs on Workers depends on what those packages do.

A package that uses standard JavaScript can be straightforward. One that opens local files, spawns a process, loads a native library, or calls Deno APIs needs another approach.

Search for runtime-specific calls before changing the entry point:

rg 'Deno\.|node:|Bun\.' src

Then inspect dependencies too. Application source can look portable while a transitive dependency performs unsupported work during import.

Move request-specific work into request or object methods. Avoid generating request IDs, opening network connections, or creating timers as side effects of importing a module.

Don’t install a broad globalThis.Deno shim just to make every import stop throwing. Implement the specific configuration boundary you need, or replace the dependency. A partial shim can keep the application running with incorrect behavior that is harder to spot than a clear failure.

Libraries that generate code dynamically also need checking. Workers restricts runtime code generation. Some validation libraries offer an interpreter mode; others need a different build or replacement. Verify the option for the package you use rather than applying a global setting from someone else’s migration.

Measure expanded memory

Workers has a per-isolate memory limit. An isolate can serve multiple concurrent requests, so don’t estimate memory as one fresh isolate per HTTP request. The limits documentation describes the shared memory constraint.

Large tokenization tables, dictionaries, document parsers, and duplicated dependencies can consume much more memory after loading than their compressed download size suggests.

Keep heavyweight processing out of the request path where possible. Store files in appropriate object storage, stream bounded data, and measure the largest inputs you intend to support.

A location hint for a Durable Object also doesn’t reserve an always-warm machine in a chosen data center. Hints are best effort, as described in the data location documentation. Design for reconstruction and place latency-sensitive dependencies with that constraint in mind.

Deploy the example

Local development hasn’t created a hosted application. To deploy, log in to your Cloudflare account:

deno run -A npm:wrangler login

Then create the production secret through the interactive prompt:

deno run -A npm:wrangler secret put API_TOKEN

Use a strong secret here, rather than the local tutorial token. Wrangler may offer to create the named Worker if it doesn’t exist yet. Review the account and resource name it displays.

Check the build without uploading it:

deno run -A npm:wrangler deploy --dry-run

Then deploy:

deno task deploy

The configured migration creates the SQLite-backed class. Follow the printed Worker URL and repeat the authenticated PUT and GET requests against that address, using your production secret.

The deployed namespace has its own storage, separate from your local data. The research conversation created locally and the one created in production are separate objects.

Keep secrets out of shell history when writing your own production commands. You can load an Authorization header from a protected local curl configuration file instead of pasting the token into every command.

Add the controls a public service needs

The example uses one bearer token for one tenant. That is enough to follow the migration mechanics locally, but a customer-facing product needs account authentication and authorization for every conversation.

The object name must use the authenticated tenant. Accepting a tenant ID from a query parameter lets a caller choose someone else’s storage address.

Add a maximum pending-job count per conversation, request rate limits, and retention for completed rows. Otherwise a caller can create storage indefinitely, even though every individual page fetch is bounded.

Decide what users can see when a page fails. The tutorial stores a short error for diagnosis. A product might expose a stable error code while keeping implementation details in structured logs.

Track the age of the oldest queued job, failed jobs, attempts per job, and job completion time. A healthy HTTP success rate can hide a background queue that stopped advancing.

If jobs can be cancelled, persist cancellation and check it around external calls. Cancelling a local promise doesn’t necessarily undo an action already accepted by another service.

For real conversation history, also decide whether jobs after a failed turn should continue. This example advances after the attempt budget ends. An assistant may need to stop the conversation until the failed step is resolved.

How I would use this workflow

I would keep Deno for an existing application whose tests and development tasks already work well. I’d move a small request path to Workers first, then replace storage through explicit domain methods.

For a conversation-based assistant, I’d give each conversation an object that owns pending operations and their status. A job ID would follow the operation from the client through any model or tool call.

I’d use a simple alarm runner for short, bounded work. Once the job involved many steps, long waits, or human approval, I’d move execution to Workflows and keep the object responsible for conversation coordination.

I would avoid this architecture for a workload that depends on a persistent local filesystem or native binaries. A long-running container or conventional server may fit that application better and require less adaptation.

Before migrating a large codebase, I would make this small queue work with the application’s real authentication and one real storage operation. Then I’d interrupt it during a request, retry a submission, and rehearse moving a sample of production data. Those checks tell you much more than a successful hello-world deployment.

Want me to talk about your product? You can sponsor this site.

Related posts about cloudflare: