How Browsers Handle Text Input

#Editor#JavaScript#Browser

Browser events (Keyboard/Composition/BeforeInput), IME, and the iOS Safari differences

Text input isn't just about appending characters to a string. The moment a single character shows up on screen, IME composition, Selection/Range, DOM updates, focus, and layout (line breaks/reflow) are all moving in lockstep behind the scenes.

That's why most text input bugs aren't really about "string handling." They come from drawing the wrong boundary between:

  • what to leave to the browser (IME/selection),
  • what JS should own, and
  • when to sync the two.

In this post I want to walk through the browser event flow for text input and dig into how things work in a contentEditable-based setup.


Understanding Browser Text Input Events

Before a character shows up on screen, several input events fire inside the browser in a tangled order. They roughly fall into three categories.

Keyboard / Composition / Input

Keyboard Events (keydown, keyup) react to physical key presses. You can tell which key was pressed, but not which character actually ends up being inserted. They often fail to accurately reflect the state of a Hangul character that's still being composed.

Composition Events (compositionstart, compositionupdate, compositionend) tell you about the IME composition state. They fire for languages like Korean, Japanese, or Chinese where multiple key presses combine into a single character.

Input Events (beforeinput, input) fire when the DOM value actually changes. The inputType tells you "what kind of edit this was," which makes them the most important events in modern editor development.

English vs. Hangul Input Event Order

(The events that fire and their order can shift depending on the browser/OS/IME combination.)

CaseEvent order
Englishkeydownbeforeinputinputkeyup
Hangulkeydowncompositionstartcompositionupdateinputkeyup

English wraps up "key press → text committed" almost instantly, but Hangul has a composition state in between.

During IME input, composition* events fire across the composition stages, and beforeinput/input can slip in between them.

Depending on the environment, beforeinput may be skipped during composition, or the inputType may come through differently.

What isComposing Means

isComposing is a flag that tells you whether the current character is mid-composition (for example, the process of 'ㄱ' becoming '가' — Hangul jamo combining into a syllable). It lives on both KeyboardEvent and InputEvent, and you can use it to prevent bugs where the in-progress character gets entered twice when Enter is pressed.

inputElement.addEventListener('input', (e) => {
  if (e.isComposing) return; // Skip logic while IME composition is in progress
  console.log('Final input committed:', e.target.value);
});

Timing varies between platforms and browsers, though, so in practice it's more reliable to manage your own flag via compositionstart/end and treat isComposing as a secondary signal.

Note: isComposing is often used in Enter-key handling. An Enter during composition can be a "commit" action, so it's used to suppress the submit/send behavior. Since isComposing reliability varies by environment, it's safer to back it up with explicit compositionstart/compositionend flags.

let composing = false;
element.addEventListener('compositionstart', () => (composing = true));
element.addEventListener('compositionend', () => (composing = false));
 
element.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && (e.isComposing || composing)) {
    e.preventDefault();
  }
});

Event Logging Code

When input bugs show up, watching the raw events is the fastest way to find the cause.

[
  'keydown', 'keyup',
  'beforeinput', 'input',
  'compositionstart', 'compositionupdate', 'compositionend',
].forEach((type) => {
  element.addEventListener(type, (e) => {
    console.log(type, {
      key: e.key,
      data: e.data,
      inputType: e.inputType,
      isComposing: e.isComposing,
    });
  });
});

Text Editing Built on contentEditable

What contentEditable Hands Off to the Browser

Setting contentEditable="true" hands the element's text editing over to the browser. The browser takes care of:

  • Key presses → character insertion
  • IME composition (Korean, Japanese, etc.)
  • Cursor/Selection management
  • Basic editing commands (copy, paste, undo/redo, etc.)

This is territory that browsers and operating systems have already battle-tested across countless edge cases, so leaning on it as much as possible is the stable choice.

JS Handling vs. Browser Handling Trade-offs

JS handles directlyDelegate to the browser
ProsFull controlIME/multilingual support comes for free
ConsHave to implement IME, edge cases explodeHard to fine-tune behavior

If JS tries to handle input directly, you end up owning composition, candidate conversion (Japanese/Chinese), RTL languages, and selection behavior all by yourself. Realistically, handing it off to the browser is usually the better call for maintenance.

Data Flow with contentEditable: DOM → Model

With contentEditable, the browser modifies the HTML first, and JS checks the result in the input event to sync the model.

User input → Browser updates HTML → input event → JS updates model

Reverse this order and things break.


How to Approach Text Input Handling

Don't Modify the DOM Directly on keydown/compositionupdate

// ❌ Rewriting the DOM mid-composition will very likely break things
element.addEventListener('compositionupdate', (e) => {
  element.textContent = transform(element.textContent);
});

