wandres.dev
CRON TRIGGERS · workers programados

El `scheduled` handler: un Worker sin cliente

Un Worker no solo responde a peticiones: puede despertar solo. El handler scheduled es la función que Cloudflare invoca según un reloj, sin Request ni Response; su firma, sus tres argumentos, y cómo convive con fetch en el mismo isolate.

⏱ 13 min

Un Worker que ya conoces es reactivo: duerme hasta que alguien llama a su puerta con una Request, y su vida entera consiste en devolver una Response. Pero existe una segunda puerta, y no la abre un cliente: la abre el reloj de Cloudflare. El scheduled handler es la función que la plataforma invoca cuando un cron trigger se dispara —sin petición que atender y sin respuesta que devolver—. Entenderlo como el gemelo de fetch es aceptar que un Worker también puede ser un sujeto que actúa por su cuenta, no solo un objeto al que se le pide algo.

🎯 Al terminar esta lección sabrás
  • Entender que scheduled es un handler invocado por el reloj de Cloudflare, no por una petición.
  • Leer la firma completa —controller, env y ctx— y qué expone cada argumento.
  • Distinguir scheduled de fetch y ver cómo conviven en un mismo Worker.
  • Interpretar controller.cron para ramificar cuando hay varios horarios sobre el mismo handler.

Un evento que nadie pide

El runtime invoca fetch una vez por cada petición HTTP: hay un cliente al otro lado esperando bytes. El scheduled handler rompe esa premisa. Lo invoca el programador interno de Cloudflare cuando llega la hora marcada por un cron trigger, y no hay ningún cliente: no recibes una Request y no devuelves una Response. Su tipo de retorno es Promise<void>, porque no hay nadie a quien contestar.

export default {
  async scheduled(controller, env, ctx) {
    console.log("cron procesado");
  },
};

Ese handler mínimo ya es un Worker de cron completo. Fíjate en lo que no hay: ni request, ni un new Response(...), ni un valor de vuelta. La plataforma no juzga el éxito por lo que devuelves —no devuelves nada—, sino por si tu handler y sus promesas se resuelven o lanzan una excepción.

La firma, campo por campo

Escrita con todos sus tipos, la firma revela tres argumentos, los mismos tres que ya viste en un fetch moderno pero con el primero cambiado:

export default {
  async scheduled(
    controller: ScheduledController,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<void> {
    const cuando = new Date(controller.scheduledTime);
    console.log(`disparado por ${controller.cron} el ${cuando.toISOString()}`);
  },
} satisfies ExportedHandler<Env>;

Donde fetch recibía una Request, scheduled recibe un ScheduledController: el objeto que describe el evento de reloj. Sus campos son pocos y precisos.

controller

El evento de reloj: controller.cron (la expresión que disparó), controller.scheduledTime (milisegundos desde época) y controller.noRetry() para renunciar al reintento.

🔌

env

Tus bindings: KV, D1, R2, secretos. Idénticos a los del fetch; un cron accede a los recursos exactamente igual que un Worker HTTP.

ctx

El contexto de ejecución. Su método clave es ctx.waitUntil, que prolonga la vida del evento y merece su propia lección.

📝
scheduledTime es UTC y es un número

controller.scheduledTime no es una fecha, es un entero: milisegundos desde el 1 de enero de 1970 en UTC. Para trabajarlo como fecha lo envuelves con new Date(controller.scheduledTime). Y es la hora que la plataforma tenía programada, no necesariamente el instante exacto de ejecución: úsalo como referencia del horario, no como cronómetro de precisión.

fetch y scheduled en el mismo Worker

Un mismo Worker puede exportar los dos handlers a la vez: mismo archivo, mismo modelo de isolate, mismos bindings, dos puntos de entrada. También puede exportar solo scheduled —un Worker de fondo sin superficie HTTP—; en ese caso, una petición web no encuentra fetch que la atienda y termina en error.

export default {
  async fetch(request, env, ctx): Promise<Response> {
    return new Response("sirvo HTTP");
  },
  async scheduled(controller, env, ctx): Promise<void> {
    // corro en el horario, sin nadie que pida nada
    await env.KV.put("ultimo-cron", new Date().toISOString());
  },
};

Cuando varios horarios apuntan al mismo Worker, todos invocan la misma función scheduled. Para ejecutar lógica distinta según el que se disparó, ramificas con controller.cron:

async scheduled(controller, env, ctx) {
  switch (controller.cron) {
    case "*/5 * * * *":
      await sincronizar(env);
      break;
    case "0 0 * * *":
      await limpiezaDiaria(env);
      break;
  }
}
flowchart TB
Cliente -->|Request| Fetch[handler fetch]
Fetch -->|Response| Cliente
Reloj[programador de Cloudflare] -->|cron dispara| Scheduled[handler scheduled]
Scheduled -->|sin respuesta| Fin[trabajo terminado]

Los dos handlers comparten la naturaleza del Worker —una función en el edge, efímera y sin estado entre invocaciones— pero difieren en su causa: a fetch lo causa un usuario, a scheduled lo causa el tiempo.

El Worker que despierta solo

El instinto del desarrollador de backend es el del par petición/respuesta: el código duerme hasta que alguien llama, y su razón de ser es contestar. El cron invierte esa causalidad, y ahí está el salto mental. Con scheduled, el Worker deja de ser un objeto al que se le pide algo y pasa a ser un sujeto que actúa: nadie llama, nadie espera, no hay Response que devolver porque no hay destinatario. La ausencia de respuesta no es una carencia de la API, es la esencia del patrón —lo que se ejecuta no es una contestación, es una acción con voluntad propia disparada por el reloj—. Piensa en cómo se hacía esto antes del edge: un demonio cron en una máquina, un proceso siempre encendido, un planificador externo… todos exigen un servidor vivo esperando a que llegue la hora, y pagas por tenerlo despierto aunque no haga nada el noventa y nueve por ciento del tiempo. En el edge no hay máquina que mantener encendida: el programador es parte de la plataforma, materializa un isolate solo para correr tu handler cuando toca, y lo descarta al terminar. Cuando interiorizas que scheduled y fetch son las dos caras de un mismo modelo —la función en el edge, una reactiva y otra proactiva— dejas de pensar “necesito un servidor para la tarea programada” y empiezas a pensar “exporto un segundo handler”. Ese cambio de marco es lo que convierte el cron de una pieza de infraestructura a mantener en una simple función más de tu Worker.

⚔️ Añade la segunda puerta
  1. Escribe la firma del scheduled handler de memoria, con sus tres tipos: ScheduledController, Env y ExecutionContext.
  2. Añade un scheduled handler a un Worker que ya tenga fetch; registra con console.log el controller.cron y el controller.scheduledTime ya convertido a fecha.
  3. Razona en voz alta: si un Worker exporta solo scheduled y abres su URL en el navegador, ¿qué ocurre y por qué?
  4. Diseña dos horarios que apunten al mismo handler y escribe el switch sobre controller.cron que separa su lógica.