How JavaScript evolves
Learn how TC39 turns an idea into a JavaScript feature: the stages from 0 to 4, stage 2.7 and test262, browser implementations, and Baseline.
- 01Name the stagesSay what has to be true for a proposal at stage 0, 1, 2, 2.7, 3 and 4.
- 02Read a testRead a test in the style of test262, and say why tests come before browsers ship a feature.
- 03Decide when to use a featureUse a proposal's stage and its Baseline status to decide whether your code can rely on it.
How JavaScript evolves: one proposal at a time
Since 2015, JavaScript gets a new edition every year. Each edition is a collection of small, finished features. Every one of them started as a proposal: a written idea for a change to the language, which a committee checks step by step.
var prices = [3, 4, 5];
console.log(prices.at(-1));
console.log(prices.findLast((p) => p < 5));
console.log(Object.groupBy(prices, (p) => (p < 5 ? "cheap" : "dear")).cheap.length);Line 1 makes an array of prices. Line 2 uses at(-1), which counts back from the end, and prints 5. It arrived in ES2022.
Line 3 uses findLast, from ES2023, which finds the last price under 5 and prints 4. Line 4 uses Object.groupBy, from ES2024, which sorts the prices into groups. The cheap group holds 3 and 4, so it prints 2.
Each of these three was a proposal for years before it reached your browser. Every proposal climbs the same six steps, called stages.
at, findLast and Object.groupBy from the example each reached stage 4, and then went into the next yearly edition.Picture a city adding a bus line. Anyone can suggest one. The council first agrees the need is real, then planners draw a route, and the council approves the final plan. Buses then drive the route for real to find problems. Only when it works does the line go on the printed map.
- In real life: Someone suggests a bus line
- In JavaScript: Stage 0: an idea
- In real life: The council agrees there is a real need
- In JavaScript: Stage 1: the problem is accepted
- In real life: Planners draw a route, then approve the final plan
- In JavaScript: Stages 2 and 2.7: a draft, then approved spec text
- In real life: Test drives on the real streets
- In JavaScript: Stage 3: browsers build and ship it
- In real life: The line goes on the printed city map
- In JavaScript: Stage 4: it joins the next edition
New JavaScript features are made by TC39, a committee, in public. Each one is a proposal that moves through stages 0, 1, 2, 2.7, 3 and 4. At stage 4, it joins the next yearly edition. You can use it safely once every browser your users have supports it.
TC39 and Ecma: who decides
Ecma International is a standards group. Its Technical Committee 39, TC39, writes the ECMAScript standard, the rulebook for JavaScript. The early editions lesson tells how it started.
TC39 is made of delegates, people sent by its members:
- browser makers, such as Google, Mozilla, Apple and Microsoft,
- other companies that use JavaScript a lot,
- invited experts, such as people who maintain popular tools.
The delegates meet about six times a year. A proposal moves up a stage only by consensus: everyone must agree, or at least not object. One objection is enough to make a proposal wait until the concern is solved.
Each proposal has a champion, one or more delegates who write it and move it forward. Every proposal lives in a public repository on GitHub, so anyone can read it and comment. Many proposals start from code that people already write by hand again and again.
function lastItem(list) {
return list[list.length - 1];
}
console.log(lastItem([3, 4, 5]));
console.log([3, 4, 5].at(-1));Lines 1 to 3 make lastItem, a helper that reads the last item of an array. Line 4 prints 5.
Code like this appeared in project after project, and that was part of the case for the at proposal. Line 5 does the same job with at(-1), and prints 5 too.
Stages 0 to 4: from an idea to the standard
The stages are written down in the TC39 process document. Read each row of the table as: "at this stage, the proposal means this, and to get here it needs this."
| The stage | What it means | What it needs to get there |
|---|---|---|
| 0 | An idea. Anyone can write one down. | Nothing. It doesn't need the committee's approval. |
| 1 | The committee agrees the problem is worth solving. | A champion, a description of the problem and a rough solution, and a public repository. |
| 2 | The committee has picked a solution, but its details may still change. | First spec text covering the main parts. TODO notes are allowed. |
| 2.7 | The design is approved. Only tests and real use can change it now. | Complete spec text, signed off by the chosen reviewers and the spec editors. |
| 3 | Browsers are asked to build and ship it. | Enough tests, usually in test262. |
| 4 | Finished. It goes into the next yearly edition. | Two engines that pass the tests, real use in the field, and an approved change to the spec. |
A proposal can stop at any stage. Some stay at stage 1 for years, and some are withdrawn. Only stage 4 means a proposal is finished.
Stage 3 is when engines build the feature, so it often reaches browsers before stage 4. A program can check whether the browser running it has a feature with typeof, which gives "undefined" for something that doesn't exist.
var checks = {
"Array.prototype.at": typeof [].at,
"Object.groupBy": typeof Object.groupBy,
"Promise.withResolvers": typeof Promise.withResolvers,
};
for (var name in checks) {
console.log(name + ": " + (checks[name] === "function" ? "built in" : "missing"));
}Lines 1 to 5 make an object with the typeof of three features that reached stage 4 in recent years. Lines 6 to 8 loop over the names.
In any current browser, all three lines say built in. In a browser from before those features shipped, they would say missing.
Stage 2.7 and test262: tests before shipping
Stage 2.7 is the newest stage. The committee added it between stage 2 and stage 3, so that a feature's tests are written before browsers are asked to build it. Then every engine checks its work against the same tests.
Those tests go into test262, the shared test suite for JavaScript engines. The test262 lesson covers it in depth. A test runs some code and checks the result.
function assertSame(actual, expected, message) {
if (actual !== expected) {
throw new Error(message + ": expected " + expected + ", got " + actual);
}
}
assertSame([3, 4, 5].at(0), 3, "at(0) reads the first item");
assertSame([3, 4, 5].at(-1), 5, "at(-1) reads the last item");
assertSame([3, 4, 5].at(3), undefined, "at(3) is past the end");
console.log("3 tests passed");Lines 1 to 5 define assertSame. If the two values differ, line 3 throws an error, and a thrown error means the test failed.
Lines 6 to 8 are three tests: the first item, the last item, and a position past the end, which must give undefined. None throws, so line 9 prints 3 tests passed.
A replay of the example code, not a view inside a JavaScript engine. The tests run for real.
script
if (actual !== expected) { throw new Error(message + ": expected " + expected + ", got " + actual); }}assertSame([3, 4, 5].at(0), 3, "at(0) reads the first item");assertSame([3, 4, 5].at(-1), 5, "at(-1) reads the last item");assertSame([3, 4, 5].at(3), undefined, "at(3) is past the end");console.log("3 tests passed");Press Step to run each test and watch the count of passed tests beside line 9. This replays the example code; it isn't a view inside the JavaScript engine.
Here is what a failing test looks like.
function assertSame(actual, expected, message) {
if (actual !== expected) {
throw new Error(message + ": expected " + expected + ", got " + actual);
}
}
try {
assertSame([3, 4, 5].at(-1), 3, "at(-1) reads the last item");
} catch (error) {
console.log(error.message);
}Line 7 expects at(-1) to give 3. It gives 5, so assertSame throws. Line 9 prints the message: at(-1) reads the last item: expected 3, got 5.
Real test262 files look much the same. They start with a comment block that says which part of the spec they test.
/*---
esid: sec-array.prototype.at
description: at() with a negative index counts back from the end
features: [Array.prototype.at]
---*/
var prices = [3, 4, 5];
assert.sameValue(prices.at(-1), 5, "prices.at(-1) is 5");
assert.sameValue(prices.at(-3), 3, "prices.at(-3) is 3");Lines 1 to 5 are the comment block. Line 2 names the section of the spec, and line 4 names the feature. Lines 7 and 8 use assert.sameValue, which test262's own helper files provide. That is why this box has no Run button.
at(-1) gives 5 everywhere.Implementations and Baseline: when you can use it
An implementation is an engine that has built the feature. To reach stage 4, a proposal needs two engines that pass its tests, and real use in the field. Then it joins the draft spec, and the next edition is approved each summer.
But stage 4 doesn't tell you whether your users can run the feature. Browsers ship at different times, and people update at different times. Baseline answers that question. It is a status shown on MDN and other sites, based on the core browsers: Chrome, Edge, Firefox and Safari, on desktop and mobile.
Read each row of the table as: "a feature with this status is supported like this, so you can use it like this."
| The status | What it means | What it means for your code |
|---|---|---|
| Limited availability | At least one core browser doesn't support it yet. | Use it only with a fallback or a polyfill. |
| Newly available | The current version of every core browser supports it. | Fine for many projects, but people with older browsers may not have it. |
| Widely available | It has been newly available for 30 months. | Safe to use without a fallback for almost everyone. |
Here is a small model of Baseline, written for this lesson, with today fixed at October 2026.
function monthsBetween(from, to) {
return (to.year - from.year) * 12 + (to.month - from.month);
}
function baseline(shipped, today) {
if (shipped.includes(null)) return "limited availability";
var last = shipped.reduce((a, b) => (monthsBetween(a, b) > 0 ? b : a));
return monthsBetween(last, today) >= 30 ? "widely available" : "newly available";
}
var today = { year: 2026, month: 10 };
console.log(baseline([{ year: 2023, month: 9 }, { year: 2023, month: 10 }, { year: 2024, month: 3 }], today));
console.log(baseline([{ year: 2025, month: 6 }, { year: 2026, month: 1 }, { year: 2026, month: 2 }], today));
console.log(baseline([{ year: 2025, month: 6 }, null, { year: 2026, month: 2 }], today));Lines 1 to 3 count the months between two dates. Lines 4 to 8 work out the status. If any browser hasn't shipped it, line 5 returns limited availability.
Otherwise, line 6 finds the date of the last browser to ship it, and line 7 checks whether 30 months have passed since then. Line 9 sets today.
Line 10 lists three ship dates, the last in March 2024. That was 31 months ago, so it prints widely available. Line 11's last date is February 2026, only 8 months ago, so it prints newly available. Line 12 has a null, so it prints limited availability.
widely available in October 2026.Following and trying proposals
You don't have to wait for a new edition to learn about a feature. The list of active proposals, with their stages, is public at github.com/tc39/proposals. Each proposal's repository explains the problem, shows examples, and links to discussions.
You can try a proposal in three ways:
- a polyfill, code that adds a missing built-in feature, for methods and objects,
- a compiler such as Babel or TypeScript, which turns new syntax into older syntax,
- a browser or Node.js setting that turns on an unfinished feature, for testing only.
Polyfills for stage 3 features are common, because the design is stable. A careful polyfill adds the feature only if the browser doesn't have it.
if (typeof Array.prototype.findLast !== "function") {
Object.defineProperty(Array.prototype, "findLast", {
value: function (test) {
for (var i = this.length - 1; i >= 0; i--) {
if (test(this[i])) return this[i];
}
return undefined;
},
writable: true,
configurable: true,
});
}
console.log([3, 4, 5].findLast((p) => p < 5));Line 1 checks whether findLast already exists. In current browsers it does, so lines 2 to 11 are skipped. In an older browser, lines 2 to 11 add it, and lines 4 to 6 loop from the last item back to the first.
Line 13 prints 4 either way. Avoid polyfills for proposals below stage 3: their design can still change, and your code would depend on the old design.
The playground runs the Baseline model with ship dates you choose. Pick the year each browser shipped a feature. Guess the status before you look.
function monthsBetween(from, to) { return (to.year - from.year) * 12 + (to.month - from.month);}function baseline(shipped, today) { if (shipped.includes(null)) return "limited availability"; var last = shipped.reduce((a, b) => (monthsBetween(a, b) > 0 ? b : a)); return monthsBetween(last, today) >= 30 ? "widely available" : "newly available";}var today = { year: 2026, month: 10 };console.log(baseline([{ year: 2023, month: 6 }, { year: 2023, month: 6 }, { year: 2024, month: 6 }], today));newly availableJune 2023June 2023June 2024The last browser shipped it in June 2024, less than 30 months before October 2026. It works in every current browser, but people with older versions may not have it yet.
The starting dates give newly available: Safari shipped in June 2024, only 28 months before October 2026. Move Safari to June 2023 and it becomes widely available. Pick "not yet" for any browser and it becomes limited availability.
- Anyone wrote down an idea; the committee hasn't looked at it
- A champion and a description of the problem
- First spec text, with some TODO notes
- Complete spec text, signed off by reviewers and editors
- Writing the tests is the work happening now
- Browsers are building it and finding problems
- Two engines pass the tests, and it is used in the field
- It is merged into the next yearly edition
Put each description with the stage it belongs to: stage 0 or 1, stage 2 or 2.7, stage 3, or stage 4.
Common mix-ups
Most mix-ups come from treating stage 3 as finished, or stage 4 as "in every browser".
- "Stage 3 means the feature is in the standard." No. It joins the standard at stage 4.
- "Stage 4 means every browser has it." No. It needs two engines. Check Baseline for your users' browsers.
- "One company decides what goes into JavaScript." No. TC39 decides by consensus, and any delegate can object.
- "Every proposal eventually ships." No. Many stop at stage 1 or 2, and some are withdrawn.
- "Stage 2.7 is a typo for stage 2 or 3." No. It is a real stage, for writing tests before browsers build the feature.
- "Baseline is a TC39 stage." No. Baseline is about which browsers support a feature, not about the standard.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| The topic | What people think | What is true |
|---|---|---|
| Stage 3 | The feature is finished and in the standard. | It is still being implemented. It joins the standard at stage 4. |
| Stage 4 | Every browser supports it. | Two engines support it. Others may still be working on it. |
| A proposal | One company decides whether it happens. | The whole committee decides by consensus. |
Practice exercises
Every program below is a sloppy classic script. Guess first, then run it.
What does line 1 print?
console.log(typeof [].at, typeof [].findLast, typeof [].flatten);console.log(typeof [].at, typeof [].findLast, typeof [].flatten);at and findLast are built in, so typeof gives function. flatten never shipped, so it is undefined. Line 1 prints function function undefined.
What does the program print? Is the bug in the engine or in the test? Fix it so it prints passed.
function assertSame(actual, expected, message) {
if (actual !== expected) {
throw new Error(message + ": expected " + expected + ", got " + actual);
}
}
try {
assertSame([3, 4, 5].findLast((p) => p < 5), 3, "findLast finds the last match");
console.log("passed");
} catch (error) {
console.log(error.message);
}function assertSame(actual, expected, message) {
if (actual !== expected) {
throw new Error(message + ": expected " + expected + ", got " + actual);
}
}
try {
assertSame([3, 4, 5].findLast((p) => p < 5), 4, "findLast finds the last match");
console.log("passed");
} catch (error) {
console.log(error.message);
}The test expected 3, but findLast correctly gives 4, so it prints the failure message. The engine was right and the test was wrong. The version shown expects 4 and prints passed.
What does line 4 print? Then fix the polyfill so it never replaces a built-in at.
Array.prototype.at = function (index) {
return this[index];
};
console.log([3, 4, 5].at(-1));if (typeof Array.prototype.at !== "function") {
Object.defineProperty(Array.prototype, "at", {
value: function (index) {
return this[index < 0 ? this.length + index : index];
},
writable: true,
configurable: true,
});
}
console.log([3, 4, 5].at(-1));The careless version replaces the real at with one that doesn't count from the end, so line 4 prints undefined. The version shown only adds at when it is missing, and handles negative positions, so it prints 5.
What does line 4 print?
function monthsBetween(from, to) {
return (to.year - from.year) * 12 + (to.month - from.month);
}
console.log(monthsBetween({ year: 2024, month: 3 }, { year: 2026, month: 9 }));function monthsBetween(from, to) {
return (to.year - from.year) * 12 + (to.month - from.month);
}
console.log(monthsBetween({ year: 2024, month: 3 }, { year: 2026, month: 9 }));monthsBetween multiplies the 2 years by 12 and adds the 6 months between March and September: 30. So a feature newly available in March 2024 is widely available in September 2026.
Replace line 7 with two tests, in the style of test262, that check the groups. What does your program print when both pass?
function assertSame(actual, expected, message) {
if (actual !== expected) {
throw new Error(message + ": expected " + expected + ", got " + actual);
}
}
var groups = Object.groupBy([3, 4, 5], (p) => (p % 2 === 0 ? "even" : "odd"));
// Write two tests here.
console.log("done");function assertSame(actual, expected, message) {
if (actual !== expected) {
throw new Error(message + ": expected " + expected + ", got " + actual);
}
}
var groups = Object.groupBy([3, 4, 5], (p) => (p % 2 === 0 ? "even" : "odd"));
assertSame(groups.odd.length, 2, "3 and 5 are odd");
assertSame(groups.even[0], 4, "4 is even");
console.log("done");Both checks pass: groups.odd has 2 items, and groups.even[0] is 4. Neither throws, so the program prints done. If groupBy were wrong, a check would throw before line 9.
A feature became newly available in March 2024. What does line 7 print?
function addMonths(date, months) {
var total = date.year * 12 + (date.month - 1) + months;
return { year: Math.floor(total / 12), month: (total % 12) + 1 };
}
var newly = { year: 2024, month: 3 };
var widely = addMonths(newly, 30);
console.log(widely.year + "-" + widely.month);function addMonths(date, months) {
var total = date.year * 12 + (date.month - 1) + months;
return { year: Math.floor(total / 12), month: (total % 12) + 1 };
}
var newly = { year: 2024, month: 3 };
var widely = addMonths(newly, 30);
console.log(widely.year + "-" + widely.month);March 2024 plus 30 months is September 2026, so line 7 prints 2026-9. The month is printed as a plain number, so it is 9, not 09.
Check your understanding
For each question, ask which stage the proposal is at, and what that stage needs. For Baseline, count the months. Each answer explains why it's right or wrong.
Question 1 of 6Which stage means browsers are asked to build and ship a proposal?
Choose an answer to see the explanation.
Question 2 of 6What is stage 2.7 for?
Choose an answer to see the explanation.
Question 3 of 6What does this failing test print?
Read the code, then predictfunction assertSame(actual, expected) { if (actual !== expected) throw new Error("expected " + expected + ", got " + actual); } try { assertSame([3, 4].at(-2), 4); } catch (error) { console.log(error.message); }Choose an answer to see the explanation.
Question 4 of 6How does TC39 decide to move a proposal to the next stage?
Choose an answer to see the explanation.
Question 5 of 6Is a feature that became newly available in June 2024 widely available in October 2026?
Read the code, then predictfunction monthsBetween(from, to) { return (to.year - from.year) * 12 + (to.month - from.month); } console.log(monthsBetween({ year: 2024, month: 6 }, { year: 2026, month: 10 }) >= 30);Choose an answer to see the explanation.
Question 6 of 6What does it mean that a feature is newly available in Baseline?
Choose an answer to see the explanation.
Key takeaways
JavaScript grows in public, one small proposal at a time. Knowing the stages tells you how close a feature is, and Baseline tells you when your users have it.
- TC39, a committee of Ecma, writes the JavaScript standard. Its delegates decide by consensus.
- Every new feature is a proposal with a champion and a public repository.
- The stages are 0 (idea), 1 (problem), 2 (draft), 2.7 (tests), 3 (ships) and 4 (done).
- At stage 2.7 the design is approved and tests are written for test262, before browsers build it.
- Stage 4 needs two engines that pass the tests. Then the feature joins the next yearly edition.
- Baseline says when browsers support a feature: limited, newly available, then widely available 30 months later.
- Try proposals with polyfills or compilers, and avoid relying on anything below stage 3.
Remember the one-liner.
A proposal climbs from 0 to 4 by consensus, tests come before browsers, and Baseline tells you when your users have it.
Coming next: Modern JavaScript: ES2015 to today. It goes through what each yearly edition added, feature by feature.