JavaScript User Timing Table
| Piece | What it does | Field note |
|---|---|---|
performance.now() | The clock | monotonic, high-resolution, immune to clock steps - one clock per figure, never mix with Date.now() (wall time steps backward) |
performance.mark(name) | The breadcrumb | nanosecond-cost named stamp into the performance timeline; DevTools shows it on the User Timing track |
performance.measure(name, start, end) | The duration | two marks become a named measure; missing mark names throw SyntaxError - silent catches here empty your dashboard |
the detail payload | The context | {detail: {variant, hits, cohort}} rides the entry - timing plus context in one entry is what turns debugging into analytics |
PerformanceObserver | The collector | observe type measure with buffered: true to replay entries that fired before attach - fixes the bootstrap race every load |
the RUM beacon | The ship-out | batch and send on visibilitychange-to-hidden via sendBeacon/keepalive; sample if volume demands it |
clearMarks/clearMeasures | The cleanup | the timeline buffer caps (~150 marks/~250 measures) and SPAs leak entries per route - clear on transition or stream continuously |
the naming discipline | The taxonomy | product-language names (catalog-load, search) survive refactors; name churn silently breaks every historical comparison |
User Timing is the API for answering one question with precision: how long did THIS take? performance.mark(name) drops a timestamped breadcrumb into the performance timeline, performance.measure(name, startMark, endMark) turns two breadcrumbs into a named duration, and both are readable as entries, visible in the DevTools performance panel, and observable from PerformanceObserver - which makes your timings first-class citizens of the same timeline the browser itself reports on.
Bottom line: the clock is performance.now() - monotonic, high-resolution (microsecond-ish, deliberately coarsened slightly against timing attacks), immune to system clock changes and timezones - and marks and measures are just named stamps over it. The measurement rule that keeps numbers honest: measure from marks or from explicit timeOrRouteCall parameters, never mix performance.now() arithmetic with Date.now() in the same figure (Date is wall-clock: NTP can step it backward mid-measurement), and name things by what they mean to the product (parse-catalog-json) not to the implementation (fn2-timer).
The underrated half is the detail payload: both mark and measure accept a second argument with a detail field - an object you can retrieve from the entry later - so a single measure can carry which variant ran, which endpoint responded, which cohort the user is in. Timing plus context in one entry is what turns console debugging into analytics: PerformanceObserver picks your measures up in a RUM beacon with the who and the why attached, no separate instrumentation pipeline.
How to use
- Stamp the moments: performance.mark('app-start'); performance.mark('catalog-fetched'); - marks cost nanoseconds, take a name and optional {detail}, and land in the performance timeline where DevTools shows them.
- Measure between marks: performance.measure('catalog-load', 'app-start', 'catalog-fetched'); - or measure('parse', {start: 'catalog-fetched', duration: 0}) style options with start/end/duration; the result is also returned as a PerformanceMeasure you can read immediately.
- Attach context: performance.measure('search', {start: 'q-start', end: 'q-end', detail: {variant: 'fuzzy', hits: 23}}); - detail rides the entry; retrieve later via entry.detail (some engines need getEntriesByName and JSON round-trips for structured clones).
- Observe continuously: new PerformanceObserver(list => list.getEntries().forEach(e => beacon(e.name, e.duration, e.detail))).observe({type: 'measure', buffered: true}); - buffered true replays entries that fired before the observer attached, which fixes the bootstrapping race on every page load.
- Clean the timeline: performance.clearMeasures('catalog-load') and clearMarks in hot paths and single-page transitions - the timeline buffer holds entries for the page life, and unbounded marks in an SPA become a slow leak that DevTools and observers both pay for.
Frequently asked questions
Why use marks and measures instead of just subtracting performance.now() calls?
Subtraction gives you a number; the timeline gives you an instrument. Six concrete upgrades. Marks and measures appear in the DevTools performance panel (User Timing track) alongside frames, network and layout - your instrumentation sits in context with what the browser was doing, which is where the why of a slow operation lives. They are named, so dashboards and alerts reference stable product-language keys instead of magic constants scattered through the code. They are entries: PerformanceObserver, getEntriesByType and buffered replay come free, which is the entire RUM collection story. They carry detail payloads, so one measure answers duration plus variant plus cohort. They survive refactorings better - timing code drifts out of sync with the code it times, while a named measure pair moves with the function. And they compose with the browser timings on the same axis: your mark sits next to navigationStart-derived paint timings, so catalog-fetched is comparable against first-contentful-paint with no clock juggling. Subtraction is fine for a quick debug; anything that reaches a dashboard deserves the timeline.
What are the classic ways these measurements lie?
Four recurring ones. Wall-clock contamination: mixing Date.now() into the figure - Date steps with NTP and timezone; performance.now() is monotonic, so one clock per figure, ever. Async gaps: measuring start to end across an await chain measures scheduling too - a 'db-query' measure that includes a stalled microtask queue reports storage problems that are actually main-thread congestion; mark immediately around the awaited call, not around the whole handler. First-call inflation: the first measure of any code path pays JIT warmup, cache fills and lazy module init - report the median of many runs, never the first sample, or you are measuring the compiler. And buffer truncation: default buffers cap (150 for marks, 250 for measures in Chrome-family) - long sessions silently drop early entries, which biases every getEntriesByName analysis toward recent activity; either observe-and-ship continuously or raise the buffer and know the cap. There is a fifth micro-lie: coarsened clocks round to fractions of a millisecond by design - durations under ~0.1ms are noise; do not report sub-tick precision as if it were measured.
How does this fit with RUM and analytics - what do I actually ship?
Ship measures as events with three fields and one discipline. Fields: name (the stable product-language key), duration (the number), detail (the dimensions - variant, endpoint, cohort). The discipline: collect via a single PerformanceObserver with buffered: true, batch and beacon on visibilitychange-to-hidden (the only reliable send moment - sendBeacon or fetch keepalive), and sample if volume demands it (the observer sees every measure; beaconing every one of them from every user is a self-inflicted outage). What to instrument: the operations your product promises - search latency, checkout step durations, offline-sync catch-up - plus one end-to-end measure per critical path, because executives read the end-to-end number and engineers debug the step numbers. What not to instrument: framework internals and render loops (the browser's own long-task and paint timings already cover those), and anything you cannot name in product language - if the dashboard cannot say it in a sentence, the measure will be noise within a sprint. Finally, version the names: measure-name churn silently breaks every historical comparison; treat the taxonomy like an API.
Do marks and measures cost anything, and where are the edges?
Almost nothing where it matters, with three real edges. Cost: a mark is a nanosecond-scale stamp and a measure a trivial entry append - you can afford them in production at human-scale frequencies; the cost is in the observers and beacons you attach, not the entries. Edge one: buffer growth in SPAs - every navigation adding a dozen marks is a slow leak; clearMeasures/clearMarks on route transitions, or accept the default caps and stream out continuously. Edge two: name collisions - marks share one global namespace per document, so two libraries marking 'start' corrupt each others measures; prefix with your module name. Edge three: cross-realm and worker isolation - performance timelines are per-realm, so a worker marks itself, never your window timeline; postMessage the durations or observe inside the worker and beacon from there. And one trap at the API edge: measure() with nonexistent mark names throws SyntaxError (not a silent null) - which is correct behavior, but in try/catch-heavy startup code it can be swallowed and leave your dashboard mysteriously empty; fail loudly on missing marks in development.