> ## 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 KV a fondo: clave-valor global, consistencia eventual y caché bien entendida
- URL: https://blog.devutilites.com/cloudflare-workers-kv-a-fondo/
- Published: 2026-10-03T03:13:57.000Z
- Updated: 2026-10-03T03:13:57.000Z
- Description: Cómo funciona KV por dentro, lecturas calientes y frías, consistencia eventual, cacheTtl, caducidad, metadatos, listados con cursor, lecturas masivas, precios y límites, con un acortador de enlaces y feature flags probado en local.
- Author: Luis Collantes
- Tags: Cloudflare, Backend

**Workers KV** es el almacenamiento más sencillo de Cloudflare: guardas un valor con una clave y lo lees desde cualquier Worker del mundo con una latencia de pocos milisegundos. No hay esquema, no hay consultas, no hay conexiones. Solo `get`, `put`, `delete` y `list`.

Esa sencillez esconde un diseño muy concreto: KV está **optimizado para leer muchísimo y escribir poco**, y a cambio sus escrituras tardan en verse en todo el mundo. Entender eso es la diferencia entre usar KV bien (configuración, *feature flags*, redirecciones, sesiones, caché de respuestas) y usarlo mal (contadores, inventario, cualquier cosa que cambie cada segundo).

En este artículo vamos a ver cómo funciona por dentro, qué significa exactamente "consistencia eventual" aquí, cuánto cuesta y un acortador de enlaces con *feature flags* que usa caducidad, metadatos, listados paginados y lecturas masivas, probado en local con Wrangler y Vitest.

🧪

**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** (KV en local con Miniflare), **@cloudflare/vitest-plugin 1.3.6**, **Vitest 4.1.11** y **Node.js 24.21.0**. La propagación global de las escrituras no se puede reproducir en local; lo que cuento de ella sale de la documentación oficial, consultada ese mismo día.

## Cómo funciona por dentro

KV guarda tus datos en **unos pocos almacenes centrales** y los **cachea en los centros de datos de Cloudflare a medida que se leen**. Cuando escribes, el valor va a los almacenes centrales; no se envía a todo el mundo.

La primera lectura de una clave en un centro de datos es una **lectura fría**: no hay copia local, así que KV la busca en una caché regional, luego en una central y, en último caso, en los almacenes centrales. Las siguientes lecturas en ese centro de datos son **lecturas calientes**: salen de la caché local y son las más rápidas. KV refresca los valores cacheados en segundo plano, sin que las peticiones tengan que esperar.

