PRACTICAL GUIDE / frontend backend latency diagnosis

The API is fast, so why is the page still slow?

Learn to separate server, network, resource, and rendering delay with browser timing evidence, then assign the slowdown to the right owner in CI.

By The Testing AcademyUpdated August 7, 202620 min read
All field guides
In this guide7 sections
  1. Split one journey into boundaries you can observe
  2. Capture the browser and server evidence in one run
  3. Work through three ownership disputes
  4. Distinguish a regression from a changed experiment
  5. Separate an upstream wait from a browser-side intercept
  6. Fix the measured boundary and prove the trade-off
  7. Know when this browser method is the wrong one

What you will learn

  • Split one journey into boundaries you can observe
  • Capture the browser and server evidence in one run
  • Work through three ownership disputes
  • Distinguish a regression from a changed experiment

The API dashboard is green, but the results table stays blank long enough for users to click Refresh. The backend team sees a fast handler. The frontend team sees a slow page. Both observations can be true.

Split one journey into boundaries you can observe

“Page load time” hides several clocks. A top-level navigation can include redirects, DNS lookup, connection and TLS setup, request transmission, server and intermediary work, response download, subresource loading, JavaScript execution, layout, paint, data fetching, and application-specific initialization. One total cannot tell you which owner should act.

Choose a user-visible finish point first. A load event is not automatically useful. It can fire before an asynchronously loaded table has data, or after an irrelevant image finishes. Define readiness in product terms: the first result row is visible, the search control is enabled, and clicking a row works. The marker should represent a task the user can perform.

The browser's Navigation Timing entry describes the top-level document. MDN documents responseStart as the moment just after the browser receives the first byte of a response. For a request phase, responseStart minus requestStart is a useful split, but it includes the network path after the request begins as well as server and intermediary time. It is not a pure backend measurement.

Connection phases matter too. A cold browser may perform DNS, TCP, and TLS work. A warm browser can reuse a connection. Redirects can add another request before the final document. A team comparing a direct API client on a reused connection with a fresh browser navigation is not comparing the same path.

Server-Timing supplies the next layer when the application emits it. The W3C specification defines a response header that communicates named server metrics and optional durations or descriptions. A database, cache, template, or edge phase can be represented. The server chooses what to report, so missing time remains missing. The specification intentionally omits a server startTime because client and server clocks cannot be assumed to be synchronized.

Resource Timing covers scripts, stylesheets, fonts, images, fetches, and other fetched resources visible to the page. Each entry has timestamps and sizes that help distinguish waiting, transfer, and ordering. Cross-origin details can be restricted. MDN notes that responseStart can be zero when detailed timing is unavailable without Timing-Allow-Origin, and also in some cache or canceled cases. Zero is an ambiguity to investigate, not evidence of perfect speed.

User Timing closes the application gap. Product code can call performance.mark when a meaningful state is reached. The browser records that mark on the same performance timeline as navigation and resources. A mark is only trustworthy if it is placed after the state it names. Calling results-ready when a request begins produces clean data and a false conclusion.

A practical evidence record for one journey contains:

  • route, user role, data shape, build, browser project, and cache condition;
  • the final document URL and redirect count;
  • navigation requestStart, responseStart, responseEnd, DOM milestones, and load event;
  • the Server-Timing header or exposed serverTiming entries;
  • key resource start times, durations, transfer sizes, and initiator types;
  • application ready marks and the visible assertion that confirms them;
  • a correlation ID that links the browser response to backend logs.

Keep units explicit. Browser performance timestamps are high-resolution values relative to a time origin, while service logs may use wall-clock timestamps. Compare durations directly. Use a correlation ID to join events across clocks rather than subtracting unrelated timestamps.

Capture the browser and server evidence in one run

Start with a command-line request because it quickly separates connection phases from response wait. This curl command saves headers and the body, then prints phase durations. It does not execute page JavaScript, so it is a network and server diagnostic rather than a user-ready measurement.