During Hangul input, the browser and OS IME pin a specific DOM node (a text node) as "the input target" and keep composing on it. If JS modifies or deletes that node, things go wrong.

  • Situation: The user is mid-typing '가' (ga — composed from ㄱ + ㅏ)
  • JS interference: On compositionupdate, you read the text and wrap "가" in <span>가</span>
  • Result: The IME thinks the text node it was managing has disappeared. Composition is force-terminated, the cursor jumps to the front of the editor, or the last character gets duplicated.

If you touch the DOM/selection during composition, the browser can decide it has lost the editing location.

Recommended Approach: Routing on beforeinput/input + inputType

element.addEventListener('beforeinput', (e) => {
  switch (e.inputType) {
    case 'insertText':
      // Regular text insertion
      break;
    case 'insertCompositionText':
      // (Varies by environment) IME composing — let the browser handle it
      break;
    case 'deleteContentBackward':
      // Backspace
      break;
    case 'insertFromPaste':
      // Paste
      break;
  }
});

beforeinput/input represent the actual editing action, and inputType lets you classify the intent. This lets JS focus on "syncing" based on what the browser already did, while keeping risky DOM/selection changes to a minimum during composition.

  • beforeinput: Figure out the user's intent (insert vs. delete). You can call preventDefault() and render the DOM yourself, but that's dangerous mid-Hangul-composition.
  • input: Right after the browser has applied the character to the DOM. Read the updated DOM state and update only your internal data model.

Pinning the Editing Location During Composition

For IME composition to work properly, the editing location must not change during composition.

Triggers That Break Composition

TriggerDescription
DOM reset/replacementinnerText = '', node swaps
Selection changesselection.collapse(), etc.
Focus movesfocus(), blur()
Layout reflowInput node moving due to line wrap or page split
Style/gesture effectsChanges in user-select policy, etc.

Deferring Layout Changes During Composition

When the editor needs to reflow text (e.g. paginating), if that reflow involves a selection change, composition breaks. So you need a policy that defers layout changes during composition.

let pendingLayoutUpdate = null;
let isComposing = false;
 
element.addEventListener('compositionstart', () => {
  isComposing = true;
});
 
element.addEventListener('compositionend', () => {
  isComposing = false;
  if (pendingLayoutUpdate) {
    pendingLayoutUpdate();
    pendingLayoutUpdate = null;
  }
});
 
function updateLayout() {
  if (isComposing) {
    pendingLayoutUpdate = () => actualLayoutUpdate();
    return;
  }
  actualLayoutUpdate();
}

iOS vs. macOS

Even within Safari, input events can fire differently on iOS and macOS.

iOS Leans Heavily on beforeinput/input

On macOS, the keydowncompositionupdateinput order is predictable. But on iOS, much more comes in through inputType-based events, so building on keydown alone hits a wall fast.

Quirks of Hangul Input on iOS

When typing Hangul on iOS:

  • compositionstart/update/end often don't fire at all
  • It's handled via an insertText + deleteContentBackward combo
  • isComposing may come through as always false
Type 'ㄱ' (Hangul jamo):         insertText 'ㄱ'
Form '가' (ga, ㄱ + ㅏ):           deleteContentBackward + insertText '가'
Form '간' (gan, ㄱ + ㅏ + ㄴ):     deleteContentBackward + insertText '간'

Why Code That Works on macOS Blows Up on iOS

// Works on macOS
element.addEventListener('keyup', (e) => {
  if (!e.isComposing) {
    clearBuffer(); // iOS: runs on every keystroke → composition breaks
  }
});

On iOS, isComposing can come in unexpectedly (e.g. always false), which means your buffer gets cleared on every keystroke.

Tips for Handling iOS

  • Instead of isComposing, back it up with a flag and logs based on compositionstart/end
  • Avoid innerText resets and node swaps; if you must, defer them until after compositionend
  • If user-select: none is set, caretRangeFromPoint won't work — check the parent styles

Things to Watch Out For When Handling Text Input

Flag Management

const state = {
  isComposing: false,
  lastInputType: null,
};
 
element.addEventListener('compositionstart', () => {
  state.isComposing = true;
});
 
element.addEventListener('compositionend', () => {
  state.isComposing = false;
});
 
element.addEventListener('beforeinput', (e) => {
  state.lastInputType = e.inputType;
});

Rules for Safe Input Handling

  1. Don't change the Selection during composition
  2. Don't reset or clean up the DOM during composition (defer until composition ends if needed)
  3. Use inputType-based routing on beforeinput/input to separate insert/delete/paste

Troubleshooting

  1. Log events to check the actual order and any missing events
  2. Trace back any code that touches DOM/selection/focus during composition
  3. Verify iOS on a real device (simulators can behave differently)

Conclusion

"Let the browser do what it's good at; JS interferes as little as possible."

IME and selection are where browsers and operating systems shine. If JS tries to implement input from scratch, platform and language edge cases pile up endlessly. So let the browser handle input, have JS quietly sync via beforeinput/input, and defer DOM/selection/layout changes as much as possible during composition. That's the most stable strategy.