Guides

How to measure how long code takes in JavaScript — and why the first run always lies

Illustration: a big stopwatch on a desk and a row of identical gears being timed one by one

Here's what usually happens. You asked AI to speed up a function. It rewrote it and said: "now it's three times faster." You measured once — and yes, faster. You measured again — and suddenly the old version was fast too.

It's not magic. The first run is almost always slower than the rest: the JavaScript engine hasn't "warmed up" and optimized the code yet. One measurement proves nothing. Let's build an honest one, step by step.

Step 1. A quick estimate — console.time

When you just need the order of magnitude — "milliseconds or seconds?" — two lines are enough:

console.time("sort")
items.sort((a, b) => a.price - b.price)
console.timeEnd("sort") // sort: 12.4 ms

Use the same "sort" label in both lines. In between you can call console.timeLog("sort") to print the elapsed time without stopping the timer.

That's an estimate, not a precise measurement. To compare two versions, keep going.

Step 2. Precise time — performance.now

performance.now() returns milliseconds with a fractional part since the page loaded. The difference between two calls is the duration:

const start = performance.now()
doWork()
const ms = performance.now() - start
console.log(`${ms.toFixed(2)} ms`)

Why not Date.now()? Two reasons. It counts only whole milliseconds. And it's tied to the system clock: if the computer syncs its time over the network, the difference can even go negative. performance.now() only moves forward.

One detail. To defend against timing attacks, browsers coarsen performance.now(): usually to 100 microseconds, and to 5 on pages with special isolation headers. So a single very fast operation will show 0 or 0.1. That's the next step.

Step 3. Repeat many times and take the median

A single run is noise. Measure a series and throw away the first runs as warm-up:

function bench(fn, runs = 50) {
  for (let i = 0; i < 5; i++) fn() // warm-up, not counted
  const times = []
  for (let i = 0; i < runs; i++) {
    const t = performance.now()
    fn()
    times.push(performance.now() - t)
  }
  times.sort((a, b) => a - b)
  return times[Math.floor(times.length / 2)] // median
}

console.log("old:", bench(oldVersion))
console.log("new:", bench(newVersion))

Why the median and not the average? One 40 ms garbage-collection pause ruins the average for everyone. The median simply won't notice it. It's the same logic as percentiles.

If a function is very fast, time a loop of a thousand calls instead of one, and divide by a thousand.

One more fairness rule: measure both versions on the same data and in the same run. Comparing "old yesterday" with "new today" is pointless — your laptop may have had other tabs open or battery saver on.

Step 4. Async code — put await inside the measurement

A classic mistake is a "0.1 ms" request to the server:

const t = performance.now()
fetch("/api/items") // forgot await
console.log(performance.now() - t) // 0.1 — measured only the start

fetch returns a promise right away, and the response arrives later. The measurement has to wait for the result:

const t = performance.now()
const res = await fetch("/api/items")
await res.json()
console.log(performance.now() - t)

This measures both the network and parsing — what the user actually feels. More on why code "doesn't wait" in the article on async.

Step 5. Don't let the engine throw the work away

The engine is smart. If a computation's result goes nowhere, it may skip computing it altogether. And you'll be timing nothing.

let sink
bench(() => { sink = heavyCalc(data) })
console.log(sink) // the result is used — the engine keeps the work

Warning sign: the "optimized" version is suddenly 1000 times faster. Most likely it isn't doing anything.

Step 6. See the measurement on the DevTools timeline

Want to see where time goes among rendering and network requests? Add marks:

performance.mark("load-start")
await loadFeed()
performance.mark("load-end")
performance.measure("feed load", "load-start", "load-end")

Open DevTools → Performance tab → record a profile. Your "feed load" span shows up as its own bar in the Timings section. Right next to it you see what else was happening at that moment.

What you get

You'll have a bench function that gives an honest "before/after" comparison by median. Now when AI says "it's three times faster," you answer with numbers, not faith. And you find the genuinely slow spot before you speed up your site at random.

How do I measure time in Node.js?

The same way: performance.now() is global in Node. If you need nanoseconds, there's process.hrtime.bigint() — it returns time as a BigInt.

How many runs are enough?

For comparing two versions, 20–50 is usually enough. If the medians differ by less than 5–10%, treat the versions as equal: noise will eat a gap that small.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →