Cambiar tema

Cloudflare Workers KV en la práctica: almacenamiento clave-valor distribuido de principio a fin

Easton editorial illustration: bottleneck pressure gauge

Mirando la curva de latencia en el Dashboard de Cloudflare. La línea roja sigue por encima de 200 ms — con Workers y código ya mínimo, ¿por qué cada request del usuario espera tanto?

El cuello de botella era la base de datos. Cada consulta de session iba del edge a Europa y volvía. Workers ejecuta en 5 ms; la red se come el resto.

Workers es stateless. Hace falta almacenamiento que «viva en el edge» — Cloudflare Workers KV.

Este artículo recoge tropiezos, benchmarks y código: qué es KV, por qué sub-10 ms, session storage y API cache completos, y cuándo elegir D1 o R2.

KV — entender el almacenamiento edge distribuido

En pocas palabras: la «memoria de bolsillo» de Workers, repartida en 300+ nodos edge. Usuario en Tokio → datos posiblemente en Tokio; en Fráncfort → en Fráncfort.

Lectura muy rápida: hot keys 500µs-10 ms — al principio dudé hasta correr benchmarks en single-digit ms.

Replicación global: escribes una vez, se replica a todos los edges. No es Redis cluster: «escribe una vez, lee en todas partes», ideal lectura intensiva.

Alto throughput: miles de RPS por key en edge cache.

500µs - 10ms
Rango latencia hot keys

Matriz de almacenamiento Cloudflare

ServicioModeloMejor paraEscrituraLatencia
KVKey-ValueSession, cache, config1 RPS/keyhot 500µs-10ms
D1SQL (SQLite)Usuarios, pedidos, informesSin límite duro50-200ms típico
R2Object StorageArchivos, imágenesSin límite duroDescarga rápida
Durable ObjectsObjeto con estadoColaboración, WebSocketSin límite duroNodo específico

1 RPS/key = una escritura por segundo por clave — el límite más crítico de KV.

Cuándo usar KV

Recomendado:

  • Session storage
  • API response cache
  • Rate limiting counters
  • Feature flags / configuración
  • Redirect mapping

No recomendado:

  • Escritura frecuente (>1 RPS/key)
  • Consultas SQL complejas → D1
  • Archivos grandes → R2
  • Consistencia fuerte (finanzas) → Durable Objects

Documentación Cloudflare: alta lectura, baja modificación, sin consistencia instantánea. OpenAuth usa KV por defecto para session.

Arquitectura — por qué es tan rápido

Tres capas de caché:

Request → Edge Cache (más rápido)
            ↓ miss
          Regional Cache
            ↓ miss
          Central Store (más lento)

Blog Cloudflare oct 2025: ~30% resueltos en caché, sin central.

30%
Hit rate caché edge

Datos de rendimiento

Oficial:

  • Hot keys: 500µs-10ms
  • Cold keys: más latencia, requiere origen

Prueba propia:

// Código simple de prueba de latencia
const start = Date.now();
await env.KV.get("test-key");
const latency = Date.now() - start;
console.log(`Latency: ${latency}ms`);

100 lecturas: hot ~5-8 ms promedio; cold primera vez >50 ms, luego baja.

2025: Workers y KV conectados directo (sin Front Line), rutas simplificadas — ~3× más rápido. Beneficio en Turnstile, Waiting Room, etc.

Consistencia eventual

Escribes → no aparece al instante en todos los edges; propagación en segundos-decenas de segundos.

Problema: login escribe session; otro edge no la ve aún.

No problema: feature flags, API cache, redirects — retraso de segundos OK.

¿Consistencia instantánea? → Durable Objects.

Configuración con Wrangler CLI

Namespace + binding en wrangler.toml.

Crear namespace

Máximo 1000 namespaces por cuenta (subió de 200 en 2025).

# Crear namespace de producción
wrangler kv namespace create MY_KV

# Salida similar:
# Created namespace with id "abc123def456..."
# Add the following to your wrangler.toml:
# [[kv_namespaces]]
# binding = "MY_KV"
# id = "abc123def456..."

Preview para desarrollo local:

# Crear namespace preview
wrangler kv namespace create MY_KV --preview

