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)
Validator fast path —
lib/mutations/src/touched-paths.tsderives touched root paths directly from the typed op envelope instead of running a fulldiffYDocs(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 singlejob.createwrite the validator now materialises one job's JSON instead of all jobs.No-op detection fast path — same
touched-paths.tsmachinery viatouchedProjectionChanged.commitSubDocOp's pre/poststableSerialize(jsonProjectionFor(doc, sub))compare used to project the whole sub-doc twice per write; now it projects just the touched root paths.Empirical measurements (2026-05-17, prod, post-optimization):
Scenario Ceiling (sequential job.createbefore 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) Bulk-import perf gate = 50 —
ui/e2e/perf/bulk-import.spec.tsenforces 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.API-published limits —
GET /limits— single source of truth for the chunk size + cooldown. Client code does NOT hardcode values; it fetches them viafetchApiLimits()from@cv/workspace-client. The current shape:{ "version": 1, "bulkImport": { "maxBatch": 50, "cooldownMs": 2000, "maxConcurrency": 1 } }- Tunable per-deploy by editing
BULK_IMPORT_LIMITSinlib/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-clientchunks + sleeps according to the response. Any UX that does sequential POSTs against one workspace should route through it. Seelib/workspace-client/src/limits.ts+test/limits.test.tsfor the contract. - Falls back to
FALLBACK_LIMITS(same values as today'sBULK_IMPORT_LIMITS) if/limitsis unreachable; bulk flows never block on a limits probe.
- Tunable per-deploy by editing
Perf test —
ui/e2e/perf/bulk-import.spec.tsasserts 50 sequential/jobsPOSTs 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 toucheslib/mutations/src/touched-paths.ts,lib/mutations/src/validate.ts, orlib/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:
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.
Eliminate full-snapshot decode/encode per write — the current commit pattern reads the entire
workspace_sub_doc.snapshot_bytes, decodes throughdecodeSnapshotBytes(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.Docper 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.
- Keep a hot in-memory
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.
Related
CLAUDE.md §22— account-wide 100k req/day quota policy (different limit, samefree-tier-bitefamily).lib/mutations/src/touched-paths.ts— the validator fast path.lib/mutations/src/validate.ts— accepts optionalopto skipdiffYDocs.ui/e2e/perf/budgets.jsonbulk.import.50jobs— the budget entry.ui/e2e/perf/bulk-import.spec.ts— the regression test.lib/server/src/mirror.tsresolveWorkspaceWithCloudKeys— fused resolver, 1 RTT saved per request that needs both workspace resolution AND chain config.lib/server/src/chain-config.tsresolveChainConfigForWorkspace(env, id, prefetched?)— accepts the prefetched row to skip its first CAS read.