DEV Community

Vladimir Elchinov for Session Replay

Posted on Edited on

Your input Handler Is Firing on Half a Character, and You Cannot Reproduce It

A report comes in saying the search box "goes mad" while they type, or that the form will not accept their name, or that the character counter is wrong. You open the page, type the same thing, and everything is fine.

They are using an input method editor. You are not.

What a composition session is

An IME is how you type a language with more characters than keys. You type several keystrokes, the system shows a provisional string, and at some point you commit it and the real text lands. Japanese, Chinese and Korean most obviously, but the same machinery covers something far more common than that, which I will come back to.

The W3C UI Events specification calls this a composition session, and describes it as "one compositionstart event, one or more compositionupdate events" and "one compositionend event".

So far so reasonable. The part that breaks code is what happens inside the session.

Your input listener fires on the provisional text

Section 3.6.6 of the spec is titled "Input Events During Composition", and it sets the order: for each update, beforeinput, then compositionupdate, then input.

So input fires repeatedly, during the session, carrying text the user has not committed and in many cases would not recognise as anything they typed.

And then the sting, from the same section:

Since there are no DOM updates associated with the compositionend event, beforeinput and input events should not be sent at that time.

Read that twice if you have ever written field.addEventListener('input', validate). Your handler runs several times on provisional fragments, and then does not run at the moment the user finishes.

The practical results are all the ones in that bug report. A validator rejecting a half-formed string. A character counter counting the wrong thing. A search-as-you-type sending a request per keystroke of a word that does not exist yet. An autosave storing a fragment.

The flag that fixes it

Both InputEvent and KeyboardEvent carry isComposing, defined as:

true if the input event occurs as part of a composition session,

and, completing the sentence, "i.e., after a compositionstart event and before the corresponding compositionend event." Its uninitialized value "MUST be false", so on ordinary typing it is simply false and costs you nothing.

let committed = field.value

function commit() {
  if (field.value === committed) return   // the same commit, delivered twice
  committed = field.value
  handle(committed)
}

field.addEventListener('input', (e) => {
  if (e.isComposing) return          // provisional text, not a value yet
  commit()
})

// and because no input event is supposed to arrive at the end of a session:
field.addEventListener('compositionend', commit)
Enter fullscreen mode Exit fullscreen mode

Two listeners. The second one is the half people leave out, and leaving it out means an IME user's committed text is never processed at all.

The committed check is there because "supposed to" is doing work in that comment. The specification says that since compositionend makes no change to the DOM, input events "should not be sent at that time". A reader reports that Safari sends one anyway, an input carrying the committed value right after compositionend, which without the check runs handle twice for one commit. Guarding on the value costs nothing in a browser that follows the spec, and stops a side effect from happening twice in one that does not.

The advice you will find, and why it is wrong

Search this problem and you will be told to check for keyCode === 229.

That does appear in the specification. It appears in section 7.3.1, under a heading that says, in the document's own words, "This section is non-normative", in a legacy appendix describing one historical model. The same document says plainly:

Authors SHOULD use the key attribute instead of the charCode and keyCode attributes.

It also notes that keyCode's values "are not normatively defined in this specification". So the famous number is not a rule, it is a description of what some implementations used to do. isComposing is the attribute that is actually specified for this, and it is on the event you are already handling.

One more correction while here: it is often said that key events stop during composition. The spec says the opposite. "During the composition session, keydown and keyup events MUST still be sent," and those events "MUST have the isComposing attribute set to true".

Why this is not only a CJK problem

Here is the line that should widen your idea of who is affected. The spec describes isComposing on the input event as true when the event is dispatched

during a dead key sequence or while an input method editor is active

Dead keys. A French, Czech, or Vietnamese layout, or a US-International layout, produces accented characters by a dead key followed by a letter. That is a composition session too. Anybody typing é, ñ, ô or ü can hit the same code path, on the same keyboard hardware you have.

Which is the honest reason this survives so long in so many codebases. It is not exotic. It is simply invisible from a machine configured the way yours is, and the people who hit it describe it in the only words available to them: it does not work when I type my name.

That sentence contains the entire bug and explains none of it, and no amount of re-reading the ticket will get you to a dead key.


Specification quotes are from the W3C UI Events Working Draft of 21 February 2026, which asks to be cited as a work in progress.

Top comments (1)

Collapse
 
komopdf profile image
KomoPDF •

Handling IME composition is especially finicky when syncing text back into canvas overlays or PDF form fields.

If your input listener recalculates auto-sizing fonts or text bounding boxes on every stroke, firing on provisional text causes the bounding box and line wraps to jump while the user is still choosing candidates.

There is also a cross-browser ordering quirk between Chromium and WebKit. On Safari, compositionend often fires immediately before a final input event that carries the committed value. On Chromium, compositionend fires without that trailing input. If you attach your commit logic to both input (checking !e.isComposing) and compositionend, you need to track the last committed string so Safari does not run the commit routine twice in a single frame.