JavaScript Basics: Measure Work with Performance Marks
A performance mark records a moment, and a measure describes the elapsed interval between moments. Naming those boundaries makes timing code easier to inspect than scattered clock arithmetic. The examples verify that measurements exist without pretending elapsed milliseconds are reproducible across runs.
These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only the installed version above was tested. They use built-in modules, make no network requests, and assert their results. The output below is exact, not a sketch.
Prepare a scratch directory
You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep the same terminal open and save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Measure one synchronous operation
Save this as measure-array-sort.mjs:
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
try {
performance.mark('sort:start');
const sorted = [8, 2, 11, 3].toSorted((a, b) => a - b);
performance.mark('sort:end');
const entry = performance.measure('sort', 'sort:start', 'sort:end');
assert.deepEqual(sorted, [2, 3, 8, 11]);
assert.equal(entry.entryType, 'measure');
assert.ok(Number.isFinite(entry.duration) && entry.duration >= 0);
console.log(`sorted: ${sorted.join(', ')}`);
console.log(`measurement: ${entry.name}; valid duration: true`);
} finally {
performance.clearMarks('sort:start');
performance.clearMarks('sort:end');
performance.clearMeasures('sort');
}Run it:
node measure-array-sort.mjsOutput:
sorted: 2, 3, 8, 11
measurement: sort; valid duration: trueThe comparator gives a numeric order rather than the default string ordering. measure returns an entry whose duration is expressed in milliseconds. The program checks that value but does not print it, because real elapsed time varies. Inspect entry.duration when investigating your own workload. The finally block removes only entries belonging to this operation. See marks and measures.
2. Include awaited work inside the interval
Save this as measure-awaited-operation.mjs:
import assert from 'node:assert/strict';
import { performance } from 'node:perf_hooks';
import { setTimeout as delay } from 'node:timers/promises';
async function loadSample() {
await delay(5);
return { rows: 3 };
}
try {
performance.mark('load:start');
const result = await loadSample();
performance.mark('load:end');
performance.measure('load', 'load:start', 'load:end');
const entries = performance.getEntriesByName('load', 'measure');
assert.equal(entries.length, 1);
assert.equal(result.rows, 3);
assert.ok(entries[0].duration >= 0);
console.log(`loaded rows: ${result.rows}`);
console.log(`stored measures: ${entries.length}`);
} finally {
performance.clearMarks('load:start');
performance.clearMarks('load:end');
performance.clearMeasures('load');
}Run it:
node measure-awaited-operation.mjsOutput:
loaded rows: 3
stored measures: 1The end mark is placed after await, so this interval includes waiting rather than only the time needed to create a promise. The timer simulates waiting locally; it makes no network request. A requested five-millisecond delay is not an exact elapsed-time promise. This measures elapsed latency, not CPU consumption. Querying by name and type avoids confusing the measure with a mark that happens to share its name.
3. Consume a measure with an observer
Save this as observe-parser-measure.mjs:
import assert from 'node:assert/strict';
import { performance, PerformanceObserver } from 'node:perf_hooks';
let observer;
const received = new Promise(resolve => {
observer = new PerformanceObserver(list => {
const entry = list.getEntriesByName('parse', 'measure')[0];
if (entry) resolve(entry);
});
observer.observe({ entryTypes: ['measure'] });
});
try {
performance.mark('parse:start');
const rows = JSON.parse('[10,20,30]');
performance.mark('parse:end');
performance.measure('parse', {
start: 'parse:start', end: 'parse:end', detail: { rows: rows.length }
});
const entry = await received;
assert.equal(entry.detail.rows, 3);
assert.ok(Number.isFinite(entry.duration) && entry.duration >= 0);
console.log(`observed: ${entry.name}`);
console.log(`rows in measurement detail: ${entry.detail.rows}`);
} finally {
observer.disconnect();
performance.clearMarks('parse:start');
performance.clearMarks('parse:end');
performance.clearMeasures('parse');
}Run it:
node observe-parser-measure.mjsOutput:
observed: parse
rows in measurement detail: 3The observer subscribes before the measure is created, then receives it asynchronously. The detail field carries the row count alongside timing information, allowing one metrics consumer to interpret measurements created elsewhere. Disconnecting the observer stops notifications; clearing timeline entries is a separate cleanup step. Neither is a substitute for the other. See PerformanceObserver.
Practical limits
Use unique names or separate numeric timestamps for overlapping operations, so one request cannot accidentally match another request’s marks. Clear entries after collecting them to avoid accumulating an unbounded timeline. Instrumentation adds overhead, and tiny examples are not meaningful benchmarks. Repeat representative workloads, account for warm-up and noise, and never infer a reliable speed ranking from a single timing.
Clean up
In the same terminal, remove only the three named source files, return to your starting directory, and remove the now-empty scratch directory:
rm -- measure-array-sort.mjs measure-awaited-operation.mjs observe-parser-measure.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.