Browser Keyboard Event Timing Explained

What happens between a key going down and your JavaScript handler running — and which part of that chain a web page can honestly time.

Inspect your own key events

Published 15 August 2026 · Last updated 15 August 2026

A web page never sees a key being pressed. It sees a KeyboardEvent object that the browser has already built, queued and dispatched. Everything measured on this site lives in the gap between two moments: when the browser stamped that event, and when our handler actually ran.

That gap is small, real and reproducible. It is also the only segment of the input chain a browser can measure without guessing. This guide walks the pipeline, explains what KeyboardEvent.timeStamp represents, and shows why the way a handler is written changes the number it reports.

The path from key press to handler

A single keystroke passes through several independent systems before any JavaScript runs. Roughly, in order:

  1. The switch closes. The keyboard's controller notices on its next scan of the key matrix, once its debounce filter is satisfied.
  2. The controller builds a report describing which keys are down and waits for its next transmission opportunity — a poll from the USB host, or a radio window on a wireless link.
  3. The operating system's input stack receives the report, applies the keyboard layout, runs it past any driver or remapping software, and routes it to the focused window.
  4. The browser receives the key from the OS. In current mainstream browsers the tab's content runs in a separate process from the browser's own UI, so the input is passed across a process boundary to the renderer for your tab.
  5. The renderer creates the KeyboardEvent and stamps its timeStamp.
  6. Dispatching that event is a task for the page's main thread. If the main thread is busy, the event waits in the queue — tasks are not interrupted mid-run.
  7. The event is dispatched: capture phase down the tree to the target, then bubble phase back up. Every registered listener runs in order, on the same thread.
  8. Our handler runs and reads the clock.

Steps 1 to 3 happen entirely outside the browser. No information about their duration travels with the event, which is the single most important fact about browser-based keyboard measurement — see why browser tests can't measure hardware latency for the full argument.

What KeyboardEvent.timeStamp actually is

In modern browsers, Event.timeStamp is a DOMHighResTimeStamp: a value in milliseconds, measured from the page's time origin, on the same monotonic clock that performance.now() reads. Because the two share a timeline, you can subtract one from the other directly and get a meaningful duration.

Three properties matter:

  • It is monotonic, not wall-clock. It does not jump when the system clock is corrected, so a time-sync event cannot corrupt a measurement.
  • It is stamped at event creation — the earliest browser-side moment available. It is not a hardware timestamp and does not describe when the switch closed.
  • It is coarsened like every other high-resolution clock in the page. Browsers deliberately reduce timer precision as a side-channel defence, so the smallest difference the value can express depends on the browser and its configuration.

Some engines and configurations have historically handed back an epoch-scale value instead — a number near 1.7 × 1012 rather than a few thousand. Our timing module normalises before doing any arithmetic: anything above 1012 has performance.timeOrigin subtracted from it, and anything that is not a finite positive number is rejected outright so the sample is excluded rather than silently turned into nonsense.

The measurement

The flagship number on this site is the browser input event latency:

latency = performance.now()          // read FIRST inside the keydown handler
        − normalizeEventTimestamp(ev) // the event's own timestamp

That delta covers event creation, the wait in the main-thread task queue, dispatch through the DOM tree, and any listener that ran before ours. It covers nothing before the browser built the event and nothing after our handler returns. Three related channels are collected from the same events:

The three browser-side timing channels
ChannelFormulaWhat it describes
Event latency handlerNow − ev.timeStamp Browser input event latency — queue wait plus dispatch
Input-to-frame frameNow − ev.timeStamp Browser-side time until the next animation frame begins
Event interval ev[i].timeStamp − ev[i−1].timeStamp Browser-observed gap between consecutive events

keydown, keypress, keyup and repeat

keydown fires when a key goes down and carries key, code, location and the modifier flags. It is the right event to time, because it is the first one the browser produces for a press.

keypress is deprecated and should not be used in new code. It was only ever fired for character-producing keys, its behaviour differed across engines, and it is not part of how modern browsers are expected to report text input. If you need the text a key produces, listen for beforeinput or input instead.

keyup fires on release. Its latency can be measured the same way, but a release is rarely the interesting moment; it is more useful for hold time and for spotting keys that never report a release, which is what the stuck key test looks for.

Two event properties decide whether a sample counts at all:

  • event.repeat is true when the event is auto-repeat generated by the operating system while a key is held. It is not a second physical press, so it is routed to the repeat channel and excluded from press latency.
  • event.isTrusted is false for events created by script and delivered with dispatchEvent(). Those get a timestamp at dispatch time, so their apparent latency is near zero and would quietly drag every statistic down. They are excluded.

If you want to see all of these fields for real keystrokes, the keyboard event tester prints them as they arrive.

Why reading the clock first matters

Everything that executes before the clock read is counted as latency. Fetching ev.key, calling document.querySelector(), logging to the console, or reading a layout-dependent property all take time, and that time lands inside your measurement rather than outside it. The clock read has to be the first statement in the handler:

el.addEventListener('keydown', function (ev) {
  var t0 = performance.now();   // nothing above this line
  // validation, storage and rendering all happen after
}, { capture: true });

The listener is registered in the capture phase for the same reason. Capture listeners run on the way down the tree, before listeners attached to the target or its ancestors in the bubble phase, so another script's handler cannot add its own execution time to our number first.

How main-thread work inflates the number

JavaScript in a page runs on one thread, and a running task is not preempted. If a long task is executing when your keystroke arrives, the event sits in the queue until that task finishes. A 30 ms layout recalculation happening at the wrong instant produces a 30 ms sample, and the keyboard had nothing to do with it.

Common sources of that delay: large DOM updates, forced synchronous layout, parsing or stringifying big objects, garbage collection, animation callbacks doing real work, other listeners on the same event, and browser extension content scripts injected into the page.

This is why the measurement handler here is kept nearly empty. It reads the clock, validates the event against the exclusion rules, pushes a small object onto an array, and returns. Nothing is formatted, nothing is drawn, and no layout is read. The visible readouts are updated inside a requestAnimationFrame callback at most once per frame, and charts are drawn after the run ends. A test page that redrew a chart on every keystroke would be measuring its own rendering cost.

The practical consequence for anyone reading a result: a median that suddenly rises, or a 95th percentile far above the median, is usually a statement about how busy the machine and the page were, not about the keyboard. Why results vary between systems covers the rest of those variables.

Input-to-frame is a different question

The second channel schedules a requestAnimationFrame callback from inside the handler and takes its timestamp. That callback runs just before the frame is composited, so the value is a lower bound on when pixels could have changed — it still excludes compositing, scan-out and the display's own response.

It is also quantised by your refresh rate. At 60 Hz frames are 16.7 ms apart, so an event arriving at a random point in the cycle waits about 8.3 ms on average and up to 16.7 ms in the worst case. At 144 Hz that becomes a 6.9 ms frame, at 240 Hz a 4.2 ms frame. Mixing this channel with event latency in one average would hide that structure entirely, which is why they are reported separately.

What the number is, and what it is not

It is a browser-observed measurement of the software path between event creation and handler execution, on this machine, in this browser, right now. It is reproducible enough to compare two sessions on the same setup, and sensitive enough to reveal a busy main thread or a misbehaving extension.

It is not your keyboard's hardware latency, not a switch measurement, and not a transport measurement. No browser API exposes those. The measurement methodology states the formulas, the validation rules and the limits in full, and you can watch the numbers appear for yourself on the keyboard latency test.

Related reading and tools