Persistence at the Edge: Workers KV, R2 Object Storage and D1 SQLite
Practical comparison between the three storage layers available in Cloudflare Workers: KV for global key-value data, R2 for object storage without egress fees, and D1 for relational SQL queries at the edge.
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.







