Cloudflare Workers run TypeScript at the edge in 300+ data centers, with ~0ms cold starts. Combined with D1 (SQLite), KV (key-value), R2 (object storage), and Queues (background jobs), it's a complete backend alternative without any infrastructure management.
Setup
npm install -g wrangler
wrangler login
npm create cloudflare@latest my-api
// src/index.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/health') {
return Response.json({ status: 'ok', region: request.cf?.colo })
}
return new Response('Not Found', { status: 404 })
}
}
# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
wrangler deploy
# → my-api.your-subdomain.workers.dev
Hono for Routing
npm install hono @hono/zod-validator zod
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
type Bindings = {
DB: D1Database
KV: KVNamespace
BUCKET: R2Bucket
QUEUE: Queue
}
const app = new Hono<{ Bindings: Bindings }>()
app.use('*', logger())
app.use('*', cors({ origin: ['https://yourapp.com'] }))
app.get('/posts', async (c) => {
const { results } = await c.env.DB.prepare(
'SELECT id, title, slug, created_at FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT 20'
).all()
return c.json(results)
})
app.get('/posts/:slug', async (c) => {
const post = await c.env.DB.prepare(
'SELECT * FROM posts WHERE slug = ?'
).bind(c.req.param('slug')).first()
return post ? c.json(post) : c.json({ error: 'Not found' }, 404)
})
export default app
D1: SQLite at the Edge
wrangler d1 create my-database
Add to wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-id-here"
Schema:
-- migrations/0001_initial.sql
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
wrangler d1 execute my-database --local --file migrations/0001_initial.sql
wrangler d1 execute my-database --remote --file migrations/0001_initial.sql
D1 queries — always use prepared statements:
// Single row
const post = await env.DB.prepare(
'SELECT * FROM posts WHERE slug = ?'
).bind(slug).first<Post>()
// Multiple rows
const { results } = await env.DB.prepare(
'SELECT id, title FROM posts WHERE published = 1 LIMIT ?'
).bind(20).all<PostSummary>()
// Insert
const result = await env.DB.prepare(
'INSERT INTO posts (title, slug, content, published, created_at) VALUES (?, ?, ?, ?, ?)'
).bind(title, slug, content, 1, new Date().toISOString()).run()
// Batch (atomic)
await env.DB.batch([
env.DB.prepare('UPDATE users SET last_login = ? WHERE id = ?').bind(new Date().toISOString(), userId),
env.DB.prepare('INSERT INTO login_events (user_id, ts) VALUES (?, ?)').bind(userId, new Date().toISOString())
])
Drizzle ORM with D1
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
published: integer('published', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at').notNull()
})
// Usage
import { drizzle } from 'drizzle-orm/d1'
import { eq, desc } from 'drizzle-orm'
app.get('/posts', async (c) => {
const db = drizzle(c.env.DB, { schema })
const results = await db.select()
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20)
return c.json(results)
})
KV: Key-Value Storage
Globally replicated, reads from nearest datacenter:
[[kv_namespaces]]
binding = "KV"
id = "your-namespace-id"
wrangler kv namespace create SESSIONS
// Store with TTL
await env.KV.put(
`session:${token}`,
JSON.stringify({ userId, email }),
{ expirationTtl: 60 * 60 * 24 * 30 } // 30 days
)
// Read
const raw = await env.KV.get(`session:${token}`)
const session = raw ? JSON.parse(raw) : null
// Delete
await env.KV.delete(`session:${token}`)
// Rate limiting
const key = `rate:${ip}:${Math.floor(Date.now() / 60000)}`
const count = Number(await env.KV.get(key) ?? '0')
if (count >= 100) return c.json({ error: 'Rate limit exceeded' }, 429)
await env.KV.put(key, String(count + 1), { expirationTtl: 120 })
R2: Object Storage (Zero Egress)
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-uploads"
// Upload
app.post('/upload', async (c) => {
const formData = await c.req.formData()
const file = formData.get('file') as File
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) return c.json({ error: 'Type not allowed' }, 400)
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'Too large' }, 400)
const key = `uploads/${crypto.randomUUID()}-${file.name}`
await c.env.BUCKET.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type }
})
return c.json({ key, url: `https://cdn.yourapp.com/${key}` }, 201)
})
// Serve
app.get('/files/:key{.+}', async (c) => {
const object = await c.env.BUCKET.get(c.req.param('key'))
if (!object) return c.json({ error: 'Not found' }, 404)
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('Cache-Control', 'public, max-age=31536000')
return new Response(object.body, { headers })
})
Queues: Background Jobs
[[queues.producers]]
binding = "QUEUE"
queue = "my-jobs"
[[queues.consumers]]
queue = "my-jobs"
max_batch_size = 10
max_batch_timeout = 5
// Enqueue from API (non-blocking)
app.post('/users', async (c) => {
// ... create user in D1
await c.env.QUEUE.send({ type: 'welcome_email', email, name })
return c.json({ success: true }, 201)
})
// Consumer Worker
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const message of batch.messages) {
try {
switch (message.body.type) {
case 'welcome_email':
await sendWelcomeEmail(message.body.email, env)
break
}
message.ack()
} catch {
message.retry()
}
}
}
}
Secrets
wrangler secret put JWT_SECRET # prompts for value
wrangler secret put API_KEY
wrangler secret list # shows names only
Non-secret config in wrangler.toml:
[vars]
APP_URL = "https://yourapp.com"
ENVIRONMENT = "production"
TypeScript Bindings
// src/types.ts
export interface Env {
DB: D1Database
KV: KVNamespace
BUCKET: R2Bucket
QUEUE: Queue
JWT_SECRET: string
APP_URL: string
}
Auto-generate from wrangler.toml:
wrangler types # → worker-configuration.d.ts
Local Development
wrangler dev # local with simulated bindings
wrangler dev --remote # uses real Cloudflare resources
Workers vs Alternatives
| Feature | Cloudflare Workers | AWS Lambda | Vercel Functions |
|---|---|---|---|
| Cold start | ~0ms | 100ms–5s | 50ms–2s |
| Global PoPs | 300+ | Regional | ~30 |
| Runtime | V8 (not Node.js) | Node.js | Node.js |
| Built-in DB | D1 (SQLite) | External | External |
| Free tier | 100k req/day | 1M req/month | Limited |
The tradeoff: ~0ms cold starts and 300+ locations everywhere, but non-Node.js runtime means some packages need nodejs_compat or a Workers-compatible alternative.
The full Cloudflare stack covers the same surface area as a traditional backend — without servers to manage, with automatic global distribution, and a generous free tier.
Full article at stacknotice.com/blog/cloudflare-workers-complete-guide-2026
Top comments (0)