wrangler.toml

name = "my-worker"
main = "src/index.ts"

[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456..."
preview_id = "preview_abc123..."

En código: env.MY_KV.

Binding API vs REST API

Binding API (recomendado): env.MY_KV.get() en Worker, sin HTTP extra, gratis (cuenta en tiempo de ejecución).

REST API: HTTP externo, token, límites globales REST. Para CI/CD, import masivo, debug.

Comandos Wrangler KV

# Escribir
wrangler kv key put --namespace-id=abc123 "my-key" "my-value"

# Leer
wrangler kv key get --namespace-id=abc123 "my-key"

# Eliminar
wrangler kv key delete --namespace-id=abc123 "my-key"

# Listar (prefijo)
wrangler kv key list --namespace-id=abc123 --prefix="session:"

Código TypeScript

CRUD básico

// src/index.ts
interface Env {
  MY_KV: KVNamespace;
}

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

    if (path === "/put") {
      const key = url.searchParams.get("key") || "default";
      const value = url.searchParams.get("value") || "hello";
      await env.MY_KV.put(key, value);
      return new Response(`Saved: ${key} = ${value}`);
    }

    if (path === "/get") {
      const key = url.searchParams.get("key") || "default";
      const value = await env.MY_KV.get(key);
      if (value === null) {
        return new Response("Key not found", { status: 404 });
      }
      return new Response(value);
    }

    if (path === "/delete") {
      const key = url.searchParams.get("key") || "default";
      await env.MY_KV.delete(key);
      return new Response(`Deleted: ${key}`);
    }

    if (path === "/list") {
      const prefix = url.searchParams.get("prefix") || "";
      const keys = await env.MY_KV.list({ prefix });
      const keyList = keys.keys.map(k => k.name).join("\n");
      return new Response(keyList || "No keys found");
    }

    return new Response("Try /put, /get, /delete, or /list");
  },
};
wrangler dev
curl "http://localhost:8787/put?key=test&value=helloworld"
curl "http://localhost:8787/get?key=test"

Session storage completo

// src/session.ts
interface SessionData {
  userId: string;
  email: string;
  createdAt: number;
  expiresAt: number;
}

interface Env {
  SESSION_KV: KVNamespace;
}

const SESSION_TTL = 3600; // Expira en 1 hora

class SessionManager {
  private kv: KVNamespace;

  constructor(kv: KVNamespace) {
    this.kv = kv;
  }

  async create(userId: string, email: string): Promise<string> {
    const sessionId = crypto.randomUUID();
    const sessionData: SessionData = {
      userId,
      email,
      createdAt: Date.now(),
      expiresAt: Date.now() + SESSION_TTL * 1000,
    };

    await this.kv.put(
      `session:${sessionId}`,
      JSON.stringify(sessionData),
      { expirationTtl: SESSION_TTL }
    );

    return sessionId;
  }

  async get(sessionId: string): Promise<SessionData | null> {
    const raw = await this.kv.get(`session:${sessionId}`);
    if (!raw) return null;
    try {
      return JSON.parse(raw) as SessionData;
    } catch {
      return null;
    }
  }

  async delete(sessionId: string): Promise<void> {
    await this.kv.delete(`session:${sessionId}`);
  }

  async refresh(sessionId: string): Promise<boolean> {
    const session = await this.get(sessionId);
    if (!session) return false;

    session.expiresAt = Date.now() + SESSION_TTL * 1000;
    await this.kv.put(
      `session:${sessionId}`,
      JSON.stringify(session),
      { expirationTtl: SESSION_TTL }
    );

    return true;
  }
}

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

    if (url.pathname === "/login" && request.method === "POST") {
      const body = await request.json();
      const sessionId = await sessionManager.create(
        body.userId as string,
        body.email as string
      );
      return new Response(JSON.stringify({ sessionId }), {
        headers: { "Content-Type": "application/json" },
      });
    }

    if (url.pathname === "/verify") {
      const sessionId = url.searchParams.get("sessionId");
      if (!sessionId) {
        return new Response("Missing sessionId", { status: 400 });
      }
      const session = await sessionManager.get(sessionId);
      if (!session) {
        return new Response("Session not found", { status: 401 });
      }
      return new Response(JSON.stringify(session), {
        headers: { "Content-Type": "application/json" },
      });
    }

    if (url.pathname === "/logout") {
      const sessionId = url.searchParams.get("sessionId");
      if (sessionId) {
        await sessionManager.delete(sessionId);
      }
      return new Response("Logged out");
    }

    return new Response("Not found", { status: 404 });
  },
};

