Final StretchOrdered learning track

Performance Budget for Networked React

Learn React Client-Server Communication - Part 065

Performance budget untuk React application yang sangat bergantung pada komunikasi client-server, mencakup latency budget, payload budget, request budget, cache budget, retry budget, observability, dan governance production.

9 min read1613 words
PrevNext
Lesson 6572 lesson track60–72 Final Stretch
#react#client-server#performance#network+2 more

Performance Budget for Networked React

React app yang network-heavy tidak gagal karena fetch() lambat saja. Ia gagal karena tidak punya budget.

Budget adalah kontrak engineering yang menjawab:

Untuk satu user journey, berapa banyak network work yang boleh kita lakukan sebelum experience, cost, reliability, atau fairness sistem rusak?

Tanpa budget, semua feature kecil terlihat masuk akal:

  • satu endpoint tambahan untuk badge count,
  • satu lookup tambahan untuk display name,
  • satu analytics call tambahan,
  • satu refetch saat focus,
  • satu retry otomatis,
  • satu GraphQL fragment baru,
  • satu prefetch “biar cepat”,
  • satu websocket subscription “biar realtime”.

Secara lokal semuanya benar. Secara sistem, semuanya bisa menjadi waterfall, storm, overfetching, cache churn, dan tail latency.

Bagian ini membangun performance budget untuk React client-server communication sebagai control system, bukan sekadar daftar tips optimasi.


1. Core Mental Model

Remote data punya empat harga utama:

user-perceived latency
+ network bytes
+ backend work
+ client memory/CPU
+ operational risk

React engineer sering hanya melihat loading spinner. Production system melihat semua ini:

Performance budget adalah cara membatasi dan mengarahkan semua biaya itu.


2. Budget Bukan Hanya “Harus Cepat”

Kalimat seperti “page harus cepat” tidak bisa direview. Budget harus operasional.

Contoh budget yang bisa direview:

Case detail page:
- p75 interactive data ready <= 1200 ms on target network
- p95 <= 2500 ms
- initial route critical API requests <= 3
- total critical JSON payload <= 180 KB compressed
- no sequential user-blocking request deeper than 2 levels
- no automatic retry for non-idempotent mutation
- background refetch must not block primary content
- stale read allowed for summary cards up to 60 seconds
- PII payload excluded unless visible in current viewport

Budget yang baik punya sifat:

PropertyMeaning
measurablebisa diukur di CI, RUM, logs, atau synthetic test
user-journey basedmengikuti flow nyata, bukan endpoint random
tieredbeda budget untuk initial load, interaction, background sync
explicit trade-offtahu mana yang stale boleh, mana yang harus fresh
enforceableada review, alert, test, atau lint rule

3. Jenis Budget dalam Networked React

3.1 Latency Budget

Latency budget menjawab: “berapa lama user boleh menunggu?”

Jangan hanya ukur total page load. Pecah menjadi milestones:

navigation start
→ shell visible
→ primary data visible
→ above-the-fold complete
→ interaction ready
→ background data complete

Contoh:

MilestoneBudgetNotes
route shell visible300 msskeleton/layout sudah muncul
primary entity loaded1000 msdata inti terlihat
secondary panels loaded2000 msboleh lazy/progressive
background enrichment5000 mstidak boleh block interaction

React consequence:

  • data utama jangan tenggelam di bawah request sekunder,
  • Suspense boundary harus mengikuti prioritas user,
  • route loader harus memuat minimum viable data,
  • query cache harus memisahkan critical vs non-critical query.

3.2 Request Count Budget

Request count budget menjawab: “berapa banyak round trip yang boleh terjadi?”

Masalahnya bukan request count absolut saja, tetapi dependency depth.

Empat request paralel bisa lebih murah daripada empat request serial.

Budget yang lebih akurat:

critical request depth <= 2
critical parallel request count <= 4
non-critical request count <= 8 after primary data visible

Smell:

component render → fetch entity → render child → fetch relation → render grandchild → fetch metadata

Ini bukan component composition. Ini hidden distributed transaction.


3.3 Payload Budget

Payload budget menjawab: “berapa byte dan berapa shape data yang boleh dikirim?”