![Esquema de Workers KV: las escrituras van a los almacenes centrales; las lecturas pasan por la caché local del centro de datos, una caché regional y una central antes de llegar al almacén](https://blog.devutilites.com/content/images/2026/10/cf-kv-arquitectura.png)

Una lectura fría recorre los niveles hasta el almacén central; una caliente sale de la caché local. Diagrama: devutilites.

### Consistencia eventual

De ese diseño sale la regla que tienes que recordar:

- En el centro de datos donde escribes, el cambio **suele** verse al instante (pero no está garantizado).
- En el resto del mundo, un cambio puede tardar **60 segundos o más** en verse, porque las cachés tienen su copia anterior.
- Las **búsquedas negativas también se cachean**: si alguien leyó una clave cuando no existía, en ese centro de datos puede seguir "sin existir" hasta un minuto después de crearla.

Y una limitación de escritura: **una escritura por segundo a la misma clave**. Más rápido, y KV devuelve error 429\. KV no tiene operaciones atómicas (no hay "incrementa" ni "escribe si no ha cambiado"), así que dos Workers que leen, modifican y escriben la misma clave a la vez se pisarán. Si necesitas eso, lo que buscas son **Durable Objects**.

## Para qué es bueno (y para qué no)

| Encaja muy bien                      | No encaja                                          |
| ------------------------------------ | -------------------------------------------------- |
| Configuración y *feature flags*      | Contadores, votos, likes                           |
| Redirecciones y acortadores          | Inventario, saldos, reservas                       |
| Sesiones y tokens con caducidad      | Datos que cambian varias veces por segundo         |
| Caché de respuestas de APIs externas | Consultas por campos o filtros (usa D1)            |
| Listas de bloqueo, claves de API     | Ficheros grandes (usa R2)                          |
| Traducciones, contenido por país     | Lectura justo después de escribir desde otro sitio |

## Lo que cuesta

|                | Workers Free   | Workers Paid                                |
| -------------- | -------------- | ------------------------------------------- |
| Lecturas       | 100.000 al día | 10 millones al mes, luego 0,50 $ por millón |
| Escrituras     | 1.000 al día   | 1 millón al mes, luego 5,00 $ por millón    |
| Borrados       | 1.000 al día   | 1 millón al mes, luego 5,00 $ por millón    |
| Listados       | 1.000 al día   | 1 millón al mes, luego 5,00 $ por millón    |
| Almacenamiento | 1 GB           | 1 GB, luego 0,50 $ por GB-mes               |

Fíjate en la proporción: escribir es **diez veces más caro** que leer, y en el plan gratuito solo tienes 1.000 escrituras al día. Eso es lo que marca el diseño: KV es para datos que se escriben pocas veces y se leen muchas. Un acortador de enlaces en el plan gratuito puede crear 1.000 enlaces al día, pero servir 100.000 redirecciones.

Las lecturas masivas (pedir varias claves de una vez) se facturan **por clave leída**, no por operación.

## Primeros pasos

```bash
# Con cuenta
npx wrangler kv namespace create ENLACES
npx wrangler kv namespace create CONFIG

# En local (sin cuenta), las mismas órdenes con --local
npx wrangler kv key put --binding CONFIG "flag:modo-oscuro" true --local
npx wrangler kv key get --binding CONFIG "flag:modo-oscuro" --local
npx wrangler kv key list --binding ENLACES --local
npx wrangler kv bulk put --binding CONFIG flags.json   # muchas claves de golpe
```

La configuración, con dos *namespaces* (los ids de relleno valen para trabajar en local):

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "kv-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "kv_namespaces": [
    { "binding": "ENLACES", "id": "11111111111111111111111111111111" },
    { "binding": "CONFIG", "id": "22222222222222222222222222222222" }
  ]
}
```

## La API

| Método                                                     | Qué hace                                                                                           |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| get(clave, { type, cacheTtl })                             | Devuelve el valor como "text" (por defecto), "json", "arrayBuffer" o "stream", o null si no existe |
| get(\[claves\], opciones)                                  | Lectura masiva: hasta 100 claves, devuelve un Map                                                  |
| getWithMetadata(clave)                                     | El valor y sus metadatos en una sola lectura                                                       |
| put(clave, valor, { expiration, expirationTtl, metadata }) | Escribe un string, ArrayBuffer o ReadableStream                                                    |
| delete(clave)                                              | Borra                                                                                              |
| list({ prefix, limit, cursor })                            | Lista claves (hasta 1.000 por página) con sus metadatos, sin los valores                           |

Tres opciones que merecen explicación:

- **`expirationTtl`** (segundos desde ahora) o **`expiration`** (fecha absoluta en segundos Unix) hacen que la clave **se borre sola**. El mínimo es 60 segundos.
- **`metadata`**: hasta 1.024 bytes de JSON que se guardan con la clave y, sobre todo, **salen en `list()`**. Con ellos puedes construir un listado de administración sin leer cada valor.
- **`cacheTtl`**: cuántos segundos guarda el centro de datos el valor tras una lectura (por defecto 60, mínimo 30). Subirlo hace las lecturas más rápidas a cambio de tardar más en ver los cambios.

## Un acortador de enlaces con *feature flags*

```typescript
// Acortador de enlaces + interruptores de funcionalidades (feature flags) sobre Workers KV
type MetaEnlace = { creado: string; destino: string };

const FLAGS = ["flag:nuevo-editor", "flag:modo-oscuro", "flag:beta-pagos"];

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);

    // POST /acortar  { "url": "https://..." , "dias": 30 }
    if (request.method === "POST" && url.pathname === "/acortar") {
      const { url: destino, dias } = await request.json<{ url: string; dias?: number }>();
      if (!URL.canParse(destino)) return Response.json({ error: "URL no válida" }, { status: 400 });

      const codigo = crypto.randomUUID().slice(0, 8);
      await env.ENLACES.put(`e:${codigo}`, destino, {
        // Caduca sola: mínimo 60 segundos
        expirationTtl: dias ? dias * 86_400 : undefined,
        // Hasta 1024 bytes de JSON que viajan con la clave (y salen en list())
        metadata: { creado: new Date().toISOString(), destino } satisfies MetaEnlace,
      });
      return Response.json({ codigo, corto: `${url.origin}/${codigo}` }, { status: 201 });
    }

    // GET /admin/enlaces?cursor=...  lista sin leer los valores, gracias a los metadatos
    if (url.pathname === "/admin/enlaces") {
      const pagina = await env.ENLACES.list<MetaEnlace>({
        prefix: "e:",
        limit: 100,
        cursor: url.searchParams.get("cursor") ?? undefined,
      });
      return Response.json({
        enlaces: pagina.keys.map((k) => ({ codigo: k.name.slice(2), ...k.metadata, caduca: k.expiration ?? null })),
        siguiente: pagina.list_complete ? null : pagina.cursor,
      });
    }

    // GET /flags  lectura masiva: hasta 100 claves en una sola operación
    if (url.pathname === "/flags") {
      const valores = await env.CONFIG.get(FLAGS, { type: "json", cacheTtl: 300 });
      return Response.json(Object.fromEntries([...valores].map(([k, v]) => [k.slice(5), v ?? false])));
    }

    // GET /:codigo  redirección
    const codigo = url.pathname.slice(1);
    if (request.method === "GET" && /^[0-9a-f]{8}$/.test(codigo)) {
      // cacheTtl: cuánto tiempo guarda este centro de datos el valor (mínimo 30 s)
      const destino = await env.ENLACES.get(`e:${codigo}`, { cacheTtl: 3600 });
      if (!destino) return new Response("Enlace no encontrado o caducado", { status: 404 });
      return Response.redirect(destino, 301);
    }

    return new Response("No existe", { status: 404 });
  },
} satisfies ExportedHandler<Env>;
```

Así responde con `npx wrangler dev --port 8792`, después de cargar dos *flags* con `wrangler kv key put --local`:

```bash
$ npx wrangler kv key put --binding CONFIG "flag:modo-oscuro" true --local
$ npx wrangler kv key put --binding CONFIG "flag:nuevo-editor" '{"porcentaje":20}' --local