Shell
#!/usr/bin/env bash
set -euo pipefail

: "${TARGET_URL:?set TARGET_URL to the exact document or API URL}"

curl_args=(
  --silent
  --show-error
  --location
  --max-redirs 10
  --dump-header response-headers.txt
  --output response-body.bin
  --write-out $'dns=%{time_namelookup}\nconnect=%{time_connect}\ntls=%{time_appconnect}\nredirect_time=%{time_redirect}\nfirst_byte=%{time_starttransfer}\ntotal=%{time_total}\nredirects=%{num_redirects}\n'
)

curl "${curl_args[@]}" "$TARGET_URL"

Interpret the relationship, not a universal threshold. If connection setup accounts for most of the delay, changing database code is unlikely to help that run. If time_starttransfer grows while connection phases stay stable, inspect the final request, upstream route, and server metrics. If total grows after first_byte, response transfer is contributing. None of those fields reveal client rendering.

Run curl with the same route, authentication, payload, region, and cache condition as the browser when possible. A public API response and an authenticated HTML document can hit different infrastructure and return different data. Record redirects instead of silently comparing the last URL with an API endpoint that had no redirect.

Add an application mark at the actual completion boundary. This example renders result rows, enables the control, waits for the browser to schedule a visual update, then records the mark. It is ordinary browser TypeScript and does not depend on a test runner.

TypeScript
type SearchResult = {
  id: string;
  title: string;
};

export function showResults(results: SearchResult[]): void {
  const table = document.querySelector<HTMLTableElement>(
    '[data-testid="results-table"]',
  );
  const filter = document.querySelector<HTMLInputElement>(
    '[data-testid="results-filter"]',
  );

  if (!table || !filter) {
    throw new Error('Results UI is not mounted');
  }

  const body = table.tBodies.item(0) ?? table.createTBody();
  body.replaceChildren(
    ...results.map((result) => {
      const row = document.createElement('tr');
      row.dataset.resultId = result.id;

      const cell = document.createElement('td');
      cell.textContent = result.title;
      row.append(cell);

      return row;
    }),
  );

  filter.disabled = false;

  requestAnimationFrame(() => {
    performance.mark('results-ready');
  });
}

The requestAnimationFrame callback does not prove every pixel has been painted. It schedules the mark near a rendering opportunity after the DOM mutation. The end-to-end test must still assert the visible and enabled UI. If image decoding or a framework transition is part of readiness, instrument the state that the product actually promises.

Now collect the layers in Playwright. The test waits for the visible contract, reads the Navigation Timing and Resource Timing entries inside the page, captures the Server-Timing response header, and attaches a JSON record without imposing invented budgets.

TypeScript
import { expect, test } from '@playwright/test';