Ukuran payload punya tiga biaya:

  1. transfer time,
  2. parse/decode time,
  3. memory/cache/render pressure.

Contoh policy:

Initial route JSON compressed <= 180 KB
Single list page compressed <= 120 KB
Single item detail compressed <= 80 KB
No unbounded arrays in initial payload
No hidden PII fields outside visible UI need

Jangan hanya ukur compressed bytes. JSON besar bisa murah di network tetapi mahal di parsing dan rendering.

Bad response:

{
  "cases": [
    {
      "id": "case_1",
      "title": "...",
      "attachments": [/* 500 items */],
      "auditTrail": [/* 2000 events */],
      "participants": [/* full profile */]
    }
  ]
}

Better shape:

{
  "cases": [
    {
      "id": "case_1",
      "title": "...",
      "attachmentCount": 500,
      "latestAuditEvent": {
        "id": "evt_9",
        "type": "STATUS_CHANGED"
      }
    }
  ]
}

Payload budget is also privacy budget.


3.4 Cache Budget

Cache bukan gratis. Cache menyimpan:

  • memory,
  • stale risk,
  • security exposure,
  • invalidation complexity,
  • persisted data risk.

Budget cache harus menjawab:

Which data may be cached?
For how long may it remain fresh?
For how long may it remain resident?
Is it scoped by user, tenant, role, locale, feature flag?
Can it survive logout?
Can it be persisted?

Example policy:

export const queryPolicy = {
  caseDetail: {
    staleTimeMs: 30_000,
    gcTimeMs: 10 * 60_000,
    persisted: false,
    scope: ['tenantId', 'userId', 'roleVersion'],
  },
  publicReferenceData: {
    staleTimeMs: 24 * 60 * 60_000,
    gcTimeMs: 7 * 24 * 60 * 60_000,
    persisted: true,
    scope: ['locale'],
  },
  piiProfile: {
    staleTimeMs: 0,
    gcTimeMs: 60_000,
    persisted: false,
    scope: ['tenantId', 'userId'],
  },
} as const;

3.5 Retry Budget

Retry memperbaiki transient failure, tetapi juga bisa memperburuk incident.

Retry budget menjawab:

How many extra requests may this client create when the system is degraded?

Bad:

1000 clients × 5 queries × 3 retries = 15000 extra requests

Better:

- Retry only idempotent reads by default
- No retry for 400/401/403/404/409/422
- Respect Retry-After for 429/503
- Exponential backoff with jitter
- Global retry budget per route/session
- Mutation retry requires idempotency key

Retry is not just an API client concern. It is a production traffic amplifier.


3.6 Prefetch Budget

Prefetch can reduce perceived latency. It can also waste bandwidth, leak intent, warm the wrong cache, and overload backend.

Budget prefetch by signal quality:

SignalConfidenceExample policy
current route requires ithighload now
next route from hover/focusmediumprefetch small metadata
viewport link visiblemedium-lowprefetch only static/code or cheap data
speculative ML guesslowdisable unless measured

Rule:

Never prefetch data that you cannot afford to throw away.


4. Critical vs Non-Critical Data

Tidak semua data punya prioritas sama.

Mapping to React:

Data priorityLoading strategy
criticalroute loader / RSC / blocking query
importantSuspense child boundary / parallel query
backgroundlazy query / idle prefetch / after-visible fetch
optionaluser-triggered fetch

Bad design:

<CasePage>
  <CaseHeader />
  <AuditTrail />
  <RecommendationPanel />
  <AttachmentPreview />
</CasePage>

If all children fetch independently on mount, everything competes as if equally important.

Better:

function CasePage() {
  const caseQuery = useCaseCriticalQuery();

  return (
    <CaseLayout>
      <CaseHeader case={caseQuery.data} />

      <Suspense fallback={<PanelSkeleton />}>
        <ImportantPanels caseId={caseQuery.data.id} />
      </Suspense>

      <DeferredBackgroundPanels caseId={caseQuery.data.id} />
    </CaseLayout>
  );
}

5. Building a Budget Document

For every major route, create a route budget.

