How to Insert Text Into Textarea at Cursor Fast

It’s great to see the web platform grow and get features like Service Workers, but one of the areas that traditionally gets little to no attention is dealing with user input.

While working on another project, I needed a way to insert some text at the current cursor position in a textarea to auto-complete what user is typing. It turns out this simple task is almost impossible to achieve if you want the browser to not choke on a big text inside a textarea.

The final code from this article is available for use as an npm package.

How Do People Usually Do It?

Of course, as I stumbled onto this problem, I reached out to a trusty search engine pointing me towards ever-prevailing StackOverflow and a couple of answers there (1, 2).

If you ignore old IEs, proposed solution in modern browsers boils down to roughly this:

function insertAtCursor (input, textToInsert) {
  // get current text of the input
  const value = input.value;

  // save selection start and end position
  const start = input.selectionStart;
  const end = input.selectionEnd;

  // update the value with our text inserted
  input.value = value.slice(0, start) + textToInsert + value.slice(end);

  // update cursor to be at the end of insertion
  input.selectionStart = input.selectionEnd = start + textToInsert.length;
}

While this does work, I became very suspicious that it might not perform well with large documents. A quick check in JSBin revealed that on a decent-sized document Firefox, IE and Edge spend around 10ms-15ms on my beefy desktop computer and Chrome is spending more than 45ms. It might not seem like much, but if I want to do any other work (like animation), I’m already out of my performance budget even in the best performing browsers. In Chrome the delay is noticeable even without other work.

If we try to push the size of the data in the textarea even further, things become truly grim. On a sizeable 500kb document I’m getting 0.5-second to 2.5-second delay depending on the browser and the document, that are unacceptable. The regular input, however, stays very responsive.

What Else Can We Do?

At this point, I remembered about document.execCommand and was pleasantly surprised that it does work with textareas and inputs in most browsers (Chrome, Opera, Safari, Edge). Performance is on par with regular text input by the user, and the code is extremely simple:

function insertAtCursor (input, textToInsert) {
  // make sure we have focus in the right input
  input.focus();
  // and just run the command
  document.execCommand('insertText', false /*no UI*/, textToInsert);
}

Firefox, unfortunately, does not support this behavior. While it’s performance with text operations is usually one of the best among browsers, I still was curious if it would be possible to avoid the initial inefficient solution. After some significant digging through MDN I stumbled upon a non-standard setRangeText that did not have any documentation, but did exactly what I wanted, except for updating cursor position and sending input event, but both are super easy to fix. So together we now have:

function insertAtCursor (input, textToInsert) {
  const isSuccess = document.execCommand("insertText", false, textToInsert);

  // Firefox (non-standard method)
  if (!isSuccess && typeof input.setRangeText === "function") {
    const start = input.selectionStart;
    input.setRangeText(textToInsert);
    // update cursor to be at the end of insertion
    input.selectionStart = input.selectionEnd = start + textToInsert.length;

    // Notify any possible listeners of the change
    const e = document.createEvent("UIEvent");
    e.initEvent("input", true, false);
    input.dispatchEvent(e);
  }
}

Adding support for old Internet Explorer browsers (8, 9, 10) is also very straightforward:

function insertAtCursor (input, textToInsert) {
  // IE 8-10
  if (document.selection) {
    const ieRange = document.selection.createRange();
    ieRange.text = text;

    // Move cursor after the inserted text
    ieRange.collapse(false /* to the end */);
    ieRange.select();

    return;
  }

  // the rest is the same as above
}

It looks like we might be done, but if you pay attention, there is still on browser missing — IE 11. And there is a good reason for it.

IE 11

Probably a better title of this section is "a story on how not to deal with backward compatibility". One of the biggest release notes for IE was around abandoning the “old ways” and embracing the standards. What it means in practice is that the code shown at the end of the previous section does not work in version 11, and neither does any method, except for the re-setting the value.

When I was almost ready to give, I noticed that the following code did add stuff to the textarea when it was empty:

function insertAtCursorIE11 (input, textToInsert) {
  const range = document.createRange();
  range.setStart(input, 0);
  range.setEnd(input, 0);
  range.insertNode(document.createTextNode(textToInsert));
}

The code did error out if I tried to insert on pretty much any other position than 0, but it got me curious.

Armed with developer tools and a bunch of console log statements I was able to figure out that unlike every other browser, IE11 treats the content of a textarea as a text node. With regular interaction, you either have none (if the textarea) is empty, or one text node. What is interesting though, is that the browser allows multiple text nodes inside.

As far as position argument for setStart and setEnd goes, it turned out that for textarea IE11 counts the number of text nodes and not characters inside of the textarea. So it is natural that I was initially confused by the results where it sometimes worked on a non-zero position. To be honest, I can't even imagine how the browser code looks like considering that ranges work more or less correctly for the text nodes...

My next evil plan was to use the splitText method of the Text node and then insert a new text node manually at the right position. Unfortunately, this resulted in all kind of weird behavior where IE would just insert things at random places.

Since Range seemed to have almost worked, I decided to get back to that idea, but instead of setting up Range on the textarea itself, I set it up on the child nodes. The calculations to figure out starting and ending node is a bit hairy:

function insertAtCursorIE11 (input, textToInsert) {
  const start = input.selectionStart;
  const end = input.selectionEnd;
  const textNode = document.createTextNode(textToInsert);
  let node = input.firstChild;

  // If textarea is empty, just insert the text
  if (!node) {
    input.appendChild(textNode);
  } else {
    // Otherwise we need to find a nodes for start and end
    let offset = 0;
    let startNode = null;
    let endNode = null;

    // To make a change we just need a Range, not a Selection
    const range = document.createRange();

    while (node && (startNode === null || endNode === null)) {
      const nodeLength = node.nodeValue.length;

      // if start of the selection falls into current node
      if (start >= offset && start <= offset + nodeLength) {
        range.setStart((startNode = node), start - offset);
      }

      // if end of the selection falls into current node
      if (end >= offset && end <= offset + nodeLength) {
        range.setEnd((endNode = node), end - offset);
      }

      offset += nodeLength;
      node = node.nextSibling;
    }

    // If there is some text selected, remove it as we should replace it
    if (start !== end) {
      range.deleteContents();
    }

    // Finally insert a new node. The browser will automatically
    // split start and end nodes into two if necessary
    range.insertNode(textNode);
  }
}

The result is worth the effort though. Insertions are again on par with regular text input. One unfortunate thing is that it does not work for regular text inputs (only textareas), but I would not expect anyone to put a few hundred KB text into an input, so it seems ok.


As I mentioned at the start of the article, I have gathered all of the code from this article, sprinkled some extra code for resilience in case any of the browsers decide to drop support for undocumented features the code relies on and published it as an npm package. Enjoy!