Cloudflare Workers KV a fondo: clave-valor global, consistencia eventual y caché bien entendida

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.

Portada: Cloudflare Workers KV a fondo: clave-valor global, consistencia eventual y caché bien entendida

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
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 bienNo encaja
Configuración y feature flagsContadores, votos, likes
Redirecciones y acortadoresInventario, saldos, reservas
Sesiones y tokens con caducidadDatos que cambian varias veces por segundo
Caché de respuestas de APIs externasConsultas por campos o filtros (usa D1)
Listas de bloqueo, claves de APIFicheros grandes (usa R2)
Traducciones, contenido por paísLectura justo después de escribir desde otro sitio

Lo que cuesta

Workers FreeWorkers Paid
Lecturas100.000 al día10 millones al mes, luego 0,50 $ por millón
Escrituras1.000 al día1 millón al mes, luego 5,00 $ por millón
Borrados1.000 al día1 millón al mes, luego 5,00 $ por millón
Listados1.000 al día1 millón al mes, luego 5,00 $ por millón
Almacenamiento1 GB1 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

# 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):

{
  "$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étodoQué 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

// 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:

$ 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

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/);
  });
});
$ 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:

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ímiteFreePaid
Escrituras a la misma clave1 por segundo1 por segundo
Operaciones por invocación1.0001.000
Namespaces por cuenta1.0001.000
Almacenamiento1 GBIlimitado (se paga a partir de 1 GB)
Claves por namespaceIlimitadasIlimitadas
Tamaño de la clave512 bytes512 bytes
Metadatos por clave1.024 bytes1.024 bytes
Tamaño del valor25 MiB25 MiB
cacheTtl mínimo30 segundos30 segundos
Caducidad mínima60 segundos60 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