Developer Tools · Editors and IDEs
Byte Offsets, UTF-16 Code Units, and Editor Positions
Why the same cursor sits at several different numbers depending on the encoding counted, how the position encoding for a language server is negotiated, and where the conversions lose alignment.
A cursor in an editor has no single numeric position. The buffer described in Piece Tables, Gap Buffers, and Ropes in Editor Buffers stores bytes, and the offset of a byte is unambiguous — but a language server is told a line number and a character within the line, and the meaning of “character” is a choice that has to be agreed on. Get that agreement wrong by one unit and every range in every reply is shifted: diagnostics underline the wrong token, renames corrupt text, and formatting rewrites the wrong span. The failure is not a crash, which is why it survives testing on ASCII source and appears only on files that contain an emoji, an accented identifier, or CJK text.
Four different things called a position
Four measures are routinely in play for the same point in a document. A byte offset counts the UTF-8 encoding of the text, one to four bytes per code point. A code point index counts abstract Unicode characters. A UTF-16 code unit index counts 16-bit units, which is the same as the code point index for everything in the Basic Multilingual Plane (code points up to U+FFFF) and two units for everything above it, since those are stored as a surrogate pair drawn from U+D800–U+DFFF. A grapheme cluster index counts what a reader perceives as one character, which may be several code points: a base letter plus a combining mark, or a family emoji built from several joined sequences.
The distinction that bites is between the first three, because they differ in count for non-ASCII text but agree for the ASCII that most test suites contain. A single emoji in a line shifts every subsequent byte offset by three relative to its code point index, and by one relative to its UTF-16 unit index. A line of Japanese text typed in a system that reports UTF-16 offsets produces positions roughly one third as large as a byte-oriented consumer expects.
Rendering and cursor movement are a further layer. Motion commands typically step by grapheme cluster so that the caret never lands inside a surrogate pair or between a base character and its combining mark, and column display positions account for tab expansion and for the double-width East Asian characters whose glyphs occupy two terminal cells. None of that display geometry belongs in protocol positions, and mixing them is a common source of columns that are right on screen and wrong on the wire.
Why the wire format counts UTF-16 code units
The default character count for positions on the wire is UTF-16 code units. The reason is historical rather than technical: the host languages that dominated editor and language-server implementations represent strings as sequences of UTF-16 code units, so the length of a string and the index of a character in it were already unit counts, and exposing those numbers directly required no conversion at all. A protocol designed on top of a UTF-8-native host language has the opposite preference, which is why the specification added a negotiation step rather than fixing on one encoding.
Run-length properties matter too. UTF-16 indexing is constant-time in the host language, while byte indexing in a UTF-8 buffer requires counting from a known anchor; and byte offsets in UTF-8 are stable under concatenation, which helps when a server works over memory-mapped files or offsets recorded in a database. Neither property is decisive alone, which is why both encodings remain in use and the choice is made per session.
Negotiating the encoding affects every later range
Encoding is agreed during the handshake, before any document exists. The client advertises the encodings it supports, as an ordered list with the most preferred first, through a client capability; the server picks one and reports it back in its elected capabilities. The only mandatory encoding is UTF-16 — the specification requires that it be representable, and it remains the value in effect when the server reports nothing. A server that omits the property is not making a neutral choice; it is choosing UTF-16.
Two consequences follow. First, the negotiated encoding applies to every subsequent position in that session: ranges, edits, diagnostics, semantic tokens, and completion text edits. Second, the client must apply exactly the encoding it advertised as chosen, including in the ranges it sends for changes, so a mismatch in the change notification corrupts the server’s mirror of the document rather than merely misplacing a squiggle. The synchronization channel that carries those ranges is described in Incremental Document Synchronization Between Client and Server.
Importantly, the encoding governs positions, not lengths of transport payloads. The framing header of each message counts bytes of UTF-8 JSON regardless of the negotiated position encoding, so even a session that selected a byte-based encoding must convert between bytes of encoded JSON and character positions, and those are not the same number once anything outside ASCII appears in the message body.
The conversion path and what it costs per query
Converting a position to a byte offset takes two steps: find the byte offset of the start of the requested line, then walk forward counting units until the requested count is consumed, stopping early if the line ends first. The reverse direction — given a byte offset, produce a line and character — requires knowing how many units precede the offset within its line, which normally means scanning the line from its start.
The second step is where the cost lives, because line lengths are unbounded and hostile inputs exist. A minified JavaScript file can be one line of several megabytes; a query at the end of it walks millions of units, and a server answering a completion request per keystroke pays that walk per request. Editors mitigate it by caching, per line, the mapping between byte offsets and unit counts at some granularity, or by maintaining the line index in whichever unit the session negotiated so that the walk is only ever bounded by the segment between the last cache entry and the query.
def utf16_len(s: str) -> int:
return len(s) + sum(1 for ch in s if ord(ch) > 0xFFFF)
def position_to_byte(lines: list[bytes], line: int, char_units: int) -> int:
"""lines are UTF-8 byte strings without terminators."""
start = 0
for i in range(line):
start += len(lines[i]) + 1 # +1 for the '\n'
if char_units == 0:
return start
raw = lines[line]
if raw.isascii(): # fast path: units == bytes
if char_units > len(raw):
raise ValueError("character beyond end of line")
return start + char_units
seen = 0
for i, byte in enumerate(raw):
if (byte & 0xC0) != 0x80: # lead byte of a UTF-8 sequence
if seen == char_units:
return start + i
seen += 2 if byte >= 0xF0 else 1 # 4-byte sequence: surrogate pair
if seen < char_units:
raise ValueError("character beyond end of line")
return start + len(raw)
The clamped behaviour in the failure branches is defined by the protocol rather than left to the implementation: a position whose line exceeds the number of lines is interpreted as the end of the document, and a character value beyond the end of a line is clamped to the line’s end. Servers that instead reject such positions produce spurious errors during rapid typing, when the client and server views are legitimately one edit apart.
Line terminators, tabs, and other mismatches
Line numbering has its own ambiguity. Text files in the wild end lines with LF, with CRLF, and occasionally with a lone CR, and a buffer that recognizes only LF reports a file with CRLF endings as having half as many lines as the protocol’s line count, offsetting every subsequent position. Tabs introduce a second, opposite mismatch: a tab is one code unit for position purposes but occupies several columns on screen, so a column number derived from the rendered layout is wrong by the expansion width of every preceding tab. Both cases argue for keeping protocol positions computed from text alone, never from layout.
Grapheme boundaries are not visible to the protocol at all. A combining mark following a base character is a separate code point and therefore a separate position offset, so a range that covers “one character” as a reader sees it must span two or more units, and tooling that assumes one code point per user-visible character will clip selections. Which sequences form a single cluster is specified by the Unicode text segmentation rules, and the same rules decide how many positions backspace should consume, so an editor’s motion model and a server’s range model can legitimately disagree about granularity while both being correct.
Normalization is a third, quieter source of misalignment. Two byte sequences that render identically — a precomposed é versus e followed by a combining acute — differ in length, so a token present in the buffer may not match a token produced by a server that normalized its input, and offsets computed before normalization no longer refer to the same bytes afterwards.
Failure patterns that survive review
The telltale of an encoding bug is an off-by-N that grows with the number of non-ASCII characters earlier in the line, appearing only on ranges that follow such characters. Three concrete patterns recur. A client that counts code points instead of UTF-16 units under-reports columns on astral characters, so edits land one unit early per emoji. A server that splits a surrogate pair in a range boundary produces text edits that are individually invalid, and the edit application fails or silently duplicates the character. A diagnostic range computed in bytes and transmitted as units underlines text that starts correctly and ends early, proportional to the number of three-byte characters in the span.
The defensive measure is to make units explicit at every boundary: store positions internally in one unit, convert at the edges, and never pass a number between two subsystems without the unit attached to it in the type or in the name. Empirically the failures appear in the transitions — between the renderer and the model, between the model and the wire, and between the server’s parse and the server’s reply — which is why the encoding is negotiated once at session start and then treated as an invariant rather than a per-call parameter.
References
- RFC 3629: UTF-8, a transformation format of ISO 10646 — IETF
- The Unicode Standard — Unicode Consortium
- Unicode Standard Annex #29: Unicode Text Segmentation — Unicode Consortium
- Language Server Protocol Specification 3.17 — Microsoft