export type RouteNetworkBudget = {
  routeId: string;
  userJourney: string;
  targetNetwork: 'fast-4g' | 'slow-4g' | 'enterprise-vpn' | 'mobile-3g';
  latency: {
    shellVisibleP75Ms: number;
    primaryDataP75Ms: number;
    interactiveP75Ms: number;
    primaryDataP95Ms: number;
  };
  requests: {
    criticalMaxCount: number;
    criticalMaxDepth: number;
    nonCriticalMaxCount: number;
  };
  payload: {
    criticalCompressedKb: number;
    totalInitialCompressedKb: number;
  };
  cache: {
    allowsPersistedServerState: boolean;
    maxFreshnessMsByDataClass: Record<string, number>;
  };
  retry: {
    maxAttemptsForReads: number;
    maxAttemptsForMutations: number;
  };
};

Example:

export const caseDetailBudget: RouteNetworkBudget = {
  routeId: 'case-detail',
  userJourney: 'Investigator opens case detail from work queue',
  targetNetwork: 'enterprise-vpn',
  latency: {
    shellVisibleP75Ms: 300,
    primaryDataP75Ms: 1200,
    interactiveP75Ms: 1500,
    primaryDataP95Ms: 2800,
  },
  requests: {
    criticalMaxCount: 3,
    criticalMaxDepth: 2,
    nonCriticalMaxCount: 8,
  },
  payload: {
    criticalCompressedKb: 180,
    totalInitialCompressedKb: 350,
  },
  cache: {
    allowsPersistedServerState: false,
    maxFreshnessMsByDataClass: {
      caseCore: 30_000,
      permissions: 0,
      referenceData: 86_400_000,
    },
  },
  retry: {
    maxAttemptsForReads: 2,
    maxAttemptsForMutations: 0,
  },
};

6. Measuring the Budget in the Browser

Use browser timing APIs to measure resource behavior.

Minimal collector:

export type ResourceTimingSummary = {
  name: string;
  initiatorType: string;
  transferSize: number;
  encodedBodySize: number;
  decodedBodySize: number;
  durationMs: number;
  ttfbMs: number;
};

export function getResourceTimings(): ResourceTimingSummary[] {
  return performance
    .getEntriesByType('resource')
    .filter((entry): entry is PerformanceResourceTiming =>
      entry instanceof PerformanceResourceTiming,
    )
    .map((entry) => ({
      name: entry.name,
      initiatorType: entry.initiatorType,
      transferSize: entry.transferSize,
      encodedBodySize: entry.encodedBodySize,
      decodedBodySize: entry.decodedBodySize,
      durationMs: entry.duration,
      ttfbMs: entry.responseStart - entry.requestStart,
    }));
}

Route-scoped measurement:

export function markRouteStart(routeId: string) {
  performance.mark(`${routeId}:start`);
}

export function markPrimaryDataReady(routeId: string) {
  performance.mark(`${routeId}:primary-data-ready`);
  performance.measure(
    `${routeId}:primary-data-latency`,
    `${routeId}:start`,
    `${routeId}:primary-data-ready`,
  );
}

Send only summarized telemetry. Do not send raw URLs containing PII.

function sanitizeResourceName(url: string): string {
  const parsed = new URL(url);
  return `${parsed.origin}${parsed.pathname.replace(/[a-zA-Z0-9_-]{16,}/g, ':id')}`;
}

7. Measuring Query-Level Performance

Network budget should connect to query keys and route ownership.

type QueryPerformanceEvent = {
  routeId: string;
  queryKeyHash: string;
  queryFamily: string;
  phase: 'start' | 'success' | 'error' | 'cancel';
  durationMs?: number;
  payloadKb?: number;
  cacheHit?: boolean;
  stale?: boolean;
};

Do not log full query keys if they contain IDs or PII. Log query family and route context.

Example wrapper:

export async function measuredQuery<T>(input: {
  routeId: string;
  queryFamily: string;
  run: () => Promise<T>;
}): Promise<T> {
  const startedAt = performance.now();

  try {
    const result = await input.run();

    emitQueryPerf({
      routeId: input.routeId,
      queryFamily: input.queryFamily,
      phase: 'success',
      durationMs: performance.now() - startedAt,
    });

    return result;
  } catch (error) {
    emitQueryPerf({
      routeId: input.routeId,
      queryFamily: input.queryFamily,
      phase: 'error',
      durationMs: performance.now() - startedAt,
    });

    throw error;
  }
}

