> ## 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 Workers a fondo: código en el borde de la red, explicado por dentro y con código probado
- URL: https://blog.devutilites.com/cloudflare-workers-a-fondo/
- Published: 2026-10-03T03:13:57.000Z
- Updated: 2026-10-03T03:13:57.000Z
- Description: Isolates de V8, el ciclo de vida de una petición, wrangler.jsonc, bindings, variables y secretos, ficheros estáticos, Cache API, cron, desarrollo local con Miniflare, pruebas con Vitest, despliegues graduales y límites reales de Workers, con todo el código probado.
- Author: Luis Collantes
- Tags: Cloudflare, Backend

Durante años, "desplegar un backend" significaba elegir una región, levantar un servidor (o un contenedor, o una función Lambda) y rezar para que el usuario no estuviera en la otra punta del mundo. **Cloudflare Workers** le da la vuelta a esa idea: tu código no vive en una región, sino en todos los centros de datos de la red de Cloudflare a la vez, y se ejecuta en el que esté más cerca de quien hace la petición.

Lo interesante no es solo dónde se ejecuta, sino **cómo**. Un Worker no es un contenedor ni una máquina virtual: es un *isolate* de V8, el mismo mecanismo que usa Chrome para separar las pestañas. Arranca en milisegundos, comparte proceso con miles de Workers de otros clientes y cobra por el tiempo de CPU que de verdad usas, no por el tiempo que pasas esperando a una base de datos.

En este artículo vamos a verlo a fondo: qué es, cómo funciona por dentro, cómo se instala con Wrangler, cómo se configura con `wrangler.jsonc`, cómo se conecta con el resto de la plataforma mediante *bindings*, y después mucho código: rutas, variables y secretos, ficheros estáticos, la Cache API, tareas programadas con cron, desarrollo local, pruebas con Vitest y despliegue. Es el artículo central de la serie; el resto (R2, D1, KV, Durable Objects, Queues, Pages y los planes) se apoya en lo que veremos aquí.

🧪

**Todo el código de este artículo se ha ejecutado de verdad** el 2 de octubre de 2026 con **Wrangler 4.147.0**, **workerd 1.20261001.1** (el runtime de Workers, en local mediante Miniflare), **create-cloudflare 2.73.2**, **@cloudflare/vitest-plugin 1.3.6**, **Vitest 4.1.11**, **TypeScript 5.9.3** y **Node.js 24.21.0**, sin cuenta de Cloudflare. Los límites y precios están comprobados ese mismo día en la documentación oficial. Lo único que no he podido ejecutar es lo que necesita una cuenta real (`wrangler deploy`, `wrangler secret put`, despliegues graduales); lo marco donde aparece.

## Qué es Cloudflare Workers

Workers es la plataforma de computación *serverless* de Cloudflare. Escribes una función (en JavaScript, TypeScript, Python o cualquier lenguaje que compile a WebAssembly), la despliegas con un comando y Cloudflare la ejecuta en su red global cada vez que llega una petición. No hay servidores que mantener, ni regiones que elegir, ni escalado que configurar.

Un Worker moderno es un **módulo ES que exporta un objeto con manejadores**: `fetch` para peticiones HTTP, `scheduled` para tareas programadas, `queue` para consumir colas, `email` para correo entrante... El más importante es `fetch`, que recibe un `Request` estándar y devuelve un `Response` estándar, los mismos objetos que usas con `fetch()` en el navegador.

```typescript
export default {
  async fetch(request, env, ctx): Promise<Response> {
    return new Response("¡Hola desde el borde!");
  },
} satisfies ExportedHandler<Env>;
```

Los tres parámetros son la clave de todo lo demás:

- `**request**`: la petición entrante, con una propiedad extra, `request.cf`, donde Cloudflare te cuenta el país, la ciudad, el centro de datos (*colo*), el protocolo y bastante más.
- `**env**`: los *bindings*. Variables de entorno, secretos, y las conexiones a KV, R2, D1, colas, Durable Objects u otros Workers. No hay `process.env` ni cadenas de conexión: Cloudflare te inyecta objetos ya listos para usar.
- `**ctx**`: el contexto de ejecución. Su método estrella es `ctx.waitUntil(promesa)`, que permite seguir trabajando después de haber respondido al cliente.

### Lo que lo hace distinto

- **Sin arranques en frío apreciables.** Un isolate de V8 se crea en milisegundos dentro de un proceso que ya está en marcha; no hay que arrancar un contenedor ni un sistema operativo.
- **Pagas CPU, no espera.** El límite y la factura se miden en tiempo de CPU. Mientras esperas a un `fetch()` o a una consulta, el reloj de CPU no corre.
- **Estándares web.** `fetch`, `Request`, `Response`, `URL`, `crypto.subtle`, `ReadableStream`, `WebSocket`, `Cache`... Si sabes escribir JavaScript para el navegador, ya sabes escribir un Worker. Con el flag `nodejs_compat` tienes además buena parte de la API de Node.
- **Toda la plataforma a un binding de distancia.** Almacenamiento de objetos (R2), base de datos SQL (D1), clave-valor global (KV), estado coordinado (Durable Objects), colas, IA... todo se conecta con dos líneas de configuración.
- **Una capa gratuita generosa.** 100.000 peticiones al día sin pagar nada; el plan de pago empieza en 5 dólares al mes. Lo detallo en [el artículo de los planes](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/).

