The Problem of Persistence at the Edge

The V8 isolates that power Cloudflare Workers are stateless by design: each isolated can be created and destroyed in microseconds, and local JavaScript state cannot persists between requests (or persists only occasionally, due to concurrency on the same isolated). To build real applications you need external persistence.

Cloudflare offers three native storage products, each designed for a use case specific. Choosing the wrong one can mean slow queries, high costs or inconsistencies that are difficult to debug. This article compares them in detail with Real code examples.

What You Will Learn

  • Workers KV: eventually consistent architecture, API, limits and ideal use cases
  • Cloudflare R2: S3-compatible object storage without egress fee, multipart upload
  • D1 SQLite: relational database at the edge, SQL queries, migrations with Drizzle ORM
  • Cost comparison: KV vs R2 vs D1 at different usage scales
  • Integration Pattern: How to combine KV + D1 for intelligent caching
  • Configuring bindings in wrangler.toml for each storage type

Workers KV: Global Key-Value and Eventually Consistent

Workers KV is a globally distributed key-value datastore. When you write a value, is replicated across all Cloudflare PoPs within seconds. When you read, get value from nearest PoP: read latency typically less than 1ms.

The fundamental trade-off: KV is eventually consistent. A writing it can take up to 60 seconds to be visible globally. This makes it unsuitable for data that changes frequently and must be read immediately after writing.

KV configuration in wrangler.toml

# wrangler.toml

[[kv_namespaces]]
binding = "CACHE"          # Nome del binding nel codice TypeScript
id = "xxxxxxxxxxxxxxxxxx"  # ID del namespace in produzione
preview_id = "yyyyyyyyyy"  # ID del namespace per wrangler dev

[[kv_namespaces]]
binding = "SESSIONS"
id = "aaaaaaaaaaaaaaaaa"
preview_id = "bbbbbbbbbbbbb"

# Per creare i namespace:
# wrangler kv namespace create CACHE
# wrangler kv namespace create CACHE --preview

Workers KV API in TypeScript

// src/services/cache.service.ts

export interface Env {
  CACHE: KVNamespace;
  SESSIONS: KVNamespace;
}

// -------- OPERAZIONI BASE --------

// WRITE: put con TTL opzionale (in secondi)
await env.CACHE.put('user:123', JSON.stringify({ name: 'Mario', role: 'admin' }), {
  expirationTtl: 3600,  // scade tra 1 ora
});

// WRITE senza TTL (valore permanente)
await env.CACHE.put('config:features', JSON.stringify({ darkMode: true }));

// WRITE con expiration assoluta (Unix timestamp)
await env.CACHE.put('promo:summer', 'active', {
  expiration: Math.floor(Date.now() / 1000) + 86400,  // scade tra 24h
});

// READ: get con tipo di decodifica
const raw = await env.CACHE.get('user:123');
// raw: string | null

const parsed = await env.CACHE.get<{ name: string; role: string }>('user:123', 'json');
// parsed: { name: string; role: string } | null

// READ con metadata
const { value, metadata } = await env.CACHE.getWithMetadata('user:123', 'json');

// DELETE
await env.CACHE.delete('user:123');

// -------- LISTING (con limitazioni) --------
// KV supporta listing, ma e lento e con limite di 1000 chiavi per chiamata
const listing = await env.CACHE.list({ prefix: 'user:', limit: 100 });
for (const key of listing.keys) {
  console.log(key.name, key.expiration, key.metadata);
}

// -------- PATTERN: cache-aside --------
async function getUserCached(userId: string, env: Env): Promise<User | null> {
  const cacheKey = `user:${userId}`;

  // 1. Controlla la cache KV
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) {
    return cached;  // Cache hit: risposta da KV < 1ms
  }

  // 2. Cache miss: fetch dall'origine
  const user = await fetchUserFromDatabase(userId);
  if (!user) return null;

  // 3. Popola la cache (eventually consistent, ok per profili utente)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: 300,  // 5 minuti
  });

  return user;
}
Characteristic KV detail
Consistency model Eventually consistent (60s max propagation)
Read latency < 1ms (from local PoP after warm)
Write latency ~100ms (confirmation), asynchronous global replication
Max value size 25MB per value
Max key size 512 bytes
Reading cost $0.50 per million (free: 10M/month)
Writing cost $5 per million (free: 1M/month)
Ideal for Configuration, feature flags, session store, API caching
Not suitable for Real-time counters, data that changes every second