8. Budget Enforcement in Code Review

Every PR that changes client-server communication should answer:

1. Does this add a new request to initial route load?
2. Is the request critical, important, background, or optional?
3. Can it run in parallel with existing requests?
4. Is the payload bounded?
5. Does it include hidden fields not used by current UI?
6. What is the staleTime/gcTime policy?
7. What happens on 401/403/404/409/422/429/5xx?
8. Is retry allowed?
9. Is cancellation wired?
10. Is telemetry redacted?

PR template:

## Network Impact

- [ ] Adds no new initial critical request
- [ ] Adds request but within route budget
- [ ] Adds background-only request
- [ ] Adds mutation
- [ ] Adds prefetch

Route budget affected:

Measured locally:

Failure behavior:

Cache/invalidation behavior:

9. Budget Enforcement in Tests

A Playwright-style budget check can fail if route request behavior regresses.

test('case detail stays within critical network budget', async ({ page }) => {
  const apiRequests: string[] = [];

  page.on('request', (request) => {
    const url = new URL(request.url());
    if (url.pathname.startsWith('/api/')) {
      apiRequests.push(url.pathname);
    }
  });

  await page.goto('/cases/case_123');
  await page.getByRole('heading', { name: /case/i }).waitFor();

  const initialApiRequests = apiRequests.filter((path) =>
    ['/api/cases/case_123', '/api/me/permissions', '/api/reference/statuses'].includes(path),
  );

  expect(initialApiRequests.length).toBeLessThanOrEqual(3);
});

Payload budget test:

test('case detail payload remains bounded', async ({ page }) => {
  const responses: Array<{ url: string; size: number }> = [];

  page.on('response', async (response) => {
    const url = response.url();
    if (!new URL(url).pathname.startsWith('/api/')) return;

    const body = await response.body().catch(() => Buffer.alloc(0));
    responses.push({ url, size: body.length });
  });

  await page.goto('/cases/case_123');
  await page.getByText('Current Status').waitFor();

  const totalApiBytes = responses.reduce((sum, item) => sum + item.size, 0);
  expect(totalApiBytes).toBeLessThan(350 * 1024);
});

Use this carefully. Test environments may not represent compression and CDN behavior. Budget tests are guardrails, not absolute truth.


10. Budget Enforcement in Runtime

Runtime budget is useful for development warnings and production telemetry.

export function warnIfRouteBudgetExceeded(input: {
  routeId: string;
  requestCount: number;
  totalDecodedKb: number;
  primaryDataMs: number;
}) {
  if (process.env.NODE_ENV === 'production') return;

  const budget = routeBudgets[input.routeId];
  if (!budget) return;

  if (input.requestCount > budget.requests.criticalMaxCount) {
    console.warn(
      `[network-budget] ${input.routeId}: request count exceeded`,
      input,
    );
  }

  if (input.totalDecodedKb > budget.payload.totalInitialCompressedKb) {
    console.warn(
      `[network-budget] ${input.routeId}: payload budget exceeded`,
      input,
    );
  }

  if (input.primaryDataMs > budget.latency.primaryDataP75Ms) {
    console.warn(
      `[network-budget] ${input.routeId}: primary data latency exceeded`,
      input,
    );
  }
}

11. Common Performance Budget Smells

Smell 1: “It is only one more request”

One more request might be cheap. One more serial dependency is not.

Ask:

Can the route render without it?
Can it be included in an existing aggregate response?
Can it be prefetched?
Can it be cached as reference data?
Can it load after the primary action is possible?

Smell 2: “GraphQL solves overfetching”

GraphQL can reduce overfetching, but it can also hide expensive joins and make frontend fragments grow without route-level budget.

Budget still needs:

  • operation complexity,
  • response size,
  • field-level authorization,
  • fragment ownership,
  • cache normalization pressure,
  • persisted operation governance.

Smell 3: “React Query dedupes it”

Deduplication prevents identical concurrent requests. It does not solve:

  • wrong query keys,
  • fragmented representations,
  • large payloads,
  • query waterfalls,
  • over-invalidation,
  • stale policy mistakes.

