Cloudflare Workflows a fondo: procesos de varios pasos que sobreviven a fallos, esperas y reinicios

Qué es Workflows y cómo funciona la ejecución duradera, step.do con reintentos, step.sleep, waitForEvent y sendEvent, NonRetryableError, estados de una instancia, límites y precios, un proceso de alta con confirmación por email probado en local y sus tests con introspección.

Portada: Cloudflare Workflows a fondo

Hay procesos que no caben en una petición: dar de alta a un usuario y esperar a que confirme su email, cobrar un pedido y avisar al almacén, generar un informe que tarda diez minutos, recordarle algo a alguien dentro de tres días. Con una cola puedes hacer cada trozo por separado, pero acabas guardando a mano en qué paso va cada proceso, qué falló y qué hay que reintentar.

Workflows es el motor de ejecución duradera de Cloudflare: escribes el proceso entero como una función normal, dividida en pasos, y la plataforma guarda el resultado de cada paso. Si algo falla, solo se reintenta ese paso; si el proceso tiene que esperar un día o a que llegue un evento, se duerme sin consumir nada y continúa donde lo dejó.

En este artículo vamos a ver cómo funciona por dentro, sus límites y precios, y un proceso de alta de usuario con reintentos, una espera por evento y una pausa, ejecutado de principio a fin en local y probado con las utilidades de introspección del plugin de Vitest.

🧪
Todo el código de este artículo se ha ejecutado de verdad el 3 de octubre de 2026 con Wrangler 4.147.0, workerd 1.20261001.1 (Workflows emulado en local), @cloudflare/vitest-plugin 1.3.6, Vitest 4.1.11 y Node.js 24.21.0. El despliegue real necesita cuenta y no lo he probado. Límites y precios comprobados en la documentación oficial ese mismo día.

Cómo funciona: pasos que se guardan

Un Workflow es una clase que extiende WorkflowEntrypoint con un método run(event, step). Cada vez que lo lanzas se crea una instancia con sus parámetros. Dentro de run, el trabajo se divide en pasos:

  • step.do(nombre, fn): ejecuta fn y guarda lo que devuelve. Si la instancia se reinicia (por un fallo, un despliegue o porque estaba dormida), el motor vuelve a ejecutar run desde el principio, pero los pasos ya completados no se repiten: devuelven el resultado guardado al instante.
  • step.sleep(nombre, duración) y step.sleepUntil(nombre, fecha): duermen la instancia. Puede ser un segundo o hasta un año.
  • step.waitForEvent(nombre, { type, timeout }): espera a que alguien le envíe un evento desde fuera (un webhook, un clic en un enlace), con un plazo de entre 1 segundo y 365 días (24 horas por defecto).
Esquema de Workflows: una instancia ejecuta pasos en orden; cada resultado se guarda; un paso que falla se reintenta; la instancia puede dormir o esperar un evento y continuar después
Cada paso se guarda al terminar. Al reanudar, los pasos completados devuelven su resultado sin volver a ejecutarse. Diagrama: devutilites.

De ahí sale la regla más importante: todo lo que tenga efectos (escribir en una base de datos, cobrar, enviar un correo) va dentro de un step.do, y el código fuera de los pasos debe ser determinista, porque puede ejecutarse varias veces. Y como un paso puede reintentarse, conviene que sea idempotente.

Reintentos

Si la función de un paso lanza una excepción, se reintenta. Sin configuración, cada paso tiene 5 reintentos con 10 segundos de espera y crecimiento exponencial, y un tiempo máximo de 10 minutos por intento. Todo se puede cambiar por paso (limit, delay, backoff constante, lineal o exponencial, y timeout). Si un error no tiene sentido reintentarlo (datos no válidos), lanza un NonRetryableError y la instancia termina en error al momento.

Estados de una instancia

queued (en cola), running, paused, waiting (durmiendo o esperando un evento), complete, errored y terminated, entre otros. Las instancias que esperan no cuentan para el límite de instancias concurrentes: puedes tener millones de altas esperando confirmación.

Límites y precios

LímiteWorkers FreeWorkers Paid
CPU por paso10 ms30 s por defecto, configurable hasta 5 min
Duración de reloj por pasoIlimitadaIlimitada
Resultado de un paso1 MiB1 MiB
Parámetros del evento1 MiB1 MiB
Estado guardado por instancia100 MB1 GB
Pasos por Workflow1.02410.000 (hasta 25.000)
step.sleep máximo365 días365 días
Instancias en ejecución a la vez10050.000
Creación de instancias100 por segundo300 por segundo por cuenta, 100 por Workflow
Retención del estado al terminar3 días30 días
Reintentos por paso10.00010.000

Workflows está en los dos planes. Se factura como Workers (peticiones y CPU, con las mismas cantidades incluidas) más dos conceptos propios:

Workers FreeWorkers Paid
Pasos3.000 al día500.000 al mes, luego 0,80 $ por cada 100.000
Almacenamiento1 GB-mes1 GB-mes, luego 0,20 $ por GB-mes

Una instancia dormida o esperando una respuesta no consume CPU. Los reintentos y los manejadores de deshacer no cuentan como pasos.

⚠️
Ojo con la fecha de facturación. La página de precios de Workflows dice que los pasos y el almacenamiento se cobran desde el 10 de agosto de 2026, mientras que la página de precios general de Workers sigue diciendo que no se cobrarán "antes de la fecha anunciada" en el registro de cambios. Y la página de límites, en la tabla, da 50.000 instancias concurrentes en Paid, pero en el texto de debajo habla de 10.000. He usado la tabla; compruébalo antes de dimensionar.

Un proceso de alta, paso a paso

La configuración: un binding workflows con un nombre (único en la cuenta), el nombre del binding y la clase.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "workflows-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "workflows": [
    {
      "name": "alta-usuario",        // nombre único en la cuenta
      "binding": "ALTAS",            // env.ALTAS
      "class_name": "AltaUsuario"    // la clase exportada en src/index.ts
    }
  ],
  "kv_namespaces": [{ "binding": "USUARIOS", "id": "55555555555555555555555555555555" }]
}

El Workflow valida el email, crea el usuario, envía la bienvenida (con un proveedor simulado que falla la primera vez), espera hasta 7 días a que el usuario confirme, hace una pausa y activa la cuenta. El Worker de abajo expone tres rutas: crear una instancia, consultar su estado y enviarle el evento de confirmación.

import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";

type Alta = { email: string; nombre: string };
type Confirmacion = { aceptaNewsletter: boolean };

// Un proceso de alta en varios pasos: cada step.do() se guarda y no se repite si algo falla después
export class AltaUsuario extends WorkflowEntrypoint<Env, Alta> {
  async run(event: WorkflowEvent<Alta>, step: WorkflowStep) {
    const { email, nombre } = event.payload;

    // 1) Validar: un error que no tiene sentido reintentar
    await step.do("validar", async () => {
      if (!email.includes("@")) throw new NonRetryableError(`Email no válido: ${email}`);
    });

    // 2) Crear el usuario. Lo que devuelve el paso queda guardado (máx. 1 MiB)
    const usuario = await step.do("crear usuario", async () => {
      const id = crypto.randomUUID();
      await guardarUsuario(this.env, id, { email, nombre, estado: "pendiente" });
      return { id };
    });

    // 3) Enviar el correo de bienvenida, con reintentos (simulamos un proveedor que falla la primera vez)
    await step.do(
      "enviar bienvenida",
      { retries: { limit: 3, delay: "1 second", backoff: "exponential" }, timeout: "30 seconds" },
      async () => {
        const intentos = Number((await this.env.USUARIOS.get(`intentos:${usuario.id}`)) ?? 0) + 1;
        await this.env.USUARIOS.put(`intentos:${usuario.id}`, String(intentos));
        console.log(`[${usuario.id.slice(0, 8)}] enviando bienvenida, intento ${intentos}`);
        if (intentos === 1) throw new Error("El proveedor de correo no responde");
        return { enviado: true, intentos };
      },
    );

    // 4) Esperar a que el usuario confirme el email (o 7 días como mucho)
    let confirmado = false;
    let newsletter = false;
    try {
      const evento = await step.waitForEvent<Confirmacion>("esperar confirmación", {
        type: "email-confirmado",
        timeout: "7 days",
      });
      confirmado = true;
      newsletter = evento.payload.aceptaNewsletter;
    } catch {
      // Si vence el plazo, waitForEvent lanza un error: seguimos con la rama "no confirmado"
    }

    // 5) Un respiro antes de activar (en producción podría ser "1 day")
    await step.sleep("pausa antes de activar", "2 seconds");

    // 6) Resultado final
    return await step.do("activar", async () => {
      const estado = confirmado ? "activo" : "caducado";
      await guardarUsuario(this.env, usuario.id, { email, nombre, estado, newsletter });
      return { id: usuario.id, estado, newsletter };
    });
  }
}

