DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Cloudflare Workers Complete Guide: Edge Computing in 2026

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
Enter fullscreen mode Exit fullscreen mode
// 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 })
  }
}
Enter fullscreen mode Exit fullscreen mode
# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
Enter fullscreen mode Exit fullscreen mode
wrangler deploy
# → my-api.your-subdomain.workers.dev
Enter fullscreen mode Exit fullscreen mode

Hono for Routing

npm install hono @hono/zod-validator zod
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

D1: SQLite at the Edge

wrangler d1 create my-database
Enter fullscreen mode Exit fullscreen mode

Add to wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-id-here"
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
wrangler d1 execute my-database --local --file migrations/0001_initial.sql
wrangler d1 execute my-database --remote --file migrations/0001_initial.sql
Enter fullscreen mode Exit fullscreen mode

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())
])
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

KV: Key-Value Storage

Globally replicated, reads from nearest datacenter:

[[kv_namespaces]]
binding = "KV"
id = "your-namespace-id"
Enter fullscreen mode Exit fullscreen mode
wrangler kv namespace create SESSIONS
Enter fullscreen mode Exit fullscreen mode
// 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 })
Enter fullscreen mode Exit fullscreen mode

R2: Object Storage (Zero Egress)

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-uploads"
Enter fullscreen mode Exit fullscreen mode
// 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 })
})
Enter fullscreen mode Exit fullscreen mode

Queues: Background Jobs

[[queues.producers]]
binding = "QUEUE"
queue = "my-jobs"

[[queues.consumers]]
queue = "my-jobs"
max_batch_size = 10
max_batch_timeout = 5
Enter fullscreen mode Exit fullscreen mode
// 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()
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Secrets

wrangler secret put JWT_SECRET   # prompts for value
wrangler secret put API_KEY
wrangler secret list             # shows names only
Enter fullscreen mode Exit fullscreen mode

Non-secret config in wrangler.toml:

[vars]
APP_URL = "https://yourapp.com"
ENVIRONMENT = "production"
Enter fullscreen mode Exit fullscreen mode

TypeScript Bindings

// src/types.ts
export interface Env {
  DB: D1Database
  KV: KVNamespace
  BUCKET: R2Bucket
  QUEUE: Queue
  JWT_SECRET: string
  APP_URL: string
}
Enter fullscreen mode Exit fullscreen mode

Auto-generate from wrangler.toml:

wrangler types  # → worker-configuration.d.ts
Enter fullscreen mode Exit fullscreen mode

Local Development

wrangler dev           # local with simulated bindings
wrangler dev --remote  # uses real Cloudflare resources
Enter fullscreen mode Exit fullscreen mode

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)