Cloudflare Workers and D1 for Edge Computing
Edge Computing Cloudflare Workers let developers build and deploy full-stack applications that run within milliseconds of every user on Earth. Unlike traditional serverless platforms that run in a handful of regions, Workers execute on Cloudflare’s network of 300+ data centers worldwide. Moreover, combined with D1 — Cloudflare’s edge SQL database — you can build complete applications with database access at the edge, and no origin server is required. As a result, the architectural assumptions that once forced teams to centralize their backend in a single region no longer apply.
This guide covers building production applications with Workers and D1, from API development and database schema design to authentication, caching, and migration strategies. Furthermore, you will learn how to combine Workers with other Cloudflare primitives like KV, R2, Queues, and Durable Objects for more complex application architectures.
Why Edge Computing Matters
Traditional applications deploy to one or a few cloud regions. A user in Tokyo hitting a server in us-east-1 experiences roughly 150-200ms of network latency before any application code even runs. Edge computing eliminates this round trip by executing your code in the data center closest to each user. Additionally, Workers have effectively zero cold starts because they run on a V8 isolate model rather than spinning up a container per request — unlike AWS Lambda, which can add 100ms to 1s of initialization time on a cold invocation.
D1 solves the database challenge that historically prevented full-stack edge computing. Previously, edge functions could only reach an external database over the network, which negated the latency benefit entirely. In contrast, D1 runs SQLite at the edge with automatic read replication, giving you fast reads from near the user while writes are routed to the primary copy.
Edge Computing Cloudflare Workers: Building a REST API
// src/index.ts — Main Worker entry point
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt';
import { logger } from 'hono/logger';
type Bindings = {
DB: D1Database;
CACHE: KVNamespace;
JWT_SECRET: string;
ASSETS: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Middleware
app.use('*', logger());
app.use('/api/*', cors({
origin: ['https://myapp.com', 'https://staging.myapp.com'],
credentials: true,
}));
// Public routes
app.get('/api/products', async (c) => {
const { category, page = '1', limit = '20' } = c.req.query();
const offset = (parseInt(page) - 1) * parseInt(limit);
// Check KV cache first
const cacheKey = `products:${category || 'all'}:${page}`;
const cached = await c.env.CACHE.get(cacheKey, 'json');
if (cached) {
return c.json(cached);
}
let query = 'SELECT * FROM products WHERE active = 1';
const params: any[] = [];
if (category) {
query += ' AND category = ?';
params.push(category);
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(parseInt(limit), offset);
const { results } = await c.env.DB.prepare(query)
.bind(...params)
.all();
// Count total for pagination
let countQuery = 'SELECT COUNT(*) as total FROM products WHERE active = 1';
if (category) {
countQuery += ' AND category = ?';
}
const countResult = await c.env.DB.prepare(countQuery)
.bind(...(category ? [category] : []))
.first();
const response = {
products: results,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: countResult?.total || 0,
}
};
// Cache for 5 minutes
await c.env.CACHE.put(cacheKey, JSON.stringify(response), {
expirationTtl: 300,
});
return c.json(response);
});
// Protected routes
app.use('/api/admin/*', jwt({ secret: 'JWT_SECRET' }));
app.post('/api/admin/products', async (c) => {
const body = await c.req.json();
const { name, description, price, category, image_url } = body;
const result = await c.env.DB.prepare(
`INSERT INTO products (name, description, price, category, image_url, active, created_at)
VALUES (?, ?, ?, ?, ?, 1, datetime('now'))
RETURNING *`
).bind(name, description, price, category, image_url).first();
// Invalidate cache
const keys = await c.env.CACHE.list({ prefix: 'products:' });
await Promise.all(keys.keys.map(k => c.env.CACHE.delete(k.name)));
return c.json(result, 201);
});
export default app;
Notice that every query uses bound parameters with ? placeholders rather than string interpolation. This is not optional style — it is your primary defense against SQL injection, and D1’s prepared-statement API enforces the discipline. Additionally, the cache-then-database pattern shown above is the single most impactful optimization on the edge: a KV read resolves locally in single-digit milliseconds, so the request never touches D1 at all on a cache hit.
D1 Database Schema and Migrations
D1 ships a first-class migration system through Wrangler. Rather than mutating the schema by hand, you write numbered SQL files and let wrangler d1 migrations apply track which have run. Consequently, the schema state of every environment — local, preview, and production — stays reproducible and reviewable in version control.
-- migrations/0001_initial_schema.sql
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL CHECK (price >= 0),
category TEXT NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_active ON products(active, created_at);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
total REAL NOT NULL,
items TEXT NOT NULL, -- JSON array
shipping_address TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_orders_user ON orders(user_id, created_at);
CREATE INDEX idx_orders_status ON orders(status);
Because D1 is SQLite under the hood, you inherit SQLite’s type affinity rules and its lack of a native BOOLEAN or TIMESTAMP type. Therefore, the schema above stores flags as INTEGER and timestamps as TEXT in ISO-8601 form. The composite index on (active, created_at) is deliberate: it matches the exact filter-and-sort pattern in the product listing query, which lets the planner satisfy both the WHERE and the ORDER BY from one index scan.
# wrangler.toml — Worker configuration
name = "my-edge-app"
main = "src/index.ts"
compatibility_date = "2026-03-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxxx-xxxx-xxxx-xxxx"
migrations_dir = "migrations"
[[kv_namespaces]]
binding = "CACHE"
id = "xxxxx"
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-app-assets"
[vars]
JWT_SECRET = "your-secret-here"
One word of caution on that final block: [vars] values land in plaintext in your config and are visible in the dashboard. For anything genuinely secret — JWT signing keys, third-party API tokens — the docs recommend wrangler secret put JWT_SECRET instead, which stores the value encrypted and injects it into the same c.env binding at runtime. The plaintext form above is fine for local development only.
Choosing the Right Storage Primitive
A frequent source of confusion is which Cloudflare storage product to reach for, since they overlap superficially. In practice, the decision comes down to access pattern. D1 is for relational data you query with SQL and join across tables — orders, users, inventory. KV is an eventually-consistent key-value store optimized for high-read, low-write caching, such as the paginated product responses above or session lookups. R2 is object storage for large binary blobs — images, PDFs, video — with an S3-compatible API and no egress fees. Durable Objects, finally, are for strongly-consistent coordination state.
As a rule of thumb, if you would put it in Postgres, use D1; if you would put it in Redis as a cache, use KV; if you would put it in an S3 bucket, use R2. Mixing them in one request — reading config from KV, the record from D1, and serving an asset from R2 — is the common and expected pattern rather than an anti-pattern.
Advanced Patterns: Durable Objects for State
When you need coordination or stateful logic at the edge — rate limiting, WebSocket fan-out, or real-time collaboration — Durable Objects provide single-threaded, strongly-consistent state per named object instance. Because each object is guaranteed to run in exactly one location at a time, you get the kind of serialized access that is otherwise hard to achieve in a globally distributed system.
// src/rate-limiter.ts — Edge rate limiting with Durable Objects
export class RateLimiter implements DurableObject {
private requests: Map<string, number[]> = new Map();
constructor(private state: DurableObjectState) {}
async fetch(request: Request): Promise<Response> {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const now = Date.now();
const windowMs = 60000; // 1 minute window
const maxRequests = 100;
// Get existing timestamps for this IP
let timestamps = this.requests.get(ip) || [];
// Remove expired timestamps
timestamps = timestamps.filter(t => now - t < windowMs);
if (timestamps.length >= maxRequests) {
return new Response('Rate limit exceeded', {
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Limit': maxRequests.toString(),
'X-RateLimit-Remaining': '0',
}
});
}
timestamps.push(now);
this.requests.set(ip, timestamps);
return new Response('OK', {
headers: {
'X-RateLimit-Limit': maxRequests.toString(),
'X-RateLimit-Remaining': (maxRequests - timestamps.length).toString(),
}
});
}
}
One subtlety worth flagging: the in-memory Map above survives only as long as the object stays warm in memory. Cloudflare can evict an idle Durable Object, which would reset the counter. For a sliding-window limiter that must survive eviction, you would persist the timestamps through state.storage rather than a plain field. The version shown is a deliberately simple starting point; production teams typically harden it with transactional storage and an alarm to expire stale keys.
Observability and Local Development
Debugging code that runs in 300+ locations sounds daunting, but the tooling is straightforward. During development, wrangler dev --local runs your Worker against a local SQLite file that mirrors D1, so you can iterate without touching the network. Subsequently, wrangler tail streams live logs from production, and Workers integrates with Logpush to ship structured logs to an external sink for retention. For tracing, the cf object on each request exposes the colo, country, and timing data, which makes it easy to confirm that requests really are being served close to users.
When NOT to Use Edge Computing
Edge computing is not suitable for every workload, and pretending otherwise leads to painful rewrites. Workers enforce a CPU-time budget per request, so heavy computation — model inference, video transcoding, large batch analytics — does not belong here and should run on dedicated compute instead. Likewise, D1 has per-database size limits and is tuned for read-heavy patterns; a write-intensive ledger or an analytics warehouse will fight the design rather than benefit from it.
If your audience is concentrated in a single region and latency is not a real constraint, traditional serverless such as AWS Lambda or Cloud Run offers a more mature ecosystem, larger memory ceilings, and a far broader catalog of managed integrations. Furthermore, applications that depend on long-lived TCP connections, raw filesystem access, or native binaries simply cannot run on the isolate model. As a result, evaluate honestly whether global distribution helps your specific users before committing to the edge — the answer is sometimes no, and that is fine.
Key Takeaways
The combination of Workers for compute, D1 for SQL, KV for caching, and R2 for storage provides a complete application platform that runs near every user. Because there are no cold starts and pricing is per request, the model is especially attractive for applications with spiky or unpredictable traffic, where you would otherwise pay for idle capacity. The migration system, bound parameters, and clear separation between storage primitives give you the guardrails to grow a small project into something production-grade without re-architecting.
Start with a simple API Worker and introduce D1 once the Workers programming model feels familiar. For comprehensive documentation, visit the Cloudflare Workers docs and the D1 database documentation. Our guides on Bun 2 runtime and Astro 5 static sites provide complementary web development approaches.
In conclusion, Edge Computing Cloudflare Workers with D1 is an essential capability for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and globally responsive systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.