Skip to content
· 15 min read · 0 views

Edge Computing with Cloudflare Workers

Build serverless, globally distributed applications with Cloudflare Workers — from basic functions to full-stack edge applications with D1 database, KV storage, and Durable Objects.

// table of contents (13 sections)

Serverless changed how we think about infrastructure. But traditional serverless still has regions, cold starts, and latency. Edge computing brings compute closer to users, executing code in hundreds of locations worldwide with near-zero cold starts.

Cloudflare Workers is my preferred edge platform. This post covers how I build production applications with Workers — from simple functions to full-stack edge apps with databases and real-time features.

Why Edge Computing?

Edge computing offers unique advantages:

  • Global distribution — Code runs in 300+ locations worldwide
  • Minimal latency — Requests hit the nearest edge location
  • Zero cold starts — Workers are always warm, milliseconds to execute
  • Automatic scaling — No capacity planning needed
  • Cost efficiency — Pay only for what you use, generous free tier
# Install Wrangler CLI
npm install -g wrangler

# Login to Cloudflare
wrangler login

# Create new project
npm create cloudflare@latest my-edge-app

Basic Worker

A simple Worker that handles requests:

// src/index.js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    
    // Route based on path
    if (url.pathname === '/') {
      return new Response('Hello from the edge!', {
        headers: { 'content-type': 'text/plain' },
      });
    }
    
    if (url.pathname === '/api/time') {
      return Response.json({
        timestamp: new Date().toISOString(),
        location: request.cf?.city || 'Unknown',
        country: request.cf?.country || 'Unknown',
      });
    }
    
    return new Response('Not Found', { status: 404 });
  },
};

Run locally:

# Development server
wrangler dev

# Deploy to production
wrangler deploy

TypeScript Support

Use TypeScript for type safety:

// src/index.ts
interface Env {
  DB: D1Database;
  KV: KVNamespace;
  API_KEY: string;
}

interface User {
  id: string;
  name: string;
  email: string;
  created_at: string;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const method = request.method;
    
    // CORS headers
    const corsHeaders = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    };
    
    // Handle CORS preflight
    if (method === 'OPTIONS') {
      return new Response(null, { headers: corsHeaders });
    }
    
    // Router
    try {
      if (url.pathname === '/api/users' && method === 'GET') {
        return await listUsers(env);
      }
      
      if (url.pathname === '/api/users' && method === 'POST') {
        return await createUser(request, env);
      }
      
      if (url.pathname.match(/^\/api\/users\/[\w-]+$/) && method === 'GET') {
        const id = url.pathname.split('/').pop()!;
        return await getUser(id, env);
      }
      
      return new Response('Not Found', { status: 404, headers: corsHeaders });
    } catch (error) {
      return Response.json(
        { error: 'Internal Server Error' },
        { status: 500, headers: corsHeaders }
      );
    }
  },
};

async function listUsers(env: Env): Promise<Response> {
  const { results } = await env.DB.prepare(
    'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC LIMIT 50'
  ).all();
  
  return Response.json({ data: results });
}

async function createUser(request: Request, env: Env): Promise<Response> {
  const body = await request.json() as Partial<User>;
  
  if (!body.name || !body.email) {
    return Response.json(
      { error: 'Name and email are required' },
      { status: 400 }
    );
  }
  
  const id = crypto.randomUUID();
  const createdAt = new Date().toISOString();
  
  await env.DB.prepare(
    'INSERT INTO users (id, name, email, created_at) VALUES (?, ?, ?, ?)'
  )
    .bind(id, body.name, body.email, createdAt)
    .run();
  
  return Response.json(
    { id, name: body.name, email: body.email, created_at: createdAt },
    { status: 201 }
  );
}

async function getUser(id: string, env: Env): Promise<Response> {
  const user = await env.DB.prepare(
    'SELECT id, name, email, created_at FROM users WHERE id = ?'
  )
    .bind(id)
    .first<User>();
  
  if (!user) {
    return Response.json({ error: 'User not found' }, { status: 404 });
  }
  
  return Response.json(user);
}

Configure in wrangler.toml:

name = "my-edge-app"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"

[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"

[vars]
API_KEY = "your-api-key"

D1 Database

Cloudflare D1 is a SQLite database at the edge:

# Create database
wrangler d1 create my-database

# Run migrations
wrangler d1 execute my-database --file=./schema.sql

Schema file schema.sql:

-- schema.sql
CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS posts (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  user_id TEXT NOT NULL,
  created_at TEXT NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_user_id ON posts(user_id);

Database operations with transactions:

// src/db.ts
interface Post {
  id: string;
  title: string;
  content: string;
  user_id: string;
  created_at: string;
}

export async function createPostWithUser(
  db: D1Database,
  postData: Omit<Post, 'id' | 'created_at'>,
  userData: Omit<User, 'id' | 'created_at'>
): Promise<{ post: Post; user: User }> {
  const userId = crypto.randomUUID();
  const postId = crypto.randomUUID();
  const now = new Date().toISOString();
  
  // Transaction using batch
  await db.batch([
    db.prepare(
      'INSERT INTO users (id, name, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
    ).bind(userId, userData.name, userData.email, now, now),
    
    db.prepare(
      'INSERT INTO posts (id, title, content, user_id, created_at) VALUES (?, ?, ?, ?, ?)'
    ).bind(postId, postData.title, postData.content, userId, now),
  ]);
  
  return {
    post: { id: postId, created_at: now, ...postData, user_id: userId },
    user: { id: userId, created_at: now, ...userData },
  };
}

KV Storage

For caching and session data:

// src/cache.ts
interface CacheOptions {
  ttl?: number; // Time to live in seconds
}

export async function getCached<T>(
  kv: KVNamespace,
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions = {}
): Promise<T> {
  const cached = await kv.get(key, 'json');
  
  if (cached) {
    return cached as T;
  }
  
  const data = await fetcher();
  
  await kv.put(
    key,
    JSON.stringify(data),
    { expirationTtl: options.ttl || 3600 }
  );
  
  return data;
}

// Usage
const user = await getCached(
  env.KV,
  `user:${userId}`,
  async () => {
    return await env.DB.prepare('SELECT * FROM users WHERE id = ?')
      .bind(userId)
      .first();
  },
  { ttl: 300 }
);

Rate limiting with KV:

// src/rateLimit.ts
interface RateLimitConfig {
  windowMs: number;
  maxRequests: number;
}

export async function checkRateLimit(
  kv: KVNamespace,
  identifier: string,
  config: RateLimitConfig = { windowMs: 60000, maxRequests: 100 }
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowStart = now - config.windowMs;
  
  // Get current count
  const current = await kv.get(key, 'json') as { count: number; windowStart: number } | null;
  
  if (!current || current.windowStart < windowStart) {
    // New window
    await kv.put(key, JSON.stringify({ count: 1, windowStart: now }), {
      expirationTtl: Math.ceil(config.windowMs / 1000),
    });
    
    return {
      allowed: true,
      remaining: config.maxRequests - 1,
      resetTime: now + config.windowMs,
    };
  }
  
  if (current.count >= config.maxRequests) {
    return {
      allowed: false,
      remaining: 0,
      resetTime: current.windowStart + config.windowMs,
    };
  }
  
  // Increment count
  await kv.put(key, JSON.stringify({ count: current.count + 1, windowStart: current.windowStart }));
  
  return {
    allowed: true,
    remaining: config.maxRequests - current.count - 1,
    resetTime: current.windowStart + config.windowMs,
  };
}

// Usage in handler
const rateLimit = await checkRateLimit(env.KV, request.headers.get('CF-Connecting-IP') || 'unknown');
if (!rateLimit.allowed) {
  return Response.json(
    { error: 'Too many requests' },
    { 
      status: 429,
      headers: {
        'X-RateLimit-Remaining': rateLimit.remaining.toString(),
        'X-RateLimit-Reset': rateLimit.resetTime.toString(),
      }
    }
  );
}

Durable Objects

For stateful, real-time features:

// src/DurableObject.ts
import { DurableObject } from 'cloudflare:workers';

interface Env {
  DB: D1Database;
}

export class Counter extends DurableObject {
  private value: number = 0;
  
  constructor(state: DurableObjectState, env: Env) {
    super(state, env);
    
    // Load persisted state
    state.blockConcurrencyWhile(async () => {
      const stored = await state.storage.get<number>('value');
      this.value = stored || 0;
    });
  }
  
  async increment(amount: number = 1): Promise<number> {
    this.value += amount;
    await this.ctx.storage.put('value', this.value);
    return this.value;
  }
  
  async getValue(): Promise<number> {
    return this.value;
  }
  
  async reset(): Promise<void> {
    this.value = 0;
    await this.ctx.storage.put('value', 0);
  }
  
  // WebSocket support
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    
    if (url.pathname === '/ws' && request.headers.get('Upgrade') === 'websocket') {
      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);
      
      this.ctx.acceptWebSocket(server);
      
      return new Response(null, { status: 101, webSocket: client });
    }
    
    return new Response('Not found', { status: 404 });
  }
  
  async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
    const data = JSON.parse(message);
    
    if (data.action === 'increment') {
      const newValue = await this.increment(data.amount || 1);
      ws.send(JSON.stringify({ type: 'update', value: newValue }));
    }
  }
}

Use Durable Object from Worker:

// src/index.ts (updated)
import { Counter } from './DurableObject';

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    
    // Get Durable Object for a specific ID (e.g., per user, per room)
    const id = env.COUNTER.idFromName('global-counter');
    const counter = env.COUNTER.get(id);
    
    if (url.pathname === '/api/counter' && request.method === 'GET') {
      const value = await counter.getValue();
      return Response.json({ value });
    }
    
    if (url.pathname === '/api/counter/increment' && request.method === 'POST') {
      const body = await request.json() as { amount?: number };
      const value = await counter.increment(body.amount || 1);
      return Response.json({ value });
    }
    
    return new Response('Not found', { status: 404 });
  },
};

Update wrangler.toml:

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

[[migrations]]
tag = "v1"
new_classes = ["Counter"]

Authentication

JWT-based auth on the edge:

// src/auth.ts
import jose from 'jose';

interface UserPayload {
  id: string;
  email: string;
  role: string;
}

export async function signJWT(
  payload: UserPayload,
  secret: string,
  expiresIn: string = '24h'
): Promise<string> {
  const secretKey = new TextEncoder().encode(secret);
  
  return await new jose.SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(secretKey);
}

export async function verifyJWT(
  token: string,
  secret: string
): Promise<UserPayload | null> {
  try {
    const secretKey = new TextEncoder().encode(secret);
    const { payload } = await jose.jwtVerify(token, secretKey);
    return payload as UserPayload;
  } catch {
    return null;
  }
}

// Auth middleware
export async function requireAuth(
  request: Request,
  secret: string
): Promise<UserPayload | Response> {
  const authHeader = request.headers.get('Authorization');
  
  if (!authHeader?.startsWith('Bearer ')) {
    return Response.json(
      { error: 'Missing authorization header' },
      { status: 401 }
    );
  }
  
  const token = authHeader.slice(7);
  const user = await verifyJWT(token, secret);
  
  if (!user) {
    return Response.json(
      { error: 'Invalid or expired token' },
      { status: 401 }
    );
  }
  
  return user;
}

Full Example: Todo API

Complete edge API with all features:

// src/index.ts
import { signJWT, verifyJWT, requireAuth } from './auth';
import { getCached } from './cache';
import { checkRateLimit } from './rateLimit';

interface Env {
  DB: D1Database;
  KV: KVNamespace;
  JWT_SECRET: string;
}