Puntos: expirationTtl auto-expira; prefijo session:; JSON manual.

API response cache

// src/api-cache.ts
interface Env {
  CACHE_KV: KVNamespace;
}

const DEFAULT_CACHE_TTL = 300; // Caché 5 minutos

async function cachedFetch(
  kv: KVNamespace,
  cacheKey: string,
  url: string,
  ttl: number = DEFAULT_CACHE_TTL
): Promise<Response> {
  const cached = await kv.get(cacheKey, "text");

  if (cached) {
    console.log(`Cache hit: ${cacheKey}`);
    return new Response(cached, {
      headers: {
        "Content-Type": "application/json",
        "X-Cache": "HIT",
      },
    });
  }

  console.log(`Cache miss: ${cacheKey}`);
  const response = await fetch(url);
  const body = await response.text();

  await kv.put(cacheKey, body, {
    expirationTtl: ttl,
  });

  return new Response(body, {
    headers: {
      "Content-Type": "application/json",
      "X-Cache": "MISS",
    },
  });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const apiUrl = url.searchParams.get("api");

    if (!apiUrl) {
      return new Response("Missing api parameter", { status: 400 });
    }

    const cacheKey = `api:${apiUrl}`;
    return cachedFetch(env.CACHE_KV, cacheKey, apiUrl);
  },
};

cacheTtl

// Datos de configuración de alto tráfico: caché edge más larga
await env.MY_KV.get("config:feature-flags", {
  cacheTtl: 3600, // 1 hora en edge
});

Flags y config: 1 h en edge reduce origen.

KV vs D1 vs R2 — guía de decisión

¿Qué tipo de dato?

├─ ¿Archivos (imagen, video, PDF)?
│   └─ SÍ → R2

├─ ¿SQL (usuarios, pedidos, joins)?
│   └─ SÍ → D1

├─ ¿Key-value simple, lectura &gt;&gt; escritura?
│   ├─ ¿Escritura &gt; 1 RPS/key?
│   │   └─ SÍ → no KV; D1 o Durable Objects
│   └─ NO → KV ✓

├─ ¿Consistencia instantánea?
│   └─ SÍ → Durable Objects
│   └─ NO → KV puede valer

└─ ¿Dudas?
    └─ Prueba KV; si basta, no cambies
500µs-10ms
Latencia KV hot key
50-200ms
Latencia D1
25MB
Value máximo KV
Source: Documentación Cloudflare
DimensiónKVD1R2
ModeloKey-ValueSQLObject
Consultaget/put/deleteSQL completoPor ruta
Escritura1 RPS/keySin límite duroSin límite
Lectura500µs-10ms hot50-200msDescarga rápida
ConsistenciaEventualFuerte (región)Eventual
Max value25 MBLímite fila SQLite5 TB archivo
Gratis100k reads/día5 GB + 25M rows read10 GB
EscenarioSession, cacheDatos relacionalesArchivos

Auth/session → KV

Perfiles/pedidos → D1

Imágenes/archivos → R2

Rate limiting → KV con cuidado (o Durable Objects / Upstash si >1 RPS/key)

API cache terceros → KV (TTL minutos, eventual OK)

Combinación típica:

interface Env {
  SESSION_KV: KVNamespace;
  CACHE_KV: KVNamespace;
  DATABASE_D1: D1Database;
  FILES_R2: R2Bucket;
}

Optimización de rendimiento

1. cacheTtl

// ❌ Default 60 s
await env.KV.get("config:feature-flags");

// ✅ Config: 1 h edge
await env.KV.get("config:feature-flags", {
  cacheTtl: 3600,
});

Aumenta para flags, URLs estáticas, redirects. No para session ni contadores en tiempo real.

