How OpenSEO Uses Cloudflare Workers and Durable Objects for Edge-Native SEO Tools
OpenSEO runs entirely on Cloudflare Workers, using Durable Objects for stateful audit progress, chat context, and rate limiting while leveraging KV, R2, D1, and Access for a complete edge-native architecture.
The OpenSEO project (every-app/open-seo) is a modern SEO platform built from the ground up on Cloudflare's edge computing stack. Unlike traditional server-based architectures, OpenSEO executes all business logic directly at Cloudflare's global edge locations—eliminating cold starts, reducing latency, and simplifying infrastructure management. This article examines exactly how OpenSEO implements Cloudflare Workers and Durable Objects, with direct references to the source code implementation.
Cloudflare Workers as the Foundation
Every component of OpenSEO's backend runs inside the Cloudflare Workers Vercel-compatible runtime. The entry point imports the Workers environment through the cloudflare:workers module:
import { env } from "cloudflare:workers";
This env object provides type-safe access to all Cloudflare services bound to the Worker. The project's wrangler.jsonc declares these bindings, including KV namespaces, R2 buckets, D1 databases, Durable Object classes, and Access policies. The entire deployment provisions through a single wrangler deploy command.
The main server code resides in src/server/, with features organized by domain (audit, chat, workflows) and middleware handling cross-cutting concerns like authentication and caching. All server-side code pulls configuration and secrets from this unified env object rather than environment variables or external configuration files.
Durable Objects Architecture
OpenSEO uses Durable Objects whenever it needs mutable, strongly-consistent state that survives across requests. These are not merely caches—they are single-threaded JavaScript environments with persistent storage, guaranteeing atomic operations without race conditions.
Declaring Durable Object Namespaces
The type definitions in src/env.d.ts declare all Durable Object namespaces used throughout the application:
// src/env.d.ts
interface Env {
AUDIT_SCRATCHPAD: DurableObjectNamespace;
SAM_CHAT: DurableObjectNamespace;
ONBOARDING_CHAT: DurableObjectNamespace;
RATE_LIMIT: DurableObjectNamespace;
// ... other bindings
}
Each namespace corresponds to a specific Durable Object class implementation. The wrangler.jsonc binds these namespaces to their implementing classes:
{
"durable_objects": {
"bindings": [
{ "name": "AUDIT_SCRATCHPAD", "class_name": "AuditScratchpad" },
{ "name": "SAM_CHAT", "class_name": "SamChat" },
{ "name": "ONBOARDING_CHAT", "class_name": "OnboardingChat" },
{ "name": "RATE_LIMIT", "class_name": "RateLimit" }
]
}
}
Implementing a Durable Object Class
The AuditScratchpad class in src/server/features/audit/AuditScratchpad.ts demonstrates the complete Durable Object pattern:
// src/server/features/audit/AuditScratchpad.ts
import { DurableObject } from "cloudflare:workers";
export class AuditScratchpad extends DurableObject {
constructor(state: DurableObjectState, private readonly env: Env) {
super(state);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/progress") {
const progress = await this.state.storage.get("progress");
return Response.json({ progress });
}
if (url.pathname === "/pages") {
const pages = await this.state.storage.get<string[]>("pages") ?? [];
return Response.json({ pages });
}
return new Response("Not found", { status: 404 });
}
async recordPage(pageUrl: string): Promise<void> {
const pages = (await this.state.storage.get<string[]>("pages")) ?? [];
pages.push(pageUrl);
await this.state.storage.put("pages", pages);
}
async updateProgress(percent: number): Promise<void> {
await this.state.storage.put("progress", percent);
}
}
Key characteristics of this implementation:
- Single-threaded execution: Each
AuditScratchpadinstance processes one request at a time, eliminating concurrency bugs - Persistent storage: The
this.state.storageAPI provides transactional, strongly-consistent key-value storage - Request routing: The
fetch()method acts as an HTTP router for external requests - Direct method calls: Internal orchestration can call methods directly for type-safe interactions
Instantiating Durable Objects from Workers
From any Worker handler, OpenSEO retrieves a Durable Object stub using the namespace binding:
// src/routes/api/audit.ts
import { env } from "cloudflare:workers";
export async function onRequest(context) {
const { request } = context;
const url = new URL(request.url);
const auditId = url.searchParams.get("id");
// Derive unique ID from string, or use idFromName() for named objects
const id = env.AUDIT_SCRATCHPAD.idFromString(auditId);
const scratchpad = env.AUDIT_SCRATCHPAD.get(id);
// Forward request to Durable Object's fetch handler
return scratchpad.fetch(request);
}
The idFromString() method creates deterministic IDs from strings—enabling the same audit ID to always route to the same Durable Object instance across requests.
Core Use Cases for Durable Objects
Audit Progress Tracking
AUDIT_SCRATCHPAD maintains mutable state during site audits:
- Tracks pages crawled, queued, and skipped
- Stores intermediate analysis results
- Reports real-time progress percentages
async function startAudit(siteUrl: string) {
const auditId = crypto.randomUUID();
const id = env.AUDIT_SCRATCHPAD.idFromString(auditId);
const scratchpad = env.AUDIT_SCRATCHPAD.get(id);
// Initialize audit state
await scratchpad.fetch(new Request("https://internal/init", {
method: "POST",
body: JSON.stringify({ siteUrl })
}));
return auditId; // Client polls this ID for progress
}
Chat Context Management
SAM_CHAT and ONBOARDING_CHAT preserve conversation history across multiple HTTP requests:
// Inside SamChat Durable Object
async getHistory(): Promise<Message[]> {
const history = await this.state.storage.get<Message[]>("history") ?? [];
return history;
}
async addMessage(role: "user" | "assistant", content: string): Promise<void> {
const history = await this.getHistory();
history.push({ role, content, timestamp: Date.now() });
await this.state.storage.put("history", history.slice(-100)); // Keep last 100
}
This pattern enables stateful AI assistants without external databases or session stores.
Rate Limit Enforcement
RATE_LIMIT provides atomic counter increments for tier enforcement:
// RateLimit Durable Object
async checkLimit(userId: string, maxCalls: number): Promise<boolean> {
const key = `calls:${userId}`;
const current = (await this.state.storage.get<number>(key)) ?? 0;
if (current >= maxCalls) {
return false; // Limit exceeded
}
await this.state.storage.put(key, current + 1);
return true;
}
The single-threaded guarantee prevents race conditions that would allow limit violations with external databases.
Integration with Cloudflare Services
OpenSEO combines Durable Objects with other Cloudflare primitives for a complete data platform:
| Service | Purpose | Example Usage |
|---|---|---|
| KV | Read-heavy caches, configuration | Static SEO data, crawled page metadata |
| R2 | Object storage for large files | Exported PDF reports, bulk data exports |
| D1 | Relational database | Persistent project data, user accounts |
| Access | Zero-trust authentication | Self-hosted deployment protection |
| Queues | Background job processing | Crawl task distribution |
R2 Object Storage Integration
Large exports bypass Durable Object storage limits using R2:
// src/server/lib/r2.ts
export async function uploadReport(auditId: string, data: Blob): Promise<string> {
const key = `reports/${auditId}/${Date.now()}.pdf`;
await env.REPORTS_BUCKET.put(key, data);
return `${env.PUBLIC_R2_URL}/${key}`;
}
D1 for Relational Data
The src/db/provider.ts configures D1 as the primary database for structured data:
import { env } from "cloudflare:workers";
export function getDb() {
return env.DB; // D1Database binding
}
// Example: Create project record
await env.DB.prepare(`
INSERT INTO projects (id, name, url, created_at)
VALUES (?1, ?2, ?3, datetime('now'))
`).bind(id, name, url).run();
Cloudflare Access for Security
Self-hosted deployments use Cloudflare Access for authentication without custom auth code:
// src/middleware/ensure-user/cloudflareAccess.ts
export async function resolveCloudflareAccessContext(headers: Headers) {
const token = headers.get("CF-Access-Jwt-Assertion");
if (!token) return null;
// Verify JWT with Cloudflare's JWKS endpoint
const payload = await verifyJwt(token, env.CF_ACCESS_TEAM_DOMAIN);
return {
userId: payload.sub,
email: payload.email,
groups: payload.groups
};
}
Cloudflare Workflows for Orchestration
Beyond individual Durable Objects, OpenSEO uses Cloudflare Workflows (beta) for multi-step processes. The SiteAuditWorkflow in src/server/workflows/SiteAuditWorkflow.ts coordinates crawl, analysis, and report generation:
// src/server/workflows/SiteAuditWorkflow.ts
import { WorkflowEntrypoint, WorkflowStep } from "cloudflare:workers";
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) {
// Step 1: Initialize audit state
const auditId = await step.do("initialize", async () => {
return await this.initializeAudit(event.payload);
});
// Step 2: Crawl site (may run for minutes)
const pages = await step.do("crawl", async () => {
return await this.crawlSite(event.payload.url);
});
// Step 3: Analyze in batches
await step.do("analyze", async () => {
return await this.analyzePages(auditId, pages);
});
// Step 4: Generate final report
await step.do("report", async () => {
return await this.generateReport(auditId);
});
}
}
Workflows handle durability, retries, and observability automatically—each step executes independently with checkpointed state.
Summary
- Complete edge runtime: OpenSEO has no server infrastructure—all code executes in Cloudflare Workers
- Stateful edge computing: Durable Objects provide strongly-consistent, transactional storage for audit progress, chat history, and rate limits
- Declarative bindings:
wrangler.jsonccentralizes service configuration;env.d.tsprovides TypeScript safety - Complementary services: KV for caches, R2 for files, D1 for relational data, Access for security, Workflows for orchestration
- Single-threaded safety: Each Durable Object processes one operation at a time, eliminating race conditions without complex locking
Frequently Asked Questions
How does OpenSEO handle large audit datasets that exceed Durable Object storage limits?
OpenSEO streams large datasets to R2 object storage and uses Durable Objects only for coordination state. The AuditScratchpad stores page lists and progress indicators, while full crawl results write to env.REPORTS_BUCKET in chunks. This hybrid approach keeps Durable Object storage under limits while maintaining strong consistency for critical state.
Can OpenSEO run without Cloudflare's ecosystem?
No—OpenSEO is deeply integrated with Cloudflare primitives. The codebase relies on cloudflare:workers imports, Durable Objects for state, and Workflows for orchestration. Porting to another platform would require replacing these services with alternative implementations, significantly changing the architecture.
What triggers Durable Object eviction, and how does OpenSEO handle it?
Cloudflare may evict Durable Objects after periods of inactivity or memory pressure. OpenSEO treats all Durable Object memory as ephemeral—critical state persists to storage after every mutation. On restart, the constructor rehydrates state from storage. This "stateless in memory, durable on disk" pattern ensures correctness across evictions.
How does OpenSEO manage Durable Object ID generation for user-scoped resources?
OpenSEO uses deterministic ID derivation via idFromName() for user-scoped resources and idFromString() for URL-derived identifiers. For example, env.SAM_CHAT.idFromName(userId) ensures the same user always routes to the same chat object. For anonymous audits, crypto.randomUUID() generates unique IDs stored in cookies or URLs for subsequent request routing.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →