Scheduling HTTP requests on Cloudflare Workers

Building a reliable scheduling system is harder than it looks. The scheduler and its storage backend have to be designed for scale from day one, or you'll run into limits as soon as you start adding real traffic. Most scheduling services use a central database and a cron-like process to scan for due jobs — a design that gets complicated once you need redundancy and horizontal scaling.

Cloudflare Workers, Durable Objects, and Alarms turn out to be a natural fit for this kind of workload. Durable Objects each come with their own isolated storage and an alarm scheduler, both automatically replicated and failed over by the platform. That means you get a scheduling primitive that is reliable by default, without needing to operate your own queue or database.

Common use cases include webhook delivery, sending reminder emails a week after signup, or invoicing. In this walkthrough, we'll build a scalable service that can fire HTTP requests either once at a specified time or on a repeating interval, using one Durable Object per scheduled request.

How the pieces fit together

  • Wrangler — the CLI for developing and publishing Workers
  • Cloudflare Workers — the runtime that executes our code at the edge
  • Durable Objects and Alarms — per-request storage and timers

The application has two parts: a scheduling API and a Durable Object class. The API accepts a scheduled request, stores its metadata (URL, headers, body, timing), and creates a Durable Object. Each Durable Object holds one scheduled request in its own storage and sets an alarm as the trigger that wakes it up when it's time to fire.

Because each request lives in its own Durable Object, the system scales horizontally as far as you need — there's no shared bottleneck. The Cloudflare network manages data replication and guarantees the alarm fires when the time comes.

Setting up the project

Start by scaffolding a new project with:

wrangler init -y durable-objects-requests-scheduler

Then define the TypeScript types that describe the scheduled request. Doing this upfront keeps the rest of the implementation cleaner.

src/types.ts

export interface Env {
    DO_REQUEST: DurableObjectNamespace
}
 
export interface ScheduledRequest {
  url: string // URL of the request
  triggerAt?: number // optional, unix timestamp in milliseconds, defaults to `new Date()`
  requestInit?: RequestInit // optional, includes method, headers, body
}

The Durable Object class

The Durable Object that handles one scheduled request is just a few lines. In the object's constructor logic, it accepts the request payload, persists it, and sets the alarm. Later, when the worker runtime wakes the object, the alarm() method reads the stored data, performs the HTTP request, and — if the schedule is repeating — sets another alarm for the next interval.

src/request-durable-object.ts

import { ScheduledRequest } from "./types";
 
export class RequestDurableObject {
  id: string|DurableObjectId
  storage: DurableObjectStorage
 
  constructor(state:DurableObjectState) {
    this.storage = state.storage
    this.id = state.id
  }
 
    async fetch(request:Request) {
    // read scheduled request from request body
    const scheduledRequest:ScheduledRequest = await request.json()
     
    // save scheduled request data to Durable Object storage, set the alarm, and return Durable Object id
    this.storage.put("request", scheduledRequest)
    this.storage.setAlarm(scheduledRequest.triggerAt || new Date())
    return new Response(JSON.stringify({
      id: this.id.toString()
    }), {
      headers: {
        "content-type": "application/json"
      }
    })
  }
 
  async alarm() {
    // read the scheduled request from Durable Object storage
    const scheduledRequest:ScheduledRequest|undefined = await this.storage.get("request")
 
    // call fetch on scheduled request URL with optional requestInit
    if (scheduledRequest) {
      await fetch(scheduledRequest.url, scheduledRequest.requestInit ? webhook.requestInit : undefined)
 
      // cleanup scheduled request once done
      this.storage.deleteAll()
    }
  }
}

With the class written, you need to tell Wrangler about it. Add a Durable Object binding in wrangler.toml, pointing at the module and exported class name.

wrangler.toml

name = "durable-objects-request-scheduler"
main = "src/index.ts"
compatibility_date = "2022-08-02"
 
# added Durable Objects configuration
[durable_objects]
bindings = [
  { name = "DO_REQUEST", class_name = "RequestDurableObject" },
]
 
[[migrations]]
tag = "v1"
new_classes = ["RequestDurableObject"]

The scheduling API

The API itself is a Worker that accepts JSON payloads describing a scheduled request. It exposes a single endpoint:

  • POST /:scheduledRequestId? — create a new scheduled request, or update an existing one by passing its ID as the URL path.

Any other HTTP method returns 405 - Method Not Allowed. The payload must match the schema defined in types.ts: a required url, optional headers and body, and exactly one of triggerAt (a Unix timestamp) or triggerEverySeconds. If neither timing field is provided, the request fires immediately.

src/index.ts

import { Env } from "./types"
export { RequestDurableObject } from "./request-durable-object"
 
export default {
    async fetch(
        request: Request,
        env: Env
    ): Promise<Response> {
        if (request.method !== "POST") {
            return new Response("Method Not Allowed", {status: 405})
        }
 
        // parse the URL and get Durable Object ID from the URL
        const url = new URL(request.url)
        const idFromUrl = url.pathname.slice(1)
 
        // construct the Durable Object ID, use the ID from pathname or create a new unique id
        const doId = idFromUrl ? env.DO_REQUEST.idFromString(idFromUrl) : env.DO_REQUEST.newUniqueId()
 
        // get the Durable Object stub for our Durable Object instance
        const stub = env.DO_REQUEST.get(doId)
 
        // pass the request to Durable Object instance
        return stub.fetch(request)
    },
}

One limitation worth noting: the API doesn't keep a registry of created requests. If you need to list scheduled or completed webhooks, you'd either save each Durable Object ID in your own backend at creation time, or build a separate registry using Workers storage.

Testing locally

Wrangler can run the entire stack locally, which is handy for iterating without deploying. The dev server listens on localhost:8787 and doesn't require an internet connection.

wrangler dev --local

With the server running, POST a JSON payload to schedule your first request:

{
	"url":  "https://example.com",
	"triggerEverySeconds": 30,
}

This example sends a GET to https://example.com every 30 seconds, as configured by triggerEverySeconds. The logs show the generated Durable Object ID and the recurring alarm.

BLOG-1285 Embedded Image - i4TUqE

To change the schedule later — say, double the interval — send another POST with the same request ID in the path and the updated payload. The system will overwrite the stored metadata and reset the alarm.

Deploying

Once you're satisfied with the local test, push it live:

wrangler publish

Wrangler bundles the Worker, wires up the Durable Object bindings, and publishes to your workers.dev subdomain, printing the deployment URL. The whole service, from a clean project to a scalable scheduler running on the edge, takes only a few files.

The full source is available in the Workers templates repository, or you can try an interactive version in your browser via the StackBlitz template.