2. Lecturas en paralelo

// ❌ Serial: 3× latencia
const user = await env.KV.get(`user:${userId}`);
const settings = await env.KV.get(`settings:${userId}`);
const permissions = await env.KV.get(`permissions:${userId}`);

// ✅ Paralelo: ~1× latencia
const [user, settings, permissions] = await Promise.all([
  env.KV.get(`user:${userId}`),
  env.KV.get(`settings:${userId}`),
  env.KV.get(`permissions:${userId}`),
]);

3 keys: serial ~20 ms, paralelo ~8 ms.

60%
Reducción latencia (paralelo vs serial)
Source: Datos de prueba

3. Hot keys

Datos globales compartidos → una key hot (config:global-flags). Datos por usuario → session:${userId}.

4. Namespaces

[[kv_namespaces]]
binding = "SESSION_KV"
id = "xxx"

[[kv_namespaces]]
binding = "CACHE_KV"
id = "yyy"

[[kv_namespaces]]
binding = "CONFIG_KV"
id = "zzz"

Aislamiento, TTL distintos, métricas separadas.

5. list() y limpieza

const result = await env.SESSION_KV.list({ prefix: "session:" });
for (const key of result.keys) {
  console.log(key.name);
}

Borrado masivo con cuidado — consume cuota de escritura.

Precios y límites

100,000
Lecturas gratis/día
1,000
Escrituras gratis/día
1 GB
Almacenamiento gratis
$5
Plan de pago/mes
Source: Página de precios Cloudflare
MétricaFreePaid ($5/mes)
Lecturas100.000/díaIlimitadas (pago uso)
Escrituras1.000/díaIlimitadas
Almacenamiento1 GBPago uso
Namespaces10001000

Write rate limit

1 RPS por unique key

// ❌ Falla en bucle rápido
for (let i = 0; i < 10; i++) {
  await env.KV.put("counter", String(i));
}

// ✅ Key por segundo
await env.KV.put(`counter:${Math.floor(Date.now() / 1000)}`, value);

Estrategias: timestamp en key, UUID por escritura, o D1/DO.

Tamaño value: 25 MB max

// ❌ &gt;25 MB error
const largeData = generateBigString(30_000_000);
await env.KV.put("large-key", largeData);

// ✅ R2 para grandes
await env.R2_BUCKET.put("large-key", largeData);

Coste estimado (Paid)

Mes = $5 + lecturas + escrituras + almacenamiento
Lecturas = count × $0.01 / 100,000
Escrituras = count × $1.00 / 1,000,000

100K requests/día ≈ $5,35/mes total.

Resumen

KV = «memoria de bolsillo» de Workers para session, cache y config lectura intensiva.

KV si: key-value, lectura >> escritura, consistencia eventual OK, ≤1 RPS/key escritura.

D1 si: SQL, joins, >1 RPS escritura.

R2 si: archivos o >25 MB.

Durable Objects si: consistencia instantánea, colaboración en vivo.

Siguiente paso: conecta session storage con el código de arriba. Más en la serie cloudflare-bindui para D1 y R2.

FAQ

¿Límite de escritura en Workers KV?
1 RPS por unique key. Superarlo falla. Estrategias: keys con timestamp (counter:timestamp) o D1/Durable Objects.
¿KV para session de usuario?
Muy adecuado. Key-value simple, muchas lecturas (cada request), pocas escrituras (login/logout). TTL expira solo.
¿KV vs D1?
• KV: key-value, hot keys 500µs-10ms, 1 RPS/key escritura
• D1: SQL SQLite, consultas complejas, sin límite duro de escritura

SQL → D1; key-value lectura intensiva → KV.
¿Por qué latencia 500µs-10ms?
Edge → Regional → Central. ~30% hit en edge. Optimización 2025: ~3× más rápido.
¿Para qué sirve cacheTtl?
TTL de caché en edge. Default 60 s. Datos calientes (flags, config): 3600 s (1 h) reduce origen.
¿Tamaño máximo del value?
25 MB (subió desde 10 MB a inicios 2025). Más grande → R2 Object Storage.

9 min de lectura · Publicado el: 22 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog