Performance budgets
Status: Reference Last reviewed: 2026-10-03
Round 30 production-readiness — documented budgets + how to verify them.
Mobile (Hermes bundle)
| Platform | Budget | Current (commit bd81763 per OTA logs) | Headroom |
|---|---|---|---|
| iOS | 8 MB | 7.6 MB | +5% |
| Android | 9 MB | 7.7 MB | +17% |
How to measure: yarn size from repo root. Runs the installed workspace Expo CLI for both platforms with --source-maps external into a fresh apps/mobile/dist/bundle-size-* directory. It reads the version-0 Metro metadata.json declarations and requires a non-empty entry for both iOS and Android inside that export. Missing entries, unsupported metadata, source maps, and links outside the current export fail the check. Hermes and JavaScript entries are measured against the budget in scripts/lib/bundle-size.mjs. Measure deployable code with source maps split out; source maps must be uploaded/retained as separate release artifacts, not counted as runtime startup code.
Why not in CI? expo export is ~3–5 min × 2 platforms. Adding it to every PR would dominate CI time. Run manually after features that touch many files; if the bundle grows >2% in a single commit, investigate.
Bumping the budget: do it explicitly in scripts/lib/bundle-size.mjs with a comment naming the feature that justified the bump. Don't silently ratchet upward.
What contributes: every package in @twomore/app and @twomore/ui plus its transitive deps. Biggest contributors today: React Native, Tamagui, TanStack Query, Reanimated, Sentry. New native modules (e.g., Kakao SDK Phase 8) trigger a runtime version bump AND likely a budget bump.
Web (Core Web Vitals)
Every page reports vitals to /api/vitals via WebVitalsReporter. The endpoint logs to client_errors with severity='info' and context.vitals=true — same dashboards/queries that surface error rates slice by perf percentile.
Targets (Google's recommended thresholds):
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
| FCP (First Contentful Paint) | ≤ 1.8 s | 1.8–3.0 s | > 3.0 s |
| TTFB (Time to First Byte) | ≤ 0.8 s | 0.8–1.8 s | > 1.8 s |
With experimental.webVitalsAttribution: ['CLS', 'LCP', 'INP'] enabled in apps/web/next.config.ts, every captured metric includes attribution data identifying WHICH element / interaction / layout shift triggered the score. Query examples:
-- LCP P75 over last 7 days
SELECT percentile_cont(0.75) WITHIN GROUP (ORDER BY (context->>'value')::float)
FROM client_errors
WHERE context->>'vitals' = 'true'
AND context->>'name' = 'LCP'
AND created_at > now() - INTERVAL '7 days';
-- INP "poor" by route
SELECT route, COUNT(*)
FROM client_errors
WHERE context->>'vitals' = 'true'
AND context->>'name' = 'INP'
AND context->>'rating' = 'poor'
GROUP BY route
ORDER BY COUNT(*) DESC;Action thresholds: if INP P75 > 200ms for any route, profile via Chrome DevTools (Performance > Web Vitals panel) and identify the slow handler. Most common causes: synchronous JSON parsing, non-deferred state updates inside onPress, large lists rendering without virtualization.
Mobile (FPS + JS thread)
Sentry profiling enabled at 10% prod sample rate (profilesSampleRate in packages/app/src/lib/sentry.ts:34). Flamegraphs appear in Sentry Performance tab → Profiles. No budget enforced; profiling is a diagnostic tool, not a gate. If specific screens show > 16ms frames (60fps target), use the existing usePrefersReducedMotion pattern + applyOptimisticPatches to keep work off the UI thread.
Bundle observability per OTA
Every yarn ota:preview prints bundle sizes:
[expo-cli] _expo/static/js/ios/entry-<hash>.hbc (7.6MB)
[expo-cli] _expo/static/js/android/entry-<hash>.hbc (7.7MB)These get captured in commit messages by convention (see Round 24's OTA group output in CLAUDE.md). When the number moves >2% from the prior OTA, note it.
Future automation (not yet shipped)
- CI gate for mobile bundle size: requires either (a) caching
expo exportoutput across runs or (b) running it nightly instead of per-PR. Defer until bundle growth becomes a real problem. - Source-map analyzer:
metro-visualizerorreact-native-bundle-visualizerfor chunk-level breakdowns. Useful when a specific dep needs to be slimmed. - Web Lighthouse CI: full PageSpeed run as part of release. Skip for now; the Web Vitals stream gives equivalent signal continuously rather than per-deploy.