Open‑SEO Performance: 8 Critical Considerations for Edge‑Based SEO APIs
Open‑SEO optimizes performance by hard‑limiting GSC API rows to 1,000 per call, paginating with startRow, caching expensive payloads in Cloudflare R2/KV, and keeping DB queries minimal—all within Cloudflare Workers' strict CPU‑time constraints.
Open‑SEO is a Cloudflare‑Workers‑based SaaS that pulls data from Google Search Console (GSC) and third‑party APIs, storing results in Cloudflare KV, R2, and D1/Postgres. Because the service runs on edge workers, every request must respect strict limits imposed by the GSC API, the Cloudflare runtime, and underlying storage layers. Understanding these performance considerations ensures your SEO data pipelines remain fast and reliable.
API Row Limits and Pagination Strategy
Google Search Console caps the rows returned per API call. Open‑SEO enforces this constraint at the code level to prevent failures and wasted requests.
In src/server/features/gsc/searchAnalytics.ts, the following constants define the boundaries:
GSC_DEFAULT_ROW_LIMIT = 1000
GSC_MAX_ROW_LIMIT = 1000
When a user requests more data than a single call allows, the system falls back to pagination via startRow. This pattern appears throughout the search performance functions, ensuring large datasets are fetched in bounded chunks rather than attempted in a single, failing request.
Table Pagination Without Extra Count Queries
The UI displays "queries" and "pages" tables with client‑side pagination. Rather than executing a separate COUNT query to determine hasNextPage, the server function fetches pageSize + 1 rows and trims the extra row before returning the response.
This optimization lives in src/serverFunctions/searchPerformance.ts:
// Fetch one extra row to detect next page existence
rowLimit: pageSize + 1,
startRow: (page - 1) * pageSize,
The approach eliminates a database round‑trip while preserving accurate pagination metadata for the frontend.
Striking‑Distance Scan Limits
The "Search Performance" overview computes "striking‑distance" rows—queries ranking just below the first page. To prevent a costly full‑property scan, the code deliberately pulls a limited number of rows (currently 1,000) for this calculation.
This bounded scan, implemented in src/serverFunctions/searchPerformance.ts, trades exhaustive analysis for predictable, sub‑second response times.
Export Row Caps for GSC Compliance
CSV and Google Sheets exports must also respect the GSC per‑call maximum. The EXPORT_ROW_LIMIT constant enforces this boundary:
EXPORT_ROW_LIMIT = 1000
When users request larger exports, the system either paginates internally or surfaces a clear limitation—preventing hung requests and API quota exhaustion.
R2 Caching for Expensive Payloads
Certain operations generate expensive payloads: Lighthouse audits, AI‑search prompts, and large third‑party API responses. Open‑SEO caches these in Cloudflare R2 with a soft TTL stored in object metadata.
The implementation in src/server/lib/r2-cache.ts provides:
// Cheap "hot read" from edge cache
const cached = await getJsonFromR2(cacheKey);
if (cached) return cached;
// On miss: fetch, process, then cache
await putTextToR2(cacheKey, payload, { ttlDays: 30 });
Subsequent reads bypass the origin entirely, reducing latency and eliminating redundant computation.
KV Caching for Short‑Lived Reference Data
Per‑country location lookups and other small, stable datasets live in Cloudflare KV with a 30‑day TTL. This pattern appears in src/server/lib/dataforseo/serp-locations.ts:
const locations = await kv.get("serp-locations:us", "json");
if (!locations) {
const fresh = await fetchDataForSeoLocations();
await kv.put("serp-locations:us", JSON.stringify(fresh), {
expirationTtl: 60 * 60 * 24 * 30,
});
}
KV's edge‑replicated design makes these lookups effectively free compared to repeated external API calls.
Database Schema Minimization
Open‑SEO keeps D1/Postgres tables lean. Read‑only analytics are served primarily from cache layers (R2, KV), while the relational database holds:
- Project metadata
- User configuration
- Generated reports and user‑created content
This architectural choice, reflected in drizzle.config.ts schema definitions, minimizes database load and query latency.
Edge Runtime Constraints and Background Tasking
Cloudflare Workers enforce a ~50ms CPU‑time limit per request. Open‑SEO respects this by splitting heavy work across boundaries:
| Workload Type | Execution Strategy |
|---|---|
| Real‑time API responses | Lightweight, bounded GSC calls with caching |
| Large data processing | Background tasks via src/server/mcp/tools/* |
| Scheduled maintenance | Cron triggers and deferred execution |
The search-console-tools.ts and related MCP tools handle bulk operations outside the critical request path, ensuring user‑facing endpoints remain responsive.
Graceful Degradation on Auth Failures
When GSC tokens expire or are revoked, the system detects isExpectedGrantFailure and returns a lightweight "connected": false payload rather than propagating an error. This prevents cascading failures and keeps the UI functional for disconnected properties.
Found in src/server/features/gsc/services/GscService.ts:
if (isExpectedGrantFailure(error)) {
return { connected: false, error: "token_revoked" };
}
Code Examples
Paginated Performance Table Fetch
// Client request for page 2, 25 rows
const result = await getSearchPerformanceTable({
projectId: "proj_123",
dateRange: "last_28_days",
dimension: "query",
page: 2,
pageSize: 25,
});
Server implementation trims the extra detection row automatically.
Capped CSV Export
const csv = await exportSearchPerformanceTable({
projectId: "proj_123",
dateRange: "last_7_days",
dimension: "page",
// Implicitly capped at EXPORT_ROW_LIMIT (1000)
});
Manual R2 Cache Interaction
import { getJsonFromR2, putTextToR2 } from "@/server/lib/r2";
const cacheKey = "lighthouse/example.com/home";
// Attempt cheap read
const cached = await getJsonFromR2(cacheKey);
if (cached) return cached;
// Populate on miss
const fresh = await runLighthouseAudit(url);
await putTextToR2(cacheKey, JSON.stringify(fresh), {
metadata: { ttl: Date.now() + 86400000 },
});
return fresh;
Key Source Files
| File | Responsibility |
|---|---|
src/serverFunctions/searchPerformance.ts |
Server functions for UI; enforces limits, pagination, caching |
src/server/features/gsc/services/GscService.ts |
GSC client wrapper; token handling, error classification |
src/server/features/gsc/searchAnalytics.ts |
Request schema, limits, date range utilities |
src/server/lib/r2-cache.ts |
JSON cache layer with soft TTL on R2 |
src/server/lib/r2.ts |
Raw R2 get/put operations |
src/server/lib/gscClient.ts |
Low‑level GSC HTTP client, error types |
drizzle.config.ts |
Database schema definitions |
Summary
- Hard limits: GSC calls capped at 1,000 rows via
GSC_MAX_ROW_LIMIT - Smart pagination:
pageSize + 1fetches eliminate count queries - Multi‑layer caching: KV for small data, R2 for large payloads
- Minimal database: Analytics served from cache; DB holds metadata only
- Edge‑aware execution: Heavy work deferred to background tools
- Resilient degradation: Auth failures return lightweight status, not errors
Frequently Asked Questions
What happens if I request more than 1,000 rows from GSC?
Open‑SEO automatically paginates using the startRow parameter. The system fetches data in 1,000‑row chunks and aggregates results internally, transparent to the caller.
Why does Open‑SEO use R2 instead of KV for Lighthouse results?
R2 stores objects up to 5 TB with no per‑key size limits, while KV caps values at 25 MB. Lighthouse JSON and AI prompt responses often exceed KV limits, making R2 the appropriate tier for large, infrequently changing payloads.
How does the edge runtime limit affect my data exports?
Exports complete within the request timeout because they're capped at EXPORT_ROW_LIMIT (1,000) rows. Larger exports require scheduled background tasks rather than synchronous API calls.
What triggers the "connected: false" response?
This occurs when GscService detects an expected grant failure—typically an expired or revoked OAuth token. The UI receives a minimal status payload without the full error stack, allowing graceful re‑authentication flows.
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 →