interface Todo {
  id: string;
  title: string;
  completed: boolean;
  user_id: string;
  created_at: string;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const method = request.method;
    
    const corsHeaders = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    };
    
    if (method === 'OPTIONS') {
      return new Response(null, { headers: corsHeaders });
    }
    
    // Rate limiting
    const rateLimit = await checkRateLimit(env.KV, request.headers.get('CF-Connecting-IP') || 'unknown');
    if (!rateLimit.allowed) {
      return Response.json({ error: 'Too many requests' }, { status: 429, headers: corsHeaders });
    }
    
    try {
      // Auth routes
      if (url.pathname === '/api/auth/register' && method === 'POST') {
        return await register(request, env, corsHeaders);
      }
      
      if (url.pathname === '/api/auth/login' && method === 'POST') {
        return await login(request, env, corsHeaders);
      }
      
      // Protected routes
      const authResult = await requireAuth(request, env.JWT_SECRET);
      if (authResult instanceof Response) {
        return authResult;
      }
      const user = authResult;
      
      if (url.pathname === '/api/todos' && method === 'GET') {
        return await listTodos(user.id, env, corsHeaders);
      }
      
      if (url.pathname === '/api/todos' && method === 'POST') {
        return await createTodo(request, user.id, env, corsHeaders);
      }
      
      if (url.pathname.match(/^\/api\/todos\/[\w-]+$/)) {
        const id = url.pathname.split('/').pop()!;
        
        if (method === 'PUT') {
          return await updateTodo(request, id, user.id, env, corsHeaders);
        }
        
        if (method === 'DELETE') {
          return await deleteTodo(id, user.id, env, corsHeaders);
        }
      }
      
      return new Response('Not Found', { status: 404, headers: corsHeaders });
    } catch (error) {
      console.error(error);
      return Response.json(
        { error: 'Internal Server Error' },
        { status: 500, headers: corsHeaders }
      );
    }
  },
};

async function register(request: Request, env: Env, headers: HeadersInit): Promise<Response> {
  const body = await request.json() as { name: string; email: string; password: string };
  
  if (!body.email || !body.password) {
    return Response.json({ error: 'Email and password required' }, { status: 400, headers });
  }
  
  const id = crypto.randomUUID();
  const passwordHash = await hashPassword(body.password);
  
  try {
    await env.DB.prepare(
      'INSERT INTO users (id, name, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)'
    )
      .bind(id, body.name, body.email, passwordHash, new Date().toISOString())
      .run();
    
    const token = await signJWT({ id, email: body.email, role: 'user' }, env.JWT_SECRET);
    
    return Response.json({ token, user: { id, name: body.name, email: body.email } }, { status: 201, headers });
  } catch (error) {
    return Response.json({ error: 'Email already exists' }, { status: 400, headers });
  }
}

async function login(request: Request, env: Env, headers: HeadersInit): Promise<Response> {
  const body = await request.json() as { email: string; password: string };
  
  const user = await env.DB.prepare(
    'SELECT id, name, email, password_hash FROM users WHERE email = ?'
  )
    .bind(body.email)
    .first<{ id: string; name: string; email: string; password_hash: string }>();
  
  if (!user || !(await verifyPassword(body.password, user.password_hash))) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401, headers });
  }
  
  const token = await signJWT({ id: user.id, email: user.email, role: 'user' }, env.JWT_SECRET);
  
  return Response.json({ token, user: { id: user.id, name: user.name, email: user.email } }, { headers });
}

async function listTodos(userId: string, env: Env, headers: HeadersInit): Promise<Response> {
  const todos = await getCached(
    env.KV,
    `todos:${userId}`,
    async () => {
      const { results } = await env.DB.prepare(
        'SELECT id, title, completed, created_at FROM todos WHERE user_id = ? ORDER BY created_at DESC'
      )
        .bind(userId)
        .all();
      return results;
    },
    { ttl: 60 }
  );
  
  return Response.json({ data: todos }, { headers });
}

