Edge-First Architecture with Cloudflare: D1, R2, KV, and Queues Working Together in Production
A unified architecture guide for building full-stack applications on Cloudflare's edge platform. Covers when to use each primitive, how they compose in a real application, data flow patterns, consistency tradeoffs, and production gotchas with a concrete TypeScript example.
Most guides to Cloudflare Workers cover one primitive at a time. Here is how to use D1, R2, KV, and Queues, here is why KV is fast, here is a Queue consumer. That is useful for orientation, but production applications do not use one primitive. They use all of them, and the hard part is knowing which one owns which piece of your data and how they talk to each other.
This article covers a unified architecture across all four primitives, with concrete data flow patterns, consistency tradeoffs, and the production issues you will hit before you hit them on your own.
Why This Combination Exists
Each primitive has a clear job. Cloudflare did not create four overlapping storage options; they created four non-overlapping ones with a specific contract for each.
D1 is SQLite at the edge. It is for relational data with joins, constraints, and transactions. The catch is that it has one primary region and cross-region replication with eventual consistency on reads. You write to a primary and reads from far-away PoPs may lag by seconds.
R2 is object storage. Blobs, files, images, exports, backups. No egress fees, which matters at scale. Not a database. Put and get by key. No querying, no filtering by content.
KV is a globally distributed key-value store with strong read performance anywhere in the world. It is not for write-heavy workloads: writes propagate in seconds to minutes across the network, and it does not support atomic compare-and-swap. It is ideal for config, feature flags, session tokens, and cache entries that tolerate brief staleness.
Queues decouples work from the request path. A Worker can enqueue a message and return immediately. Another Worker consumes it asynchronously. This handles webhooks, email sending, file processing after upload, and any operation where the user should not wait.
The architecture question is not “which one do I use?” It is “which one owns each piece of data, and where are the handoffs?”
A Concrete Application: Media Upload with Processing
To make this specific, walk through a feature: users upload images, the application processes them (resize, generate thumbnails, extract metadata), stores results, and updates the user’s record. This single feature touches all four primitives.
Here is the data ownership breakdown before writing any code:
| Data | Primitive | Why |
|---|---|---|
| User record, upload metadata | D1 | Relational, needs joins with other tables |
| Raw uploaded file | R2 | Binary blob, large, no query needs |
| Processed thumbnail | R2 | Binary blob |
| Processing job state | Queues | Async, decoupled from upload response |
| User’s upload count quota | KV | Fast read on every upload, tolerable staleness |
| Feature flags (processing enabled) | KV | Global config, rarely changes |
The upload endpoint writes to D1 and R2 and enqueues a job, all within one Worker invocation. The processing runs in a separate Queue consumer. Cache and config come from KV.
The Upload Handler
// src/workers/upload.ts
import { Hono } from 'hono';
type Bindings = {
DB: D1Database;
STORAGE: R2Bucket;
CACHE: KVNamespace;
PROCESSING_QUEUE: Queue;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post('/uploads', async (c) => {
const userId = c.get('userId'); // set by auth middleware
const formData = await c.req.formData();
const file = formData.get('file') as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Check quota from KV — fast global read, tolerate ~60s staleness
const quotaKey = `quota:${userId}`;
const quotaRaw = await c.env.CACHE.get(quotaKey);
const currentCount = quotaRaw ? parseInt(quotaRaw, 10) : 0;
const maxUploads = 100; // would come from a feature flag KV key in practice
if (currentCount >= maxUploads) {
return c.json({ error: 'Upload quota exceeded' }, 429);
}
const uploadId = crypto.randomUUID();
const r2Key = `uploads/${userId}/${uploadId}/${file.name}`;
// Write raw file to R2
await c.env.STORAGE.put(r2Key, file.stream(), {
httpMetadata: {
contentType: file.type,
},
customMetadata: {
userId,
uploadId,
originalName: file.name,
},
});
// Write metadata record to D1
await c.env.DB.prepare(
`INSERT INTO uploads (id, user_id, r2_key, filename, size, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?)`
)
.bind(uploadId, userId, r2Key, file.name, file.size, Date.now())
.run();
// Enqueue processing job — fire and forget from the request's perspective
await c.env.PROCESSING_QUEUE.send({
uploadId,
userId,
r2Key,
contentType: file.type,
});
// Update quota count in KV — eventual consistency is fine here
await c.env.CACHE.put(quotaKey, String(currentCount + 1), {
expirationTtl: 3600,
});
return c.json({ uploadId, status: 'pending' }, 202);
});
export default app;
A few things to notice here. The R2 write happens before the D1 write. If D1 fails, you have an orphaned object in R2 with no metadata record pointing to it. This is a real production scenario that needs a cleanup strategy (more on that below). The Queue message is sent last, after both storage writes succeed, so the consumer can assume the data it needs already exists.
The Queue Consumer
// src/workers/processor.ts
type Env = {
DB: D1Database;
STORAGE: R2Bucket;
CACHE: KVNamespace;
};
type ProcessingMessage = {
uploadId: string;
userId: string;
r2Key: string;
contentType: string;
};
export default {
async queue(
batch: MessageBatch<ProcessingMessage>,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const { uploadId, userId, r2Key } = message.body;
try {
// Fetch the original file from R2
const object = await env.STORAGE.get(r2Key);
if (!object) {
// File missing — acknowledge to avoid infinite retry
console.error(`R2 object not found for upload ${uploadId}`);
message.ack();
continue;
}
const buffer = await object.arrayBuffer();
// Simplified: in practice you'd use an image processing library
// or call an external service. Workers has access to Canvas API.
const thumbnailBuffer = await generateThumbnail(buffer);
const thumbnailKey = `thumbnails/${userId}/${uploadId}/thumb.webp`;
// Write thumbnail back to R2
await env.STORAGE.put(thumbnailKey, thumbnailBuffer, {
httpMetadata: { contentType: 'image/webp' },
customMetadata: { uploadId, sourceKey: r2Key },
});
// Update upload record in D1
await env.DB.prepare(
`UPDATE uploads
SET status = 'processed',
thumbnail_key = ?,
processed_at = ?
WHERE id = ?`
)
.bind(thumbnailKey, Date.now(), uploadId)
.run();
// Invalidate any cached presigned URL or upload listing for this user
await env.CACHE.delete(`upload-list:${userId}`);
message.ack();
} catch (err) {
// Returning without ack causes the message to be retried
console.error(`Processing failed for upload ${uploadId}:`, err);
message.retry();
}
}
},
};
async function generateThumbnail(buffer: ArrayBuffer): Promise<ArrayBuffer> {
// Placeholder — real implementation depends on your processing approach
return buffer;
}
The consumer explicitly acks or retries each message. Missing file in R2 gets acked (not retried) because retrying will not fix a missing file. A processing error gets retried because the file is there and the error might be transient.
Cloudflare Queues delivers at least once. Your consumer must be idempotent, or you need to guard against double-processing. A simple guard is checking whether the upload status is already processed before doing work:
const row = await env.DB.prepare(
`SELECT status FROM uploads WHERE id = ?`
).bind(uploadId).first<{ status: string }>();
if (row?.status === 'processed') {
message.ack();
continue;
}
KV Patterns Worth Being Precise About
KV is genuinely fast for reads (most PoPs serve from in-memory cache), but the write propagation delay makes it unsuitable for several patterns that seem reasonable on the surface.
Do not use KV for counters you need to be accurate. The quota example above works because a user hitting 101 uploads when the limit is 100 is an acceptable edge case. If you are counting something where off-by-one matters (billing, rate limiting with hard cutoffs), use D1 or Durable Objects instead.
Do not use KV as a message broker. It has no subscribe/notify mechanism, no ordering guarantees, and no way to atomically pop a value. That is what Queues is for.
KV excels at read-heavy, write-rare data. Feature flags, JWT signing key rotation (with a short TTL to pick up rotations), per-user preferences that are set once and read on every request, geographic routing rules.
A useful pattern for feature flags:
type FeatureFlags = {
imageProcessingEnabled: boolean;
maxUploadSizeMb: number;
allowedContentTypes: string[];
};
async function getFeatureFlags(kv: KVNamespace): Promise<FeatureFlags> {
const cached = await kv.get<FeatureFlags>('feature-flags', { type: 'json' });
if (cached) return cached;
// Fallback to defaults if KV is empty (cold start, first deploy)
return {
imageProcessingEnabled: true,
maxUploadSizeMb: 10,
allowedContentTypes: ['image/jpeg', 'image/png', 'image/webp'],
};
}
The { type: 'json' } option handles deserialization. Set a reasonable TTL when you write flags so stale values expire automatically.
D1 Consistency Model
D1 uses SQLite under the hood with a single primary region and read replicas. As of early 2026, it operates in “first-class” mode with session consistency: if you write to the primary and immediately read from the same session, you will get your write back. Cross-region reads outside your session may lag.
For most CRUD workloads this is fine. The places where it matters:
- After an update, if you redirect the user to a page that reads from D1, and that read hits a different PoP, they may see stale data. A 200-500ms delay or a cache-busting technique (read from the primary directly for one request after a write) handles this.
- Batch inserts during high throughput hit D1’s connection limits. Use batch prepared statements:
const stmt = env.DB.prepare(
`INSERT INTO events (id, user_id, type, payload, created_at) VALUES (?, ?, ?, ?, ?)`
);
const results = await env.DB.batch(
events.map((e) =>
stmt.bind(e.id, e.userId, e.type, JSON.stringify(e.payload), e.timestamp)
)
);
Batching is not just a performance optimization. D1 charges per query, so batching 50 inserts into one call versus 50 separate calls also affects your bill.
Production Failure Modes
Orphaned R2 objects from partial writes. If R2 succeeds but D1 fails, you have a file with no metadata record. A cleanup job (Cron Trigger + Worker) that scans for R2 objects with no corresponding D1 row and deletes them after a grace period handles this. Use R2 custom metadata (set on upload) to store the upload ID, then query D1 to check if the record exists.
Queue consumer crashes mid-batch. Messages that were not acked will be redelivered. Your consumer needs to handle this without duplicating side effects. The idempotency guard shown above covers the D1 update. For R2 writes, check if the object already exists before writing:
const existing = await env.STORAGE.head(thumbnailKey);
if (!existing) {
await env.STORAGE.put(thumbnailKey, thumbnailBuffer, { ... });
}
head() is cheaper than get() when you only need existence check.
KV cold reads adding latency. On the first request after a KV cache miss, the round-trip to KV adds latency. For data read on every request (feature flags, global config), consider writing it at deploy time with a long TTL so the edge cache is warm. Cloudflare’s cache API can also cache KV responses in the edge cache for sub-millisecond reads on hot paths.
D1 read replica lag on user-visible data. If a user updates their profile and then navigates to a page that shows their profile, they may see old data for a second or two. Either read from the primary for that specific request (Workers allows you to hint the nearest region), or cache the write result in KV with a short TTL so the next read returns fresh data without hitting D1 at all.
Tradeoffs at a Glance
| Concern | D1 | R2 | KV | Queues |
|---|---|---|---|---|
| Read latency | ~10-30ms (replica), ~50-100ms (primary) | ~10-50ms | ~1-5ms (cached) | N/A |
| Write latency | ~50-100ms | ~10-50ms | ~50-200ms | ~10-50ms enqueue |
| Consistency | Session consistent, eventual cross-region | Strongly consistent per object | Eventually consistent (~60s) | At-least-once delivery |
| Querying | Full SQL | Key-only | Key-only | N/A |
| Max size | 10GB per database | No practical limit | 25MB per value, 512MB per account (free) | 128KB per message |
| Transactions | Yes (SQLite) | No | No | No |
How They Compose
The data flow across these primitives follows a clear pattern in most applications:
- Request path: KV for fast config and quota reads. D1 for relational lookups and writes. R2 for file puts.
- Async path: Queue carries the job. Consumer fetches from R2, processes, writes results back to R2, updates D1, invalidates KV cache.
- Serving path: Presigned R2 URLs or R2 public buckets serve files directly. KV caches hot metadata. D1 serves structured queries.
The primitives are not redundant. Each one has a job it does well and jobs it handles poorly. The architecture question is always about matching data shape and access pattern to the right primitive, not about using fewer primitives for simplicity.
One Real Issue You Will Hit
Cloudflare Workers has a CPU time limit per invocation (50ms on the free plan, 30 seconds on paid). Image processing in the request path will hit this. The pattern above (accept the upload, enqueue, return 202) exists partly because of this constraint. The Queue consumer also has a CPU time limit, but it resets per message batch, giving you more headroom for expensive operations.
If your processing cannot complete within the consumer’s CPU budget, the correct architecture is to write the R2 object, enqueue a message, and have the consumer call an external processing endpoint (a VPS, a Lambda, another service) rather than doing the work inside the Worker itself.
Closing
Cloudflare’s edge platform gives you a coherent set of primitives that cover the common needs of a full-stack application: relational data, objects, fast global config, and async work. Each primitive has clear constraints. D1’s consistency model requires you to think about read-after-write scenarios. KV’s write propagation delay rules it out for accuracy-sensitive counters. R2’s key-only access means any querying happens in D1 on metadata, not in the object store itself. Queues’ at-least-once delivery requires idempotent consumers.
The patterns that work are not complicated, but they require you to be deliberate about data ownership before writing code. Know which primitive owns which data, define the handoffs, and handle partial failures explicitly. That is the same discipline that works in any distributed system, applied to a platform where the distributed nature is less visible than usual.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.