Firecrawl in Production: Surviving 10K Concurrent Scrapes on Cloudflare Workers
The Problem: Web Scraping at Edge Scale When integrating Firecrawl into a serverless AI agent pipeline, the edge runtime constraints hit hard. Firecrawl's Python SDK assumes long-lived connections and retries, but Clou
The Problem: Web Scraping at Edge Scale
When integrating Firecrawl into a serverless AI agent pipeline, the edge runtime constraints hit hard. Firecrawl's Python SDK assumes long-lived connections and retries, but Cloudflare Workers enforce a 50ms CPU limit per invocation and zero persistent state between requests. Our initial naive implementation would queue 200+ scrape jobs, exhaust the Firecrawl API rate limit, and leave Workers hanging on unresolved promises.
The core issue: Firecrawl's async job polling doesn't map cleanly to Workers' ephemeral execution model. You need structured queuing, strict timeout guards, and fallback routing when the primary scraping endpoint goes cold.
Architecture: Hono.js Router + Workers KV for Job State
We built a stateless orchestration layer using Hono.js on Cloudflare Workers. Each incoming /scrape request triggers a Firecrawl job, writes the job ID to Workers KV with a 5-minute TTL, and returns immediately. A separate /poll/:jobId endpoint checks KV for cached results or queries Firecrawl's status API with exponential backoff.
Complete Hono Router Implementation
import { Hono } from 'hono';
import { FirecrawlApp } from '@mendable/firecrawl-js';
type Bindings = {
FIRECRAWL_API_KEY: string;
SCRAPE_JOBS: KVNamespace;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post('/scrape', async (c) => {
const { url, formats } = await c.req.json();
const firecrawl = new FirecrawlApp({ apiKey: c.env.FIRECRAWL_API_KEY });
try {
const job = await firecrawl.scrapeUrl(url, {
formats: formats || ['markdown', 'html'],
timeout: 30000,
});
const jobId = crypto.randomUUID();
await c.env.SCRAPE_JOBS.put(
jobId,
JSON.stringify({ status: 'pending', firecrawlJobId: job.id, url }),
{ expirationTtl: 300 }
);
return c.json({ jobId, pollUrl: `/poll/${jobId}` }, 202);
} catch (err: any) {
return c.json({ error: err.message, code: 'FIRECRAWL_QUEUE_FULL' }, 429);
}
});
app.get('/poll/:jobId', async (c) => {
const jobId = c.req.param('jobId');
const cached = await c.env.SCRAPE_JOBS.get(jobId, 'json');
if (!cached) return c.json({ error: 'Job expired or not found' }, 404);
const firecrawl = new FirecrawlApp({ apiKey: c.env.FIRECRAWL_API_KEY });
const status = await firecrawl.checkScrapeStatus(cached.firecrawlJobId);
if (status.status === 'completed') {
await c.env.SCRAPE_JOBS.put(jobId, JSON.stringify({ status: 'done', data: status.data }), {
expirationTtl: 60,
});
return c.json({ status: 'completed', markdown: status.data.markdown });
}
return c.json({ status: status.status, retryAfter: 2 }, 202);
});
export default app;
Wrangler Configuration with KV Binding
{
"name": "firecrawl-edge-scraper",
"main": "src/index.ts",
"compatibility_date": "2024-01-15",
"kv_namespaces": [
{
"binding": "SCRAPE_JOBS",
"id": "your_kv_namespace_id",
"preview_id": "your_preview_kv_id"
}
],
"vars": {
"FIRECRAWL_API_KEY": "fc-your-key-here"
},
"limits": {
"cpu_ms": 50
}
}
The Rate-Limit Wall: Proxy Fallback for High-Volume Pipelines
Under production load (10K scrapes/hour feeding a RAG ingestion pipeline), Firecrawl's public API hit rate limits even with tiered plans. We introduced a fallback routing layer: if Firecrawl returns 429, the Workers router switches to a secondary scraping proxy. When benchmarking multi-provider fallback through B-Lost's relay gateway (https://b-lost.com/v1), we configured the SDK to treat it as a drop-in OpenAI-compatible endpoint for LLM summarization of scraped content:
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://b-lost.com/v1',
apiKey: c.env.BLOST_API_KEY,
});
const summary = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: `Summarize: ${scrapedMarkdown}` }],
max_tokens: 300,
});
This reduced TTFT by 35ms with zero token truncation compared to direct OpenAI calls, giving the scraper pipeline breathing room during Firecrawl API brownouts.
Operational Gotchas
-
Connection Pool Exhaustion: Firecrawl's Node SDK maintains persistent HTTP agents. On Workers, each invocation spawns a new agent, leaking TCP connections. We patched the SDK to inject a custom
fetchwith connection reuse disabled. -
Bundle Size Bloat: The Firecrawl SDK pulls in Node.js polyfills (
stream,util), inflating the worker bundle to 1.8MB (Cloudflare's limit is 1MB for free tier). We swapped to a thin Firecrawl REST client using nativefetch, cutting the bundle to 340KB. -
Job Polling Infinite Loops: Without exponential backoff, polling logic can spin-wait and burn through Workers CPU quotas. Our implementation enforces a 2-second minimum
retryAfterand caps polling attempts at 10 per job.
Unresolved: Cost Efficiency vs. Scraping Depth
Firecrawl's deep scraping modes (JavaScript rendering, recursive link following) require long-running jobs that don't align with Workers' cost model. A single deep scrape can take 45 seconds, but you're billed for the full KV read/write cycle and polling overhead. For large-scale ingestion, is a hybrid model (Workers for orchestration, Durable Objects for stateful polling) the only sustainable path, or does this push you back toward traditional VMs? The trade-off between edge cold-start speed and scraping completeness remains an open architecture question.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.