test('captures latency boundaries for search results', async ({
  page,
}, testInfo) => {
  const targetUrl = process.env.TARGET_URL;
  if (!targetUrl) {
    throw new Error('TARGET_URL is required');
  }

  const documentResponse = await page.goto(targetUrl, {
    waitUntil: 'domcontentloaded',
  });
  if (!documentResponse) {
    throw new Error('Navigation did not produce a document response');
  }

  const table = page.getByTestId('results-table');
  const filter = page.getByTestId('results-filter');

  await expect(table.locator('tbody tr').first()).toBeVisible();
  await expect(filter).toBeEnabled();
  await page.waitForFunction(
    () => performance.getEntriesByName('results-ready', 'mark').length > 0,
  );

  const browserTiming = await page.evaluate(() => {
    const navigation = performance.getEntriesByType(
      'navigation',
    )[0] as PerformanceNavigationTiming | undefined;
    const ready = performance.getEntriesByName(
      'results-ready',
      'mark',
    ).at(-1);

    if (!navigation || !ready) {
      throw new Error('Required performance entries are missing');
    }

    const resources = (
      performance.getEntriesByType('resource') as PerformanceResourceTiming[]
    )
      .map((entry) => ({
        name: entry.name,
        initiatorType: entry.initiatorType,
        startTime: entry.startTime,
        duration: entry.duration,
        responseStart: entry.responseStart,
        responseEnd: entry.responseEnd,
        transferSize: entry.transferSize,
        serverTiming: entry.serverTiming.map((metric) => ({
          name: metric.name,
          duration: metric.duration,
          description: metric.description,
        })),
      }))
      .sort((left, right) => right.duration - left.duration)
      .slice(0, 20);

    return {
      navigation: {
        name: navigation.name,
        redirectCount: navigation.redirectCount,
        requestStart: navigation.requestStart,
        responseStart: navigation.responseStart,
        responseEnd: navigation.responseEnd,
        domContentLoadedEventEnd: navigation.domContentLoadedEventEnd,
        loadEventEnd: navigation.loadEventEnd,
      },
      ready: {
        name: ready.name,
        startTime: ready.startTime,
      },
      resources,
    };
  });

  const evidence = {
    finalUrl: page.url(),
    status: documentResponse.status(),
    serverTimingHeader: documentResponse.headers()['server-timing'] ?? null,
    browserTiming,
  };

  await testInfo.attach('latency-evidence.json', {
    body: Buffer.from(JSON.stringify(evidence, null, 2)),
    contentType: 'application/json',
  });
});

The slowest twenty resource entries are a diagnostic sample, not a complete waterfall. Keep all entries in a machine-readable metrics backend if the investigation needs them. The browser resource buffer is finite, and entries observed late can be incomplete in a resource-heavy page. A PerformanceObserver registered early is more appropriate for exhaustive collection.

Server metrics may appear in the response header even when the browser does not expose detailed cross-origin entries to page JavaScript. Conversely, a proxy can alter or add timing fields. Record the final response and deployment route so the owner knows which component supplied each metric.

Work through three ownership disputes

The first case has a slow wait before the document's first byte. Resource downloads and the ready marker follow quickly once the response begins. The direct browser response carries a Server-Timing metric for a database phase that dominates the reported server durations.

That pattern supports a backend investigation, but do not jump straight to the database. Confirm the request was not redirected to a different region, that the cache status matches the baseline, and that Server-Timing accounts for the relevant server work. Join the browser's correlation ID to service logs. If the total browser wait is much larger than all reported server phases, inspect queues, load balancers, edge processing, and the network path.

The near-miss has the same high responseStart but no corresponding server phase. Curl shows connection or TLS setup taking most of the command's total, especially from a fresh environment. The server handler can be fast and the first byte can still arrive late. Reusing a connection or running from a different region changes the result. Assigning that run to application code would be premature.

The second case receives the HTML promptly, then loads a large client script and executes work before the table appears. The Navigation Timing responseStart looks healthy. Resource Timing shows the script arriving later or taking time to transfer, and results-ready sits well after responseEnd. A browser performance profile, not just the Playwright action log, can separate download, parse, execution, layout, and rendering work.

A resource duration alone does not prove it blocked readiness. An analytics script can be the slowest request yet run independently of the table. Check initiator, start time, dependency, and whether delaying or removing that resource in a controlled experiment changes the ready mark. One-variable experiments turn a suspicious waterfall row into causal evidence.

The corresponding near-miss is a fast asset with slow execution. Transfer size and download duration look harmless, but the main thread performs heavy hydration or transforms a large response. Resource Timing cannot measure JavaScript CPU cost. Use a browser performance recording and application profiling. Do not ask the CDN team to optimize a file that already arrived quickly.

The third case is an API request that begins after the document becomes interactive. The top-level TTFB is fast, all static resources are cached, but the page waits for /api/results. The backend dashboard the team quoted measures a different endpoint or smaller data set. Capture the exact request URL, method, user, payload, response size, and correlation ID.

