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¶
kpis.md— the metrics this budget polices- RFC-0007 — Observability stack — picks the tools that measure these budgets
- RFC-0001 — Hosting target — picks the infra these budgets run on
- RFC-0002 — Background jobs — defines the scraper queue runtime
personas.md§5.2 device + connection — the rationale for mobile-first and image-weight budgetscompetitive-landscape.md§5.1 + §3.7 — craft baseline (Lebanese retailers 5–6.5/10, the genre absent of 2024-design-quality bar) — the budget exists to hit a craft level the genre doesn'tarchitecture/ingest-pipeline.md— the scraper pipeline whose runtime is budgeted in §4retailers.md§6 scraper roadmap — per-retailer SKU scale informing §4 ceilings- ADR-0002 — the all-slots-at-once layout shapes the build-page weight target in §2.3
- ADR-0004 — English-only at M1 is what allows the font-subset Latin-only budget in §2.2