How Browsers Handle Text Input
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.)
| Case | Event order |
|---|---|
| English | keydown → beforeinput → input → keyup |
| Hangul | keydown → compositionstart → compositionupdate → input → keyup |
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:
isComposingis 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. SinceisComposingreliability varies by environment, it's safer to back it up with explicitcompositionstart/compositionendflags.
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 directly | Delegate to the browser | |
|---|---|---|
| Pros | Full control | IME/multilingual support comes for free |
| Cons | Have to implement IME, edge cases explode | Hard 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
| Trigger | Description |
|---|---|
| DOM reset/replacement | innerText = '', node swaps |
| Selection changes | selection.collapse(), etc. |
| Focus moves | focus(), blur() |
| Layout reflow | Input node moving due to line wrap or page split |
| Style/gesture effects | Changes 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 keydown → compositionupdate → input 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/endoften don't fire at all- It's handled via an
insertText+deleteContentBackwardcombo isComposingmay 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
innerTextresets and node swaps; if you must, defer them until aftercompositionend - If
user-select: noneis set,caretRangeFromPointwon'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
- Don't change the Selection during composition
- Don't reset or clean up the DOM during composition (defer until composition ends if needed)
- Use
inputType-based routing on beforeinput/input to separate insert/delete/paste
Troubleshooting
- Log events to check the actual order and any missing events
- Trace back any code that touches DOM/selection/focus during composition
- 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.