Edge Computing & Serverless Guide | Cloudflare Workers
이 글의 핵심
Edge functions run your code in 300+ locations worldwide ??5-10ms response times vs 100-300ms from a central server. This guide covers Cloudflare Workers, Vercel Edge Functions, AWS Lambda, and when each deployment model wins.
What edge computing is (and is not)
Edge computing here means running request-handling code at Points of Presence (PoPs) close to the user, not “edge” as in IoT gateways. A typical request path: DNS ??nearest PoP ??your Worker/Edge function ??optional edge storage (KV, R2) ??response. The origin (regional server or serverless) is only needed when the answer cannot be computed or cached at the edge.
Architecture sketch: clients hit a global anycast network; the platform routes to a nearby isolate (V8) or a small managed runtime. Stateful coordination may hop to a Durable Object (single leader per key) or back to a regional API. The mental model is fan-out reads, funnel writes: reads and feature flags are edge-friendly; heavy transactional writes often still want a real database in one region (or a purpose-built edge SQL like D1 with known consistency tradeoffs).
Honest tradeoff: you trade Node ecosystem compatibility and long CPU work for latency, geographic distribution, and per-request pricing. If your workload is 30s of video transcoding, the edge is the wrong place.
Cloudflare Workers vs AWS Lambda@Edge vs Deno Deploy
| Dimension | Cloudflare Workers | Lambda@Edge | Deno Deploy |
|---|---|---|---|
| Runtime | V8 isolates (not full Node) | Node.js 20 / Node 22 (viewer & origin triggers differ) | Deno (Web APIs) |
| Cold start (typical) | Effectively none for HTTP Workers | Tens of ms to >1s depending on size & path | Low; similar isolate model |
| Max CPU time | CPU limits per request; 30s wall on Workers paid plans (configurable) | Short for viewer request; 5s viewer / 30s origin (see current AWS docs) | Configurable; plan-based |
| Ecosystem | fetch, Web Crypto, R2/SQLite/D1/KV | Full Node for Node runtimes; packaging into CloudFront is fiddly | npm compat via Deno; import maps |
| Data plane | KV, D1, R2, Durable Objects, Queues | CloudFront + S3, Dynamo, regional Lambda | Deno KV, limited patterns vs CF |
| DevEx | Wrangler, great dashboard | CloudFormation/Lambda + CloudFront = heavier | deployctl, simple for small services |
When to pick which: use Cloudflare Workers if you want the strongest edge data story (R2, D1, Durable Objects) and predictable isolate behavior. Use Lambda@Edge if you are already all-in on AWS and need to intercept CloudFront request/response at the edge with Node. Use Deno Deploy for Web-standard TypeScript with a small surface area and quick deploys; verify pricing and data locality for your case.
Caveat: Lambda@Edge has two flavors (CloudFront viewer vs origin); limits and runtimes differ. Always read the current AWS table before designing.
Cold start: what actually helps
Edge (Workers / Deno Deploy): cold starts are usually irrelevant for HTTP because isolates start quickly and stay warm. What hurts you instead: large bundles, synchronous work before first byte, and unbounded await chains.
Lambda (regional and @Edge): cold start = init + import + optional ENI. Mitigations that actually work in production:
// 1) Init expensive clients once per execution environment, not per invoke
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function handler(event: { key: string }) {
// Reuses s3 on warm invocations
return s3.send(new GetObjectCommand({ Bucket: 'app-data', Key: event.key }));
}
// 2) Lazy-import heavy code only in cold paths
export async function handler(event: { mode: 'light' | 'heavy' }) {
if (event.mode === 'heavy') {
const { transform } = await import('./heavy.js');
return transform(event);
}
return { ok: true };
}
- ARM + smaller bundles often improve init time vs x86 and fat
node_modules. - Provisioned concurrency (regional Lambda): pays to keep functions warm; use when p99 latency SLOs are strict.
- Avoid
require()of huge JSON or loading ML models in the init path unless cached.
Edge corollary: do not block the response on N sequential KV reads; batch or pipeline where the API allows.
Edge storage patterns: KV, D1, R2
KV ??eventually consistent, low-latency reads, good for feature flags, redirect maps, A/B config. Writes propagate globally; do not use as a primary transactional store.
D1 ??SQLite at the edge; good for relational edge reads/writes with explicit expectations (single-region strong semantics vs distributed caveats). Ideal for: per-tenant config, read-heavy small tables, edge-local aggregations when your consistency model allows.
R2 ??S3-like object storage without egress to Workers in the same account; use for assets, large JSON dumps, user uploads after auth, with Workers as the control plane.
// R2: signed upload pattern (simplified; add real auth and limits)
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === 'PUT') {
const url = new URL(request.url);
const key = url.pathname.slice(1);
await env.MY_BUCKET.put(key, request.body, {
httpMetadata: { contentType: request.headers.get('content-type') ?? 'application/octet-stream' },
});
return Response.json({ key });
}
if (request.method === 'GET') {
const key = new URL(request.url).pathname.slice(1);
const object = await env.MY_BUCKET.get(key);
if (!object) return new Response('Not found', { status: 404 });
return new Response(object.body, { headers: { 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream' } });
}
return new Response('Method not allowed', { status: 405 });
},
};
interface Env {
MY_BUCKET: R2Bucket;
}
// D1: parameterized query (always use bindings ??never string-concat user input)
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { results } = await env.DB.prepare('SELECT slug, title FROM posts WHERE status = ?').bind('published').all();
return Response.json(results);
},
};
interface Env {
DB: D1Database;
}
Pattern: KV for hot read-mostly config; D1 for structured data that fits SQLite; R2 for blobs and static exports. Replicate to regional Postgres only when you need full SQL, cross-region transactions, or heavy analytics.
Practical use cases: A/B testing, personalization, geolocation
Geolocation ??use request.cf (Cloudflare) or framework request.geo (Vercel) to rewrite, not to store PII at the edge without compliance review.
// Cloudflare: route EU visitors to a cookie-consent path (illustrative)
export default {
async fetch(request: Request): Promise<Response> {
const country = request.cf?.country ?? 'XX';
const url = new URL(request.url);
if (country === 'DE' && !url.pathname.startsWith('/de')) {
return Response.redirect('https://example.com/de' + url.pathname, 302);
}
return fetch(request); // or origin fetch
},
};
Personalization ??cache stable fragments at the edge, personalize headers or small JSON payloads; keep user-specific secrets off the edge cache.
// Edge: choose variant from KV, set header for the origin or CDN to vary on
const variant = await env.FLAGS.get(`user:${userId}`) ?? 'control';
const res = await fetch(request);
const next = new Response(res.body, res);
next.headers.set('X-Exp-Variant', variant);
return next;
A/B testing ??assign a sticky bucket in a signed cookie or HMAC query param; edge assigns bucket if missing. Below is a minimal cookie-based split:
// middleware / Worker: 50/50 split with sticky bucket
const cookie = request.headers.get('cookie') ?? '';
const match = /ab=([AB])/.exec(cookie);
const bucket = match?.[1] ?? (Math.random() < 0.5 ? 'A' : 'B');
const res = new Response('OK', { headers: { 'Set-Cookie': `ab=${bucket}; Path=/; Max-Age=2592000; Secure; HttpOnly; SameSite=Lax` } });
Pair this with server-side metrics (events to your analytics pipeline). Edge-only experiments can bias toward fast users unless you log assignment server-side.
Performance, benchmarks, and real limits
What numbers often look like (order of magnitude, not a guarantee): edge HTTP TTFB from the same metro can be single-digit to low tens of ms for tiny handlers; regional Lambda p50 may still be good but p99 spikes with cold starts. Do not trust blog benchmarks without your payload size, region mix, and auth stack.
Limits that bite in production:
- CPU time caps on edge: long JSON transforms or image work may need to move to a queue + worker or regional job.
- Subrequest limits: chaining many internal HTTP calls from one Worker can add latency; collapse calls or use storage bindings.
- D1 / KV consistency: design for eventual reads and explicit staleness; never assume read-your-write across the globe in KV.
- Lambda@Edge packaging and CloudFront association complexity often outweighs raw ms savings for small teams.
Security at the edge
- TLS terminates at the edge ??you still must validate application auth. Use HMAC or signed cookies for state you round-trip; never trust unverified headers like
X-User-Idfrom clients. - Secrets: store API keys in Wrangler secrets / AWS Secrets Manager, not in source. In Workers,
env.SECRETfrom bindings, not from client-visible vars. - SSRF and open redirects: if your Worker fetches user-supplied URLs, block private IP ranges; validate redirect targets.
- Rate limits: Durable Objects or centralized Redis remain options for global abuse protection; per-IP limits at the edge are good first defense.
- Supply chain: pin dependencies; edge bundles are world-readable once deployed in behavior (not the code) ??your leak risk is in logs and error messages. Strip sensitive data from
ctx.waitUntillogging.
// Constant-time token compare (Web Crypto) ??do not use === for secrets
async function safeEqual(a: string, b: string): Promise<boolean> {
const enc = new TextEncoder();
const bufA = enc.encode(a);
const bufB = enc.encode(b);
if (bufA.length !== bufB.length) return false;
return crypto.subtle.timingSafeEqual(bufA, bufB);
}
Cost: how to think before you get the bill
- Cloudflare Workers ??priced per request and CPU time (plan-dependent). Cheap at huge scale of tiny requests; watch R2 operation classes and D1 row reads.
- Lambda@Edge ??per-request and duration; plus CloudFront. Easy to underestimate if you add origin Lambdas and data transfer.
- Deno Deploy ??review current pricing for requests and regions; good for small projects, compare at your QPS.
Rule of thumb: if most traffic is cacheable at CDN or KV, edge wins on cost+latency. If every request must hit a regional PostgreSQL, you may pay edge + origin + DB ??sometimes a regional API behind a cache is cheaper.
A honest production story (anecdotal)
We shipped a geo-aware redirect + feature flag service on Cloudflare Workers with KV for flag payloads and Durable Objects for per-tenant rate limits. The good: p95 redirect latency dropped versus a small VPS in a single region; deploys with Wrangler were boring in a good way. The bad: an early version stored too much business logic in the Worker; debugging required structured logs to a central sink, and a mis-tuned Durable Object key caused hot keys (one object thrashing). We fixed it by sharding the DO namespace and moving policy evaluation to read-mostly KV with five-minute staleness, which we could tolerate.
Lesson: edge is not magic ??it is distributed systems with a friendly DX. Design for failure, cap CPU, and measure p99 from real countries, not from your office VPN.
Edge vs Serverless vs Traditional
Traditional Server (e.g., EC2, VPS):
Location: 1-3 regions
Latency: 100-300ms from far users
Scaling: manual or auto-scaling groups
Cold start: 0 (always running)
Cost: pay for uptime
Serverless (Lambda, Cloud Functions):
Location: 1-3 regions
Latency: 50-300ms (+ cold start)
Scaling: automatic, per-request
Cold start: 100ms-3s
Cost: pay per invocation
Edge (Workers, Edge Functions):
Location: 100-300+ PoPs globally
Latency: 5-50ms (closest PoP)
Scaling: automatic
Cold start: ~0ms (V8 isolates)
Cost: pay per request
Constraint: no Node.js APIs, limited CPU
Cloudflare Workers
Workers run JavaScript/TypeScript at Cloudflare’s 300+ global edge locations.
# Install Wrangler CLI
npm install -D wrangler
# Create a new Worker
npx wrangler init my-worker
cd my-worker && npm install
Basic Worker
// src/index.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Route handling
if (url.pathname === '/') {
return new Response('Hello from the edge!', {
headers: { 'Content-Type': 'text/plain' },
});
}
if (url.pathname === '/api/time') {
return Response.json({
time: new Date().toISOString(),
region: request.cf?.colo, // Cloudflare data center code
country: request.cf?.country, // User's country
});
}
return new Response('Not Found', { status: 404 });
},
};
interface Env {
// KV namespace bindings from wrangler.toml
MY_KV: KVNamespace;
// Secret environment variables
API_KEY: string;
}
KV Storage (Edge Key-Value Store)
// KV: eventually consistent, read-optimized, global
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.searchParams.get('key');
if (request.method === 'GET' && key) {
// Read from KV (served from nearest PoP)
const value = await env.MY_KV.get(key);
if (!value) return new Response('Not found', { status: 404 });
return new Response(value);
}
if (request.method === 'PUT' && key) {
const body = await request.text();
// Write to KV (propagates globally within ~60s)
await env.MY_KV.put(key, body, {
expirationTtl: 3600, // Optional TTL in seconds
});
return new Response('OK');
}
return new Response('Method not allowed', { status: 405 });
},
};
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "MY_KV"
id = "your-kv-namespace-id"
[vars]
ENVIRONMENT = "production"
Durable Objects (Strongly Consistent State)
// Durable Object: single-instance, strongly consistent (not KV)
// Use for: real-time collaboration, rate limiting, WebSocket rooms
export class RateLimiter {
private state: DurableObjectState;
private requests: number = 0;
private windowStart: number = Date.now();
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
// Reset window every minute
if (now - this.windowStart > 60_000) {
this.requests = 0;
this.windowStart = now;
}
this.requests++;
if (this.requests > 100) {
return new Response('Rate limit exceeded', { status: 429 });
}
return Response.json({ requests: this.requests, limit: 100 });
}
}
// Worker uses the Durable Object
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const id = env.RATE_LIMITER.idFromName(ip);
const limiter = env.RATE_LIMITER.get(id);
return limiter.fetch(request);
},
};
Vercel Edge Functions
// app/api/geo/route.ts ??Next.js Edge Route Handler
import { NextRequest } from 'next/server';
export const runtime = 'edge'; // Run at the edge
export async function GET(request: NextRequest) {
// Vercel populates geo data from request headers
const country = request.geo?.country ?? 'US';
const city = request.geo?.city ?? 'Unknown';
return Response.json({ country, city, timestamp: Date.now() });
}
Edge Middleware (Next.js)
// middleware.ts ??runs at the edge before every request
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl;
// Auth check at the edge (fast ??no origin roundtrip)
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Geo-based routing
const country = request.geo?.country;
if (pathname === '/' && country === 'JP') {
return NextResponse.rewrite(new URL('/ja', request.url));
}
// A/B testing (edge flag)
const bucket = request.cookies.get('ab-bucket')?.value ?? (Math.random() > 0.5 ? 'A' : 'B');
const response = NextResponse.next();
response.cookies.set('ab-bucket', bucket);
response.headers.set('X-AB-Bucket', bucket);
return response;
}
export const config = {
matcher: ['/((?!_next|api/public|favicon.ico).*)'],
};
AWS Lambda
// handler.ts ??Lambda function
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
export async function handler(
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> {
const { pathParameters, body } = event;
if (!body) {
return { statusCode: 400, body: JSON.stringify({ error: 'Body required' }) };
}
try {
const data = JSON.parse(body);
const result = await processData(data);
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
};
} catch (error) {
console.error('Handler error:', error);
return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) };
}
}
Reduce Lambda Cold Starts
// ??Initialize SDK clients outside the handler (reused across warm invocations)
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
const dynamodb = new DynamoDBClient({ region: 'us-east-1' }); // Created once
export async function handler(event: any) {
// dynamodb is reused on warm starts ??no reconnection overhead
const result = await dynamodb.send(/* ... */);
return result;
}
// ??Keep bundle small ??cold start time correlates with bundle size
// Use tree-shaking, avoid large dependencies
// Lambda: target < 10MB bundle, ideally < 1MB
// ??Provisioned Concurrency (AWS) ??keep Lambdas pre-warmed
// Set in Lambda configuration: Provisioned concurrency = 5
// Always-warm instances, eliminates cold starts for those 5 slots
Choosing the Right Deployment Model
Use Edge when:
??Authentication/authorization checks
??Geo-based routing and personalization
??Rate limiting
??A/B testing
??Static responses with simple logic
??Caching and cache invalidation
??Complex database queries
??File I/O
??Node.js-specific libraries
Use Serverless (Lambda) when:
??Event-driven processing (S3 upload, SQS message)
??Scheduled jobs (cron)
??APIs with variable traffic (zero to burst)
??Backend for mobile apps
??Data transformation pipelines
??Long-running processes (>15 min on Lambda)
??Always-on services (pay per-invocation less efficient)
Use Traditional Server when:
??WebSocket connections
??Long-running processes
??Stateful services
??Complex computation
??When you need predictable cost at scale
Observability at the Edge
// Cloudflare Workers: use ctx.waitUntil for background tasks
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const start = Date.now();
const response = await handleRequest(request, env);
// Log after response is sent ??doesn't block the response
ctx.waitUntil(
logRequest({
url: request.url,
status: response.status,
duration: Date.now() - start,
country: request.cf?.country,
})
);
return response;
},
};
Related posts:
- [Next.js App Router Guide](/en/blog/nextjs-app-router-rendering-strategies/
- [Docker Compose Complete Guide](/en/blog/docker-compose-complete-guide/
- [GitHub Actions CI/CD Guide](/en/blog/github-actions-complete-guide/
FAQ (quick reference)
How do I choose between KV and D1? Use KV for low-latency, globally distributed key?�value data where eventual consistency is OK. Use D1 when you need SQL, joins, and a clearer consistency model for relational data (still know D1?�s limits).
Is Lambda@Edge the same as CloudFront Functions? No. CloudFront Functions are a lighter JS subset for very short manipulations. Lambda@Edge is full Node (depending on config) and more powerful but with higher latency and more operational overhead.
Can I ?�eliminate??cold starts everywhere? On regional Lambda, only provision or keep traffic high enough; on edge isolates, focus on bundle size and CPU instead of ?�cold??
Related reading (on this blog)
- [Next.js App Router ??rendering strategies and caching](/en/blog/nextjs-app-router-rendering-strategies/
- [Docker Compose ??multi-container setup](/en/blog/docker-compose-complete-guide/
- [GitHub Actions ??CI/CD and deployment](/en/blog/github-actions-complete-guide/
This article focuses on platform tradeoffs. Always confirm quotas, pricing, and runtime tables in the vendor docs for the date you deploy.