How Audit Capacity Limits and Tier-Based Throttling Work in Open SEO
Open SEO enforces hard caps on pages crawled and "capacity units" consumed per audit, with tier-specific limits checked at runtime before any crawl begins.
Open SEO is an open-source SEO auditing platform by every-app that protects infrastructure through audit capacity limits and tier-based throttling. These mechanisms prevent resource abuse by binding crawl scope to account tiers—free, paid, or self-hosted—and rejecting oversized requests before they start.
What Are Audit Capacity Limits?
Audit capacity limits are dual constraints that govern every audit:
- Page limits — the maximum URLs a single audit may crawl
- Capacity units — an abstract cost metric combining page count with Lighthouse analysis overhead
Both values are tier-bound constants defined in src/shared/audit-limits.ts and src/server/features/audit/services/audit-capacity.ts. The system evaluates these limits in AuditService.start() before spawning crawler workers.
Tier Definitions and Limits
Open SEO recognizes three account tiers with escalating boundaries:
| Tier | Max Pages | Max Capacity Units |
|---|---|---|
| Free | 50 pages | 2,000 units |
| Paid | 10,000 pages | 100,000 units |
| Self-hosted | Unlimited | Unlimited |
The free tier limits originate in audit-limits.ts at lines 5–6:
export const AUDIT_LIMITS = {
free: { maxPages: 50, maxCapacityUnits: 2000 },
paid: { maxPages: 10000, maxCapacityUnits: 100000 },
} as const;
Self-hosted deployments bypass enforced ceilings. As implemented in audit-capacity.ts lines 37–38, self-hosted accounts inherit the paid page bound of 10,000 for display purposes but receive Infinity for actual capacity enforcement.
How Page Limits Are Calculated
User-submitted maxPages values pass through clampAuditMaxPages() before storage. This function sanitizes input against tier minimums and maximums:
export function clampAuditMaxPages(maxPages?: number) {
// Enforces: min 10, max 10,000, default 50 for free tier
// Returns bounded integer based on account tier
}
Unit tests in audit-capacity.test.ts confirm the clamping behavior:
expect(clampAuditMaxPages()).toBe(50); // default for omitted value
expect(clampAuditMaxPages(1)).toBe(10); // minimum enforced
expect(clampAuditMaxPages(50000)).toBe(10000); // maximum cap
This preprocessing ensures no downstream component receives out-of-range values.
How Capacity Units Are Estimated
Capacity units represent anticipated resource consumption. The function getEstimatedAuditCapacity() (starting at line 49 in audit-capacity.ts) computes a cost preview:
export function getEstimatedAuditCapacity(input: {
maxPages?: number;
lighthouseStrategy: 'none' | 'auto';
}) {
const pagesTotal = clampAuditMaxPages(input.maxPages);
const unitsPerPage = input.lighthouseStrategy === 'auto' ? 2 : 1;
const total = pagesTotal * unitsPerPage;
return { pagesTotal, unitsPerPage, total, strategy: input.lighthouseStrategy };
}
The unitsPerPage multiplier increases from 1 to 2 when Lighthouse analysis is enabled. This estimate allows preemptive rejection of expensive audits.
Practical Estimation Example
import { clampAuditMaxPages, getEstimatedAuditCapacity } from '@/server/features/audit/services/audit-capacity';
// Free user requests 200 pages with Lighthouse enabled
const maxPages = clampAuditMaxPages(200); // → 50 (tier-limited)
const estimate = getEstimatedAuditCapacity({
maxPages,
lighthouseStrategy: 'auto',
});
console.log(estimate);
// { pagesTotal: 50, unitsPerPage: 2, total: 100, strategy: 'auto' }
The clamped 50 pages yield 100 capacity units—well below the free tier's 2,000 ceiling.
Tier-Based Throttling Enforcement
Throttling occurs at audit initiation in AuditService.start(). The sequence is:
- Insert audit row (occupies a "running audit" slot)
- Compute capacity estimate
- Compare against tier limits
- Throw error if exceeded
The enforcement logic at line 98 of AuditService.ts:
const reservation = getEstimatedAuditCapacity({ maxPages, lighthouseStrategy });
const limits = getTierLimits(accountTier);
if (reservation.total > limits.maxCapacityUnits) {
throw new Error('Audit capacity reached');
}
Critical ordering note: The audit row is inserted before the capacity check. This guarantees that concurrent requests cannot circumvent the "running audit" quota through race conditions—each candidate audit consumes a slot before validation.
Complete Server-Side Audit Start Pattern
import { AuditService } from '@/server/features/audit/services/AuditService';
async function launchConfiguredAudit(
projectId: string,
startUrl: string,
requestedPages?: number
) {
// All validation happens inside AuditService.start():
// - Pages clamped to tier maximum
// - Capacity estimated with strategy multiplier
// - Total compared against tier ceiling
// - Error thrown if limits exceeded
const audit = await AuditService.start({
projectId,
startUrl,
maxPages: requestedPages,
lighthouseStrategy: 'auto',
});
return { auditId: audit.id, pagesReserved: audit.config.maxPages };
}
User-Facing Error Handling
When capacity limits block an audit, the system surfaces specific messaging. The error string constant resides in src/client/lib/error-messages.ts:
export const AUDIT_PAGE_LIMIT_EXCEEDED =
"You've reached audit capacity for your account. Upgrade to run larger audits.";
Client components can intercept server rejections and display tier-appropriate guidance:
import { AUDIT_PAGE_LIMIT_EXCEEDED } from '@/client/lib/error-messages';
async function handleAuditStart(config: AuditConfig) {
try {
return await api.audits.start(config);
} catch (error) {
if (error.message.includes('capacity reached')) {
// Show upgrade CTA for free users, or resource warning for paid
showCapacityModal(AUDIT_PAGE_LIMIT_EXCEEDED);
}
throw error;
}
}
The UI proactively disables the "Start audit" button when maxPages exceeds FREE_MAX_AUDIT_PAGES (50), displaying a tooltip referencing the same constant.
What "Capacity Units" Actually Model
Capacity units abstract real infrastructure costs:
- Base cost: 1 unit per crawled page (network fetch, parsing, storage)
- Lighthouse premium: +1 unit per page when
strategy: 'auto'(Chrome instance overhead, compute time)
Free tier users effectively receive 40 pages of Lighthouse analysis (50 × 2 = 100 units minimum; actual ceiling permits more due to math). Paid users can execute 50,000 pages without Lighthouse, or 50,000 with, depending on configuration.
Self-hosted operators face no artificial limits—the platform assumes direct infrastructure ownership and cost responsibility.
Testing the Limit System
The test suite in audit-capacity.test.ts documents expected boundaries:
// Free tier ceiling validation
const freeAudit = getEstimatedAuditCapacity({
maxPages: 50,
lighthouseStrategy: 'auto'
});
expect(freeAudit.total).toBeLessThan(AUDIT_LIMITS.free.maxCapacityUnits);
// Paid tier headroom confirmation
const paidAudit = getEstimatedAuditCapacity({
maxPages: 10000,
lighthouseStrategy: 'auto'
});
expect(paidAudit.total).toBeLessThan(AUDIT_LIMITS.paid.maxCapacityUnits);
These assertions freeze tier behavior—any change to constants breaks the build.
Summary
- Audit capacity limits combine page counts (50–10,000) with abstract capacity units (2,000–100,000) to throttle resource consumption
- Three tiers apply: free (strict), paid (expanded), self-hosted (unlimited)
- Input clamping via
clampAuditMaxPages()prevents out-of-range requests before processing - Cost estimation via
getEstimatedAuditCapacity()previews resource needs using Lighthouse strategy multipliers - Runtime enforcement in
AuditService.start()rejects exceeding audits with user-actionable errors - Race-condition safety achieved by inserting audit rows before capacity validation
Frequently Asked Questions
How do I check my current tier's limits programmatically?
Import the constants from audit-limits.ts and audit-capacity.ts to inspect boundaries without triggering enforcement:
import { AUDIT_LIMITS } from '@/shared/audit-limits';
import { getTierLimits } from '@/server/features/audit/services/audit-capacity';
const freeLimits = getTierLimits('free'); // { maxPages: 50, maxCapacityUnits: 2000 }
Why does Open SEO reject my audit after showing it as "starting"?
The platform inserts audit records before final capacity checks to prevent concurrent over-allocation. This "pessimistic locking" pattern means your audit briefly occupies a slot even if rejected moments later. Failed audits are cleaned automatically; no capacity units are consumed for rejected attempts.
Can self-hosted deployments impose custom limits?
Yes. The getTierLimits() function returns Infinity for self-hosted capacity, but you may fork audit-capacity.ts to inject organization-specific boundaries. The enforcement architecture remains identical—only the limits object changes.
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 →