Cloudflare R2: S3-Compatible Object Storage Without Egress Fee

R2 is the product that made the most noise at its launch in 2022: object storage compatible with AWS S3 APIs, with zero egress costs. On S3, Transferring 1TB of data to the internet costs ~$90. On R2, it's free.

R2 is ideal for: uploading user files, static assets, backups, archived logs, any binary object that needs to be retrieved by browsers or other Workers.

R2 configuration in wrangler.toml

# wrangler.toml

[[r2_buckets]]
binding = "ASSETS"         # Nome del binding TypeScript
bucket_name = "my-assets"  # Nome del bucket in Cloudflare

# Per creare il bucket:
# wrangler r2 bucket create my-assets

R2 API in TypeScript

// src/services/storage.service.ts

export interface Env {
  ASSETS: R2Bucket;
}

// -------- UPLOAD --------

// Upload di testo semplice
await env.ASSETS.put('documents/readme.txt', 'Contenuto del file', {
  httpMetadata: { contentType: 'text/plain; charset=utf-8' },
  customMetadata: { uploadedBy: 'user-123', version: '1' },
});

// Upload di JSON
const data = { users: [{ id: 1, name: 'Mario' }] };
await env.ASSETS.put('data/users.json', JSON.stringify(data), {
  httpMetadata: { contentType: 'application/json' },
});

// Upload di un file binario da una Request (multipart form)
async function handleFileUpload(request: Request, env: Env): Promise<Response> {
  const formData = await request.formData();
  const file = formData.get('file') as File | null;

  if (!file) {
    return new Response('No file provided', { status: 400 });
  }

  // Genera un nome unico per evitare collisioni
  const key = `uploads/${Date.now()}-${file.name}`;

  await env.ASSETS.put(key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
      contentLength: file.size,
    },
    customMetadata: {
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  });

  return Response.json({ key, size: file.size, type: file.type });
}

// -------- DOWNLOAD --------

async function serveAsset(key: string, env: Env): Promise<Response> {
  const object = await env.ASSETS.get(key);

  if (!object) {
    return new Response('Not Found', { status: 404 });
  }

  // Leggi i metadata HTTP
  const headers = new Headers();
  object.writeHttpMetadata(headers);
  headers.set('etag', object.httpEtag);

  // Aggiungi headers di caching appropriati
  headers.set('Cache-Control', 'public, max-age=31536000, immutable');

  return new Response(object.body, { headers });
}

// -------- LISTING --------

async function listUploads(prefix: string, env: Env) {
  const listed = await env.ASSETS.list({
    prefix: `uploads/${prefix}`,
    limit: 100,
    // cursor: paginationCursor  // per paginare
  });

  return listed.objects.map(obj => ({
    key: obj.key,
    size: obj.size,
    uploaded: obj.uploaded.toISOString(),
    etag: obj.etag,
    customMetadata: obj.customMetadata,
  }));
}

// -------- DELETE --------

await env.ASSETS.delete('uploads/old-file.txt');

// Delete multiplo (fino a 1000 chiavi)
await env.ASSETS.delete(['file1.txt', 'file2.txt', 'file3.txt']);

// -------- HEAD: verifica esistenza senza download --------

const headResult = await env.ASSETS.head('uploads/documento.pdf');
if (headResult) {
  console.log('Size:', headResult.size);
  console.log('Uploaded:', headResult.uploaded);
}

R2 with Public Access and Presigned URL

// Pattern: servire file pubblicamente tramite Worker con access control

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // URL pattern: /files/{key}
    const key = url.pathname.replace('/files/', '');

    if (!key) {
      return new Response('Key required', { status: 400 });
    }

    // Implementa qui la tua logica di autenticazione/autorizzazione
    const authorized = await checkAccess(request, key, env);
    if (!authorized) {
      return new Response('Forbidden', { status: 403 });
    }

    // Gestisci il conditional request (ETag/If-None-Match)
    const etag = request.headers.get('If-None-Match');
    const object = await env.ASSETS.get(key, {
      onlyIf: etag ? { etagDoesNotMatch: etag } : undefined,
      range: request.headers.get('Range') ?? undefined,
    });

    if (!object) {
      // Potrebbe essere 404 o 304 Not Modified
      const head = await env.ASSETS.head(key);
      if (!head) return new Response('Not Found', { status: 404 });
      return new Response(null, { status: 304, headers: { etag: head.httpEtag } });
    }

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('etag', object.httpEtag);

    if (object.range) {
      headers.set('Content-Range', `bytes ${object.range.offset}-${
        (object.range.offset ?? 0) + (object.range.length ?? 0) - 1
      }/${object.size}`);
      return new Response(object.body, { status: 206, headers });
    }

    return new Response(object.body, { headers });
  },
};