async function guardarUsuario(env: Env, id: string, datos: object) {
  await env.USUARIOS.put(`usuario:${id}`, JSON.stringify(datos));
}

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);
    const [, recurso, id, accion] = url.pathname.split("/"); // /altas/:id/confirmar

    if (recurso !== "altas") return new Response("No existe", { status: 404 });

    // POST /altas  { email, nombre }  -> crea una instancia
    if (request.method === "POST" && !id) {
      const datos = await request.json<Alta>();
      const instancia = await env.ALTAS.create({ params: datos });
      return Response.json({ id: instancia.id, estado: await instancia.status() }, { status: 202 });
    }

    if (!id) return new Response("Falta el id", { status: 400 });
    const instancia = await env.ALTAS.get(id);

    // POST /altas/:id/confirmar  -> envía el evento que la instancia está esperando
    if (request.method === "POST" && accion === "confirmar") {
      await instancia.sendEvent({ type: "email-confirmado", payload: await request.json<Confirmacion>() });
      return Response.json({ ok: true });
    }

    // GET /altas/:id  -> estado (queued, running, waiting, complete, errored...)
    return Response.json(await instancia.status());
  },
} satisfies ExportedHandler<Env>;

Fíjate en cómo se maneja la espera: si vence el plazo, waitForEvent lanza un error, y el try/catch convierte ese caso en la rama "no confirmado". Así el mismo proceso sirve para los dos finales.

Ejecución en local

wrangler dev emula Workflows por completo, incluidos los reintentos, las esperas y los eventos:

$ curl -X POST localhost:8801/altas -H "Content-Type: application/json" -d '{"email":"[email protected]","nombre":"Ana"}'
{"id":"1ba39b55-7a9e-4062-be2b-76aa4601b713","estado":{"status":"running","__LOCAL_DEV_STEP_OUTPUTS":[null],"output":null}}

# 4 segundos después: creado, bienvenida enviada al segundo intento, esperando el evento
$ curl localhost:8801/altas/1ba39b55-7a9e-4062-be2b-76aa4601b713
{"status":"running","__LOCAL_DEV_STEP_OUTPUTS":[null,{"id":"a4dc224e-..."},{"enviado":true,"intentos":2}],"output":null}

$ curl -X POST localhost:8801/altas/1ba39b55-7a9e-4062-be2b-76aa4601b713/confirmar \
    -H "Content-Type: application/json" -d '{"aceptaNewsletter":true}'
{"ok":true}

# tras la pausa de 2 segundos
$ curl localhost:8801/altas/1ba39b55-7a9e-4062-be2b-76aa4601b713
{"status":"complete", ..., "output":{"id":"a4dc224e-...","estado":"activo","newsletter":true}}

$ curl -X POST localhost:8801/altas -H "Content-Type: application/json" -d '{"email":"sin-arroba","nombre":"Error"}'
{"id":"d31668a0-...","estado":{"status":"errored","__LOCAL_DEV_STEP_OUTPUTS":[],"output":null,
 "error":{"name":"WorkflowFatalError","message":"The execution of the Workflow instance was terminated, as a step threw an NonRetryableError and it was not handled"}}}

Y en el log de wrangler dev se ve el reintento:

[a4dc224e] enviando bienvenida, intento 1
✘ [ERROR] Uncaught Error: El proveedor de correo no responde
[a4dc224e] enviando bienvenida, intento 2
[wrangler:info] POST /altas/1ba39b55-7a9e-4062-be2b-76aa4601b713/confirmar 200 OK (10ms)

Con wrangler dev en marcha, la CLI también trabaja contra las instancias locales (hay que indicarle el puerto si no es el 8787):

$ npx wrangler workflows instances list alta-usuario --local --port 8801
Showing 2 instances from page 1:
┌──────────────────────────────────────┬────────────────────────┬──────────────┐
│ Instance ID                          │ Created                │ Status       │
├──────────────────────────────────────┼────────────────────────┼──────────────┤
│ d31668a0-711f-4fd7-9183-92f75c273ca0 │ 10/3/2026, 12:30:23 AM │ ❌ Errored   │
├──────────────────────────────────────┼────────────────────────┼──────────────┤
│ 1ba39b55-7a9e-4062-be2b-76aa4601b713 │ 10/3/2026, 12:30:15 AM │ ✅ Completed │
└──────────────────────────────────────┴────────────────────────┴──────────────┘
🐛
Tres diferencias que encontré en local. Mientras espera el evento, la instancia aparece como running y no como waiting. El campo __LOCAL_DEV_STEP_OUTPUTS (los resultados de cada paso) solo existe en local: no lo uses en tu código. Y el error de una instancia que acaba con NonRetryableError es genérico (WorkflowFatalError); el mensaje original ("Email no válido...") no llega a status(). Si necesitas saber por qué falló, guárdalo tú (en KV o D1) antes de lanzar el error.