## Cómo funciona por dentro

### Isolates de V8 en lugar de contenedores

La mayoría de plataformas *serverless* ejecutan cada función en su propio contenedor o microVM con su propio runtime de Node o Python. Eso tiene un coste fijo por instancia: memoria, arranque, un proceso entero.

Workers paga ese coste **una sola vez por máquina**. Cada servidor de Cloudflare ejecuta un proceso del runtime (**workerd**, que es código abierto) y dentro de él crea un *isolate* de V8 por Worker. Un isolate es un contexto aislado de memoria: tu código no puede ver la memoria de otro isolate, pero comparten proceso, así que crear uno cuesta muy poco. Según la documentación, un isolate arranca unas cien veces más rápido que un proceso de Node en un contenedor.

![Esquema de cómo Cloudflare Workers ejecuta una petición: el usuario llega al centro de datos más cercano, workerd elige el isolate del Worker, que ejecuta fetch(request, env, ctx) con acceso a bindings como KV, R2, D1 y colas, y devuelve un Response](https://blog.devutilites.com/content/images/2026/10/cf-workers-arquitectura.png)

Cada petición entra por el centro de datos más cercano y la atiende un isolate de V8 con sus bindings. Diagrama: devutilites.

### Una petición, un evento

Cuando llega una petición a tu dominio (o a tu subdominio `*.workers.dev`), el centro de datos que la recibe busca tu Worker, reutiliza un isolate si ya hay uno caliente o crea uno nuevo, y llama a `fetch(request, env, ctx)`. Lo que devuelvas es la respuesta.

Un mismo isolate puede atender **varias peticiones a la vez** en un único hilo con su bucle de eventos, igual que Node. Mientras una petición espera a un `fetch`, otra puede avanzar.

### CPU frente a tiempo de reloj

Esta distinción es la más importante para entender los límites y la factura:

- **Tiempo de CPU**: lo que tu código pasa calculando de verdad. Es lo que se limita (10 ms en el plan Free; 30 segundos por defecto y hasta 5 minutos en el de pago) y lo que se cobra en el plan de pago.
- **Tiempo de reloj** (*wall time*): lo que dura la petición de principio a fin, esperas incluidas. Para peticiones HTTP **no hay límite** mientras el cliente siga conectado.

Según la propia documentación, un Worker medio usa unos 2,2 ms de CPU por petición. Validar un JWT, renderizar una plantilla o parsear un JSON grande sube a 10-20 ms; esperar 800 ms a una API externa no suma nada.

### Estado global: no te fíes

Como un isolate puede atender muchas peticiones y Cloudflare puede crearlo y destruirlo cuando quiera, **las variables globales no son un almacenamiento**. Sirven como caché en memoria (por ejemplo, para no reconstruir algo caro en cada petición), pero dos peticiones del mismo usuario pueden caer en isolates distintos, en centros de datos distintos. Para estado de verdad tienes KV, D1, R2 y, sobre todo, Durable Objects.

## Descarga e instalación

No hay nada que instalar en el sistema más allá de Node.js: todo se hace con **Wrangler**, la CLI oficial, que se instala como dependencia de desarrollo de cada proyecto.

| Recurso                              | Enlace                                                                                                                                                                                            |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Documentación**                    | [developers.cloudflare.com/workers](https://developers.cloudflare.com/workers/?ref=blog.devutilites.com)                                                                                          |
| **Wrangler**                         | [npmjs.com/package/wrangler](https://www.npmjs.com/package/wrangler?ref=blog.devutilites.com) · [comandos](https://developers.cloudflare.com/workers/wrangler/commands/?ref=blog.devutilites.com) |
| **Runtime (workerd)**                | [github.com/cloudflare/workerd](https://github.com/cloudflare/workerd?ref=blog.devutilites.com)                                                                                                   |
| **Wrangler, C3, Miniflare y Vitest** | [github.com/cloudflare/workers-sdk](https://github.com/cloudflare/workers-sdk?ref=blog.devutilites.com)                                                                                           |
| **Límites y precios**                | [límites](https://developers.cloudflare.com/workers/platform/limits/?ref=blog.devutilites.com) · [precios](https://developers.cloudflare.com/workers/platform/pricing/?ref=blog.devutilites.com)  |

### Crear un proyecto con C3

La forma recomendada es **C3** (`create-cloudflare`), que genera el proyecto con TypeScript, Vitest y la configuración ya preparada:

```bash
# Interactivo: te pregunta plantilla, lenguaje, git y si quieres desplegar
npm create cloudflare@latest mi-worker

# Sin preguntas: "hola mundo" en TypeScript, sin git y sin desplegar
npm create cloudflare@latest mi-worker -- --type=hello-world --lang=ts --no-git --no-deploy

cd mi-worker
npm run dev      # wrangler dev -> http://localhost:8787
```

🚫

**C3 necesita Node.js 22 o superior.** Con Node 20 (que sigue siendo el de muchas distribuciones), `create-cloudflare` 2.73 se niega a arrancar con *"create-cloudflare requires at least Node.js v22.0.0"*. Lo descubrí en la primera línea de este artículo; instalé Node 24 LTS y listo.

Las plantillas que ofrece `--type` incluyen `hello-world`, `hello-world-with-assets` (API + ficheros estáticos), `hello-world-assets-only`, `hello-world-durable-object`, `hello-world-workflows`, `scheduled` (cron), `queues` y `openapi`, además de plantillas para frameworks (`--framework=react`, `astro`, `next`, `hono`...).

Lo que genera la plantilla `hello-world` es esto:

```json
{
  "name": "hola-worker",
  "scripts": {
    "deploy": "wrangler deploy",
    "dev": "wrangler dev",
    "start": "wrangler dev",
    "test": "vitest",
    "cf-typegen": "wrangler types"
  },
  "devDependencies": {
    "@cloudflare/vitest-plugin": "^1.0.0",
    "@types/node": "^26.6.4",
    "typescript": "^5.5.2",
    "vitest": "~4.1.0",
    "wrangler": "^4.147.0"
  }
}
```

### Añadir Wrangler a un proyecto existente

```bash
npm install -D wrangler
npx wrangler --version     # 4.147.0
npx wrangler login         # abre el navegador para autorizar tu cuenta (solo para desplegar)
```

Para desarrollar en local y pasar las pruebas **no necesitas cuenta ni `wrangler login`**: todo lo que verás en este artículo funciona en tu máquina gracias a Miniflare, que ejecuta el mismo runtime `workerd` que producción.

## La configuración: wrangler.jsonc

Cada Worker tiene un fichero de configuración. Wrangler acepta TOML (`wrangler.toml`) y JSON con comentarios (`wrangler.jsonc`); **Cloudflare recomienda `wrangler.jsonc` para proyectos nuevos** y avisa de que algunas funciones nuevas solo estarán disponibles con JSON. Es lo que uso en toda la serie.

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "hola-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "observability": { "enabled": true },
  "upload_source_maps": true
}
```

- `**name**`: el nombre del Worker en tu cuenta (y del subdominio `nombre.tu-cuenta.workers.dev`).
- **`main`**: el punto de entrada. Wrangler lo empaqueta con esbuild; no necesitas configurar ningún *bundler*.
- **`compatibility_date`**: la fecha cuyo comportamiento del runtime quieres. Cloudflare nunca rompe un Worker existente: los cambios incompatibles se activan solo para fechas posteriores. Pon la de hoy al crear el proyecto y súbela a conciencia.
- `**compatibility_flags**`: activa comportamientos sueltos. El más común es `nodejs_compat`, que habilita módulos de Node (`node:buffer`, `node:crypto`, `node:stream`, `process.env`...).
- **`observability`**: activa Workers Logs, los registros persistentes que ves en el panel.

El `$schema` no es decorativo: con él, VS Code te autocompleta y valida cada opción.

## Un Worker de verdad

Vamos con un ejemplo que toca casi todo lo que usarás a diario: rutas, variables, un secreto, la información de `request.cf`, la Cache API con `waitUntil`, una tarea programada que escribe en KV y ficheros estáticos. Esta es la configuración completa:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "workers-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },

  // Variables de texto (no secretas): visibles en el panel y en el código
  "vars": { "SALUDO": "Hola desde el borde", "ENTORNO": "desarrollo" },

  // Un KV para guardar la última ejecución del cron
  "kv_namespaces": [{ "binding": "ESTADO", "id": "00000000000000000000000000000000" }],

  // Ficheros estáticos: se sirven sin ejecutar el Worker (y sin cobrar)
  "assets": {
    "directory": "./public",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*"]
  },

  // Cron: cada 15 minutos (hora UTC)
  "triggers": { "crons": ["*/15 * * * *"] }
}
```

Los secretos **nunca** van en `wrangler.jsonc`. En local se leen de un fichero `.dev.vars` (que debe estar en `.gitignore`) y en producción se suben con `wrangler secret put`:

```bash
# .dev.vars (solo en tu máquina)
API_TOKEN="token-de-desarrollo-123"

# En producción (necesita cuenta): te pide el valor por la terminal
npx wrangler secret put API_TOKEN
```

Y el código:

```typescript
// Un Worker en módulos ES: un objeto con un manejador por tipo de evento
export default {
  async fetch(request, env, ctx): Promise<Response> {
    const url = new URL(request.url);

    // GET /api/hola?nombre=Luis
    if (url.pathname === "/api/hola") {
      const nombre = url.searchParams.get("nombre") ?? "mundo";
      return Response.json({ mensaje: `${env.SALUDO}, ${nombre}`, entorno: env.ENTORNO });
    }

    // GET /api/cf: lo que Cloudflare sabe de la petición (país, colo, protocolo...)
    if (url.pathname === "/api/cf") {
      const cf = request.cf;
      return Response.json({ pais: cf?.country, ciudad: cf?.city, colo: cf?.colo, http: cf?.httpProtocol });
    }

    // GET /api/privado: un secreto que llega por env, nunca en el código
    if (url.pathname === "/api/privado") {
      const auth = request.headers.get("Authorization");
      if (auth !== `Bearer ${env.API_TOKEN}`) {
        return Response.json({ error: "No autorizado" }, { status: 401 });
      }
      return Response.json({ ok: true });
    }

    // GET /api/caro: algo costoso que guardamos en la Cache API del centro de datos
    if (url.pathname === "/api/caro") {
      const cache = caches.default;
      const clave = new Request(url.toString(), request);
      const enCache = await cache.match(clave);
      if (enCache) return enCache;

      const resultado = { calculadoEn: new Date().toISOString(), primos: primosHasta(50_000).length };
      const respuesta = Response.json(resultado, {
        headers: { "Cache-Control": "public, max-age=60", "X-Generado": "worker" },
      });
      // Guardar en caché sin retrasar la respuesta al cliente
      ctx.waitUntil(cache.put(clave, respuesta.clone()));
      return respuesta;
    }

    // GET /api/estado: lo que dejó escrito el último cron
    if (url.pathname === "/api/estado") {
      const ultima = await env.ESTADO.get("ultima-ejecucion");
      return Response.json({ ultimaEjecucion: ultima });
    }

    if (url.pathname.startsWith("/api/")) {
      return Response.json({ error: "No existe" }, { status: 404 });
    }

    // Todo lo demás: a los ficheros estáticos
    return env.ASSETS.fetch(request);
  },

  // Se ejecuta según "triggers.crons"
  async scheduled(controller, env, ctx): Promise<void> {
    const cuando = new Date(controller.scheduledTime).toISOString();
    await env.ESTADO.put("ultima-ejecucion", `${cuando} (${controller.cron})`);
    console.log(`Cron ${controller.cron} ejecutado a las ${cuando}`);
  },
} satisfies ExportedHandler<Env>;

function primosHasta(n: number): number[] {
  const criba = new Uint8Array(n + 1);
  const primos: number[] = [];
  for (let i = 2; i <= n; i++) {
    if (!criba[i]) {
      primos.push(i);
      for (let j = i * i; j <= n; j += i) criba[j] = 1;
    }
  }
  return primos;
}
```

Antes de arrancarlo, genera los tipos. `wrangler types` lee tu configuración y escribe `worker-configuration.d.ts` con la interfaz `Env` exacta y los tipos del runtime para tu `compatibility_date`:

```bash
$ npx wrangler types
✨ Types written to worker-configuration.d.ts
```

```typescript
// worker-configuration.d.ts (extracto, generado)
interface __BaseEnv_Env {
	ESTADO: KVNamespace;
	ASSETS: Fetcher;
	SALUDO: "Hola desde el borde";
	ENTORNO: "desarrollo";
	API_TOKEN: string;
}
```

Fíjate en que `SALUDO` sale tipado con su valor literal y que `API_TOKEN` aparece aunque no esté en el JSON: Wrangler lo ha leído de `.dev.vars`. Ahora sí:

```bash
$ npx wrangler dev --test-scheduled
Using secrets defined in .dev.vars
Your Worker has access to the following bindings:
Binding                                               Resource                  Mode
env.ESTADO (00000000000000000000000000000000)         KV Namespace              local
env.ASSETS                                            Assets                    local
env.SALUDO ("Hola desde el borde")                    Environment Variable      local
env.ENTORNO ("desarrollo")                            Environment Variable      local
env.API_TOKEN ("(hidden)")                            Environment Variable      local

⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8787
```

```bash
$ curl localhost:8787/
<!doctype html><html lang="es"><meta charset="utf-8"><title>workers-lab</title><h1>Hola desde los assets estáticos</h1></html>

$ curl "localhost:8787/api/hola?nombre=Luis"
{"mensaje":"Hola desde el borde, Luis","entorno":"desarrollo"}

$ curl localhost:8787/api/cf
{"pais":"US","ciudad":"Indianapolis","colo":"ORD","http":"HTTP/1.1"}

$ curl -i localhost:8787/api/privado
HTTP/1.1 401 Unauthorized

$ curl -H "Authorization: Bearer token-de-desarrollo-123" localhost:8787/api/privado
{"ok":true}

$ curl -i localhost:8787/api/caro          # primera vez: lo calcula el Worker
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
X-Generado: worker
{"calculadoEn":"2026-10-02T18:01:03.120Z","primos":5133}

$ curl -i localhost:8787/api/caro          # segunda vez: sale de la caché
HTTP/1.1 200 OK
Age: 0
CF-Cache-Status: HIT
{"calculadoEn":"2026-10-02T18:01:03.120Z","primos":5133}

$ curl localhost:8787/cualquier/ruta/spa   # no existe: devuelve index.html (modo SPA)
<!doctype html><html lang="es">...
```

Dos detalles de esta salida. El primero: en local, `request.cf` trae datos simulados (Indianápolis, centro de datos ORD); en producción verás los reales del visitante. El segundo: la segunda llamada a `/api/caro` devuelve la misma marca de tiempo y `CF-Cache-Status: HIT`, es decir, la Cache API funciona igual en local.

⚠️

**Un detalle de tipos que me hizo fallar la compilación.** Mi primera versión hacía `const cf = request.cf ?? {}` para no tener que comprobar `undefined`. TypeScript lo rechaza con *"Property 'country' does not exist on type '{}'"*: el objeto vacío "contamina" el tipo. La solución es dejar `request.cf` como está y usar encadenamiento opcional (`cf?.country`).

### waitUntil: trabajar después de responder

En `/api/caro`, la respuesta se envía al cliente y **después** se guarda en caché. Eso lo hace `ctx.waitUntil(promesa)`: el runtime mantiene vivo el Worker hasta que la promesa termina, con un máximo de 30 segundos tras enviar la respuesta. Es perfecto para guardar métricas, escribir en caché o mandar un mensaje a una cola sin hacer esperar al usuario. Si un error ocurre dentro, nadie lo verá salvo en los logs, así que úsalo para trabajo que pueda fallar sin consecuencias.

### La Cache API

`caches.default` es la caché HTTP del centro de datos donde se ejecuta el Worker. No es global: cada centro de datos tiene la suya. `cache.match()` busca, `cache.put()` guarda (respetando `Cache-Control`) y `cache.delete()` borra. Es la herramienta perfecta para respuestas caras que no cambian en cada petición.

💡

**Novedad de 2026: Workers Cache.** Además de la Cache API de toda la vida, ahora puedes activar `"cache": { "enabled": true }` en `wrangler.jsonc` para que Cloudflare sirva desde caché las respuestas de tu Worker **sin ejecutarlo**, respetando el `Cache-Control` que devuelvas. Ojo con el precio: con esta opción, todas las peticiones (incluidas las de estáticos, que normalmente son gratis) se cobran como peticiones de Worker; solo te ahorras la CPU de los aciertos. No lo he probado en local; está explicado en la [documentación de Workers Cache](https://developers.cloudflare.com/workers/cache/?ref=blog.devutilites.com).

## Bindings: cómo habla el Worker con todo lo demás

Un *binding* es una conexión declarada en la configuración que aparece como propiedad de `env`. No hay credenciales en el código ni cadenas de conexión: el runtime te inyecta un objeto con métodos.

| Binding            | Clave en wrangler.jsonc             | En el código                    | Artículo                                                                            |
| ------------------ | ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------- |
| Variables de texto | vars                                | env.SALUDO (string)             | este                                                                                |
| Secretos           | wrangler secret put / .dev.vars     | env.API\_TOKEN (string)         | este                                                                                |
| Workers KV         | kv\_namespaces                      | env.X.get(), .put(), .list()    | [KV](https://blog.devutilites.com/cloudflare-workers-kv-a-fondo/)                   |
| R2                 | r2\_buckets                         | env.X.get(), .put(), .list()    | [R2](https://blog.devutilites.com/cloudflare-r2-a-fondo/)                           |
| D1                 | d1\_databases                       | env.X.prepare(sql).bind().run() | [D1](https://blog.devutilites.com/cloudflare-d1-a-fondo/)                           |
| Durable Objects    | durable\_objects \+ migrations      | env.X.getByName("id").metodo()  | [Durable Objects](https://blog.devutilites.com/cloudflare-durable-objects-a-fondo/) |
| Colas              | queues.producers / queues.consumers | env.X.send(msg)                 | [Queues](https://blog.devutilites.com/cloudflare-queues-a-fondo/)                   |
| Otro Worker        | services                            | env.X.fetch() o RPC             | este                                                                                |
| Ficheros estáticos | assets                              | env.ASSETS.fetch(request)       | este y [Pages](https://blog.devutilites.com/cloudflare-pages-a-fondo/)              |
| Workers AI         | ai                                  | env.AI.run(modelo, entrada)     | [la visión general](https://blog.devutilites.com/cloudflare-a-fondo/)               |
| Hyperdrive         | hyperdrive                          | env.X.connectionString          | [la visión general](https://blog.devutilites.com/cloudflare-a-fondo/)               |

Dos cosas útiles sobre los bindings:

**Aprovisionamiento automático.** Si declaras un binding de KV, R2, D1 o Queues **sin id** (por ejemplo `"kv_namespaces": [{ "binding": "CACHE" }]`), `wrangler dev` crea un recurso local y `wrangler deploy` crea el real y escribe el id en tu configuración. Lo he comprobado con `wrangler deploy --dry-run`: la configuración sin ids es válida y lista los tres bindings.

**Service bindings.** Un Worker puede llamar a otro de tu cuenta sin pasar por Internet, ni con `fetch` ni con métodos RPC (`class MiApi extends WorkerEntrypoint`). Esas llamadas no se cobran como peticiones nuevas: pagas una petición (la del primer Worker) y la CPU de ambos.

## Ficheros estáticos: Workers Static Assets

Desde 2024, un Worker puede servir un sitio estático completo además de su código. Es lo que antes hacía Cloudflare Pages y es, de hecho, lo que Cloudflare recomienda ahora para proyectos nuevos (lo cuento en el [artículo de Pages](https://blog.devutilites.com/cloudflare-pages-a-fondo/)).

```json
"assets": {
  "directory": "./public",
  "binding": "ASSETS",
  "not_found_handling": "single-page-application",
  "run_worker_first": ["/api/*"]
}
```

- Por defecto, **si la ruta coincide con un fichero, se sirve el fichero sin ejecutar tu Worker**. Esas peticiones son **gratis e ilimitadas** en ambos planes.
- `not_found_handling: "single-page-application"` devuelve `index.html` para rutas que no existen (ideal para React, Vue o Svelte con enrutado en el cliente). La otra opción es `"404-page"`, que busca el `404.html` más cercano.
- `run_worker_first` invierte el orden para ciertas rutas: con `["/api/*"]`, todo lo que empiece por `/api/` va primero al Worker. En el plan Free, cuidado: si te quedas sin peticiones del día, esas rutas devuelven 429 en lugar de servir el estático.
- El binding `ASSETS` te deja servir un fichero desde código: `env.ASSETS.fetch(request)`.

Los límites: 20.000 ficheros por versión en Free y 100.000 en Paid, con un máximo de 25 MiB por fichero.

## Tareas programadas: Cron Triggers

Un Worker puede ejecutarse solo, sin peticiones, con expresiones cron en `triggers.crons` (en hora UTC). El manejador es `scheduled(controller, env, ctx)`, y `controller.cron` te dice cuál de tus expresiones lo ha disparado, por si tienes varias.

En local, `wrangler dev --test-scheduled` expone un endpoint para dispararlo a mano:

```bash
$ curl "localhost:8787/cdn-cgi/handler/scheduled?cron=*/15+*+*+*+*"
ok
$ curl localhost:8787/api/estado
{"ultimaEjecucion":"2026-10-02T18:01:04.200Z (*/15 * * * *)"}
```

⚠️

**Con ficheros estáticos, `/__scheduled` no llega a tu Worker.** La ayuda de Wrangler sigue diciendo que visites `/__scheduled`, pero en mi Worker con `assets` y modo SPA esa ruta devolvía el `index.html`: los estáticos se sirven antes que el Worker y `/__scheduled` no está en `run_worker_first`. La ruta `/cdn-cgi/handler/scheduled` sí funciona siempre.

Límites del cron: 5 por cuenta en Free y 250 en Paid. En Free cada ejecución tiene 10 ms de CPU; en Paid, 30 segundos si el intervalo es menor de una hora y 15 minutos si es de una hora o más, con un máximo de 15 minutos de reloj por ejecución.

## Desarrollo local

`wrangler dev` arranca tu Worker en `workerd` en tu máquina, con recarga automática al guardar. Por defecto **todos los bindings son simulaciones locales** que se guardan en `.wrangler/state/v3/` (hay carpetas `kv`, `r2`, `d1`, `cache`...) y persisten entre arranques. Puedes leer y escribir esos datos locales con la CLI añadiendo `--local`:

```bash
npx wrangler kv key put "config:modo" "mantenimiento" --binding ESTADO --local
npx wrangler d1 execute mi-db --local --command "SELECT count(*) FROM usuarios"
npx wrangler r2 object get mi-bucket/foto.jpg --local --file foto.jpg
```

Cuando quieras probar contra datos reales sin desplegar, marca un binding concreto con `"remote": true`: tu código sigue ejecutándose en local, pero ese binding habla con el recurso de verdad. Útil para depurar con datos de staging; peligroso con los de producción (usa [entornos](https://developers.cloudflare.com/workers/wrangler/environments/?ref=blog.devutilites.com) separados). Workers AI siempre es remoto, y los Durable Objects siempre son locales.

## Pruebas con Vitest

La integración oficial ejecuta tus tests **dentro del runtime de Workers**, con bindings reales (simulados por Miniflare) y almacenamiento aislado por fichero de test. La plantilla de C3 ya la trae configurada:

```typescript
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

export default defineConfig({
	plugins: [
		cloudflareTest({
			wrangler: { configPath: "./wrangler.jsonc" },
		}),
	],
});
```

📦

**Cambio de paquete en 2026.** Si buscas tutoriales, verás `@cloudflare/vitest-pool-workers`, `defineWorkersConfig` y `SELF.fetch()`. Eso es la integración antigua. La actual es `@cloudflare/vitest-plugin` (necesita Vitest 4.1 o superior), se configura con el plugin `cloudflareTest()` y las pruebas de integración usan `exports.default.fetch()` importado de `cloudflare:workers`. Hay una [guía de migración](https://developers.cloudflare.com/workers/testing/vitest-integration/migration-guides/migrate-to-vitest-plugin/?ref=blog.devutilites.com).

Las pruebas del Worker anterior, incluida la del cron:

```typescript
import { env, exports } from "cloudflare:workers";
import { createExecutionContext, createScheduledController, waitOnExecutionContext } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import worker from "../src/index";

describe("workers-lab", () => {
  it("saluda con la variable de wrangler.jsonc", async () => {
    const res = await exports.default.fetch("https://ejemplo.com/api/hola?nombre=Luis");
    expect(await res.json()).toEqual({ mensaje: "Hola desde el borde, Luis", entorno: "desarrollo" });
  });

  it("rechaza /api/privado sin token", async () => {
    const res = await exports.default.fetch("https://ejemplo.com/api/privado");
    expect(res.status).toBe(401);
  });

  it("acepta el token de .dev.vars", async () => {
    const res = await exports.default.fetch("https://ejemplo.com/api/privado", {
      headers: { Authorization: "Bearer token-de-desarrollo-123" },
    });
    expect(res.status).toBe(200);
  });

  it("el cron deja su marca en KV", async () => {
    const controller = createScheduledController({ scheduledTime: new Date("2026-10-02T08:15:00Z"), cron: "*/15 * * * *" });
    const ctx = createExecutionContext();
    await worker.scheduled(controller, env, ctx);
    await waitOnExecutionContext(ctx);
    expect(await env.ESTADO.get("ultima-ejecucion")).toBe("2026-10-02T08:15:00.000Z (*/15 * * * *)");
  });
});
```

```text
$ npx vitest run

 RUN  v4.1.11 /home/yo/workers-lab

Using secrets defined in .dev.vars

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Duration  1.11s (transform 28ms, setup 0ms, import 44ms, tests 22ms, environment 0ms)
```

- `env` (de `cloudflare:workers`) te da los bindings de verdad: puedes sembrar datos en KV o D1 antes del test y comprobar lo que quedó después.
- `exports.default.fetch()` llama a tu Worker como si fuera una petición real.
- `createScheduledController()`, `createExecutionContext()` y `waitOnExecutionContext()` sirven para probar manejadores que no son HTTP y para esperar a todo lo que hayas mandado a `waitUntil`.

Para que TypeScript conozca `cloudflare:test`, la carpeta `test/` lleva su propio `tsconfig.json` con `"types": ["@cloudflare/vitest-plugin/types"]`; C3 ya lo deja hecho.

## Desplegar

Esta parte necesita cuenta, así que aquí no hay salidas reales mías salvo la de la prueba en seco:

```bash
npx wrangler login                    # una vez por máquina
npx wrangler deploy                   # empaqueta, sube y publica al 100 %

# Comprobar qué se va a subir, sin subir nada (este sí lo he ejecutado)
$ npx wrangler deploy --dry-run --outdir dist
✨ Read 1 file from the assets directory /home/yo/workers-lab/public
Total Upload: 2.41 KiB / gzip: 1.06 KiB
Your Worker has access to the following bindings:
Binding                                             Resource
env.ESTADO (00000000000000000000000000000000)       KV Namespace
env.ASSETS                                          Assets
env.SALUDO ("Hola desde el borde")                  Environment Variable
env.ENTORNO ("desarrollo")                          Environment Variable
--dry-run: exiting now.
```

El límite de tamaño es de **64 MiB sin comprimir** en ambos planes (el valor `Total Upload`, no el comprimido), y el código global debe ejecutarse en menos de **1 segundo** al arrancar; si no, el despliegue falla con el error 10021 y `wrangler check startup` te genera un perfil de CPU para ver qué tarda.

### Versiones, despliegues graduales y vuelta atrás

Cada `wrangler deploy` crea una **versión** y la publica al 100 %. Si quieres más control, separa los dos pasos:

```bash
npx wrangler versions upload                         # sube una versión nueva sin publicarla
npx wrangler versions deploy <nueva>@10% <actual>@90%  # el 10 % del tráfico a la nueva
npx wrangler versions deploy <nueva>@100%            # cuando estés tranquilo
npx wrangler rollback                                # volver a la versión anterior
npx wrangler tail                                    # ver los logs en directo
```

### Dónde se publica

- En `https://<nombre>.<tu-subdominio>.workers.dev`, por defecto.
- En un **dominio propio** (`custom_domain: true` en `routes`), si el dominio está en Cloudflare. Cloudflare crea el DNS y el certificado.
- En **rutas** (`routes: [{ "pattern": "midominio.com/api/*", "zone_name": "midominio.com" }]`), cuando el Worker debe ejecutarse solo en parte de un sitio que ya existe.

## Límites que conviene conocer

| Límite                                  | Workers Free                                                       | Workers Paid                     |
| --------------------------------------- | ------------------------------------------------------------------ | -------------------------------- |
| Peticiones                              | 100.000 al día                                                     | Sin límite (10 M/mes incluidas)  |
| CPU por petición                        | 10 ms                                                              | 30 s por defecto, hasta 5 min    |
| Memoria por isolate                     | 128 MB                                                             | 128 MB                           |
| Subpeticiones por invocación            | 50                                                                 | 10.000 (configurable hasta 10 M) |
| Conexiones esperando cabeceras a la vez | 6                                                                  | 6                                |
| Variables y secretos por Worker         | 64                                                                 | 128                              |
| Tamaño del Worker (sin comprimir)       | 64 MiB                                                             | 64 MiB                           |
| Arranque del código global              | 1 s                                                                | 1 s                              |
| Workers por cuenta                      | 100                                                                | 500                              |
| Cron Triggers por cuenta                | 5                                                                  | 250                              |
| Tamaño del cuerpo de la petición        | Según el plan de la zona: 100 MB en Free y Pro, 200 MB en Business | Igual                            |

Cuando un Worker supera la CPU o la memoria, el cliente recibe el **error 1102** (*Worker exceeded resource limits*). Si en el plan Free se acaban las 100.000 peticiones diarias, verás el **error 1027**, salvo que la ruta esté en modo *fail open*, en cuyo caso la petición pasa como si no hubiera Worker. Los contadores diarios se reinician a medianoche UTC (las 02:00 en España en verano).

## Lo que cuesta

En dos líneas: el plan **Free** cuesta 0 € y da 100.000 peticiones al día con 10 ms de CPU cada una; el plan **Paid** cuesta **5 dólares al mes** e incluye 10 millones de peticiones y 30 millones de milisegundos de CPU al mes, y después cobra 0,30 dólares por millón de peticiones y 0,02 dólares por millón de milisegundos de CPU. Las peticiones a estáticos son gratis siempre y no se cobra el tráfico de salida. Todo el detalle, con ejemplos de facturas, está en [el artículo de los planes](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/).

## Errores comunes (y cómo evitarlos)

- **Guardar estado en variables globales.** Funciona en tu máquina y falla en producción en cuanto llegan peticiones a isolates distintos. Usa KV, D1 o Durable Objects.
- **Hacer trabajo pesado en el ámbito global.** Leer un esquema enorme o construir un objeto gigante fuera del manejador cuenta para el límite de arranque de 1 segundo.
- **Olvidar `await` o `waitUntil`.** Una promesa que no esperas ni pasas a `ctx.waitUntil()` puede cortarse en cuanto respondes.
- **Leer el cuerpo de una respuesta dos veces.** Si vas a guardar una respuesta en caché y devolverla, usa `respuesta.clone()`, como en el ejemplo.
- **Secretos en `vars`.** Todo lo que está en `vars` aparece en el panel y en el repositorio. Los secretos van en `.dev.vars` y en `wrangler secret put`.
- **Confiar en `/__scheduled` con assets.** Usa `/cdn-cgi/handler/scheduled`.
- **No subir la `compatibility_date`.** No rompe nada, pero te quedas sin mejoras y correcciones del runtime. Revísala de vez en cuando y prueba.
- **Usar `process.env` sin `nodejs_compat`.** En Workers la forma natural es `env`. Si necesitas `process.env` o módulos de Node, activa el flag.
- **No limitar la CPU en el plan de pago.** Por defecto una petición puede consumir hasta 30 segundos de CPU. Si tu Worker nunca debería pasar de 50 ms, ponlo en `limits.cpu_ms` y evitarás sustos en la factura.

## Cuándo usar Workers (y cuándo no)

**Workers encaja muy bien** en APIs y *backends* de aplicaciones web, *middleware* delante de otro servidor (autenticación, redirecciones, A/B testing, cabeceras), sitios estáticos o renderizados en servidor con frameworks modernos, webhooks, tareas programadas pequeñas, y cualquier cosa que deba responder rápido desde cualquier parte del mundo. Combinado con D1, R2, KV y Durable Objects puedes construir una aplicación completa sin un solo servidor.

**Piénsatelo dos veces** si necesitas procesos de larga duración con mucho cálculo continuo, binarios nativos o librerías que dependen del sistema de ficheros, o mucha memoria (el límite es 128 MB por isolate). Para eso Cloudflare tiene ahora **Containers**, o puedes seguir con tu servidor de siempre, por ejemplo [con Coolify](https://blog.devutilites.com/coolify-paas-autoalojado/). Y si tu base de datos está en una sola región lejana, cada consulta cruzará medio mundo: mira **Smart Placement** (`"placement": { "mode": "smart" }`) o Hyperdrive.

Si escribes APIs con TypeScript, mi recomendación es usar [Hono](https://blog.devutilites.com/hono-a-fondo/) encima de Workers: nació precisamente aquí, y te da rutas, validación y middleware sin perder nada de lo que has visto en este artículo (`c.env` son los mismos bindings).

## Sigue leyendo

Este artículo forma parte de la serie sobre la plataforma de desarrollo de Cloudflare:

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general, con todos los servicios y cuándo usar cada uno.
- [Cloudflare R2 a fondo](https://blog.devutilites.com/cloudflare-r2-a-fondo/): almacenamiento de objetos compatible con S3 y sin coste de salida.
- [Cloudflare D1 a fondo](https://blog.devutilites.com/cloudflare-d1-a-fondo/): SQLite gestionado, migraciones y réplicas de lectura.
- [Workers KV a fondo](https://blog.devutilites.com/cloudflare-workers-kv-a-fondo/): el almacén clave-valor global para lecturas rápidas.
- [Durable Objects a fondo](https://blog.devutilites.com/cloudflare-durable-objects-a-fondo/): estado coordinado, WebSockets y alarmas.
- [Cloudflare Queues a fondo](https://blog.devutilites.com/cloudflare-queues-a-fondo/): trabajo en segundo plano con reintentos y cola de fallidos.
- [Cloudflare Pages a fondo](https://blog.devutilites.com/cloudflare-pages-a-fondo/): sitios estáticos y funciones, y cuándo pasarse a Workers.
- [Planes de Cloudflare Workers: Free frente a Paid](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/): qué incluye cada uno y cuánto pagarás de verdad.
- [Cloudflare Workflows a fondo](https://blog.devutilites.com/cloudflare-workflows-a-fondo/): procesos largos por pasos sobre Workers.
- [Cloudflare WAF y protección DDoS a fondo](https://blog.devutilites.com/cloudflare-waf-ddos-a-fondo/): incluye el binding de rate limiting para tu Worker.
- [Cloudflare CDN y caché a fondo](https://blog.devutilites.com/cloudflare-cdn-cache-a-fondo/): la caché delante de tu Worker y la Cache API.
- [Cloudflare Browser Run a fondo](https://blog.devutilites.com/cloudflare-browser-run-a-fondo/): un Chrome controlado desde tu Worker.
- [Cloudflare Workers AI a fondo](https://blog.devutilites.com/cloudflare-workers-ai-a-fondo/): modelos de IA con un binding.
- [Cloudflare Containers a fondo](https://blog.devutilites.com/cloudflare-containers-a-fondo/): para lo que no cabe en un Worker.