Smell 4: “We can just prefetch”

Prefetching shifts latency earlier. It does not remove cost.

Bad prefetch:

hover over table row → prefetch full case detail including PII + audit history

Better prefetch:

hover over table row → prefetch small case summary or route code only
open row → fetch full authorized detail

Smell 5: “The backend is fast locally”

Production latency includes:

  • network RTT,
  • TLS/session setup,
  • CDN/proxy behavior,
  • auth middleware,
  • cold cache,
  • database contention,
  • user geography,
  • browser connection limits,
  • corporate VPN/proxy,
  • mobile radio state.

Local endpoint latency is not user-perceived latency.


12. Route Budget Example: Regulatory Case Detail

Route: /cases/:caseId
Persona: investigator
Primary job: understand case status and take next action

Data classes

DataPriorityFreshnessStrategy
case corecritical30sroute loader/query
permission decisioncritical0sserver-owned, no persisted cache
parties summaryimportant60sparallel query
attachments summaryimportant60sdeferred query
full audit trailoptional/background15stab-triggered infinite query
recommendationsbackground5mafter-visible lazy query

Request plan

Budget

Critical depth: 1
Critical requests: 2
Primary payload: <= 180 KB compressed
Primary data p75: <= 1200 ms
Audit history: not loaded until requested
Recommendations: never block case action

This design is not just faster. It is easier to reason about.


13. Performance Budget Decision Table

ProblemPreferAvoid
critical data split across many endpointsaggregate/BFF/route loadercomponent-level waterfall
slow secondary paneldefer/Suspense/lazy queryblock whole route
repeated reference datalong staleTime/persisted cache if non-sensitiverefetch on every route
expensive detail hoverprefetch minimal summaryprefetch full PII detail
transient read failurebounded retry + jitterinfinite retry loops
mutation unknown outcomeidempotency key + status lookupblind retry POST
list item needs detail-only fieldsredesign list projectionfetch detail per row
high-cardinality searchbounded cache/gc + debouncecache every keystroke forever

14. Production Metrics to Track

Track per route, not only global averages:

route.primary_data_latency.p50/p75/p95
route.api_request_count.initial
route.api_request_depth.critical
route.api_payload_kb.critical
route.query_cache_hit_rate
route.background_refetch_count
route.retry_attempt_count
route.prefetch_use_rate
route.prefetch_waste_rate
route.error_rate.by_status_family
route.abort_rate
route.duplicate_mutation_prevented_count

Important derived metric:

prefetch usefulness = prefetched resource used within N minutes / total prefetched resource

If prefetch usefulness is low, prefetch is waste.


15. Invariants

For top-tier React client-server engineering, keep these invariants:

  1. Every route has a known critical data path.
  2. Critical path request depth is visible and reviewed.
  3. Every automatic request has a reason, owner, and budget.
  4. Payloads are bounded and shaped for the current user task.
  5. Cache freshness is domain policy, not library default.
  6. Retry is bounded, classified, and idempotency-aware.
  7. Prefetch is measured by usefulness, not intention.
  8. PII is minimized before it reaches the browser.
  9. Background work must not starve foreground interaction.
  10. Performance regressions are detected before users report them.

16. Review Checklist

Before shipping a networked React route:

  • Critical data is identified.
  • Critical request depth is documented.
  • Initial request count is within budget.
  • Payload is bounded and compressed size is measured.
  • Non-critical data is deferred.
  • Query keys include correct scope.
  • staleTime and gcTime are explicit.
  • Retry policy is status-aware and idempotency-aware.
  • Cancellation is wired for route changes/search.
  • Prefetch has a measured usefulness hypothesis.
  • Telemetry redacts URL/path/query IDs where needed.
  • No hidden PII is fetched for invisible UI.
  • Performance behavior is tested or observed in RUM.

Sources

  • MDN Web Docs, PerformanceResourceTiming and Resource Timing API.
  • W3C, Resource Timing specification.
  • web.dev, resource hints and performance learning material.
  • MDN Web Docs, rel="preload" and fetchpriority.
  • TanStack Query documentation, prefetching, caching, and server-state lifecycle.
Lesson Recap

You just completed lesson 65 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.