Skip to content

Performance budgets

Status: Reference

Round 30 production-readiness — documented budgets + how to verify them.

Mobile (Hermes bundle)

PlatformBudgetCurrent (commit bd81763 per OTA logs)Headroom
iOS8 MB7.6 MB+5%
Android9 MB7.7 MB+17%

How to measure: yarn size from repo root. Runs expo export for both platforms with --source-maps external, then walks apps/mobile/dist/_expo/static/js/{ios,android}/ for the entry Hermes bundle and compares against the budget defined in scripts/measure-bundle-size.mjs. Measure the deployable .hbc bytecode 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 measure-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):

MetricGoodNeeds improvementPoor
LCP (Largest Contentful Paint)≤ 2.5 s2.5–4.0 s> 4.0 s
INP (Interaction to Next Paint)≤ 200 ms200–500 ms> 500 ms
CLS (Cumulative Layout Shift)≤ 0.10.1–0.25> 0.25
FCP (First Contentful Paint)≤ 1.8 s1.8–3.0 s> 3.0 s
TTFB (Time to First Byte)≤ 0.8 s0.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:

sql
-- 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 export output across runs or (b) running it nightly instead of per-PR. Defer until bundle growth becomes a real problem.
  • Source-map analyzer: metro-visualizer or react-native-bundle-visualizer for 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.

Markdown remains the source of truth. Run yarn docs:check before handoff.