Scanning source text
Learn how JavaScript engines decode source bytes, scan UTF-16 text into tokens, handle identifiers, regex slashes, and optimize scanners.
- 01Follow code from bytes to tokensExplain how bytes from a file or a download become text, how positions are counted in UTF-16 code units, and how the scanner splits the text into tokens.
- 02Read tricky tokensPredict what happens with escapes in names, words that are only sometimes keywords, line breaks, comments, and a slash that could be a regex or division.
- 03Connect tokens to real toolsCompare a small tokenizer with Acorn, and see why highlighters, linters, minifiers, and engines care about tokens and scanning speed.
Text becomes a list of tokens
Your JavaScript file is just a long line of characters. Before the engine can run it, it must split that text into small pieces. Each piece is one word or symbol of the language, such as let, total, or =.
The part of the engine that does this splitting is called the scanner. People also call it a lexer or a tokenizer. All three names mean the same thing. It is the very first step, before anything else in the engine happens.
The scanner reads your source text one character at a time and makes tokens. A token is one piece of code with a kind, such as a name, a keyword, a number, or a symbol like =. Each token also remembers where it starts and ends in the text. The next step, the parser, works with these tokens, not with single characters.
A teacher writes a sentence on the board. First, the class marks each word, each comma, and each full stop. Only after that do they talk about the grammar of the sentence. Marking the words is like scanning. It does not understand the sentence yet. It only gets the pieces ready.
- In real life: Letters on the page
- In JavaScript: The characters of your code
- In real life: Words, commas, and full stops
- In JavaScript: Tokens such as
total,2, and=> - In real life: The grammar lesson about the sentence
- In JavaScript: The parser building a tree
The tree the parser builds is called an AST, short for "abstract syntax tree". You will meet it in the next lesson.
This lesson uses ideas from a few earlier lessons:
- Code structure, for automatic semicolon insertion (ASI).
- Unicode & strings, for UTF-16.
- Text encoding & Base64, for bytes.
- Regex basics, for regex literals like
/ok/u.
It also follows Meet the engines, and it leads into Parsing & abstract syntax trees.
Code arrives as bytes, not tokens
STEP THROUGHA byte is a small number from 0 to 255. Files and network responses are made of bytes. So a browser that downloads a script gets bytes, and a program that reads a file from disk gets bytes too.
Bytes are not text yet. Something must turn them into characters first. This is called decoding, and the rule it follows is called an encoding, such as UTF-8. The browser or runtime does this before the scanner starts. Which encoding it uses depends on the script:
- Modules are always decoded as UTF-8.
- Older classic scripts can follow the page's encoding or a
<script charset>attribute. - A special mark at the very start, the byte order mark (BOM), can name the encoding. The decoder then removes it.
The step-through below makes UTF-8 bytes with TextEncoder, then turns them back into text with TextDecoder.
Step from bytes to text, then to the UTF-16 code units the scanner counts in.
script
const decoded = new TextDecoder("utf-8").decode(bytes);const codeUnits = Array.from( { length: decoded.length }, (_, index) => decoded.charCodeAt(index).toString(16));console.log(decoded);console.log(codeUnits.join(" "));After decoding, nobody counts in bytes any more. Error messages, browser developer tools, and source maps point at places in the decoded text. They count in the same units that JavaScript strings use. You'll see those units in the next section.
The table below keeps four ideas apart. Read each row as: "this is the thing, this is what one of it is, here is a small example, and here is why it matters."
| What it is | One of it is | Small example | Why it matters |
|---|---|---|---|
| A byte | A number from 0 to 255, from a file or a download. | 0x63, the letter c | Bytes are not code yet. The browser or runtime decodes them first. |
| A code point | Unicode's number for one character. | U+1F600, the emoji 😀 | Unicode uses it to say which characters may appear in a name, such as ID_Start. |
| A UTF-16 code unit | One 16-bit piece of a JavaScript string. | 0xD83D, 0xDE00 | Token positions and string indexes count these pieces. |
| A token | One piece of code with one job, made by the scanner. | total, 2, / | The parser works with tokens, not with single characters. |
Engines read the text as UTF-16
JavaScript strings store text in small pieces called UTF-16 code units. Most characters, like a or é, take one code unit. The engine reads your source in these same pieces.
The V8 scanner article explains how V8, the engine in Chrome and Node.js, does this. Chrome may hand V8 text stored in different ways. V8 wraps all of them in one reader called UTF16CharacterStream, so the scanner always sees UTF-16. Its method Utf16CharacterStream::Advance() gives the next code unit, or -1 when the text has ended.
Some characters need two code units, like the emoji 😀. Such a pair is called a surrogate pair. You met these in Unicode & strings. V8's article says the scanner only joins a pair back into one character when it must check whether the character may appear in a name. Inside strings and comments, it just copies the code units as they are.
Say a tool tells you a token has start: 12 and end: 14. Those numbers are string positions, counted in UTF-16 code units. One symbol you see on screen can take two positions if it is a surrogate pair.
What the scanner does, step by step
STEP THROUGHThe scanner keeps a cursor: a number that says where it is in the text. It only moves forward. At each spot it does four things:
- It skips trivia: spaces, tabs, line breaks, and comments. These don't become tokens.
- It takes the longest token that fits. For example,
===is one token, not three=tokens. - It writes down where the token starts and ends.
- It hands the token to the parser and moves the cursor past it.
The kinds of tokens it can make include:
- names, like
total, and keywords, likeif - symbols, like
=and=>, which the spec calls punctuators - numbers, including ones with
_separators like1_000and BigInts ending inn - strings, with their escapes, and pieces of template literals
- regex literals like
/ok/u, but only where the grammar allows one
The step-through below runs this lesson's own small tokenizer on the line let total = price / 2;.
Watch one short line become 7 tokens: a keyword, names, symbols, a division, and a number.
script
const tokens = tokenizeSubset(source);console.log(tokens.map((token) => token.type + ":" + token.value).join(" | "));The table below lists each job a JavaScript scanner has. Read each row as: "for this kind of text, the scanner does this."
| Kind of text | What the scanner does |
|---|---|
| Spaces and comments | It skips them, but remembers if a line break was there, for automatic semicolon insertion (ASI). |
| Names and keywords | It reads the letters, turns escapes into letters, then checks whether the word is a keyword. |
| Values written in code | It reads whole numbers, strings, template pieces, and regexes with their flags. |
| Symbols | It takes the longest symbol that fits, so >>>= wins over >>> or >. |
| Looking ahead | It reads enough to finish the current token. The parser usually looks one token ahead. |
One odd case is very old. In classic browser scripts, a line that starts with <!-- is treated as a comment. This rule is in Annex B, the part of the spec that keeps old websites working. Don't use it in new code, and never in modules.
<!-- an old HTML-style comment, allowed only in classic scriptsconsole.log("after an Annex B comment");// Old rule. Don't use it in modules or in new code.Line 1 is the old HTML-style comment, so the scanner skips it. Line 2 is normal code. Line 3 is a normal comment with a warning.
caféif1_000n/ok/u=>/* line break\n */
Sort each piece of code into the group the scanner puts it in.
Names, keywords, and escapes
REAL ERRORSA name in code, like total, is called an identifier. Here are the rules for which characters it may use:
- The first character must be a letter,
$, or_. Unicode calls these lettersID_Startcharacters. - Later characters may also be digits and a few other marks. Unicode calls these
ID_Continuecharacters.
You may also write a letter as an escape: \u followed by the letter's number in hexadecimal. For example, \u0061 is the letter a. The scanner turns the escape into its letter before the parser sees the name. So \u0061bc is just the name abc.
const \u0061bc = 1;console.log(abc); try { new Function("const \\u0069f = 1;");} catch (error) { console.log(error.name + ": " + error.message);}Line 1 makes a variable whose name is written \u0061bc. Line 2 reads it as abc and prints 1.
Lines 4 to 8 try something that must fail. new Function(...) turns a string into code at run time, so the error can be caught instead of breaking the whole page. The string spells the keyword if as \u0069f. V8, used by Node 22 and Chrome, prints SyntaxError: Keyword must not contain escaped characters. Acorn, a parser written in JavaScript, also reports a syntax error with its own message. So an escape can't hide a keyword.
if may not.Some words are only special in certain places. They are called contextual keywords. let, yield, await, async, of, get, set, and static are examples. For instance, async is special before function, but it can also be a normal variable name. The scanner only hands over the word. The parser decides whether it acts as a keyword in that place.
Is / a regex or division?
ACORN + NODEA slash can mean several things in JavaScript:
- division, as in
b / c - the start of a regex literal, as in
/c/ - the start of a comment, as in
//or/* - part of
/=, which divides and stores
Comments and /= are easy, because the next character tells them apart. But division and a regex can look the same. The scanner can't decide from the slash alone.
b, a slash divides. After =, a value must come next, so /c/ is a regex.So the parser helps. The spec gives the scanner two modes, called goals. InputElementDiv means "a slash here is division". InputElementRegExp means "a slash here starts a regex". The parser knows where it is in the grammar, so it tells the scanner which mode to use for the next token.
let a, b = 20, c = 4, d = 2;a = b / c / d;console.log(a); try { new Function('let a, b = true, d = "abc"; a = b\n/c/.test(d);');} catch (error) { console.log(error.name + ": " + error.message);} try { let x, y = 10; x = y / 2 / g;} catch (error) { console.log(error.name + ": " + error.message);}Here is what each puzzle prints:
- Line 2 is normal division:
20 / 4 / 2. Line 3 prints2.5. - Line 6 puts a line break before
/c/. It still reads as division, becausea = bmay go on with an operator on the next line. So the code meansa = b / c / .test(d), and the dot is a mistake. V8 printsSyntaxError: Unexpected token '.'. - Line 13 looks like it ends with a
gflag, but both slashes are division. JavaScript looks for a variable namedg, finds none, and throws aReferenceError.
The panel below runs five slash cases through two tokenizers. "lesson" is this lesson's small tokenizer. "acorn" is the real Acorn tokenizer.
"a = b / c / d;"identifier:a@0-1 | punctuator:=@2-3 | identifier:b@4-5 | division:/@6-7 | identifier:c@8-9 | division:/@10-11 | identifier:d@12-13 | punctuator:;@13-14name:a@0-1 | =:=@2-3 | name:b@4-5 | /:/@6-7 | name:c@8-9 | /:/@10-11 | name:d@12-13 | ;:;@13-14"value = /c/.test(name);"identifier:value@0-5 | punctuator:=@6-7 | regexp:/c/@8-11 | punctuator:.@11-12 | identifier:test@12-16 | punctuator:(@16-17 | identifier:name@17-21 | punctuator:)@21-22 | punctuator:;@22-23name:value@0-5 | =:=@6-7 | regexp:/c/@8-11 | .:.@11-12 | name:test@12-16 | (:(@16-17 | name:name@17-21 | ):)@21-22 | ;:;@22-23"a = b\n/c/.test(d);"identifier:a@0-1 | punctuator:=@2-3 | identifier:b@4-5 | division:/@6-7↵ | identifier:c@7-8 | division:/@8-9 | punctuator:.@9-10 | identifier:test@10-14 | punctuator:(@14-15 | identifier:d@15-16 | punctuator:)@16-17 | punctuator:;@17-18name:a@0-1 | =:=@2-3 | name:b@4-5 | /:/@6-7 | name:c@7-8 | /:/@8-9 | .:.@9-10 | name:test@10-14 | (:(@14-15 | name:d@15-16 | ):)@16-17 | ;:;@17-18"x = y / 2 /g;"identifier:x@0-1 | punctuator:=@2-3 | identifier:y@4-5 | division:/@6-7 | number:2@8-9 | division:/@10-11 | identifier:g@11-12 | punctuator:;@12-13name:x@0-1 | =:=@2-3 | name:y@4-5 | /:/@6-7 | num:2@8-9 | /:/@10-11 | name:g@11-12 | ;:;@12-13"if (ready) /c/.test(name);"keyword:if@0-2 | punctuator:(@3-4 | identifier:ready@4-9 | punctuator:)@9-10 | division:/@11-12 | identifier:c@12-13 | division:/@13-14 | punctuator:.@14-15 | identifier:test@15-19 | punctuator:(@19-20 | identifier:name@20-24 | punctuator:)@24-25 | punctuator:;@25-26if:if@0-2 | (:(@3-4 | name:ready@4-9 | ):)@9-10 | regexp:/c/@11-14 | .:.@14-15 | name:test@15-19 | (:(@19-20 | name:name@20-24 | ):)@24-25 | ;:;@25-26The token before a slash is a clue, not the rule. The lesson tokenizer gets the if case wrong. Only the parser knows that if (ready) is finished and a new statement starts.
a = b, code can go on with division, even after a line break. After if (ready), a new statement starts, and it may start with a regex. Acorn knows both.The lesson tokenizer uses a shortcut: it only looks at the token just before the slash. Look at the if (ready) case. The lesson tokenizer sees ) and guesses division. That is wrong. Acorn knows that if (ready) is finished and a new statement starts. So Acorn reads /c/ as one regex token, which is right.
Looking at the token before the slash is a good clue for a lesson or a simple color highlighter. But it can't know everything the parser knows. For tools that change, check, or shrink code, use a tokenizer that comes with a real parser.
Try a small tokenizer next to a real one
LIVE LABThis lesson's tokenizer is small, but it is real code. It can:
- skip spaces and comments
- read names, keywords, numbers, BigInts, strings, and template literals
- read symbols such as
=and=>
To choose between a regex and division, it only looks at the token before the slash. This shortcut is on purpose, and it is sometimes wrong. The lab shows Acorn next to it, so you can see exactly where the shortcut and real JavaScript disagree.
function tokenizeSubset(source) { // The full lesson code also reads comments, strings, numbers, and names. // For a slash, it only looks at the token before it, not the grammar. const tokens = []; let cursor = 0; let previous = null; while (cursor < source.length) { const next = scanOneToken(source, cursor, previous); if (next) { tokens.push(next.token); previous = next.token; cursor = next.end; } else { cursor += 1; } } return tokens;}Line 4 starts an empty list of tokens. Line 8 repeats until the cursor reaches the end of the text. Line 9 reads one token, and line 10 checks that it found one. Line 11 adds the token to the list. Line 12 remembers it as previous, and line 13 moves the cursor past it.
let café = 1_000n;const ok = /ok/u.test(name);if (ready) /c/.test(name);x = y / 2 /g;Lesson tokenizer
| kind | value | start–end |
|---|---|---|
contextual | let | 0–3 |
identifier | café | 4–8 |
punctuator | = | 9–10 |
bigint | 1_000n | 11–17 |
punctuator | ; | 17–18 |
keyword | const — line terminator before this token | 19–24 |
identifier | ok | 25–27 |
punctuator | = | 28–29 |
regexp | /ok/u | 30–35 |
punctuator | . | 35–36 |
identifier | test | 36–40 |
punctuator | ( | 40–41 |
identifier | name | 41–45 |
punctuator | ) | 45–46 |
punctuator | ; | 46–47 |
keyword | if — line terminator before this token | 48–50 |
punctuator | ( | 51–52 |
identifier | ready | 52–57 |
punctuator | ) | 57–58 |
division | / | 59–60 |
identifier | c | 60–61 |
division | / | 61–62 |
punctuator | . | 62–63 |
identifier | test | 63–67 |
punctuator | ( | 67–68 |
identifier | name | 68–72 |
punctuator | ) | 72–73 |
punctuator | ; | 73–74 |
identifier | x — line terminator before this token | 75–76 |
punctuator | = | 77–78 |
identifier | y | 79–80 |
division | / | 81–82 |
number | 2 | 83–84 |
division | / | 85–86 |
identifier | g | 86–87 |
punctuator | ; | 87–88 |
Acorn tokenizer
| kind | value | start–end |
|---|---|---|
name | let | 0–3 |
name | café | 4–8 |
= | = | 9–10 |
num | 1000n | 11–17 |
; | ; | 17–18 |
const | const | 19–24 |
name | ok | 25–27 |
= | = | 28–29 |
regexp | /ok/u | 30–35 |
. | . | 35–36 |
name | test | 36–40 |
( | ( | 40–41 |
name | name | 41–45 |
) | ) | 45–46 |
; | ; | 46–47 |
if | if | 48–50 |
( | ( | 51–52 |
name | ready | 52–57 |
) | ) | 57–58 |
regexp | /c/ | 59–62 |
. | . | 62–63 |
name | test | 63–67 |
( | ( | 67–68 |
name | name | 68–72 |
) | ) | 72–73 |
; | ; | 73–74 |
name | x | 75–76 |
= | = | 77–78 |
name | y | 79–80 |
/ | / | 81–82 |
num | 2 | 83–84 |
/ | / | 85–86 |
name | g | 86–87 |
; | ; | 87–88 |
Where they disagree
- token 20: toy division "/" vs acorn regexp "/c/"
- token 21: toy identifier "c" vs acorn . "."
- token 22: toy division "/" vs acorn name "test"
- token 23: toy punctuator "." vs acorn ( "("
- token 24: toy identifier "test" vs acorn name "name"
- token 25: toy punctuator "(" vs acorn ) ")"
- token 26: toy identifier "name" vs acorn ; ";"
- token 27: toy punctuator ")" vs acorn name "x"
- token 28: toy punctuator ";" vs acorn = "="
- token 29: toy identifier "x" vs acorn name "y"
- token 30: toy punctuator "=" vs acorn / "/"
- token 31: toy identifier "y" vs acorn num "2"
- token 33: toy number "2" vs acorn name "g"
- token 34: toy division "/" vs acorn ; ";"
- token 35: toy only
- token 36: toy only
The two lists disagree somewhere. The lesson tokenizer is small on purpose, and Acorn handles the whole language, so trust Acorn.
A fast scanner makes pages start faster
No code can run until it has been scanned. So engines work hard to make scanning fast. The V8 team reported these speed-ups from its scanner work:
- Reading a single token: about 1.4× faster.
- Reading strings: about 1.3× faster.
- Reading multi-line comments: about 2.1× faster.
- Reading names: about 1.2–1.5× faster, depending on how long the name is.
The table below lists the tricks behind those numbers. Read each row as: "V8 does this, and this is why it helps."
| What V8 does | Why it helps |
|---|---|
| Fast checks for ASCII | Most real code uses only ASCII characters. So V8 uses small tables to check quickly whether a character can start or continue a name. |
| A quick keyword lookup | V8's article says it uses a tool called gperf to build a perfect hash: a lookup that finds a keyword from its length and first letters in one step. |
| Fast skipping of spaces | Spaces and comments are skipped in a quick loop, which still notes any line break for ASI. |
AdvanceUntil | This method lets the scanner move over many code units at once, with less work for each character. |
You don't need to change how you write code for this. Write clear code, and let your build tool shrink it for production. Names made only of ASCII characters (a–z, A–Z, digits, $, and _) take V8's fastest path, so only use other letters in names when you have a real reason. This work is about how fast pages and tools start, not about tuning one variable name by hand.
Where you meet tokens in real tools
Many tools you use every day split code into tokens first:
- A syntax highlighter colors code. It needs to know that
/ok/uis a regex. - A minifier makes code smaller. It removes comments, but it must keep line breaks that change where semicolons go.
- A linter finds mistakes. It needs the exact start and end of an escaped keyword to point at it.
- Formatters, bundlers, source maps, and browser error messages also use token positions.
const total = 1_000n;const word = "token";const okay = /tok(en)?/u.test(word);console.log(total, word, okay);Line 1 has a keyword, a name, =, and the BigInt 1_000n. Line 2 has a string. Line 3 has a regex, /tok(en)?/u. Line 4 prints 1000n token true.
The next lesson, Parsing & abstract syntax trees, takes these tokens and builds a tree from them: declarations, expressions, statements, and early errors.
Common misconceptions
“The scanner reads bytes.”
The browser or runtime decodes the bytes first. The scanner reads the decoded text and counts positions in UTF-16 code units.
“String length counts characters.”
Positions count UTF-16 code units. A character like 😀 needs two code units, so it takes two positions.
“Keywords are just names.”
Keywords look like names, but the scanner gives them their own kind of token.
“The token before a slash always decides.”
It is only a clue. The parser knows whether a value or a new statement comes next, and it picks the mode: regex or division.
“Comments disappear completely.”
Their text is skipped, but a line break inside them still affects automatic semicolon insertion.
The table below lists code that looks like one thing but is another. Read each row as: "this looks like X, but it is really Y, and here is a safe way to think about it."
| When you see | What it really is | A safe way to think about it |
|---|---|---|
/x/ | A regex, but only where the grammar allows a regex. | The parser decides, not just the token before the slash. |
\u0061bc | The name abc. | The scanner turns escapes into letters before the parser sees the name. |
😀 in code | Two UTF-16 code units. | Tool positions count code units, like string indexes do. |
/* comment */ | Trivia that the scanner skips. | The scanner still remembers any line break inside it. |
Practice exercises
5 EXERCISESType the name that the escape makes.
const \u0061bc = 7;
console.log(abc);\u0061bc becomes abc. So line 2 reads abc and prints 7.
Read the code and type the error name that the catch block prints.
try {
new Function("const \\u0069f = 1;");
} catch (error) {
console.log(error.name + ": " + error.message);
}\u0069f becomes the keyword if, which may not be written with an escape. V8 throws a SyntaxError before the new function is made.
Both slashes are division. Type the number that prints.
let a, b = 20, c = 4, d = 2;
a = b / c / d;
console.log(a);20 / 4 is 5. Then 5 / 2 is 2.5.
Type the text that prints when the regex test passes.
const name = "café";
if (/caf\u00e9/u.test(name)) {
console.log("match");
}The regex matches café, so the code prints match.
/g surpriseType the error name that the catch block prints.
try {
let x, y = 10;
x = y / 2 / g;
} catch (error) {
console.log(error.name);
}The code means x = y / 2 / g. There is no variable named g, so JavaScript throws a ReferenceError.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is the scanner's main job?
Choose an answer to see the explanation.
Question 2 of 8What does this code with an escaped name print?
Read the code, then predictconst \u0061bc = 3; console.log(abc);Choose an answer to see the explanation.
Question 3 of 8What does V8 (in Node 22) print for a keyword written with an escape?
Read the code, then predicttry { new Function("const \\u0069f = 1;"); } catch (error) { console.log(error.name + ": " + error.message); }Choose an answer to see the explanation.
Question 4 of 8The scanner skips spaces and comments. Why does it still note line breaks?
Choose an answer to see the explanation.
Question 5 of 8What does this code with two slashes print?
Read the code, then predictlet a, b = 20, c = 4, d = 2; a = b / c / d; console.log(a);Choose an answer to see the explanation.
Question 6 of 8What decides whether a slash is a regex or division?
Choose an answer to see the explanation.
Question 7 of 8Which word is only a keyword in some places?
Choose an answer to see the explanation.
Question 8 of 8Why does the lesson tokenizer get
if (ready) /c/.test(name);wrong?Choose an answer to see the explanation.
Key takeaways
- Code arrives as bytes. The browser or runtime decodes them into text before scanning starts.
- Token positions count UTF-16 code units, the same units that JavaScript strings use.
- The scanner skips spaces and comments, takes the longest token that fits, and remembers line breaks for ASI.
- An escape in a name becomes its letter, but a keyword written with an escape is a
SyntaxError. - The parser decides whether a slash is a regex or division. The token before it is only a clue.
- Scanning comes before everything else, so a fast scanner makes code start sooner.
In one line: the scanner turns your text into tokens, and the parser decides what they mean together.
Next, Parsing & abstract syntax trees shows how these tokens become a tree the engine can compile.