Skip to content
FLAVIO COPES
flaviocopes.com

Use fetch, queue, and scheduled handlers in one Cloudflare Worker

By

Run an Astro app, background queue consumer, and retention cron from one Cloudflare Worker by keeping each handler small and explicit.

~~~

One Cloudflare Worker can respond to HTTP requests, consume queue messages, and run scheduled jobs.

You do not need three separate services.

The default export implements the handlers Cloudflare can call:

export default {
  fetch(request, env, ctx) {
    return handle(request, env, ctx)
  },

  queue(batch, env) {
    return consumeEmailEvents(batch, env)
  },

  scheduled(controller, env) {
    return purgeExpiredRows(env.DB, controller.scheduledTime)
  }
}

Exporting a handler does not activate its event source. Configure the queue consumer and Cron Trigger in wrangler.jsonc:

{
  "queues": {
    "consumers": [
      {
        "queue": "email-events",
        "max_batch_size": 10,
        "max_batch_timeout": 5
      }
    ]
  },
  "triggers": {
    "crons": ["0 3 * * *"]
  }
}

Create the email-events queue before deploying. Cron expressions use UTC, so this schedule runs every day at 03:00 UTC.

This is useful for a small product where the jobs share bindings and one domain.

Keep the fetch handler boring

If a framework owns HTTP routing, let it keep doing that:

import { handle } from '@astrojs/cloudflare/handler'

export default {
  fetch(request, env, ctx) {
    return handle(request, env, ctx)
  }
}

The Worker entry point does not need to know about every page and API route.

Astro handles HTTP. The entry point only connects Astro to Cloudflare.

Use the queue for delayed provider events

Email delivery, webhook fan-out, and other asynchronous work fit the queue handler:

async function consumeEmailEvents(batch, env) {
  for (const message of batch.messages) {
    try {
      await applyEmailEvent(env.DB, message.body)
      message.ack()
    } catch (error) {
      message.retry({ delaySeconds: 15 })
    }
  }
}

Handle acknowledgement per message.

One invalid event should not force the whole batch to run again.

The delivery-tracking article can own the detailed acknowledgement, validation, and correlation-retry policy. This handler only composes the consumer into the Worker.

Use scheduled jobs for retention

Data retention is a good scheduled task:

async function purgeExpiredRows(db, now) {
  const unixTime = Math.floor(now / 1000)

  await db.batch([
    db.prepare(`
      DELETE FROM subscribers
      WHERE status = 'pending'
        AND confirmation_expires_at < ?
    `).bind(unixTime),

    db.prepare(`
      DELETE FROM subscribers
      WHERE status = 'confirmed'
        AND confirmed_at < datetime(?, 'unixepoch', '-24 months')
    `).bind(unixTime)
  ])
}

This is better than deleting old rows during an unrelated page request.

The retention policy runs even when nobody visits the site.

Share bindings, not responsibilities

All three handlers can use the same D1 database and environment:

fetch     -> pages and API routes
queue     -> asynchronous delivery events
scheduled -> retention cleanup

They share infrastructure, but each handler should call a focused function.

Avoid building one large switch that mixes request routing, message parsing, and cleanup logic.

Type the complete export

In TypeScript, use satisfies to check the handler shape without losing inference:

export default {
  fetch(request, env, ctx) {
    return handle(request, env, ctx)
  },
  queue(batch, env) {
    return consumeEmailEvents(batch, env)
  },
  scheduled(controller, env) {
    return purgeExpiredRows(env.DB, controller.scheduledTime)
  }
} satisfies ExportedHandler<Env>

This catches a misspelled handler or incompatible signature at build time.

When to split the Worker

Keep one Worker while the handlers:

Split them when one job needs separate permissions, scaling, ownership, or deployment.

Start with the smallest useful boundary. A single Worker with three explicit handlers is often enough.

Tagged: Cloudflare ยท All topics
~~~

Related posts about cloudflare: