Developing a Typing Test: Handling Keyboard Events in JavaScript

Key Points
  • Use keydown, not keypress (deprecated) or input (too high-level for typing tests).
  • Timestamp every event with performance.now() for sub-millisecond precision.
  • Backspace should decrement a position counter, not insert a character into the buffer.
  • Block paste events to prevent cheating and maintain test integrity.
  • Maintain a character state array, not just a typed string — you need to know the state of each position.

Why Input Handling Is the Hardest Part

The WPM formula is simple arithmetic. The timer is one API call. But the input handler has to deal with a stream of raw keyboard events and make real-time decisions about every one of them: is this a printable character or a control key? Should backspace move the cursor or do nothing? What happens if the user pastes text? What if they press Shift?

Getting this right is what separates a reliable typing test from one that occasionally drops keystrokes, miscounts characters, or allows cheating through pasting.

The Event Listener Setup

The correct event to listen to is keydown, attached to the document (not the input element, because you want to capture all keystrokes regardless of focus state):

document.addEventListener('keydown', handleKeydown)

function handleKeydown(event) {
  event.preventDefault()  // Prevent browser shortcuts
  const timestamp = performance.now()
  const key = event.key
  processKey(key, timestamp)
}

event.preventDefault() prevents the browser from acting on keys like Tab (which would normally move focus) or Space (which might scroll the page).

The Character State Array

Don't maintain a simple string of "what the user typed." Maintain an array of character states, one per character in the target text:

const TEXT = "the quick brown fox"
const states = new Array(TEXT.length).fill('untyped')
// Each element is: 'untyped', 'correct', 'incorrect', 'corrected'

let position = 0

function processKey(key, timestamp) {
  if (key === 'Backspace') {
    if (position > 0) {
      position--
      if (states[position] !== 'untyped') {
        states[position] = 'untyped'  // or track 'corrected' state separately
      }
    }
    return
  }

  if (key.length !== 1) return  // Ignore modifier keys

  if (key === TEXT[position]) {
    states[position] = 'correct'
  } else {
    states[position] = 'incorrect'
  }

  recordKeystroke(key, timestamp, position)
  position++
}

Blocking Paste Events

Paste events bypass the keystroke-by-keystroke comparison and would let someone paste the entire passage instantly. Block them:

document.addEventListener('paste', (e) => {
  e.preventDefault()
})

Recording Timestamps for Anti-Cheat

Each keystroke's timestamp should be stored alongside the character and position for server-side validation. A simple array works:

const keystrokeLog = []

function recordKeystroke(key, timestamp, position) {
  keystrokeLog.push({
    key,
    position,
    timestamp: Math.round(timestamp),
    expected: TEXT[position],
    correct: key === TEXT[position]
  })
}

This log is sent to the server when the test completes. The server uses it to independently verify the claimed WPM and check for bot-like IKI patterns. See the logic behind typing speed algorithms for the server-side validation approach. For the overall architecture this input handler fits into, see how to build a typing test website from scratch.

¿Listo para ponerlo en práctica?

Haz una prueba de mecanografía gratuita y empieza a hacer seguimiento de tu progreso.

Prueba de mecanografía gratuita →