> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Workflows a fondo: procesos de varios pasos que sobreviven a fallos, esperas y reinicios
- URL: https://blog.devutilites.com/cloudflare-workflows-a-fondo/
- Published: 2026-10-03T03:14:02.000Z
- Updated: 2026-10-03T03:14:02.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Cloudflare, Backend

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](https://blog.devutilites.com/content/images/2026/10/cf-workflows-esquema.png)

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ímite                           | Workers Free    | Workers Paid                                 |
| -------------------------------- | --------------- | -------------------------------------------- |
| CPU por paso                     | 10 ms           | 30 s por defecto, configurable hasta 5 min   |
| Duración de reloj por paso       | Ilimitada       | Ilimitada                                    |
| Resultado de un paso             | 1 MiB           | 1 MiB                                        |
| Parámetros del evento            | 1 MiB           | 1 MiB                                        |
| Estado guardado por instancia    | 100 MB          | 1 GB                                         |
| Pasos por Workflow               | 1.024           | 10.000 (hasta 25.000)                        |
| step.sleep máximo                | 365 días        | 365 días                                     |
| Instancias en ejecución a la vez | 100             | 50.000                                       |
| Creación de instancias           | 100 por segundo | 300 por segundo por cuenta, 100 por Workflow |
| Retención del estado al terminar | 3 días          | 30 días                                      |
| Reintentos por paso              | 10.000          | 10.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 Free | Workers Paid                                  |
| -------------- | ------------ | --------------------------------------------- |
| Pasos          | 3.000 al día | 500.000 al mes, luego 0,80 $ por cada 100.000 |
| Almacenamiento | 1 GB-mes     | 1 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.

```json
{
  "$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.

```typescript
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:

```bash
$ curl -X POST localhost:8801/altas -H "Content-Type: application/json" -d '{"email":"ana@ejemplo.com","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:

```text
[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):

```text
$ 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.

```typescript
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: "ana@ejemplo.com", 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: "luis@ejemplo.com", 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");
  });
});
```

```text
$ 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

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general de la plataforma.
- [Cloudflare Queues a fondo](https://blog.devutilites.com/cloudflare-queues-a-fondo/): la alternativa ligera para trabajo de un solo paso.
- [Cloudflare Durable Objects a fondo](https://blog.devutilites.com/cloudflare-durable-objects-a-fondo/): estado con coordinación y alarmas.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): el runtime sobre el que corre cada paso.
- [Planes de Cloudflare Workers: Free frente a Paid](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/): cómo se facturan pasos y almacenamiento.