Pruebas sin esperar siete días

El plugin de Vitest incluye introspectWorkflowInstance, que permite modificar una instancia antes de crearla: desactivar las esperas, quitar los retrasos entre reintentos, simular el evento o forzar que venza su plazo.

import { env } from "cloudflare:workers";
import { introspectWorkflowInstance } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("Workflow de alta", () => {
  it("completa el alta cuando llega la confirmación", async () => {
    await using instancia = await introspectWorkflowInstance(env.ALTAS, "alta-1");
    await instancia.modify(async (m) => {
      await m.disableSleeps(); // no esperar los 2 segundos
      await m.disableRetryDelays(); // ni las esperas entre reintentos
      await m.mockEvent({ type: "email-confirmado", payload: { aceptaNewsletter: false } });
    });

    await env.ALTAS.create({ id: "alta-1", params: { email: "[email protected]", nombre: "Ana" } });
    await instancia.waitForStatus("complete");

    // El paso con reintentos devolvió su resultado en el segundo intento
    expect(await instancia.waitForStepResult({ name: "enviar bienvenida" })).toEqual({ enviado: true, intentos: 2 });
    expect(await instancia.getOutput()).toMatchObject({ estado: "activo", newsletter: false });
  });

  it("caduca si no confirma a tiempo", async () => {
    await using instancia = await introspectWorkflowInstance(env.ALTAS, "alta-2");
    await instancia.modify(async (m) => {
      await m.disableSleeps();
      await m.disableRetryDelays();
      await m.forceEventTimeout({ name: "esperar confirmación" }); // simula que pasan los 7 días
    });

    await env.ALTAS.create({ id: "alta-2", params: { email: "[email protected]", nombre: "Luis" } });
    await instancia.waitForStatus("complete");
    expect(await instancia.getOutput()).toMatchObject({ estado: "caducado" });
  });

  it("un email no válido termina en error sin reintentos", async () => {
    await using instancia = await introspectWorkflowInstance(env.ALTAS, "alta-3");
    await env.ALTAS.create({ id: "alta-3", params: { email: "sin-arroba", nombre: "X" } });
    await instancia.waitForStatus("errored");
    // Ojo: el error de la instancia es genérico; el mensaje original no llega aquí
    const error = await instancia.getError();
    expect(error.name).toBe("WorkflowFatalError");
    expect(error.message).toContain("NonRetryableError");
  });
});
$ npx vitest run

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  1.43s (transform 43ms, setup 0ms, import 68ms, tests 155ms, environment 0ms)

Los tres finales posibles del proceso (confirmado, caducado y error) se prueban en menos de dos segundos. El primero comprueba además que el paso de bienvenida devolvió su resultado en el segundo intento.

Errores comunes (y cómo evitarlos)

  • Efectos fuera de los pasos. Un fetch o una escritura fuera de step.do se repetirá cada vez que la instancia se reanude.
  • Pasos no idempotentes. Un paso puede ejecutarse más de una vez si falla a mitad. Usa identificadores de idempotencia al cobrar o enviar.
  • Nombres de paso dinámicos. El nombre identifica el resultado guardado. Si cambia entre ejecuciones (por ejemplo, con Date.now()), el motor no encontrará el resultado y repetirá el trabajo.
  • Devolver datos grandes. El resultado de un paso no puede pasar de 1 MiB. Guarda los ficheros en R2 y devuelve la clave.
  • No contar con el plazo de waitForEvent. Al vencer, lanza un error. Si no lo capturas, la instancia termina en errored.
  • Tipos de evento con punto. type solo admite letras, números, - y _; "email.confirmado" falla.
  • Dejar la retención por defecto. El estado de las instancias terminadas ocupa almacenamiento facturable (30 días en Paid). Si no lo necesitas, reduce default_retention.

Cuándo usar Workflows (y cuándo no)

Úsalo para procesos de varios pasos con estado que pueden fallar a medias o durar mucho: altas y bajas de usuarios, pedidos y pagos, pipelines de datos, procesos de IA encadenados, aprobaciones humanas, recordatorios programados.

No lo necesitas para una tarea suelta en segundo plano (Queues es más simple y barato), para algo que ocurre a una hora fija (Cron Triggers) ni para estado compartido en tiempo real (Durable Objects).

Sigue leyendo