Skip to content

Performance budget

Per-surface numeric targets — frontend Core Web Vitals, bundle weight, backend query latency, scraper run-time, and total monthly hosting cost. Each number is anchored in a persona, retailer, or RFC fact; nothing is a Lighthouse default carried over without a Lebanese-context rationale.

Produced for Foundation: KPIs + observability + performance budget (#43). Polices several metrics in kpis.md. Consumed by #28 page design, #29 DB / queries, #18 scraper queue, and the cost line of RFC-0001.

1. Lebanese-mobile reality (the rationale layer)

The numeric targets below exist because of these facts. If a future contributor wants to relax a budget, they need to argue against this layer first.

  • Mobile-first is non-negotiable. Per personas.md §5.2, the three primary personas (Karim, First-time builder, Casual customer) are phone-primary. Desktop is the secondary device for serious browse, not the entry surface.
  • 4G dominance, sketchy outside Mount Lebanon. Mobile-data primary; home fiber when Beirut + family-supported. Per-page weight matters acutely on the long tail of the visitor base.
  • Generator-power outages. Lebanese visitors regularly tab away mid-session because the building lost power. Pages that depend on long-lived JS state without server-side hydration penalty pay double during the recovery.
  • Cloudflare Beirut PoP exists. Cloudflare's BEY edge gives 10–30ms RTT to Beirut visitors per RFC-0001 § Why. This is the latency win the hosting choice was made for; the budget enforces realising it.
  • Image-heavy content surface. Build flows and category browse render dozens of product thumbnails per page. Image weight is the dominant cost on the visit budget — covered explicitly in §2.2.
  • Trust-density expectation per competitive-landscape.md §4.4. Lebanese retailers compete on trust signals above the fold (PCAndParts: WhatsApp + Telegram + IG + phone + "since 1998"). Trust signals must render in the LCP frame, not after JS hydration.

2. Frontend budget

2.1 Core Web Vitals

Targets are P75 over a 28-day rolling window of mobile sessions, which is the bar Cloudflare Web Analytics and the Chrome Web Vitals report both default to.

Metric Target Hard ceiling Rationale
LCP (Largest Contentful Paint) ≤ 2.5s P75 mobile 4.0s Google's "good" threshold. Achievable on 4G given the Cloudflare BEY PoP (RFC-0001) and a server-rendered RSC shell. Personas §5.2 makes mobile non-negotiable.
INP (Interaction to Next Paint) ≤ 200ms P75 mobile 500ms INP replaced FID as the responsiveness vital in 2024; current Web Vitals "good" threshold. Build flow is interaction-heavy (slot pickers, filters) — this is the relevant ceiling, not FID.
CLS (Cumulative Layout Shift) ≤ 0.1 P75 0.25 Standard "good." Particularly important on product-detail pages where the image swap + spec table rendering can shift layout if not reserved.
TTFB (Time to First Byte) ≤ 600ms P75 mobile 1.0s The Cloudflare BEY PoP (10–30ms RTT) plus a Worker invocation (cold ~50ms, warm ~10ms) plus Hyperdrive-cached Postgres read (typical ~20–40ms) sits comfortably under 600ms. If we trip the ceiling, it's a real-world signal that a bad query or non-edge fallback snuck in.
FCP (First Contentful Paint) ≤ 1.8s P75 mobile 3.0s Tracked alongside LCP; helps diagnose render-blocking resources separately from LCP element load.

Where these are measured. Cloudflare Web Analytics RUM beacon (RFC-0007 §2 Web Analytics) — auto-enabled, free, captures all five with P50/P75/P90/P99 breakdowns by URL/browser/OS/country/element. No external SaaS required.

2.2 Bundle + asset weight

Asset Target Hard ceiling Rationale
Initial JS (first navigation, gzipped) ≤ 100 KB gz 150 KB RSC + sane component imports + tree-shaking + slim PostHog/CFWA scripts (per RFC-0007). The Tailwind 4 + Next 16 + React 19 stack tree-shakes aggressively; meeting this is the test that we did configure it right, not the hope we did.
Initial CSS ≤ 30 KB gz 50 KB Tailwind 4's @theme inline config purges aggressively; if we trip 30 KB, custom CSS is leaking.
Per-product hero image ≤ 200 KB 350 KB Per personas.md §5.2 image-weight implication. AVIF first via next/image, WebP fallback, JPEG only as last resort. Lebanese retailer thumbnails come in raw at 400-800 KB; we re-compress at proxy or ingest time.
Per-listing card thumbnail ≤ 25 KB 50 KB Matters because category pages render 12-30 of these per scroll. Aggregate weight on a category page = thumbnail × N.
Total weight, build-page initial render (gzipped, all resources) ≤ 600 KB 1.0 MB Build page is the heaviest surface — lots of slot pickers, multi-retailer rows, compat panel. Hard ceiling is the pain threshold for 4G first-load.
Total weight, product-detail initial render ≤ 400 KB 750 KB Includes hero image + spec table + retailer list + price-history-chart-deferred-by-default.
Total weight, category-listing initial render ≤ 500 KB 800 KB Includes 12 listing cards × thumbnail + filter UI.
Fonts Switzer (text) + JetBrains Mono (code) via next/font self-hosted, subset Latin only at M1 Per tech-stack.md § Frontend. No FOUT/FOIT — font-display: optional on JetBrains, swap on Switzer. AR + FR subsets deferred per ADR-0004.
Largest blocking script (3rd party) ≤ 0 — none allowed No tag managers, no chat widgets, no embedded social SDKs. The only external scripts permitted are the Cloudflare Web Analytics beacon (~5–6 KB gz) and the Sentry browser SDK (~35-55 KB gz with replay disabled).

Where these are measured. next build output (per-route bundle report) gates pre-deploy. Workers Logs can capture per-route asset weight via a one-off measurement Worker. CFWA's RUM-beacon captures real-world transferred bytes per visit.

2.3 Page-specific surface budgets

The numbers above bind globally; specific high-traffic pages carry tighter targets because their persona context demands it.

Page LCP target Bundle target Why this surface is special
/build ≤ 2.0s ≤ 110 KB JS gz Karim's primary surface; FPS-per-dollar shoppers don't tolerate a slow page. The all-slots-at-once layout (ADR-0002) renders many rows server-side — JS budget is for interactivity only.
/products/[slug] (detail) ≤ 2.0s ≤ 90 KB JS gz Casual flow's primary surface (personas.md §3.3). Casual customer is one-shot, low-engagement — page must be fast or they bounce. Hero image is the LCP element.
/products (category list) ≤ 2.5s ≤ 100 KB JS gz 12+ thumbnails on first paint; image weight dominates. Use next/image with priority only on first-row hero, lazy below.
/builds/[id] (shared build) ≤ 2.0s ≤ 90 KB JS gz The viral surface. A FB-group share that loads slowly kills the long-tail SEO / sharing loop (personas.md §5.5).
/ (landing) ≤ 2.0s ≤ 80 KB JS gz First impression. Trust badges (per competitive-landscape.md §5.1) must be in the LCP frame.
/api/go/r/[retailerId]/p/[listingId] ≤ 50ms p95 n/a This is a 302 redirect — pure server time, no payload. See §3 backend budget.

3. Backend budget

The query envelope that the Worker has to live inside to make the TTFB target achievable. Per RFC-0001, queries flow through Cloudflare Hyperdrive (connection pool + edge cache for read-mostly Postgres on Neon Frankfurt).

Operation Target Hard ceiling Rationale
Product-list query (/products, /products?category=...) ≤ 100ms p95 250ms Reads the canonical Product table + latest ListingPrice per listing, paginated. Hot-path; gates the category-list TTFB.
Search query (RFC-0003 Postgres FTS) ≤ 300ms p95 600ms FTS over Product + Listing titles. Heavier than product-list because it ranks. RFC-0003 defines whether Postgres FTS holds at M1 catalog scale (~5k Products, ~10k Listings) or whether we move to Meilisearch.
Build-page initial query (load build state + per-slot retailer rows) ≤ 250ms p95 500ms Joins Build → BuildItem → Listing → Retailer → latest ListingPrice. Worst-case query in the app.
Click-out 302 (/api/go/r/[retailerId]/p/[listingId]) ≤ 50ms p95 100ms Insert one Click row + return 302. Pure write; no rendering. The north-star metric depends on this not feeling laggy — Karim clicking 9 retailer rows in a build session can't feel each one.
Worker cold start (any handler) ≤ 100ms 250ms Cloudflare Workers cold-start is typically 5–50ms; the ceiling exists so we notice if nodejs_compat shims start adding weight.
Worker P95 wall-clock (any invocation) ≤ 600ms 1.0s The TTFB ceiling minus Cloudflare PoP RTT. If a Worker invocation crosses 1s, the page misses TTFB.
Hyperdrive cache hit ratio ≥ 70% on read-mostly endpoints Read-heavy endpoints (product list, product detail) should see most of their queries served from the edge cache. Lower indicates either cache configuration drift or write-heavy traffic patterns the cache isn't shaped for.

Where these are measured. Cloudflare Workers Observability dashboard (per-route P50/P75/P95/P99 wall-clock + CPU). Workers Logs structured-event payloads carry per-query duration. Hyperdrive cache stats expose hit-ratio. No per-query APM at M1 — see RFC-0007 on why distributed tracing is deferred (Cloudflare performance.now() zeroes for CPU-bound spans, making it low-signal until tracing matures).

4. Scraper budget

Per architecture/ingest-pipeline.md and retailers.md. Worker scheduled-handler invocation under RFC-0001 Cron Triggers + pg-boss queue per RFC-0002.

Operation Target Hard ceiling Rationale
Per-retailer scrape (one retailer × all categories) ≤ 5 min wall-clock 10 min Each retailer's CPU/GPU/MB/RAM/Storage/PSU/Case/Cooler categories. Retailers vary in catalog size (Macrotronics ~300 SKUs to Mojitech 12,238 SKUs per retailers.md); largest still completes inside 5 min at conservative 1 req/sec rate.
Full roster scrape (all retailers in one nightly run) ≤ 30 min total 60 min M1 = 3 retailers × 8 categories = 24 jobs. M2 = 6-8 retailers × 8 = 48-64 jobs (#20). Sequential per retailer (politeness), parallel across retailers — fits comfortably.
Match step (per scraped listing) ≤ 200ms p95 500ms Matcher SQL over Product table per listing per retailer. Critical-path: too slow and the scrape window blows.
LLM extraction (#21, M2) per listing ≤ 3s p95 (async, off the scrape critical path) 10s Async per-listing — does not block the scrape run. Cost target: < $0.001/extraction at Haiku-class pricing.
Per-job concurrency cap 1 job per retailer at any time 2 Politeness — Lebanese retailers don't anti-bot today (per retailers.md §2-§3) but we don't want to be the reason they start.
Workers Cron Trigger cadence 1 nightly full-roster + on-demand reruns Per RFC-0001 § Behaviour and RFC-0002 § Worker process model. At 24-120 jobs/day this fits one cron firing draining the pg-boss queue.
Scraper output volume < 50 MB raw HTML / day 200 MB Ingest pipeline keeps only parsed Listing rows; raw HTML isn't persisted. The cap exists so a runaway parser doesn't burn Workers CPU budget.

Where these are measured. pg-boss pgboss.job table (job start/end timestamps, status, retries). Workers Logs (structured events per scrape). Workers Analytics Engine for per-retailer success-rate rollup.

5. Cost budget

Per RFC-0001 § Why and RFC-0007 § Cost summary. The total spend ceiling that keeps 961tech a viable evening project.

Layer M1 target Hard ceiling
Cloudflare Workers Paid plan (web app + scraper Cron Triggers + Workers Logs + Web Analytics + Notifications) $5/mo $5/mo
Neon Postgres $0/mo (free tier) $19/mo (Launch tier when free runs out)
Upstash Redis n/a — RFC-0002 picks pg-boss; no Redis
Sentry (error tracking) $0/mo (Developer free) $26/mo (Team — only if we need third-party-integration alerts)
Axiom (long-retention logs) $0/mo (Personal free) $25/mo (Cloud — only at >500 GB/mo, far beyond M1)
Cloudflare Web Analytics + Workers Analytics Engine + Workers Observability $0 (free / included in Workers Paid) $0
Logpush to R2 (1 GB/mo M1 archive) < $0.10/mo $1/mo
Domain + DNS + TLS (already paid; CF zone)
All-in M1 infra + observability ≤ $7/mo $20/mo
All-in M2 infra + observability (5× M1 traffic) ≤ $15/mo $50/mo

The hard rule per ticket: if observability spend alone exceeds $10/mo at M1, RFC-0007 was wrong and gets re-opened. The chosen stack (Cloudflare-native + Sentry Free + Axiom Free) lands at $0 incremental observability spend, so this is a comfortable floor.

6. Verification gates

How each budget gets enforced before / during deploy.

Budget Gate When
Core Web Vitals (§2.1) Lighthouse CI mobile budget + CFWA RUM dashboard Lighthouse: PR pre-merge once added (M2); RUM: continuous post-deploy
Bundle weight (§2.2) next build size report check + @next/bundle-analyzer review Pre-deploy (M2 CI)
Per-page surface budgets (§2.3) Manual: visit each page in Lighthouse mobile profile pre-merge for #28 M1 manual; M2 CI
Backend query latency (§3) Cloudflare Workers Observability P95 alerts (RFC-0007) Continuous
Scraper run-time (§4) pg-boss job duration query + Notification on > target Daily
Cost (§5) Cloudflare billing dashboard + Sentry/Axiom usage panes Weekly self-check

The Lighthouse CI / next build gates are M2 work, not M1 — adding CI workflows is its own ticket. M1 enforcement is manual via mkdocs-build-strict + a pre-merge "open the build page in mobile DevTools and look" discipline.

7. When to revise

These numbers are not eternal. Revisit when:

  • Real-user RUM data is in. Once CFWA has 30+ days of mobile-Lebanese visitor data, P75 LCP/INP/CLS readings replace the Google-default targets where Lebanese reality diverges (likely upward on LCP, downward on TTFB given the BEY PoP).
  • A retailer scraper grows past 5,000 SKUs. §4's per-retailer 5-min ceiling assumes ~1k SKUs at 1 req/sec; Mojitech's 12k SKUs (per retailers.md) already exceeds this and forces parallelism within the retailer or a higher cadence. Revisit when #20 actually adds Mojitech.
  • The cost ceiling is tested. If observability spend trends past $10/mo, re-open RFC-0007. If hosting+observability all-in trends past $20/mo at M1, re-open RFC-0001.
  • A new persona surfaces. personas.md §5.7 names anti-personas with drift signals; if a drift signal trips and the cohort gets in-scope, re-derive the per-page budgets for their device + connection profile.

8. See also