Cloudflare Workers y Durable Objects para Arquitectura de Micro Frontends
Este documento analiza cómo Cloudflare Workers y Durable Objects pueden servir como la infraestructura base para una arquitectura de micro frontends. Cubre la gestión de WebSockets mediante Durable Objects, la composición en el edge con Workers, estrategias de renderizado del lado del servidor y técnicas de optimización de rendimiento para aplicaciones de micro frontends distribuidas globalmente.
1. Durable Objects para WebSockets
Como los Durable Objects Gestionan Conexiones WebSocket
Los Durable Objects (DOs) proporcionan puntos de coordinación con estado y un solo hilo de ejecución, ideales para servidores WebSocket. Hay dos APIs disponibles:
Hibernatable WebSocket API (Recomendada)
Este es el patron principal para uso en produccion. Permite que los Durable Objects duerman mientras mantienen conexiones WebSocket, reduciendo drasticamente los costos para aplicaciones con muchas conexiones inactivas. Durante la hibernacion, los cargos por duracion facturable no se acumulan, pero la conexion WebSocket permanece abierta. Cuando llega un mensaje, el runtime recrea automaticamente el DO, ejecuta el constructor y entrega el mensaje al handler correspondiente.
import { DurableObject } from "cloudflare:workers";
interface Env {
ROOMS: DurableObjectNamespace<ChatRoom>;
}
export class ChatRoom extends DurableObject {
sessions: Map<WebSocket, { id: string; username: string }>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Re-hydrate sessions from hibernation
this.sessions = new Map();
this.ctx.getWebSockets().forEach((ws) => {
const meta = ws.deserializeAttachment();
this.sessions.set(ws, { ...meta });
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 });
}
const username = url.searchParams.get("username");
if (!username) {
return new Response("Missing username", { status: 400 });
}
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// acceptWebSocket() makes this connection "hibernatable"
// Unlike ws.accept(), this tells the runtime the DO can sleep
this.ctx.acceptWebSocket(server);
const sessionData = { id: crypto.randomUUID(), username };
server.serializeAttachment(sessionData); // Persists across hibernation (max 2048 bytes)
this.sessions.set(server, sessionData);
// Notify other clients
this.broadcast({ type: "join", username }, sessionData.id);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
if (typeof message !== "string") return;
const session = this.sessions.get(ws);
if (!session) return;
const parsed = JSON.parse(message);
switch (parsed.type) {
case "chat":
this.broadcast({
type: "chat",
username: session.username,
text: parsed.text,
timestamp: Date.now(),
});
break;
case "get-participants":
const participants = Array.from(this.sessions.values())
.map(s => s.username);
ws.send(JSON.stringify({ type: "participants", participants }));
break;
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
const session = this.sessions.get(ws);
if (session) {
this.broadcast({ type: "leave", username: session.username }, session.id);
}
this.sessions.delete(ws);
ws.close(code, "Closing"); // Always reciprocate close frames
}
async webSocketError(ws: WebSocket, error: unknown) {
const session = this.sessions.get(ws);
this.sessions.delete(ws);
ws.close(1011, "Unexpected error");
}
private broadcast(message: object, excludeId?: string) {
const payload = JSON.stringify(message);
this.ctx.getWebSockets().forEach((ws) => {
const { id } = ws.deserializeAttachment();
if (id !== excludeId) {
ws.send(payload);
}
});
}
}
// Entry Worker routes to the correct DO
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const roomName = url.pathname.split("/")[2]; // e.g., /ws/room-name
// Deterministic routing: same room name always hits same DO
const id = env.ROOMS.idFromName(roomName);
const stub = env.ROOMS.get(id);
return stub.fetch(request);
},
};
Patron clave: Estado por conexion con serializeAttachment/deserializeAttachment
Esto es critico para la hibernacion. Puedes almacenar hasta 2,048 bytes por conexion que persisten entre ciclos de hibernacion. Almacena aqui IDs de usuario, tokens de sesion y metadatos.
Patrones para Comunicación en Tiempo Real
Salas de Chat / Canales
- Un DO por sala/canal (el patron "atomo de coordinación")
- Usa
idFromName(roomName)para enrutamiento deterministico - Difunde mensajes a todos los WebSockets conectados mediante
this.ctx.getWebSockets()
Actualizaciones en Vivo (Dashboards, Notificaciones)
- Un DO por usuario o por entidad observada
- Los clientes se suscriben conectandose; el DO envia actualizaciones
- Combina con alarms para consultar fuentes de datos externas
Edicion Colaborativa
- Un DO por documento
- Almacena el estado del documento en almacenamiento respaldado por SQLite
- Usa transformaciones operacionales o CRDTs dentro del DO
- La naturaleza de hilo unico de los DOs proporciona serializacion natural de las ediciones
Jerarquias Padre-Hijo para Escalar
Cuando un solo DO se convierte en un cuello de botella, divide en DOs hijos. Por ejemplo, un DO de servidor de juegos que gestiona partidas puede crear DOs por partida.
Limites de Conexion, Precios y Escalabilidad
Precios (Workers Paid Plan, $5/mes minimo):
| Recurso | Incluido | Excedente |
|---|---|---|
| Requests (incluye mensajes WS en ratio 20:1) | 1M/mes | $0.15/millon |
| Duracion (GB-s) | 400,000/mes | $12.50/millon GB-s |
| Lecturas de filas SQLite | 25B/mes | $0.001/millon |
| Escrituras de filas SQLite | 50M/mes | $1.00/millon |
| Almacenamiento | 5 GB-mes | $0.20/GB-mes |
Facturacion especifica de WebSocket:
- Cada conexion WebSocket cuenta como 1 request
- Los mensajes WebSocket entrantes usan un ratio 20:1: 100 mensajes entrantes = 5 requests facturables
- Sin cargo por mensajes WebSocket salientes
- Con Hibernation API: sin cargos de duracion mientras hiberna (solo se cobra mientras los handlers de eventos estan ejecutandose activamente)
Limites:
- Limite flexible: ~1,000 requests/segundo por instancia de DO
- Sin limite fijo de conexiones WebSocket por DO, pero los limites practicos dependen de la carga de trabajo
- Tamano de mensaje WebSocket: 32 MiB maximo (recibido)
- CPU por request: 30 segundos (configurable hasta 5 minutos)
- Attachment por conexion: 2,048 bytes maximo
- Almacenamiento por objeto: 10 GB (respaldado por SQLite)
Caracteristicas de escalabilidad:
- Operaciones simples: ~1,000 req/seg por DO
- Procesamiento moderado: ~500-750 req/seg
- Operaciones complejas: ~200-500 req/seg
- Formula:
DOs necesarios = Total req/seg / Capacidad por DO
Estructurando un Durable Object para Salas/Canales
Patron recomendado: DO respaldado por SQLite con hibernacion
export class ChatRoom extends DurableObject {
sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
// Run migrations once, blocking concurrent requests
ctx.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_created
ON messages(created_at);
`);
});
// Re-hydrate sessions from hibernated WebSockets
this.ctx.getWebSockets().forEach((ws) => {
// Connections survive hibernation automatically
});
}
async fetch(request: Request) {
const url = new URL(request.url);
// HTTP endpoints for the room
if (url.pathname.endsWith("/history")) {
const rows = this.sql.exec(
"SELECT * FROM messages ORDER BY created_at DESC LIMIT 50"
).toArray();
return Response.json(rows);
}
// WebSocket upgrade
if (request.headers.get("Upgrade") === "websocket") {
return this.handleWebSocketUpgrade(request);
}
return new Response("Not found", { status: 404 });
}
private handleWebSocketUpgrade(request: Request): Response {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
// Tag connections for filtering (e.g., by role or sub-channel)
const tags = ["all"]; // Can be used with getWebSockets(tag)
// this.ctx.acceptWebSocket(server, tags);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string) {
const data = JSON.parse(message);
const session = ws.deserializeAttachment();
// Persist to SQLite
this.sql.exec(
"INSERT INTO messages (username, content, created_at) VALUES (?, ?, ?)",
session.username, data.text, Date.now()
);
// Broadcast to all connected clients
const outgoing = JSON.stringify({
type: "message",
username: session.username,
text: data.text,
timestamp: Date.now(),
});
for (const client of this.ctx.getWebSockets()) {
client.send(outgoing);
}
}
}
Configuración de wrangler.toml:
name = "chat-service"
main = "src/index.ts"
compatibility_date = "2026-03-03"
[[durable_objects.bindings]]
name = "ROOMS"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]
Buena Practica de Agrupacion de Mensajes
Para datos de alta frecuencia (lecturas de sensores, estado de juegos), agrupa mensajes para reducir cambios de contexto:
async webSocketMessage(ws: WebSocket, message: string) {
// Messages may arrive batched from client
const batch = JSON.parse(message);
if (Array.isArray(batch)) {
// Process batch atomically
for (const msg of batch) {
this.processMessage(ws, msg);
}
// Single broadcast with aggregated state
this.broadcastState();
}
}
Recomendacion: agrupa cada 50-100ms o cada 50-100 mensajes en el lado del cliente, lo que se alcance primero.
2. Workers como API Gateway
Implementando el Patron API Gateway
Un Worker gateway sirve como punto unico de entrada para todas las solicitudes de API, gestionando el enrutamiento, la autenticación, la limitacion de velocidad y el despacho de solicitudes a servicios backend.
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
// Service bindings to backend Workers
AUTH_SERVICE: Service<AuthService>;
USER_SERVICE: Service<UserService>;
MFE_CATALOG: Service<MfeCatalogService>;
NOTIFICATION_SERVICE: Service<NotificationService>;
// KV for rate limiting / config
GATEWAY_CONFIG: KVNamespace;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// --- CORS handling ---
if (request.method === "OPTIONS") {
return handleCORS(request);
}
// --- Rate limiting (lightweight, per-IP) ---
const clientIP = request.headers.get("CF-Connecting-IP") ?? "unknown";
const rateLimitOk = await checkRateLimit(env.GATEWAY_CONFIG, clientIP);
if (!rateLimitOk) {
return new Response("Too Many Requests", { status: 429 });
}
// --- Authentication (except public routes) ---
const publicPaths = ["/api/auth/login", "/api/auth/register", "/api/health"];
let authContext = null;
if (!publicPaths.some(p => url.pathname.startsWith(p))) {
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response("Unauthorized", { status: 401 });
}
// Validate via Auth service (RPC call, no HTTP overhead)
authContext = await env.AUTH_SERVICE.validateToken(token);
if (!authContext.valid) {
return new Response("Invalid token", { status: 401 });
}
}
// --- Route to backend services ---
try {
if (url.pathname.startsWith("/api/auth")) {
return await env.AUTH_SERVICE.fetch(stripPrefix(request, "/api/auth"));
}
if (url.pathname.startsWith("/api/users")) {
// Inject auth context into the request for downstream
const enrichedRequest = addAuthHeader(request, authContext);
return await env.USER_SERVICE.fetch(stripPrefix(enrichedRequest, "/api/users"));
}
if (url.pathname.startsWith("/api/mfe")) {
return await env.MFE_CATALOG.fetch(stripPrefix(request, "/api/mfe"));
}
if (url.pathname.startsWith("/api/notifications")) {
return await env.NOTIFICATION_SERVICE.fetch(
stripPrefix(request, "/api/notifications")
);
}
return new Response("Not Found", { status: 404 });
} catch (err) {
console.error("Gateway error:", err);
return new Response("Internal Server Error", { status: 500 });
}
},
};
function stripPrefix(request: Request, prefix: string): Request {
const url = new URL(request.url);
url.pathname = url.pathname.slice(prefix.length) || "/";
return new Request(url.toString(), request);
}
function addAuthHeader(request: Request, authContext: any): Request {
const headers = new Headers(request.headers);
headers.set("X-User-Id", authContext.userId);
headers.set("X-User-Role", authContext.role);
return new Request(request.url, { ...request, headers });
}
Service Bindings: Comunicación Worker-a-Worker
Hay dos patrones para la comunicación entre Workers:
1. RPC via WorkerEntrypoint (Recomendado)
Se siente como llamar a una funcion local. Sin sobrecarga de serializacion HTTP.
// auth-service/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
export default class AuthService extends WorkerEntrypoint<Env> {
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; role?: string }> {
// Validate JWT, check revocation list, etc.
try {
const payload = await verifyJWT(token, this.env.JWT_SECRET);
return { valid: true, userId: payload.sub, role: payload.role };
} catch {
return { valid: false };
}
}
async createSession(email: string, password: string): Promise<{ token: string }> {
// Authenticate and return JWT
const user = await this.env.DB.prepare(
"SELECT * FROM users WHERE email = ?"
).bind(email).first();
if (!user || !await verifyPassword(password, user.password_hash)) {
throw new Error("Invalid credentials");
}
const token = await signJWT({ sub: user.id, role: user.role }, this.env.JWT_SECRET);
return { token };
}
// Named entrypoint for admin operations
// Bind separately: entrypoint = "AdminAuth"
}
export class AdminAuth extends WorkerEntrypoint<Env> {
async revokeAllSessions(userId: string): Promise<void> {
await this.env.DB.prepare(
"DELETE FROM sessions WHERE user_id = ?"
).bind(userId).run();
}
}
2. HTTP via fetch() (Para reenviar solicitudes completas)
// Forward entire request to downstream Worker
const response = await env.USER_SERVICE.fetch(request);
wrangler.toml para el Gateway Worker:
name = "api-gateway"
main = "src/index.ts"
compatibility_date = "2026-03-03"
# Service bindings to other Workers
[[services]]
binding = "AUTH_SERVICE"
service = "auth-service"
[[services]]
binding = "USER_SERVICE"
service = "user-service"
[[services]]
binding = "MFE_CATALOG"
service = "mfe-catalog-service"
[[services]]
binding = "NOTIFICATION_SERVICE"
service = "notification-service"
# Named entrypoint binding
[[services]]
binding = "ADMIN_AUTH"
service = "auth-service"
entrypoint = "AdminAuth"
# KV for gateway config
[[kv_namespaces]]
binding = "GATEWAY_CONFIG"
id = "abc123"
Autenticación/Autorizacion a Nivel del Gateway
Patron: Verificación de auth centralizada, autorizacion distribuida
- El gateway valida el JWT (via RPC a AuthService) en cada solicitud
- El gateway inyecta headers
X-User-IdyX-User-Roleen las solicitudes downstream - Cada Worker downstream realiza su propia autorizacion (ej., "puede este usuario acceder a este recurso?")
// In the gateway: after auth validation
const enriched = new Request(request.url, {
method: request.method,
headers: new Headers({
...Object.fromEntries(request.headers),
"X-User-Id": authContext.userId,
"X-User-Role": authContext.role,
"X-Request-Id": crypto.randomUUID(), // For distributed tracing
}),
body: request.body,
});
Consideracion clave: Los service bindings son internos y no pueden ser accedidos desde la internet publica. Los Workers downstream confian en los headers inyectados por el gateway porque solo el gateway puede alcanzarlos mediante service bindings.
3. Workers para Servir Micro Frontends
Sirviendo Assets Estaticos
Cloudflare ofrece tres enfoques principales para servir bundles de MFE:
Opcion A: Workers Static Assets (Recomendado para MFEs)
Cada MFE es su propio Worker con assets estaticos incluidos. El Worker router reenvia solicitudes mediante service bindings.
# mfe-dashboard/wrangler.toml
name = "mfe-dashboard"
main = "src/index.ts"
compatibility_date = "2026-03-03"
[assets]
directory = "./dist" # Built frontend assets
binding = "ASSETS" # Access assets programmatically
// mfe-dashboard/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
export default class DashboardMFE extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
// Serve static assets, with SSR fallback for routes
return this.env.ASSETS.fetch(request);
}
// RPC method for the router to call
async getAsset(path: string): Promise<Response> {
return this.env.ASSETS.fetch(new Request(`https://assets.local${path}`));
}
}
Opcion B: R2 para Bundles Grandes/Versionados
Almacena multiples versiones de cada MFE en R2, usando un esquema de claves estructurado:
r2-bucket/
mfe-dashboard/
v1.2.0/
index.html
assets/
main.abc123.js
styles.def456.css
v1.3.0/
index.html
assets/
main.xyz789.js
styles.uvw012.css
mfe-settings/
v2.0.0/
...
// asset-server/src/index.ts
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const mfeName = url.pathname.split("/")[1];
// Look up active version from KV
const activeVersion = await env.MFE_CONFIG.get(`active:${mfeName}`);
if (!activeVersion) {
return new Response("MFE not found", { status: 404 });
}
// Construct R2 key
const assetPath = url.pathname.split("/").slice(2).join("/") || "index.html";
const r2Key = `${mfeName}/${activeVersion}/${assetPath}`;
const object = await env.ASSETS_BUCKET.get(r2Key);
if (!object) {
return new Response("Asset not found", { status: 404 });
}
const headers = new Headers();
headers.set("Content-Type", getContentType(assetPath));
headers.set("ETag", object.httpEtag);
// Fingerprinted assets get long cache; HTML gets short cache
if (assetPath.match(/\.[a-f0-9]{8,}\.(js|css|woff2?)$/)) {
headers.set("Cache-Control", "public, max-age=31536000, immutable");
} else {
headers.set("Cache-Control", "public, max-age=60, s-maxage=300");
}
return new Response(object.body, { headers });
},
};
Opcion C: Cloudflare Pages (La mas simple)
Cada MFE es un proyecto de Pages. El Worker router obtiene de cada dominio de Pages. Esto es mas simple pero da menos control sobre la seleccion de versiones.
Sistema de Gestión de Versiones
Usa KV (por velocidad y distribucion global) o D1 (para consultas relacionales y registros de auditoria) para almacenar que version de cada MFE esta activa.
Configuración de versiones basada en KV:
// Data structure in KV
// Key: "mfe-config"
// Value:
{
"dashboard": {
"activeVersion": "v1.3.0",
"availableVersions": ["v1.2.0", "v1.3.0", "v1.4.0-beta"],
"updatedAt": "2025-06-15T10:30:00Z",
"updatedBy": "admin@example.com"
},
"settings": {
"activeVersion": "v2.0.0",
"availableVersions": ["v1.9.0", "v2.0.0"],
"updatedAt": "2025-06-14T08:00:00Z",
"updatedBy": "admin@example.com"
},
"navbar": {
"activeVersion": "v3.1.0",
"availableVersions": ["v3.0.0", "v3.1.0"],
"updatedAt": "2025-06-10T14:00:00Z",
"updatedBy": "admin@example.com"
}
}
Worker de API de Administracion para gestión de versiones:
// mfe-admin/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
interface MfeConfig {
activeVersion: string;
availableVersions: string[];
updatedAt: string;
updatedBy: string;
}
interface AllConfigs {
[mfeName: string]: MfeConfig;
}
export default class MfeAdminService extends WorkerEntrypoint<Env> {
// Get all MFE configurations
async getConfigs(): Promise<AllConfigs> {
const config = await this.env.MFE_CONFIG.get("mfe-config", "json");
return config as AllConfigs ?? {};
}
// Get active version for a specific MFE
async getActiveVersion(mfeName: string): Promise<string | null> {
const configs = await this.getConfigs();
return configs[mfeName]?.activeVersion ?? null;
}
// Set the active version (admin operation)
async setActiveVersion(mfeName: string, version: string, adminEmail: string): Promise<void> {
const configs = await this.getConfigs();
if (!configs[mfeName]) {
throw new Error(`MFE '${mfeName}' not found`);
}
if (!configs[mfeName].availableVersions.includes(version)) {
throw new Error(`Version '${version}' not available for '${mfeName}'`);
}
configs[mfeName].activeVersion = version;
configs[mfeName].updatedAt = new Date().toISOString();
configs[mfeName].updatedBy = adminEmail;
await this.env.MFE_CONFIG.put("mfe-config", JSON.stringify(configs));
// Purge CDN cache for this MFE
await this.purgeCache(mfeName);
}
// Register a new version after deployment
async registerVersion(mfeName: string, version: string): Promise<void> {
const configs = await this.getConfigs();
if (!configs[mfeName]) {
configs[mfeName] = {
activeVersion: version,
availableVersions: [version],
updatedAt: new Date().toISOString(),
updatedBy: "system",
};
} else {
if (!configs[mfeName].availableVersions.includes(version)) {
configs[mfeName].availableVersions.push(version);
}
}
await this.env.MFE_CONFIG.put("mfe-config", JSON.stringify(configs));
}
private async purgeCache(mfeName: string) {
// Use Cloudflare API to purge cache by prefix
// or use Cache-Tag based purging
await fetch(
`https://api.cloudflare.com/client/v4/zones/${this.env.ZONE_ID}/purge_cache`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ prefixes: [`/${mfeName}/`] }),
}
);
}
// HTTP handler for admin UI
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/configs") {
return Response.json(await this.getConfigs());
}
if (request.method === "POST" && url.pathname === "/set-version") {
const body = await request.json() as any;
await this.setActiveVersion(body.mfeName, body.version, body.adminEmail);
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
}
}
Alternativa basada en D1 (para registros de auditoria):
CREATE TABLE mfe_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mfe_name TEXT NOT NULL,
version TEXT NOT NULL,
is_active BOOLEAN DEFAULT FALSE,
deployed_at TEXT NOT NULL,
activated_at TEXT,
activated_by TEXT,
UNIQUE(mfe_name, version)
);
CREATE TABLE version_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mfe_name TEXT NOT NULL,
old_version TEXT,
new_version TEXT NOT NULL,
changed_by TEXT NOT NULL,
changed_at TEXT NOT NULL,
reason TEXT
);
D1 es mejor cuando necesitas:
- Registros de auditoria de quien cambio que y cuando
- Consultar el historial de versiones
- Busquedas relacionales (ej., "que MFEs se actualizaron en las ultimas 24 horas?")
- Garantias transaccionales al actualizar multiples MFEs de forma atomica
KV es mejor cuando necesitas:
- Lecturas lo mas rapidas posible (replicado globalmente, consistencia eventual)
- Busquedas simples clave-valor para el Worker router
- Menor costo a escala
Enfoque híbrido recomendado: Usa D1 como fuente de verdad en el servicio de administracion, y sincroniza las versiones activas a KV para que el Worker router las lea con latencia sub-milisegundo.
Estrategias de Cache CDN
// In the router or asset-serving Worker
function getCacheHeaders(assetPath: string, mfeVersion: string): Headers {
const headers = new Headers();
// Fingerprinted assets (main.abc123.js) - immutable, cache forever
if (assetPath.match(/\.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|svg)$/)) {
headers.set("Cache-Control", "public, max-age=31536000, immutable");
}
// HTML files - short cache, revalidate often
else if (assetPath.endsWith(".html") || assetPath === "/") {
headers.set("Cache-Control", "public, max-age=0, must-revalidate");
headers.set("CDN-Cache-Control", "max-age=60"); // Cloudflare edge caches 60s
}
// Manifests, service workers - no cache
else if (assetPath.match(/manifest\.json|service-worker\.js/)) {
headers.set("Cache-Control", "no-cache, no-store");
}
// Everything else
else {
headers.set("Cache-Control", "public, max-age=3600, s-maxage=86400");
}
// Cache-Tag for targeted purging when version changes
headers.set("Cache-Tag", `mfe-${mfeVersion}`);
return headers;
}
Purga de cache al cambiar de version: Cuando el administrador cambia una version activa, purga la cache CDN para ese MFE usando la API de Cloudflare (purga por prefijo o Cache-Tag).
4. Patron Backend-for-Frontend en Workers
Visión General de la Arquitectura
Worker BFF por Micro Frontend
Cada MFE tiene un Worker BFF dedicado que:
- Agrega datos de multiples servicios backend
- Transforma datos a la forma que el MFE necesita
- Gestiona logica de negocio especifica del MFE
- Mantiene el MFE ligero (sin orquestación compleja de APIs en el cliente)
// bff-dashboard/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
AUTH_SERVICE: Service<AuthService>;
USER_SERVICE: Service<UserService>;
ANALYTICS_DB: D1Database;
CACHE: KVNamespace;
}
export default class DashboardBFF extends WorkerEntrypoint<Env> {
// Called by the dashboard MFE via API gateway
async getDashboardData(userId: string): Promise<DashboardData> {
// Parallel fetch from multiple sources
const [user, recentActivity, stats] = await Promise.all([
this.env.USER_SERVICE.getUser(userId),
this.getRecentActivity(userId),
this.getStats(userId),
]);
// Transform into the exact shape the dashboard MFE expects
return {
user: {
name: user.name,
avatar: user.avatarUrl,
plan: user.subscription.planName,
},
activity: recentActivity.map(a => ({
id: a.id,
description: a.description,
timeAgo: formatTimeAgo(a.timestamp),
})),
stats: {
totalProjects: stats.projects,
activeCollaborators: stats.collaborators,
storageUsed: formatBytes(stats.storageBytes),
},
};
}
private async getRecentActivity(userId: string) {
// Check KV cache first
const cached = await this.env.CACHE.get(`activity:${userId}`, "json");
if (cached) return cached;
const result = await this.env.ANALYTICS_DB.prepare(
"SELECT * FROM activity WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20"
).bind(userId).all();
// Cache for 5 minutes
await this.env.CACHE.put(
`activity:${userId}`,
JSON.stringify(result.results),
{ expirationTtl: 300 }
);
return result.results;
}
private async getStats(userId: string) {
return this.env.ANALYTICS_DB.prepare(
"SELECT COUNT(DISTINCT project_id) as projects, COUNT(DISTINCT collaborator_id) as collaborators, SUM(storage_bytes) as storageBytes FROM user_stats WHERE user_id = ?"
).bind(userId).first();
}
// HTTP handler fallback
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const userId = request.headers.get("X-User-Id");
if (!userId) return new Response("Unauthorized", { status: 401 });
if (url.pathname === "/dashboard-data") {
const data = await this.getDashboardData(userId);
return Response.json(data);
}
return new Response("Not found", { status: 404 });
}
}
Servicios Compartidos como Workers Separados
// shared/auth-service/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
export default class AuthService extends WorkerEntrypoint<Env> {
async validateToken(token: string) {
return verifyJWT(token, this.env.JWT_SECRET);
}
async getUserPermissions(userId: string): Promise<string[]> {
const result = await this.env.DB.prepare(
"SELECT permission FROM user_permissions WHERE user_id = ?"
).bind(userId).all();
return result.results.map(r => r.permission as string);
}
async hasPermission(userId: string, permission: string): Promise<boolean> {
const perms = await this.getUserPermissions(userId);
return perms.includes(permission);
}
}
// shared/user-service/src/index.ts
export default class UserService extends WorkerEntrypoint<Env> {
async getUser(userId: string): Promise<User> {
return this.env.DB.prepare(
"SELECT * FROM users WHERE id = ?"
).bind(userId).first();
}
async updateUser(userId: string, updates: Partial<User>): Promise<User> {
// ... update logic
}
}
Configuración de Service Bindings para el Patron BFF
# bff-dashboard/wrangler.toml
name = "bff-dashboard"
main = "src/index.ts"
compatibility_date = "2026-03-03"
[[services]]
binding = "AUTH_SERVICE"
service = "auth-service"
[[services]]
binding = "USER_SERVICE"
service = "user-service"
[[d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics"
database_id = "xxx"
[[kv_namespaces]]
binding = "CACHE"
id = "yyy"
Gestión de Entornos (dev/staging/prod)
Configuración por entorno:
# bff-dashboard/wrangler.toml
name = "bff-dashboard"
main = "src/index.ts"
compatibility_date = "2026-03-03"
# Shared (inheritable) config
[vars]
APP_NAME = "dashboard-bff"
# --- Development ---
[env.dev]
vars = { ENVIRONMENT = "development", LOG_LEVEL = "debug" }
[[env.dev.services]]
binding = "AUTH_SERVICE"
service = "auth-service-dev"
[[env.dev.services]]
binding = "USER_SERVICE"
service = "user-service-dev"
[[env.dev.d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics-dev"
database_id = "dev-xxx"
[[env.dev.kv_namespaces]]
binding = "CACHE"
id = "dev-yyy"
# --- Staging ---
[env.staging]
vars = { ENVIRONMENT = "staging", LOG_LEVEL = "info" }
[[env.staging.services]]
binding = "AUTH_SERVICE"
service = "auth-service-staging"
[[env.staging.services]]
binding = "USER_SERVICE"
service = "user-service-staging"
[[env.staging.d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics-staging"
database_id = "staging-xxx"
[[env.staging.kv_namespaces]]
binding = "CACHE"
id = "staging-yyy"
# --- Production ---
[env.production]
vars = { ENVIRONMENT = "production", LOG_LEVEL = "warn" }
[[env.production.services]]
binding = "AUTH_SERVICE"
service = "auth-service"
[[env.production.services]]
binding = "USER_SERVICE"
service = "user-service"
[[env.production.d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics"
database_id = "prod-xxx"
[[env.production.kv_namespaces]]
binding = "CACHE"
id = "prod-yyy"
Comandos de deploy:
# Deploy to dev
npx wrangler deploy --env dev
# Deploy to staging
npx wrangler deploy --env staging
# Deploy to production
npx wrangler deploy --env production
Secrets por entorno:
# Set secrets per environment
npx wrangler secret put JWT_SECRET --env production
npx wrangler secret put JWT_SECRET --env staging
Secrets de desarrollo local:
# .dev.vars (for default environment)
JWT_SECRET=local-dev-secret
# .dev.vars.staging (for staging environment locally)
JWT_SECRET=staging-secret
Importante: Los bindings y variables de entorno son no heredables en Wrangler. Debes redeclararlos en cada bloque de entorno. El nombre del entorno pasa a ser parte del nombre del Worker desplegado (ej., bff-dashboard-staging), que es lo que otros Workers referencian en sus service bindings.
5. Desarrollo Local con Wrangler
Ejecutando Multiples Workers Localmente
Metodo 1: Comando unico con multiples configuraciones (Recomendado)
Pasa multiples archivos de configuración a una sola invocacion de wrangler dev:
npx wrangler dev \
--config ./gateway/wrangler.toml \
--config ./bff-dashboard/wrangler.toml \
--config ./auth-service/wrangler.toml \
--config ./user-service/wrangler.toml
La primera configuración es el Worker principal expuesto via HTTP. Los Workers restantes solo son accesibles mediante service bindings desde el Worker principal.
Metodo 2: Sesiones de terminal separadas (también funciona)
Desde septiembre de 2025, los Workers ejecutandose en sesiones separadas de wrangler dev pueden comunicarse entre si mediante un dev registry. Esto significa que puedes:
# Terminal 1
cd gateway && npx wrangler dev
# Terminal 2
cd auth-service && npx wrangler dev
# Terminal 3
cd bff-dashboard && npx wrangler dev
Los service bindings se resuelven automaticamente entre comandos dev separados. El dev registry gestiona el descubrimiento.
Metodo 3: Bindings remotos para servicios que no son tuyos
Para servicios mantenidos por otros equipos, usa bindings remotos para conectar con versiones desplegadas:
# wrangler.toml (local development overrides)
[[services]]
binding = "AUTH_SERVICE"
service = "auth-service"
remote = true # Hit the deployed auth-service instead of local
Testing Local de Durable Objects
Los Durable Objects funcionan localmente con wrangler dev sin configuración adicional. Miniflare (integrado en Wrangler) simula los DOs usando el mismo runtime workerd que se usa en produccion.
npx wrangler dev
Esto automaticamente:
- Carga los bindings de DO desde tu
wrangler.toml - Crea bases de datos SQLite locales para el almacenamiento de DOs
- Gestiona conexiones WebSocket localmente
- Simula el comportamiento de hibernacion
Limitacion importante: Los bindings de Durable Objects no pueden establecerse como remote: true. Debes:
- Ejecutar los DOs localmente (comportamiento por defecto)
- Desplegar el Worker con DOs y usar un service binding remoto desde tu Worker local para comunicarte con el
Agregando datos de prueba locales:
# Seed local KV data
npx wrangler kv key put --binding MFE_CONFIG "mfe-config" '{"dashboard":{"activeVersion":"v1.0.0"}}' --local
# Seed local D1 data
npx wrangler d1 execute analytics-dev --local --command "INSERT INTO ..."
Testing con Vitest
Cloudflare proporciona @cloudflare/vitest-pool-workers para testing aislado:
// __tests__/chat-room.test.ts
import { env, runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("ChatRoom", () => {
it("should accept WebSocket connections", async () => {
const id = env.ROOMS.idFromName("test-room");
const stub = env.ROOMS.get(id);
const response = await stub.fetch("http://localhost/ws?username=alice", {
headers: { Upgrade: "websocket" },
});
expect(response.status).toBe(101);
expect(response.webSocket).toBeDefined();
});
it("should broadcast messages", async () => {
const id = env.ROOMS.idFromName("test-room");
const stub = env.ROOMS.get(id);
await runInDurableObject(stub, async (instance) => {
// Access the DO instance directly for testing
const sessions = instance.ctx.getWebSockets();
expect(sessions.length).toBeGreaterThan(0);
});
});
});
Como Funciona wrangler dev Internamente
- Wrangler lee tu configuración
wrangler.toml - Empaqueta tu codigo del Worker usando esbuild
- Inicia un proceso local de workerd (el mismo runtime que en produccion) via Miniflare
- Los bindings (KV, D1, DOs, R2) se simulan localmente usando SQLite
- La vigilancia de archivos detecta cambios y recarga el Worker en caliente
- Un dev registry (basado en sistema de archivos) permite la resolucion de service bindings entre sesiones
Flags principales:
# Default: fully local
npx wrangler dev
# Use remote resources (not recommended for DOs)
npx wrangler dev --remote
# Specify port
npx wrangler dev --port 8787
# Specify environment
npx wrangler dev --env staging
# With Inspector (Chrome DevTools debugging)
npx wrangler dev --inspector-port 9229
6. Recomendacion General de Arquitectura
Estructura de Proyecto Recomendada
cloudflare-micro-frontends/
├── packages/
│ ├── gateway/ # API Gateway Worker
│ │ ├── src/index.ts
│ │ └── wrangler.toml
│ ├── router/ # MFE Router Worker (frontend)
│ │ ├── src/index.ts
│ │ └── wrangler.toml
│ ├── mfe-dashboard/ # Dashboard MFE (static + optional SSR)
│ │ ├── src/
│ │ ├── dist/ # Built assets
│ │ └── wrangler.toml
│ ├── mfe-settings/ # Settings MFE
│ │ ├── src/
│ │ ├── dist/
│ │ └── wrangler.toml
│ ├── mfe-navbar/ # Navbar MFE (shared shell)
│ │ ├── src/
│ │ ├── dist/
│ │ └── wrangler.toml
│ ├── bff-dashboard/ # BFF for Dashboard
│ │ ├── src/index.ts
│ │ └── wrangler.toml
│ ├── bff-settings/ # BFF for Settings
│ │ ├── src/index.ts
│ │ └── wrangler.toml
│ ├── services/
│ │ ├── auth/ # Shared Auth Service
│ │ │ ├── src/index.ts
│ │ │ └── wrangler.toml
│ │ ├── user/ # Shared User Service
│ │ │ ├── src/index.ts
│ │ │ └── wrangler.toml
│ │ └── realtime/ # Durable Objects for WebSockets
│ │ ├── src/index.ts
│ │ └── wrangler.toml
│ ├── mfe-admin/ # Admin panel for version management
│ │ ├── src/index.ts
│ │ └── wrangler.toml
│ └── shared/ # Shared TypeScript types/utilities
│ ├── types/
│ └── utils/
├── package.json # Monorepo root (npm workspaces or turborepo)
├── turbo.json # If using Turborepo
└── tsconfig.base.json
Configuración del Worker Router (Patron MFE de Cloudflare)
# router/wrangler.toml
name = "mfe-router"
main = "src/index.ts"
compatibility_date = "2026-03-03"
# Service bindings to each MFE
[[services]]
binding = "DASHBOARD"
service = "mfe-dashboard"
[[services]]
binding = "SETTINGS"
service = "mfe-settings"
[[services]]
binding = "NAVBAR"
service = "mfe-navbar"
[vars]
ROUTES = '''
[
{ "path": "/dashboard", "binding": "DASHBOARD", "preload": true },
{ "path": "/settings", "binding": "SETTINGS" },
{ "path": "/", "binding": "NAVBAR" }
]
'''
# Optional: smooth transitions between MFEs
SMOOTH_TRANSITIONS = "true"
El router MFE de Cloudflare automaticamente:
- Reescribe rutas de assets (ej.,
/assets/main.jsse convierte en/dashboard/assets/main.js) - Gestiona reescrituras de CSS
url() - Soporta View Transitions API para navegacion fluida
- Inyecta Speculation Rules para prefetching en navegadores Chromium
- Elimina los prefijos de montaje antes de reenviar a los Workers MFE
7. Problemas Comunes y Trampas
Durable Objects
-
Desplegar codigo nuevo desconecta TODOS los WebSockets. Cada despliegue de codigo reinicia todas las instancias de DOs, terminando las conexiones existentes. Planifica logica de reconexion del lado del cliente con backoff exponencial.
-
Los DOs no conocen su propio nombre/ID. Usa un metodo explicito
init()o pasa la identidad mediante la URL de la solicitud al crear el DO por primera vez. -
El estado en memoria se pierde al ser desalojado. Las propiedades de clase se borran cuando un DO es desalojado de memoria por inactividad. Usa almacenamiento SQLite o
serializeAttachment()para cualquier cosa que deba sobrevivir. -
blockConcurrencyWhile()mata el rendimiento. Solo usalo para inicializacion (migraciones de esquema). Limita el rendimiento a aproximadamente 200 req/seg si cada llamada tarda 5ms. -
Los alarms pueden dispararse mas de una vez. Haz los handlers de alarms idempotentes. Verifica el estado antes de realizar acciones.
-
No hay hooks de apagado. No puedes ejecutar logica de limpieza de forma fiable cuando un DO esta siendo desalojado. Persiste el estado de forma incremental mientras procesas, no en un paso final de limpieza.
-
Un unico singleton global es un anti-patron. Nunca canalices todo el trafico a traves de una sola instancia de DO. Encuentra limites naturales de particionado (por usuario, por sala, por documento).
Service Bindings y Workers
-
Los service bindings tienen ambito de cuenta. Ambos Workers deben estar en la misma cuenta de Cloudflare. Los service bindings entre cuentas no estan soportados.
-
Se crea una nueva instancia de
WorkerEntrypointpor invocacion. Son sin estado. No almacenes estado en propiedades de instancia esperando que persistan entre llamadas. -
Siempre usa
awaiten llamadas RPC. Olvidar el await traga errores silenciosamente, causando problemas dificiles de depurar. -
Los bindings de entorno no son heredables. En entornos de Wrangler, debes redeclarar KV, D1, R2 y service bindings en cada bloque
[env.X]. No se heredan del nivel superior. -
El nombre del Worker cambia con el entorno. Un Worker llamado
my-workercon entornostagingse despliega comomy-worker-staging. Todos los service bindings que lo referencien deben usar el nombre completo.
Cache y Assets
-
KV tiene consistencia eventual. Despues de actualizar un valor en KV, puede tardar hasta 60 segundos en propagarse globalmente. Para cambios de version, purga la cache CDN inmediatamente y acepta este retraso de propagación, o usa un patron de lectura a traves de la Cache API.
-
Workers Static Assets no soporta
Cache-Controlpersonalizado via_headerspara respuestas SSR. Si tu MFE usa SSR, establece los headers de cache directamente en el codigo de tu Worker, no en el archivo_headers. -
R2 no es un CDN por defecto. Las lecturas de R2 van a la region de almacenamiento mas cercana. Pon Cloudflare Cache delante de R2 para cache en el edge, o usa Workers para cachear respuestas.
Desarrollo Local
-
Los bindings de Durable Objects no pueden ser remotos. Debes ejecutar los DOs localmente o acceder a ellos a traves de un Worker desplegado mediante un service binding remoto. No existe la opcion
remote: truepara bindings de DOs. -
wrangler dev con multiples configuraciones solo expone el primer Worker via HTTP. Los otros Workers en el mismo comando solo son accesibles mediante service bindings. Si necesitas probarlos directamente via HTTP, ejecutalos en sesiones de terminal separadas.
-
El estado de almacenamiento se reinicia entre sesiones de
wrangler dev. El almacenamiento local de KV, D1 y DOs es efimero a menos que uses--persist(habilitado por defecto en versiones recientes de Wrangler) o alimentes datos explicitamente. -
El dev registry entre sesiones esta basado en sistema de archivos. Si las sesiones estan en diferentes contextos de sistema de archivos (ej., contenedores Docker), el descubrimiento de service bindings entre sesiones puede no funcionar.
Especificos de Micro Frontends
-
La reescritura de rutas es automatica pero no magica. El router MFE de Cloudflare reescribe prefijos de assets conocidos (
/assets/,/static/,/build/,/_astro/). Si tu framework usa rutas no estandar, configuraASSET_PREFIXESexplicitamente, o la carga de assets fallara. -
Cada despliegue de MFE es independiente pero el binding del router es estatico. Agregar un nuevo MFE requiere actualizar el
wrangler.tomldel router con un nuevo service binding y redesplegar el router. Eliminar un MFE también requiere una actualizacion del router. -
Los despliegues versionados no rastrean el estado de almacenamiento. Los datos de KV, R2, D1 y DOs no se versionan junto con el codigo de tu Worker. Un rollback a una version anterior del Worker no revierte cambios en el esquema de la base de datos ni datos almacenados.
Fuentes
- Use WebSockets - Durable Objects Best Practices
- Build a WebSocket Server with Hibernation
- Rules of Durable Objects
- Durable Objects Pricing
- Durable Objects Limits
- Service Bindings - Runtime APIs
- Service Bindings - RPC (WorkerEntrypoint)
- Service Bindings GA Blog Post
- Microfrontends on Workers
- Cloudflare Workers and Micro-Frontends: Made for One Another
- Building Vertical Microfrontends on Cloudflare
- Build Microfrontend Applications on Workers (Jan 2026)
- Workers Static Assets
- Workers Versions & Deployments
- Build a Distributed Configuration Store with KV
- Wrangler Environments
- Development & Testing
- Improved Multi-Worker wrangler dev (Sep 2025)
- Miniflare
- How the Cache Works with Workers
- Run Multiple Cloudflare Workers Locally