# How Audit Capacity Limits and Tier-Based Throttling Work in Open SEO

> Understand Open SEO audit capacity limits and tier-based throttling. Learn how page crawl caps and capacity units are enforced at runtime to optimize your SEO strategy.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-10

---

**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`](https://github.com/every-app/open-seo/blob/main/src/shared/audit-limits.ts) and [`src/server/features/audit/services/audit-capacity.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/audit-limits.ts) at lines 5–6:

```ts
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`](https://github.com/every-app/open-seo/blob/main/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:

```ts
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`](https://github.com/every-app/open-seo/blob/main/audit-capacity.test.ts) confirm the clamping behavior:

```ts
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`](https://github.com/every-app/open-seo/blob/main/audit-capacity.ts)) computes a cost preview:

```ts
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

```ts
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:

1. Insert audit row (occupies a "running audit" slot)
2. Compute capacity estimate
3. Compare against tier limits
4. Throw error if exceeded

The enforcement logic at line 98 of [`AuditService.ts`](https://github.com/every-app/open-seo/blob/main/AuditService.ts):

```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

```ts
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`](https://github.com/every-app/open-seo/blob/main/src/client/lib/error-messages.ts):

```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:

```tsx
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`](https://github.com/every-app/open-seo/blob/main/audit-capacity.test.ts) documents expected boundaries:

```ts
// 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`](https://github.com/every-app/open-seo/blob/main/audit-limits.ts) and [`audit-capacity.ts`](https://github.com/every-app/open-seo/blob/main/audit-capacity.ts) to inspect boundaries without triggering enforcement:

```ts
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`](https://github.com/every-app/open-seo/blob/main/audit-capacity.ts) to inject organization-specific boundaries. The enforcement architecture remains identical—only the `limits` object changes.