$ curl -X POST localhost:8792/acortar -H "Content-Type: application/json" \
    -d '{"url":"https://blog.devutilites.com/hono-a-fondo/","dias":30}' -w " %{http_code}\n"
{"codigo":"5a155501","corto":"http://localhost:8792/5a155501"} 201

$ curl -X POST localhost:8792/acortar -H "Content-Type: application/json" \
    -d '{"url":"https://developers.cloudflare.com/kv/"}' -w " %{http_code}\n"
{"codigo":"0f44fbfd","corto":"http://localhost:8792/0f44fbfd"} 201

$ curl -X POST localhost:8792/acortar -H "Content-Type: application/json" -d '{"url":"esto no es una url"}' -w " %{http_code}\n"
{"error":"URL no válida"} 400

$ curl -i localhost:8792/5a155501
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Location: https://blog.devutilites.com/hono-a-fondo/

$ curl localhost:8792/00000000 -w " %{http_code}\n"
Enlace no encontrado o caducado 404

$ curl localhost:8792/admin/enlaces
{"enlaces":[{"codigo":"0f44fbfd","creado":"2026-10-02T18:26:34.270Z","destino":"https://developers.cloudflare.com/kv/","caduca":null},
            {"codigo":"5a155501","creado":"2026-10-02T18:26:34.250Z","destino":"https://blog.devutilites.com/hono-a-fondo/","caduca":1793557594}],
 "siguiente":null}

$ curl localhost:8792/flags
{"nuevo-editor":{"porcentaje":20},"modo-oscuro":true,"beta-pagos":false}
```

Varios detalles en esas respuestas:

- El enlace con `dias: 30` tiene `caduca: 1793557594`: la fecha Unix en la que KV lo borrará solo (el 1 de noviembre de 2026). El otro no caduca nunca.
- El listado no ha leído ningún valor: todo lo que muestra (destino y fecha de creación) viene de los **metadatos**. Una operación de listado en lugar de una lectura por enlace.
- `/flags` ha leído tres claves con **una sola llamada** (`get` con un array). `beta-pagos` no existe y sale `false` gracias al `?? false`. Con `type: "json"`, `"true"` llega como booleano y el objeto como objeto.

💡

**Las lecturas masivas no ahorran dinero, pero sí límites.** Se facturan por clave, igual que tres `get` sueltos, pero cuentan como **una** operación para el límite de 1.000 operaciones de KV por invocación, y es un único viaje en lugar de tres.

### Paginación

`list()` devuelve hasta 1.000 claves por página (aquí he pedido 100). Si hay más, `list_complete` es `false` y `cursor` lleva a la siguiente. Como en R2, **comprueba `list_complete`** y no si te han llegado `limit` claves. Las claves salen ordenadas lexicográficamente por bytes; si quieres "lo más reciente primero", tendrás que codificarlo en la clave (por ejemplo, con una marca de tiempo invertida) o usar otra herramienta.

## Pruebas

```typescript
import { env, exports } from "cloudflare:workers";
import { describe, it, expect } from "vitest";

