How OpenSEO Crawls Websites for Site Audits: A Deep Dive into the Cloudflare Workers Architecture
OpenSEO crawls websites using a multi-stage, Durable Object-backed workflow that processes pages in bounded chunks, keeping memory usage constant even for sites with thousands of pages.
The every-app/open-seo repository implements a site audit crawler designed specifically for the constraints of Cloudflare Workers. Its architecture prioritizes memory safety, incremental persistence, and fault tolerance over raw speed. This article breaks down exactly how the crawl phase works, from the initial API call to the final database write.
Overview of the Crawl Architecture
OpenSEO's crawler is not a monolithic script. It distributes work across Durable Objects for state management and uses chunked execution to respect the 128 MB memory limit of Cloudflare Workers.
| Component | Purpose | Source File |
|---|---|---|
runSiteAuditTool |
Public API entry point that validates input and launches workflows | src/server/mcp/tools/site-audit-tools.ts |
SiteAuditWorkflow |
Orchestrates three phases: discovery → crawling → reporting | src/server/workflows/SiteAuditWorkflow.ts |
runCrawlPhase |
Loops over crawl chunks until the page budget is exhausted | src/server/workflows/siteAuditWorkflowCrawl.ts |
runCrawlChunk |
Fetches a batch of URLs concurrently, with adaptive rate limiting | src/server/workflows/siteAuditWorkflowCrawl.ts |
crawlPage |
Performs the actual HTTP fetch with custom headers, redirect handling, and body size caps | src/server/workflows/site-audit-workflow-helpers.ts |
page-analyzer |
Dynamically imported HTML parser for extracting SEO signals | src/server/lib/audit/page-analyzer.ts |
AuditScratchpad |
Durable Object holding the crawl frontier and visited set | src/server/features/audit/AuditScratchpad.ts |
AuditRepository |
Persists crawled pages and derived SEO issues to the database | src/server/features/audit/repositories/AuditRepository.ts |
Starting an Audit: The Entry Point
All audits begin with the run_site_audit MCP tool. The handler validates the start URL, optional maxPages budget, and Lighthouse flag, then delegates to AuditService.startAudit.
// Example: Starting a site audit via the MCP client
await client.run_site_audit({
projectId: "proj_123",
url: "https://example.com",
maxPages: 200,
runLighthouse: true,
});
This creates an Audit database row and triggers a Cloudflare Workflow instance with workflowInstanceId.
Workflow Orchestration: Three Distinct Phases
The SiteAuditWorkflow runs as a series of durable steps—each step can suspend and resume without losing state. The crawling phase is invoked like this:
const crawl = await runCrawlPhase(step, {
auditId,
workflowInstanceId,
origin: new URL(startUrl).origin,
maxPages,
robots,
seededCount,
});
The step parameter is a Cloudflare Workflow primitive that ensures durability: if the Worker is evicted, execution resumes at the same step.
Chunked Crawling: O(1) Memory Design
The core innovation in how OpenSEO crawls websites is its chunk-and-persist model. Instead of loading the entire site into memory, runCrawlPhase loops over discrete chunks until completion.
How a Single Chunk Works (runCrawlChunk)
-
Claim URLs from the scratchpad Durable Object (
scratchpad.claimChunk), up toCHUNK_TARGET_PAGES -
Manage concurrency with an adaptive crawl window (
windowSize) that expands or contracts based on success rates -
Launch fetches via
crawlPageuntil the window fills, the 90-second soft deadline fires, or the batch queue hitsMAX_QUEUED_PERSIST_BATCHES -
Persist sub-batches of
PERSIST_BATCH_SIZEpages viapersistCrawledPages—serialized to prevent DB contention -
Return counters (
attempted,pending,endWindow) so the next chunk continues seamlessly
This design guarantees memory usage stays O(1) regardless of site size.
Fetching Individual Pages (crawlPage)
Every URL passes through crawlPage in site-audit-workflow-helpers.ts. The implementation reveals careful attention to edge cases:
const response = await fetch(url, {
headers: {
"User-Agent": "OpenSEO-Audit/1.0",
Accept: "text/html,application/xhtml+xml",
},
redirect: "manual", // Handle redirects explicitly
signal: AbortSignal.timeout(15_000), // 15s timeout
});
Key behaviors:
- Manual redirect handling — Each hop becomes its own page row with full metadata
- 1 MiB body cap —
readTextUpTo(MAX_HTML_BYTES)prevents memory exhaustion from massive pages - Fetch classification —
classifyFetch()labels responses asok,blocked(Cloudflare challenges), orerror - Dynamic parser loading — The
page-analyzerisimport()ed only when needed, keeping cold starts fast
For valid HTML pages, crawlPage returns a CrawledPageResult containing:
- Title, meta description, canonical URL
- Heading hierarchy counts (H1-H6)
- Internal/external link lists
- Image alt text coverage
- Structured data detection flags
Persistence and Frontier Management
After each sub-batch, persistCrawledPages performs several critical operations:
1. Deterministic ID Assignment
const pageId = deterministicAuditRowId(auditId, url);
This makes retries idempotent—re-processing the same URL yields the same database row.
2. Issue Generation
runPageReporters analyzes each page for SEO issues (duplicate titles, missing alt text, etc.) and generates structured issue objects.
3. Database Write
AuditRepository.insertCrawledBatch atomically inserts:
- Crawled page rows
- Generated SEO issue rows
4. Link Discovery
The system extracts:
- Internal links (up to
MAX_STORED_LINKS_PER_PAGE = 500) - Newly discovered URLs (capped at
MAX_DISCOVERED_PER_BATCH = 20,000)
These feed back into the scratchpad frontier via scratchpad.recordBatch, keeping the crawl same-origin only and robots.txt aware.
Progress Reporting and Observability
After each persisted sub-batch, the workflow calls:
AuditProgressKV.pushCrawledUrls(auditId, urls.length);
Clients poll status via get_audit_status:
async function pollStatus(auditId: string) {
while (true) {
const { text } = await client.get_audit_status({
projectId: "proj_456",
auditId,
});
console.log(text); // "Audit abc123: crawling — 42/100 pages..."
if (/completed|failed/.test(text)) break;
await new Promise(r => setTimeout(r, 5_000));
}
}
Error Handling and Resilience
OpenSEO anticipates Worker evictions and memory pressure:
| Scenario | Response |
|---|---|
| Chunk timeout | Automatic retry with reduced RETRY_CRAWL_WINDOW |
| Memory overrun | Adaptive window shrinks on next iteration |
| Fetch failure | emptyPageResult records fetchClass: "error" with partial metadata |
| Workflow crash | Durable Objects preserve frontier state; execution resumes at last completed step |
This ensures partial audit results are never lost—even catastrophic failures leave recoverable data in the database.
Complete Code Example: Custom Crawl Script
For developers building on OpenSEO's internals, the crawlPage helper is directly importable:
import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
(async () => {
const result = await crawlPage(
"https://example.com/about",
1, // crawl depth from start URL
false, // not in sitemap
);
console.log(result.title); // "About Us – Example Corp"
console.log(result.links.length); // internal link count
console.log(result.images.filter(img => !img.alt).length); // missing alt tags
})();
Summary
- OpenSEO crawls websites using a chunked, Durable Object-backed workflow that maintains constant memory regardless of site size
- Three orchestration layers separate concerns: API tools (
runSiteAuditTool), workflow phases (SiteAuditWorkflow), and fetch primitives (crawlPage) - Adaptive rate limiting via the crawl window prevents Worker memory exhaustion
- Incremental persistence after every sub-batch ensures durability and enables real-time progress reporting
- Robust error handling with automatic retries and idempotent writes guarantees audit completion even under resource constraints
Frequently Asked Questions
What happens if a site has more pages than the maxPages budget?
OpenSEO's runCrawlPhase respects maxPages as a hard limit. Once attemptedPages reaches the budget, the loop terminates regardless of remaining frontier URLs. The scratchpad Durable Object preserves the unconsumed frontier, allowing future audits to resume where this one left off.
How does OpenSEO handle JavaScript-rendered content?
According to the source code, OpenSEO does not execute JavaScript during crawling. The crawlPage function fetches raw HTML and passes it to the static page-analyzer. For SPAs or heavy JavaScript sites, the crawler sees only the initial server-rendered HTML.
Why use Durable Objects instead of a database for the crawl frontier?
Durable Objects provide sub-millisecond coordination latency and strong consistency for the frontier state—critical for concurrent chunk execution. A relational database would introduce query overhead and contention during high-frequency claim/release operations. The scratchpad design (in AuditScratchpad.ts) keeps hot path operations in memory while still persisting progress to the database in batches.
Can I run OpenSEO's crawler outside of Cloudflare Workers?
The current architecture is tightly coupled to Cloudflare primitives: Workflows for orchestration, Durable Objects for state, and KV for progress reporting. The core fetch and parse logic in site-audit-workflow-helpers.ts and page-analyzer.ts could theoretically be extracted, but the chunking and durability guarantees would require reimplementation using equivalent primitives in another environment.
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 →