This case is often mislabeled “frontend” because the delay occurs after initial rendering. Timing position does not determine ownership. If the late API spends its time in server work, the backend still owns that phase. If the request starts late because the frontend waits for sequential configuration calls, the dependency chain is a client design issue. If the response arrives quickly and rendering is late, the client owns the remaining gap.

A close look-alike is intentional deferred loading. The page may render primary controls first and fetch a secondary panel later. If that panel is not part of the chosen readiness contract, including it in the gate overstates user impact. Agree on the task before measuring. Different tasks can have different ready marks.

Distinguish a regression from a changed experiment

Cache state is the first check. A cold browser downloads assets and establishes connections. A warm context can reuse HTTP cache, service worker content, DNS state, and connections. A backend cache may also be cold or warm independently. Label both sides. “Same URL” is not enough.

Use a paired protocol when cold behavior is part of the investigation. Create a new browser context for the cold journey, capture its timing record, then repeat the same task in that context for the warm journey. Keep the records separate. The warm journey is a comparison, not a retry that replaces the cold result.

If only the first journey is slow, inspect connection setup, initial asset transfer, service-worker installation, runtime initialization, and server cache state. If both journeys are slow at the same boundary, the cause is less likely to be a one-time browser cost. If the second journey becomes slower, look for retained page state, growing client caches, duplicate listeners, or backend data created by the first action.

Do not assume a new page inside an existing context is cold. That page can share the context's HTTP cache, cookies, and service worker. A new browser context gives a cleaner browser boundary, although operating-system DNS and the server's own caches may still be warm. State that limitation in the evidence record.

Redirect count is the second. Authentication changes often add a sign-in or tenant-selection hop. A redirect may be correct but costly. Compare the final URL and chain. A direct curl call that follows redirects without retaining the chain can hide the extra round trip.

Data shape is the third. A table with more rows creates a larger response and more DOM work. A different user can have different permissions, feature flags, and queries. Record stable fixture identity and relevant cardinality. Do not invent an internet-wide performance target from one small fixture.

Environment contention can produce a false regression. Shared CI runners, preview deployments, noisy databases, and autoscaling states vary. Re-run as a new experiment with the same inputs and preserve both records. A warm retry is useful diagnostic evidence, but it is not equivalent to the first cold attempt.

Clock semantics can also mislead. Browser performance entries use a page-relative time origin. Server durations have their own clocks. Server-Timing durations describe work but do not place a server start on the browser timeline. Correlation joins the records; it does not make their clock origins identical.

Cross-origin restrictions create missing fields rather than fast fields. When responseStart or size values are zero, inspect origin and Timing-Allow-Origin. Do not add a permissive header only to make a test pass. The W3C Server Timing specification warns that detailed server metrics can reveal sensitive infrastructure information. Expose the minimum metrics to approved origins.

Test harness overhead belongs in the record. Tracing, video, coverage, debug logging, and response-body capture can change timings. Use the same settings in baseline and comparison. Keep a rich diagnostic mode for failed investigations and a lighter regression mode for routine runs.

One elapsed Playwright test duration is not a performance metric. It includes Cypress or Playwright setup, assertions, retries, reporter work, and waits unrelated to the user boundary. Collect browser and server entries inside the journey instead.

Separate an upstream wait from a browser-side intercept

Two failures can produce the same complaint and equally reassuring application logs. In both, the browser reaches the useful state late while the application handler reports little work. One request waited in front of the application, perhaps at an edge or gateway. The other spent time in a service worker before the browser made the network fetch. Sending both cases to the backend team wastes the first investigation cycle.

Add workerStart and fetchStart to the navigation record when a route can be controlled by a service worker. Resource Timing defines a nonzero workerStart when a service worker intercepted the fetch, and the difference from workerStart to fetchStart represents its processing interval. A large interval there, followed by a narrow request-to-response interval, puts the missing time before the network request. Confirm causality with a controlled context that has no registration for that origin. If the interval disappears while the deployed application and data stay fixed, the service worker owner has a reproducible boundary. The network request may also reach the server late, or never reach it when the worker supplies a cached response.

An upstream queue has a different trace. The service worker interval is absent or matches the healthy run, connection setup remains comparable, and the gap from requestStart to responseStart expands. The application log begins near the end of that gap because its timer starts only after the gateway forwards the request. The separating evidence is an ingress timestamp from the edge or gateway joined to the application record by the same correlation ID. A long edge-to-application interval belongs to the upstream path, even if the application duration itself is healthy. If no upstream record exists, the evidence is incomplete rather than proof that the browser caused the delay.

Read the attachment as deltas, not as a row of independent scores. Consider an explicitly illustrative healthy record with requestStart at 120, responseStart at 170, responseEnd at 190, and the ready mark at 245. The useful values are the gaps: 50 before the first byte, 20 for the response, and 55 from response completion to readiness. A broken run with the same first three offsets and a ready mark at 1,400 points after response delivery. A broken run with workerStart at 30 and fetchStart at 900 points before the fetch instead. Those numbers are examples of shape, not recommended budgets or measured results.

Some fields are easy to overread. An absolute responseStart of 900 does not mean the server worked for 900 milliseconds because the entry is relative to the page time origin. Subtract the appropriate earlier boundary. loadEventEnd can still be zero if the attachment is collected before the load event finishes, so zero in that field can mean “not recorded yet,” not “instant.” Server-Timing durations may overlap or omit work. Adding every server duration and subtracting that sum from browser wait can manufacture a phase that never existed.

Fix the measured boundary and prove the trade-off

When server wait dominates, reduce or parallelize the measured server work, improve caching only where correctness permits it, remove queueing, or move work out of the critical response. Each option has a cost. Caching creates invalidation and freshness risks. Parallel calls increase downstream concurrency. Deferring work can shift latency to a later user action.

When transfer dominates, reduce bytes, compression mistakes, or unnecessary critical resources. Splitting code can improve the first route while adding requests and cache complexity. Preloading can help a known critical dependency but wastes bandwidth when predictions are wrong. Verify the ready mark, not only bundle size.

When client execution dominates, profile the specific task. Reduce repeated rendering, avoid serial data dependencies, move expensive computation away from the critical interaction where appropriate, or render less data initially. Virtualization lowers DOM work but complicates accessibility and test locators. Server rendering can improve initial content while adding server cost and hydration constraints.

When a late API owns the gap, start it earlier only if prerequisites allow. Parallel requests can increase load and race conditions. Combining endpoints reduces round trips but may couple data with different cache lifetimes. Returning less data can require pagination or another user action. State the user and operational cost beside the proposed fix.

Roll the diagnostic into CI cautiously. Use one browser project, one worker, a controlled route, stable data, and consistent artifact settings. Keep broad production variability in real-user monitoring rather than pretending a shared runner represents every device and network.

YAML
name: latency-diagnostic

on:
  workflow_dispatch:
    inputs:
      target_url:
        description: Exact deployed route to measure
        required: true
        type: string

permissions:
  contents: read

jobs:
  browser-boundaries:
    runs-on: ubuntu-latest
    env:
      TARGET_URL: ${{ inputs.target_url }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Capture one controlled Chromium journey
        run: npx playwright test e2e/latency.spec.ts --project=chromium --workers=1

Installing a browser on every diagnostic run costs time; a maintained runner image can reduce that cost but adds image ownership. Running one worker improves comparability but lengthens the queue. Rich attachments help diagnosis but consume storage and may contain internal URLs. None of these choices is free.

For a suite that already exists, land observability before enforcement. First add the product mark and correlation propagation, then prove the existing functional assertion still reaches the state named by the mark. Next attach the record without a time budget and verify that missing cross-origin detail is represented as unavailable rather than converted into zero-duration success. Run that observation job on one stable route, one browser project, and a named cache condition. Only after reviewers can compare repeated records should a product-owned budget create a warning. Promotion from warning to a blocking gate comes last.

The first break is usually not a real regression. Older deployments may not emit the new mark, an authentication redirect may produce a different final document, or parallel tests may contend for the same runner. A hard wait for a mark turns the first case into a timeout with no diagnostic attachment. Preserve the visible assertion and emit a clear instrumentation failure until every target deployment contains the mark. Keep the diagnostic job separate from broad functional shards while its variance is being characterized. This adds another browser journey and can extend CI queue time, while single-worker execution gives up the throughput that parallel shards provide.

Ownership follows the first divergent boundary. Product owns the definition of useful readiness and the acceptable budget. Frontend owns the mark and client dependency chain. QA or developer productivity owns collection, artifact schema, and gate behavior. The edge or platform team owns connection and pre-application evidence. The service team owns correlation and protected server phases. A handoff must contain the exact route, build, role, fixture identity, browser project, cache condition, final URL, regression record, comparable healthy record, correlation ID, and the specific delta that changed. It must also say which controlled comparison altered that delta. A screenshot and “page slow” are not a usable cross-team handoff.

This rollout does not catch latency after the chosen ready state. A table can become visible quickly while sorting its first column blocks the main thread, a later modal fetches slowly, or a long session accumulates memory pressure. Those failures need interaction marks, profiling, or endurance coverage tied to their own user tasks. Moving the existing ready mark later to absorb every future action would make its meaning unstable and conceal which task actually regressed.

Use a reviewed product budget only after the measurement is stable. Keep phase-specific warnings where possible. A total ready-time failure tells the team users are affected. The attached phase record tells them where to investigate. Do not fabricate a threshold because the tool needs a number.

Know when this browser method is the wrong one

Do not use an end-to-end browser journey to determine database capacity. Browser variability obscures service saturation. Use an API or protocol-level load test with backend telemetry, then keep a small browser check for the user path.

Do not use Server-Timing as a public dump of internal query names, hostnames, or topology. Metrics can reveal sensitive implementation detail. Use short approved names, expose them only where needed, and preserve richer data in protected service telemetry.

Do not add Timing-Allow-Origin: * merely to fill every resource field. Cross-origin timing access is a deliberate policy decision. If the resource owner cannot expose detail, diagnose with their server telemetry or a controlled same-origin environment.

Do not wait for network silence as a universal definition of ready. Applications with polling, streaming, analytics, or background refresh may never become silent. Assert a visible task and pair it with a product mark.

Do not fail every pull request on a noisy absolute duration from shared infrastructure. Use deterministic functional assertions on every change, controlled performance checks where the environment supports them, and scheduled or production observation for broader signals. A flaky gate trains teams to rerun instead of investigate.

Above all, do not assign ownership from the position of a delay alone. A request made late can be slow on the server. A document delivered early can still execute slowly. Capture the boundary, correlate the same request, change one factor, and let the evidence decide.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 26, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official w3.org reference

    w3.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why can an API be fast while the page is slow?

The browser still has to follow redirects, download resources, execute JavaScript, render content, and wait for application data after the server responds. A backend handler duration covers only one part of that path.

How do I separate time to first byte from rendering time?

Read the navigation entry's responseStart for the first response byte, then compare it with resource entries and a product-defined ready mark. The gap after responseStart belongs outside the initial wait for the document response.

Does Server-Timing measure the complete request?

No. It carries durations the server or intermediaries choose to report, such as cache or database work. DNS, connection setup, client network conditions, downloads, and browser execution require separate evidence.

Can Playwright prove which team owns a latency regression?

Playwright can collect navigation, resource, response-header, and visible-ready evidence under one browser journey. Ownership still depends on trustworthy server instrumentation and a controlled comparison, not on one elapsed test duration.

Why does a cross-origin resource timing entry contain zeros?

A zero can mean detailed timing was hidden because the resource did not grant access with Timing-Allow-Origin; responseStart can also be zero for some cache or canceled cases. Do not interpret zero as an instant response.