describe("acortador sobre KV", () => {
  it("acorta y redirige", async () => {
    const res = await exports.default.fetch("https://x/acortar", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ url: "https://blog.devutilites.com/", dias: 1 }),
    });
    const { codigo } = await res.json<{ codigo: string }>();
    const red = await exports.default.fetch(`https://x/${codigo}`, { redirect: "manual" });
    expect(red.status).toBe(301);
    expect(red.headers.get("Location")).toBe("https://blog.devutilites.com/");

    // La clave guarda los metadatos y la caducidad
    const { metadata } = await env.ENLACES.getWithMetadata<{ destino: string }>(`e:${codigo}`);
    expect(metadata?.destino).toBe("https://blog.devutilites.com/");
  });

  it("los flags que no existen salen como false", async () => {
    await env.CONFIG.put("flag:beta-pagos", "true");
    const flags = await (await exports.default.fetch("https://x/flags")).json();
    expect(flags).toEqual({ "nuevo-editor": false, "modo-oscuro": false, "beta-pagos": true });
  });

  it("KV rechaza caducidades de menos de 60 segundos", async () => {
    await expect(env.ENLACES.put("corta", "x", { expirationTtl: 30 })).rejects.toThrow(/60/);
  });
});
```

```text
$ npx vitest run

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  1.37s (transform 37ms, setup 0ms, import 50ms, tests 46ms, environment 0ms)
```

El tercer test documenta un límite que se olvida fácil. KV en local rechaza una caducidad de 30 segundos con el mismo error que en producción:

```text
KV PUT failed: 400 Invalid expiration_ttl of 30. Expiration TTL must be at least 60.
```

⚠️

**En local todo es inmediato.** Miniflare guarda KV en `.wrangler/state` y una escritura se ve al momento en la siguiente lectura. Tus tests pasarán aunque tu código dependa de leer justo después de escribir, y fallará en producción cuando la lectura llegue a otro centro de datos. Revisa a mano cualquier flujo que haga `put` y luego `get` de la misma clave en otra petición.

## Límites que importan

| Límite                      | Free          | Paid                                 |
| --------------------------- | ------------- | ------------------------------------ |
| Escrituras a la misma clave | 1 por segundo | 1 por segundo                        |
| Operaciones por invocación  | 1.000         | 1.000                                |
| Namespaces por cuenta       | 1.000         | 1.000                                |
| Almacenamiento              | 1 GB          | Ilimitado (se paga a partir de 1 GB) |
| Claves por namespace        | Ilimitadas    | Ilimitadas                           |
| Tamaño de la clave          | 512 bytes     | 512 bytes                            |
| Metadatos por clave         | 1.024 bytes   | 1.024 bytes                          |
| Tamaño del valor            | 25 MiB        | 25 MiB                               |
| cacheTtl mínimo             | 30 segundos   | 30 segundos                          |
| Caducidad mínima            | 60 segundos   | 60 segundos                          |

## Errores comunes (y cómo evitarlos)

- **Usar KV como contador.** Leer, sumar uno y escribir desde varias peticiones a la vez pierde incrementos, y a más de una escritura por segundo recibirás 429\. Usa un Durable Object.
- **Leer justo después de escribir desde otro sitio.** Un formulario que guarda en KV y redirige a una página que lo lee puede mostrar el valor anterior si la segunda petición entra por otro centro de datos. Devuelve el dato en la propia respuesta o usa D1.
- **Comprobar una clave antes de crearla.** Si consultas una clave que no existe, esa "no existencia" queda cacheada. Crear la clave justo después no se verá en ese centro de datos durante un rato.
- **Olvidar que escribir es caro.** Escribir en KV en cada petición (por ejemplo, "última visita") agota las 1.000 escrituras diarias del plan gratuito enseguida y en Paid cuesta 5 $ por millón.
- **Un `cacheTtl` alto en datos que cambian.** Con `cacheTtl: 3600`, un *flag* que desactivas puede seguir activo una hora en algunos centros de datos. Para enlaces que no cambian es perfecto; para *flags* de emergencia, usa el valor por defecto.
- **Leer valores para listar.** Si guardas en los metadatos lo que necesitas mostrar, un listado de 1.000 claves es una operación, no 1.001.

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

**Usa KV** para datos que se leen muchísimo, cambian poco y toleran un minuto de retraso: configuración, *flags*, redirecciones, sesiones, claves de API, caché de respuestas o contenido personalizado por país. Es lo más rápido de leer y lo más simple de usar de toda la plataforma.

**No lo uses** para nada que necesite consistencia inmediata, operaciones atómicas o consultas: para eso están **Durable Objects** (estado con coordinación) y **D1** (SQL). Y para ficheros grandes, **R2**.

## Sigue leyendo

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general y cómo elegir almacenamiento.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): incluye un cron que escribe en KV.
- [Cloudflare Durable Objects a fondo](https://blog.devutilites.com/cloudflare-durable-objects-a-fondo/): lo que necesitas cuando KV se queda corto en consistencia.
- [Cloudflare D1 a fondo](https://blog.devutilites.com/cloudflare-d1-a-fondo/): SQL cuando necesitas consultar por campos.
- [Planes de Cloudflare Workers: Free frente a Paid](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/): las 1.000 escrituras diarias y qué cambia al pagar.
- [Cloudflare CDN y caché a fondo](https://blog.devutilites.com/cloudflare-cdn-cache-a-fondo/): la caché por centro de datos, la otra opción para respuestas que se repiten.