D1 SQLite: Relational Database at the Edge

D1 is the latest and most ambitious product: native SQLite at the edge. A complete relational database, with JOINs, transactions, complex queries, everything accessible by Workers without cold start connection, without egress fee, with automatic replication to all PoPs.

D1 uses SQLite internally and replicates it to Cloudflare regional data centers. The writes go to the primary node (possible consistency for the replicas), the reads they can serve as a local replica for minimal latency.

D1 configuration in wrangler.toml

# wrangler.toml

[[d1_databases]]
binding = "DB"
database_name = "mio-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# Per creare il database:
# wrangler d1 create mio-database

Migrations and Schema with D1

# Crea il file di migration
wrangler d1 migrations create mio-database "create users table"
# Crea: migrations/0001_create_users_table.sql

# migrations/0001_create_users_table.sql
-- migrations/0001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  email     TEXT UNIQUE NOT NULL,
  name      TEXT NOT NULL,
  role      TEXT NOT NULL DEFAULT 'user',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at DATETIME NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
# Applica le migration in locale (wrangler dev)
wrangler d1 migrations apply mio-database --local

# Applica le migration in produzione
wrangler d1 migrations apply mio-database

D1 API in TypeScript: Direct Queries

// src/services/user.service.ts

export interface Env {
  DB: D1Database;
}

interface User {
  id: number;
  email: string;
  name: string;
  role: string;
  created_at: string;
}

// -------- SELECT --------

// Query prepared statement (SEMPRE usare prepared statements: previene SQL injection)
async function getUserById(id: number, env: Env): Promise<User | null> {
  const result = await env.DB
    .prepare('SELECT * FROM users WHERE id = ?')
    .bind(id)
    .first<User>();
  return result;
}

// SELECT multipli
async function getUsersByRole(role: string, env: Env): Promise<User[]> {
  const { results } = await env.DB
    .prepare('SELECT id, email, name, role, created_at FROM users WHERE role = ? ORDER BY created_at DESC')
    .bind(role)
    .all<User>();
  return results;
}

// SELECT con pagination
async function getUsers(page: number, pageSize: number, env: Env) {
  const offset = (page - 1) * pageSize;

  const [{ results }, { total }] = await Promise.all([
    env.DB
      .prepare('SELECT * FROM users LIMIT ? OFFSET ?')
      .bind(pageSize, offset)
      .all<User>(),
    env.DB
      .prepare('SELECT COUNT(*) as total FROM users')
      .first<{ total: number }>()
      .then(r => r ?? { total: 0 }),
  ]);

  return {
    users: results,
    pagination: { page, pageSize, total: total.total, totalPages: Math.ceil(total.total / pageSize) },
  };
}

// -------- INSERT --------

async function createUser(
  email: string,
  name: string,
  env: Env,
): Promise<User> {
  const result = await env.DB
    .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(email, name)
    .first<User>();

  if (!result) {
    throw new Error('Failed to create user');
  }

  return result;
}

// -------- UPDATE --------

