Performance: Cloudflare Workers free-tier limits

TL;DR

Limit Free tier Where it bites in CareerVector
CPU per request 10 ms Hot path: every typed write goes through validateUpdate + Y.Doc decode/encode. Crosses 10 ms in the api worker between the 90th and 130th sequential job.create against the same workspace.
Wall time per request 30 s Not currently hit; journey.live.* budgets clamp LLM calls well under.
Memory per worker 128 MB Not currently hit.
Subrequests per worker invocation 50 Not currently hit — most endpoints make ≤ 3 outbound calls.
Account-wide requests per day 100 000 See CLAUDE.md §22. Quota tool at ~/.agent/tools/cf-quota/.

The CPU per request limit is the only one in current play. When exceeded, CF kills the request mid-flight and serves a generic 503 HTML error page from <!DOCTYPE html> (NOT JSON — the worker never gets to respond).

What we did (2026-05-17)

  1. Validator fast pathlib/mutations/src/touched-paths.ts derives touched root paths directly from the typed op envelope instead of running a full diffYDocs (which projected the entire jobs/cv_profile/cl_profile root to JSON on both sides of the update and stringify-compared every child). For a single job.create write the validator now materialises one job's JSON instead of all jobs.

  2. No-op detection fast path — same touched-paths.ts machinery via touchedProjectionChanged. commitSubDocOp's pre/post stableSerialize(jsonProjectionFor(doc, sub)) compare used to project the whole sub-doc twice per write; now it projects just the touched root paths.

  3. Empirical measurements (2026-05-17, prod, post-optimization):

    Scenario Ceiling (sequential job.create before 503)
    Empty workspace, cold worker (post-deploy) 92 ← worst case
    Empty workspace, warm worker 178–182
    50-job workspace + 60 s cool, then burst 87 more (137 total)
  4. Bulk-import perf gate = 50ui/e2e/perf/bulk-import.spec.ts enforces this as the CI-safe lower bound. In isolation the api handles ~90 sequential creates cold; the cap is lower because CF compresses the per-source ceiling sharply under sustained burst load (any other prod-targeted perf suite running in the same job effectively eats from the same budget). 50 has passed reliably across every burst pattern we've measured today, including back-to-back runs.

  5. API-published limits — GET /limits — single source of truth for the chunk size + cooldown. Client code does NOT hardcode values; it fetches them via fetchApiLimits() from @cv/workspace-client. The current shape:

    { "version": 1, "bulkImport": { "maxBatch": 50, "cooldownMs": 2000, "maxConcurrency": 1 } }
    
    • Tunable per-deploy by editing BULK_IMPORT_LIMITS in lib/server/src/api-limits.ts. No client redeploy required.
    • Edge-cacheable (max-age=300, stale-while-revalidate=3600) so the typical SPA bootstrap costs ~0 RTTs after the first call.
    • Helper runBatched(items, limits, fn) in @cv/workspace-client chunks + sleeps according to the response. Any UX that does sequential POSTs against one workspace should route through it. See lib/workspace-client/src/limits.ts + test/limits.test.ts for the contract.
    • Falls back to FALLBACK_LIMITS (same values as today's BULK_IMPORT_LIMITS) if /limits is unreachable; bulk flows never block on a limits probe.
  6. Perf testui/e2e/perf/bulk-import.spec.ts asserts 50 sequential /jobs POSTs all return 201 against a fresh workspace. A single 503 fails the test and points at the validator / snapshot decode-encode hot path. Re-run after any commit that touches lib/mutations/src/touched-paths.ts, lib/mutations/src/validate.ts, or lib/server/src/workspace-subdoc.ts.

What the new QA platform should test

When the dedicated QA platform comes online, treat the CF free-tier ceiling as a first-class budget. Concretely:

Surface Test Failure signal
POST /workspaces/:id/jobs 50 sequential against a fresh ws any 503 → regression
POST /workspaces/:id/jobs 50 sequential against a ws that already has 50 jobs (warm-on-warm) any 503 → either validator regressed or snapshot encode/decode bloated
POST /workspaces/:id/ops with selection.update × 50 sequential against a ws with a 100-node cv_profile tree any 503 → cv_profile diff/projection bloated
commitBatchEnvelope cross-sub-doc one batch op touching 20 sub-ops request CPU < 8 ms (2 ms safety margin)
Score recompute (settings.update flip + 50 job.update) sequential against a 50-job ws all 50 commits return 200

Track the per-request CPU time explicitly — wrangler tail --format pretty surfaces Exceeded CPU Limit as the error. CF's GraphQL analytics API exposes cpuTime per request; the existing ~/.agent/tools/cf-quota/check.mjs can be extended to histogram cpuTime by route.

Migration path if the ceiling becomes user-visible

Two paths, ordered by effort:

  1. Workers Standard ($5/mo) — CPU cap goes 10 ms → 30 s. Removes the budget pressure entirely. Almost certainly the right answer once we have a paying user.

  2. Eliminate full-snapshot decode/encode per write — the current commit pattern reads the entire workspace_sub_doc.snapshot_bytes, decodes through decodeSnapshotBytes (zstd), applies the op, re-encodes, writes back. For a 100-job workspace the snapshot is dozens of KB; decode + encode dominates the per-write CPU now that the validator no longer does. Options:

    • Keep a hot in-memory Y.Doc per workspace in a Durable Object that owns the canonical state, append op bytes on write, snapshot opportunistically (this is essentially what the realtime DO already does for fanout; doing it for durability is the next step).
    • Move the canonical snapshot to R2 with append-only update logs in D1 — defer reconstruction to read time, paid by readers rather than writers.

Pick (1) if revenue exists; (2) is the right answer if we stay on the free tier indefinitely.

Future work — cell-origins incremental projection

GET /workspaces/:id/cell-origins walks the full workspace_sub_doc_ops history for the jobs sub-doc on every cache miss. The endpoint is edge-cached by clock (Cache-Control: public, immutable, max-age=86400 on the cold response, key includes ?since=clock), so steady-state reads hit the CDN. But on a clock bump (every jobs write) the cold response has to rebuild the full origins map. Cost scales with op count, not change size.

Workers single-threaded JS plus sync zstd-wasm means Promise.all over the decompression loop doesn't help. The real win is incremental: cache the projection up to clock N (in R2 or D1), and on the next miss decompress only ops in (N, currentClock]. Worth doing once we see a workspace cross ~1k ops; deferred until then.

  • CLAUDE.md §22 — account-wide 100k req/day quota policy (different limit, same free-tier-bite family).
  • lib/mutations/src/touched-paths.ts — the validator fast path.
  • lib/mutations/src/validate.ts — accepts optional op to skip diffYDocs.
  • ui/e2e/perf/budgets.json bulk.import.50jobs — the budget entry.
  • ui/e2e/perf/bulk-import.spec.ts — the regression test.
  • lib/server/src/mirror.ts resolveWorkspaceWithCloudKeys — fused resolver, 1 RTT saved per request that needs both workspace resolution AND chain config.
  • lib/server/src/chain-config.ts resolveChainConfigForWorkspace(env, id, prefetched?) — accepts the prefetched row to skip its first CAS read.
Source: wiki/content/runbooks/PERF-CF-LIMITS.md