async function createTodo(request: Request, userId: string, env: Env, headers: HeadersInit): Promise<Response> {
  const body = await request.json() as { title: string };
  
  const id = crypto.randomUUID();
  const createdAt = new Date().toISOString();
  
  await env.DB.prepare(
    'INSERT INTO todos (id, title, completed, user_id, created_at) VALUES (?, ?, ?, ?, ?)'
  )
    .bind(id, body.title, false, userId, createdAt)
    .run();
  
  // Invalidate cache
  await env.KV.delete(`todos:${userId}`);
  
  return Response.json(
    { id, title: body.title, completed: false, user_id: userId, created_at: createdAt },
    { status: 201, headers }
  );
}

async function updateTodo(request: Request, id: string, userId: string, env: Env, headers: HeadersInit): Promise<Response> {
  const body = await request.json() as Partial<Todo>;
  
  await env.DB.prepare(
    'UPDATE todos SET title = COALESCE(?, title), completed = COALESCE(?, completed) WHERE id = ? AND user_id = ?'
  )
    .bind(body.title ?? null, body.completed ?? null, id, userId)
    .run();
  
  // Invalidate cache
  await env.KV.delete(`todos:${userId}`);
  
  return Response.json({ success: true }, { headers });
}

async function deleteTodo(id: string, userId: string, env: Env, headers: HeadersInit): Promise<Response> {
  await env.DB.prepare('DELETE FROM todos WHERE id = ? AND user_id = ?')
    .bind(id, userId)
    .run();
  
  // Invalidate cache
  await env.KV.delete(`todos:${userId}`);
  
  return Response.json({ success: true }, { headers });
}

// Password utilities (use bcrypt or argon2 in production)
async function hashPassword(password: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(password);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(hash)));
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return await hashPassword(password) === hash;
}

Performance Optimization

Edge Caching

// Cache responses at the edge
async function fetchWithCache(request: Request): Promise<Response> {
  const cache = caches.default;
  
  // Check cache first
  const cachedResponse = await cache.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }
  
  // Fetch and cache
  const response = await fetch(request);
  
  // Cache for 1 hour
  const headers = new Headers(response.headers);
  headers.set('Cache-Control', 'public, max-age=3600');
  
  const cachedResponse = new Response(response.body, {
    status: response.status,
    headers,
  });
  
  ctx.waitUntil(cache.put(request, cachedResponse.clone()));
  
  return cachedResponse;
}

Streaming Responses

// Stream large responses
async function streamData(env: Env): Promise<Response> {
  const { results } = await env.DB.prepare('SELECT * FROM large_table').all();
  
  const stream = new ReadableStream({
    async start(controller) {
      for (const row of results) {
        controller.enqueue(JSON.stringify(row) + '\n');
      }
      controller.close();
    },
  });
  
  return new Response(stream, {
    headers: { 'Content-Type': 'application/x-ndjson' },
  });
}

Deployment & CI/CD

# .github/workflows/deploy.yml
name: Deploy Worker

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm test
      
      - name: Deploy to Cloudflare Workers
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Key Takeaways

  1. Embrace the edge — Run code closer to users for minimal latency
  2. Use D1 for relational data — SQLite at the edge with ACID guarantees
  3. Cache aggressively — KV for session data, caching, and rate limiting
  4. Stateful features with Durable Objects — WebSockets, counters, coordination
  5. TypeScript for safety — Type-safe edge functions catch errors early
  6. Zero cold starts — Workers are always ready, perfect for latency-sensitive apps
  7. Global by default — Code runs in hundreds of locations automatically
  8. Cost-effective — Generous free tier, pay only for execution time

Edge computing is not just about performance — it is about bringing compute to where the users are. With Cloudflare Workers, you get the benefits of serverless without the traditional trade-offs.

I have built several edge applications following these patterns, similar to projects like my CSSKit tool and engineering ladders picker. The developer experience and performance are exceptional.

Edge computing is the future of serverless — not just code that runs, but code that runs everywhere.

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion