skip to content

How do you read a document's server response time and DOMContentLoaded timing from the browser's Navigation Timing API, and why is the legacy performance.timing object a poor substitute?

level: middleimportance: should knowfreq 40%

answer

  1. the document is just the first resource
  2. one entry, already zero-based
  3. no navigationStart subtraction needed
  4. epoch integers versus timeOrigin floats
  5. the old snapshot cannot be observed

basics

~20 s

Read the single PerformanceNavigationTiming entry from performance.getEntriesByType('navigation'). Its timestamps are already relative to the document's time origin, so responseStart is the byte-arrival time and domContentLoadedEventEnd is that milestone directly. The deprecated performance.timing returned Unix-epoch values requiring manual subtraction and could not be observed.

solid answer

~50 s

`const [nav] = performance.getEntriesByType('navigation')` gives you a `PerformanceNavigationTiming` entry — the same shape as a resource entry, because the document is just the first thing fetched. Every timestamp on it is a `DOMHighResTimeStamp` relative to `performance.timeOrigin`, and the navigation entry's own `startTime` is 0, so `nav.responseStart` *is* the time to the first byte and `nav.domContentLoadedEventEnd` *is* that milestone — no arithmetic required, beyond subtracting `activationStart` if the page was prerendered. The entry also carries `type` (`navigate`, `reload`, `back_forward`, `prerender`), which is essential for excluding back/forward-cache restores from load analysis. The old `performance.timing` interface returned Unix-epoch milliseconds, so every value had to be reduced by `navigationStart`; it had whole-millisecond precision, it was undefined for milestones that had not happened yet, and it could not be delivered through a `PerformanceObserver`. It is deprecated and should not be used in new code.

code

javascript · 17 lines
javascript
function readNavigation() {
  const [nav] = performance.getEntriesByType('navigation');
  if (!nav) return null;

  const activation = nav.activationStart || 0;

  return {
    type: nav.type,
    firstByte: Math.max(0, nav.responseStart - activation),
    download: nav.responseEnd - nav.responseStart,
    domContentLoaded: nav.domContentLoadedEventEnd,
    loadEnd: nav.loadEventEnd,
    viaServiceWorker: nav.workerStart > 0
  };
}

addEventListener('load', () => setTimeout(() => console.log(readNavigation())));

go deeper

for a junior

Be able to fetch the single navigation entry with performance.getEntriesByType('navigation') and name a couple of fields on it, and say that its timestamps are already relative to the start of the page.

for a middle

Explain the timeOrigin-relative model, why that removes the navigationStart subtraction the old API required, and which milestones read zero because they have not happened yet.

for a senior

Show operational judgment: segmenting by the entry's type so back/forward restores do not flatter your data, correcting for activationStart on prerendered pages, and choosing a report moment when the milestones are actually final.

for a principal

Own the definition layer — that the organisation's dashboards agree on which navigation types count, how prerender and service-worker navigations are treated, and that server-side and browser-side timings are reconciled rather than argued about.

## One entry describes the document fetch Navigation Timing Level 2 models the main document as just another fetched resource. `PerformanceNavigationTiming` inherits from `PerformanceResourceTiming`, so it carries the whole waterfall — `redirectStart`, `fetchStart`, `domainLookupStart/End`, `connectStart`, `secureConnectionStart`, `connectEnd`, `requestStart`, `responseStart`, `responseEnd` — plus the document-specific milestones: `domInteractive`, `domContentLoadedEventStart`, `domContentLoadedEventEnd`, `domComplete`, `loadEventStart`, `loadEventEnd`. There is normally exactly one: ```javascript const [nav] = performance.getEntriesByType('navigation'); ``` or, if you want it delivered rather than polled, observe `{ type: 'navigation', buffered: true }`. ## Everything is relative to timeOrigin All timeline values are `DOMHighResTimeStamp`s — sub-millisecond floats counted from `performance.timeOrigin`, the moment the document's clock started. For the navigation entry, `startTime` is 0. That single fact removes an entire class of arithmetic: `responseStart` is already the elapsed time until the first response byte, and `domContentLoadedEventEnd` is already the elapsed time until that event finished. The one correction worth knowing is `activationStart`. If the page was prerendered, it began loading long before the user saw it, and raw timestamps would credit the page with negative-feeling wins. `activationStart` holds the time at which the prerendered document was activated, so the honest user-perceived value is `Math.max(0, nav.responseStart - (nav.activationStart || 0))`. That is exactly the correction the standard field-metric libraries apply. ## Sub-timings you can derive Because the fields are ordered, deltas are straightforward: ```javascript const redirect = nav.redirectEnd - nav.redirectStart; const dns = nav.domainLookupEnd - nav.domainLookupStart; const tcp = nav.connectEnd - nav.connectStart; const tls = nav.secureConnectionStart ? nav.connectEnd - nav.secureConnectionStart : 0; const download = nav.responseEnd - nav.responseStart; ``` A redirect chain in front of the document shows up plainly here, and it is one of the most common surprises — a domain-level redirect adding a full round trip before the request that matters even starts. ## The type field `nav.type` is one of `navigate`, `reload`, `back_forward` or `prerender`. This matters for analysis, not curiosity. A `back_forward` navigation restored from the back/forward cache produces timings that look extraordinary because nothing was re-fetched; leaving those rows in a load-time dataset quietly flatters the median. Segment or exclude them explicitly. There is also `workerStart`, non-zero when a service worker handled the navigation, which lets you separate service-worker startup from network time. ## Why the legacy interface is worse `performance.timing` was the Level 1 API: a flat object of Unix-epoch millisecond integers. - **Epoch values.** Every reading had to be normalised by hand — `timing.responseStart - timing.navigationStart`. Forgetting produced a number around 1.7 trillion, which is at least obvious; getting the base wrong produced a plausible-looking wrong number, which is worse. - **Whole milliseconds.** Sub-millisecond resolution is unavailable, and rounding accumulates across derived deltas. - **Not observable.** It is a snapshot object, not a timeline entry, so no `PerformanceObserver` can deliver it. You had to pick a moment to read it, and a milestone that had not happened yet read 0 — so reading too early gave you zeros, and code that ran at `load` could not see `loadEventEnd` because the event had not finished. - **No modern fields.** There is no `type`, no `activationStart`, no `workerStart`, no transfer sizes. It is deprecated in favour of the Level 2 entry, and the only reason to touch it is reading someone's ancient analytics snippet. ## Reading it at the right moment Even with the modern API, a milestone that has not occurred reads 0 — query at the top of the document and `loadEventEnd` is 0 because the load event has not fired. The reliable pattern is to read the entry when you are about to report (typically as the page is being hidden), or to use a buffered observer so the entry is delivered to you when it is complete. Never read `loadEventEnd` from inside the `load` handler itself; the event has started but not ended.

  • Why does reading nav.loadEventEnd inside a load event handler give you zero?
    Because the milestone has not been reached yet. `loadEventEnd` is stamped when the load event's handlers have *finished*, and your handler is still running. The same applies to `domContentLoadedEventEnd` inside a DOMContentLoaded listener. Read the entry later — from a `setTimeout(…, 0)` after load, or better, at report time when the page is being hidden — or take delivery through a buffered observer for the `navigation` type.
  • What does the entry's activationStart field correct for?
    Prerendering. A prerendered document starts loading before the user navigates to it, so its raw timestamps measure from a moment the user never experienced. `activationStart` marks when the prerendered page was actually activated, and subtracting it (clamped at zero) converts the raw timeline into user-perceived time. Without the correction, prerendered navigations report implausibly fast — or even negative — user-facing timings.
  • Why should back_forward navigations be segmented separately in load analysis?
    They are restored rather than loaded. A back/forward-cache restore re-uses a fully constructed page, so its navigation timings reflect almost no work and are not comparable to a real load. Mixed into one dataset they drag the median down and mask regressions on genuine navigations. The `type` field on the navigation entry gives you the flag to segment on, so report the two populations separately.

saying these in an interview costs you the question

  • Subtracting navigationStart from Level 2 entry timestamps
  • Thinks Navigation Timing values are Unix-epoch milliseconds
  • Reads loadEventEnd from inside the load handler
  • Mixes back/forward-cache restores into load-time averages
  • Uses performance.timing in new code because examples still show it

context