async function updateUser(id: number, updates: Partial<Pick<User, 'name' | 'role'>>, env: Env) {
  const setClauses: string[] = [];
  const values: (string | number)[] = [];

  if (updates.name !== undefined) {
    setClauses.push('name = ?');
    values.push(updates.name);
  }
  if (updates.role !== undefined) {
    setClauses.push('role = ?');
    values.push(updates.role);
  }

  if (setClauses.length === 0) return;

  setClauses.push('updated_at = CURRENT_TIMESTAMP');
  values.push(id);

  await env.DB
    .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`)
    .bind(...values)
    .run();
}

// -------- TRANSAZIONI (batch) --------

async function createUserWithSession(
  email: string,
  name: string,
  sessionId: string,
  expiresAt: Date,
  env: Env,
): Promise<User> {
  // D1 supporta batch per eseguire piu statement in una singola round-trip
  const [userResult] = await env.DB.batch([
    env.DB
      .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
      .bind(email, name),
    env.DB
      .prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, last_insert_rowid(), ?)')
      .bind(sessionId, expiresAt.toISOString()),
  ]);

  const user = userResult.results[0] as User;
  return user;
}

D1 with Drizzle ORM: Type Safety Complete

Drizzle ORM supports D1 natively and offers an excellent developer experience with automatic type inference from the schema:

// npm install drizzle-orm
// npm install -D drizzle-kit @types/better-sqlite3

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').unique().notNull(),
  name: text('name').notNull(),
  role: text('role', { enum: ['user', 'admin', 'moderator'] }).default('user').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expiresAt: text('expires_at').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

// Tipi TypeScript inferiti dallo schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';

export function createDb(d1: D1Database) {
  return drizzle(d1, { schema });
}

// src/services/user.drizzle.service.ts
import { eq, desc, count } from 'drizzle-orm';
import { createDb } from '../db';
import { users, type User, type NewUser } from '../db/schema';

export async function getUserById(id: number, env: { DB: D1Database }): Promise<User | undefined> {
  const db = createDb(env.DB);
  return db.select().from(users).where(eq(users.id, id)).get();
}

export async function createUser(data: NewUser, env: { DB: D1Database }): Promise<User> {
  const db = createDb(env.DB);
  const [user] = await db.insert(users).values(data).returning();
  return user;
}

export async function getAdminUsers(env: { DB: D1Database }): Promise<User[]> {
  const db = createDb(env.DB);
  return db.select()
    .from(users)
    .where(eq(users.role, 'admin'))
    .orderBy(desc(users.createdAt))
    .all();
}

Comparison: KV vs R2 vs D1

Criterion Workers KV R2 D1 SQLite
Data type Key-value (string/blob) Binary objects (files) Relational (SQL tables)
Consistency Eventually consistent Eventual (strong with Workers) Strong (primary), eventual (replica)
Query capabilities Only get/put/delete by key Only get/put/delete by key Full SQL: JOINs, aggregates, indexes
Maximum size 25MB per value 5TB per item 10GB (beta: 2GB)
Storage cost $0.50/GB/month $0.015/GB/month $0.75/GB/month
Cost of operations $5/M write, $0.50/M read $4.50/M write, $0.36/M read $0.001/M row write, $0.001/M row read
Read latency < 1ms (from PoP cache) ~10-50ms (from storage) ~1-5ms (simple queries)
Ideal use cases Sessions, config, API cache Upload files, assets, backups CRUD app, catalog, users

Advanced Pattern: KV as Cache Layer for D1

An effective pattern combines D1 (authoritative data) with KV (fast cache):

// src/services/cached-user.service.ts

export interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
}

const USER_CACHE_TTL = 300; // 5 minuti

export async function getUserCached(userId: number, env: Env) {
  const cacheKey = `user:v2:${userId}`;

  // 1. Controlla KV cache prima (sub-ms)
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) return cached;

  // 2. Miss: leggi da D1 (query SQL)
  const user = await getUserById(userId, env);
  if (!user) return null;

  // 3. Popola la cache KV in background (non blocca la risposta)
  // (usa ctx.waitUntil() nel fetch handler per non bloccare)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: USER_CACHE_TTL,
  });

  return user;
}

export async function invalidateUserCache(userId: number, env: Env): Promise<void> {
  await env.CACHE.delete(`user:v2:${userId}`);
}

// Quando aggiorni un utente, invalida la cache
export async function updateUserAndInvalidate(
  userId: number,
  updates: Partial<User>,
  env: Env,
): Promise<void> {
  await updateUser(userId, updates, env);
  await invalidateUserCache(userId, env);
}

Conclusions and Next Steps

The choice of storage layer depends on the data type: KV for key-values with high read rate and low write rate, R2 for binary files and assets, D1 for structured data with complex relationships and queries. In many applications real you will use all three in combination.

Next Articles in the Series

  • Article 4: Durable Objects — Strongly Consistent State and WebSocket: when KV isn't enough and you need consistency strong and distributed coordination.
  • Article 5: Workers AI — Inference of LLM and Vision Models Straight to the Edge: How to run AI models in Workers using AI binding.
  • Article 10: Full-Stack Architectures at the Edge — one case Complete study combining Workers + D1 + R2 + CI/CD with GitHub Actions.