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

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.
Matriz de almacenamiento Cloudflare
| Servicio | Modelo | Mejor para | Escritura | Latencia |
|---|---|---|---|---|
| KV | Key-Value | Session, cache, config | 1 RPS/key | hot 500µs-10ms |
| D1 | SQL (SQLite) | Usuarios, pedidos, informes | Sin límite duro | 50-200ms típico |
| R2 | Object Storage | Archivos, imágenes | Sin límite duro | Descarga rápida |
| Durable Objects | Objeto con estado | Colaboración, WebSocket | Sin límite duro | Nodo 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.
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 >> escritura?
│ ├─ ¿Escritura > 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
| Dimensión | KV | D1 | R2 |
|---|---|---|---|
| Modelo | Key-Value | SQL | Object |
| Consulta | get/put/delete | SQL completo | Por ruta |
| Escritura | 1 RPS/key | Sin límite duro | Sin límite |
| Lectura | 500µs-10ms hot | 50-200ms | Descarga rápida |
| Consistencia | Eventual | Fuerte (región) | Eventual |
| Max value | 25 MB | Límite fila SQLite | 5 TB archivo |
| Gratis | 100k reads/día | 5 GB + 25M rows read | 10 GB |
| Escenario | Session, cache | Datos relacionales | Archivos |
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.
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
| Métrica | Free | Paid ($5/mes) |
|---|---|---|
| Lecturas | 100.000/día | Ilimitadas (pago uso) |
| Escrituras | 1.000/día | Ilimitadas |
| Almacenamiento | 1 GB | Pago uso |
| Namespaces | 1000 | 1000 |
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
// ❌ >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?
¿KV para session de usuario?
¿KV vs D1?
• D1: SQL SQLite, consultas complejas, sin límite duro de escritura
SQL → D1; key-value lectura intensiva → KV.
¿Por qué latencia 500µs-10ms?
¿Para qué sirve cacheTtl?
¿Tamaño máximo del value?
9 min de lectura · Publicado el: 22 abr 2026 · Actualizado el: 21 ago 2026
Cloudflare Full Stack
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Guía completa de despliegue Astro en Cloudflare: configuración SSR y latencia 3 veces menor
Despliega Astro en Cloudflare Pages desde cero: tres modos del adaptador SSR, tres estrategias de optimización de acceso (IP optimizada, CNAME y resolución por línea) con latencia 3 veces menor en pruebas reales
Parte 18 de 23
Siguiente
Cloudflare Dynamic Workers: el secreto de un sandbox para agentes de IA 100 veces más rápido que los contenedores
Cloudflare Dynamic Workers usa V8 Isolates para sandboxes de agentes de IA: arranque 100 veces más rápido que los contenedores y eficiencia de memoria 10-100 veces mayor. Análisis de principios técnicos, seguridad, API práctica y costes para elegir la mejor opción.
Parte 20 de 23



Comentarios
Inicia sesión con GitHub para dejar un comentario