This article is a concise summary of Dmitry Soshnikov’s lecture “JavaScript. The Core”, which explores the ECMAScript programming language and the key components of its runtime system.
Audience: advanced engineers, experts.
The first edition of the article covers generic aspects of JS language, using abstractions mostly from the legacy ES3 spec, with some references to the appropriate changes in ES5 and ES6 (aka ES2015).
Starting since ES2015, the specification changed descriptions and structures of some core components, introduced new models, etc. And in this edition we focus on the newer abstractions, updated terminology, but still maintaining the very basic JS structures which stay consistent throughout the spec versions.
This article covers ES2017+ runtime system.
We start our discussion with the concept of an object, which is fundamental to ECMAScript.
Object
ECMAScript is an object-oriented programming language with the prototype-based organization, having the concept of an object as its core abstraction.
null value.Let's take a basic example of an object. A prototype of an object is referenced by the internal [[Prototype]] property, which to user-level code is exposed via the __proto__ property.
For the code:
1let point = {
2 x: 10,
3 y: 20,
4};we have the structure with two explicit own properties and one implicit __proto__ property, which is the reference to the prototype of point:

Figure 1. A basic object with a prototype.
The prototype objects are used to implement inheritance with the mechanism of dynamic dispatch. Let's consider the prototype chain concept to see this mechanism in detail.
Prototype
Every object, when is created, receives its prototype. If the prototype is not set explicitly, objects receive default prototype as their inheritance object.
The prototype can be set explicitly via either the __proto__ property, or Object.create method:
1// Base object.
2let point = {
3 x: 10,
4 y: 20,
5};
6
7// Inherit from `point` object.
8let point3D = {
9 z: 30,
10 __proto__: point,
11};
12
13console.log(
14 point3D.x, // 10, inherited
15 point3D.y, // 20, inherited
16 point3D.z // 30, own
17);Object.prototype as their inheritance object.Any object can be used as a prototype of another object, and the prototype itself can have its own prototype. If a prototype has a non-null reference to its prototype, and so on, it is called the prototype chain.

Figure 2. A prototype chain.
The rule is very simple: if a property is not found in the object itself, there is an attempt to resolve it in the prototype; in the prototype of the prototype, etc. — until the whole prototype chain is considered.
Technically this mechanism is known as dynamic dispatch or delegation.
And if a property eventually is not found in the prototype chain, the undefined value is returned:
1// An "empty" object.
2let empty = {};
3
4console.log(
5
6 // function, from default prototype
7 empty.toString,
8
9 // undefined
10 empty.x,
11
12);As we can see, a default object is actually never empty — it always inherits something from the Object.prototype. To create a prototype-less dictionary, we have to explicitly set its prototype to null:
1// Doesn't inherit from anything.
2let dict = Object.create(null);
3
4console.log(dict.toString); // undefinedThe dynamic dispatch mechanism allows full mutability of the inheritance chain, providing an ability to change the delegation object:
1let protoA = {x: 10};
2let protoB = {x: 20};
3
4// Same as `let objectC = {__proto__: protoA};`:
5let objectC = Object.create(protoA);
6console.log(objectC.x); // 10
7
8// Change the delegate:
9Object.setPrototypeOf(objectC, protoB);
10console.log(objectC.x); // 20__proto__ property is standardized today, and is easier to use for explanations, on practice prefer using API methods for prototype manipulations, such as Object.create, Object.getPrototypeOf, Object.setPrototypeOf, and similar on the Reflect module.On the example of Object.prototype, we see that the same prototype can be shared across multiple objects. On this principle the class-based inheritance is implemented in ECMAScript. Let's see the example, and look under the hood of the “class” abstraction in JS.
Class
When several objects share the same initial state and behavior, they form a classification.
In case we need to have multiple objects inheriting from the same prototype, we could of course create this one prototype, and explicitly inherit it from the newly created objects:
1// Generic prototype for all letters.
2let letter = {
3 getNumber() {
4 return this.number;
5 }
6};
7
8let a = {number: 1, __proto__: letter};
9let b = {number: 2, __proto__: letter};
10// ...
11let z = {number: 26, __proto__: letter};
12
13console.log(
14 a.getNumber(), // 1
15 b.getNumber(), // 2
16 z.getNumber(), // 26
17);We can see these relationships on the following figure:

Figure 3. A shared prototype.
However, this is obviously cumbersome. And the class abstraction serves exactly this purpose — being a syntactic sugar (i.e. a construct which semantically does the same, but in a much nicer syntactic form), it allows creating such multiple objects with the convenient pattern:
1class Letter {
2 constructor(number) {
3 this.number = number;
4 }
5
6 getNumber() {
7 return this.number;
8 }
9}
10
11let a = new Letter(1);
12let b = new Letter(2);
13// ...
14let z = new Letter(26);
15
16console.log(
17 a.getNumber(), // 1
18 b.getNumber(), // 2
19 z.getNumber(), // 26
20);Technically a “class” is represented as a “constructor function + prototype” pair. Thus, a constructor function creates objects, and also automatically sets the prototype for its newly created instances. This prototype is stored in the <ConstructorFunction>.prototype property.
It is possible to use a constructor function explicitly. Moreover, before the class abstraction was introduced, JS developers used to do so not having a better alternative (we can still find a lot of such legacy code allover the internets):
1function Letter(number) {
2 this.number = number;
3}
4
5Letter.prototype.getNumber = function() {
6 return this.number;
7};
8
9let a = new Letter(1);
10let b = new Letter(2);
11// ...
12let z = new Letter(26);
13
14console.log(
15 a.getNumber(), // 1
16 b.getNumber(), // 2
17 z.getNumber(), // 26
18);And while creating a single-level constructor was pretty easy, the inheritance pattern from parent classes required much more boilerplate. Currently this boilerplate is hidden as an implementation detail, and that exactly what happens under the hood when we create a class in JavaScript.
Let's see the relationships of the objects and their class:

Figure 4. A constructor and objects relationship.
The figure above shows that every object has an associated prototype. Even the constructor function (class) Letter has its own prototype, which is Function.prototype. Notice, that Letter.prototype is the prototype of the Letter instances, that is a, b, and z.
__proto__ reference. And the explicit prototype property on the constructor function is just a reference to the prototype of its instances; from instances it's still referred by the __proto__.Now when we understand the basic relationships between ECMAScript objects, let's take a deeper look at JS runtime system. As we will see, almost everything there can also be presented as an object.
Execution context
To execute JS code and track its runtime evaluation, ECMAScript spec defines the concept of an execution context. Logically execution contexts are maintained using a stack (the execution context stack as we will see shortly), which corresponds to the generic concept of a call-stack.
There are several types of ECMAScript code: the global code, function code, eval code, and module code; each code is evaluated in its execution context. Different code types, and their appropriate objects may affect the structure of an execution context: for example, generator functions save their generator object on the context.
Let's consider a recursive function call:
1function recursive(flag) {
2
3 // Exit condition.
4 if (flag === 2) {
5 return;
6 }
7
8 // Call recursively.
9 recursive(++flag);
10}
11
12// Go.
13recursive(0);When a function is called, a new execution context is created, and pushed onto the stack — at this point it becomes an active execution context. When a function returns, its context is popped from the stack.
A context which calls another context is called a caller. And a context which is being called, accordingly, is a callee. In our example the recursive function plays both roles: of a callee and a caller — when calls itself recursively.
For our example from above we have the following stack “push-pop” modifications:

Figure 5. An execution context stack.
As we can also see, the global context is always at the bottom of the stack, it is created prior execution of any other context.
In general, the code of a context runs to completion, however as we mentioned above, some objects — such as generators, may violate LIFO order of the stack. A generator function may suspend its running context, and remove it from the stack before completion. Once a generator is activated again, its context is resumed and again is pushed onto the stack:
1function *gen() {
2 yield 1;
3 return 2;
4}
5
6let g = gen();
7
8console.log(
9 g.next().value, // 1
10 g.next().value, // 2
11);The yield statement here returns the value to the caller, and pops the context. On the second next call, the same context is pushed again onto the stack, and is resumed. Such context may outlive the caller which creates it, hence the violation of the LIFO structure.
We shall now discuss the important components of an execution context; in particular we should see how ECMAScript runtime manages variables storage, and scopes created by nested blocks of a code. This is the generic concept of lexical environments, which is used in JS to store data, and solve the “Funarg problem” — with the mechanism of closures.
Environment
Every execution context has an associated lexical environment.
So an environment is a storage of variables, functions, and classes defined in a scope.
Technically, an environment is a pair, consisting of an environment record (an actual storage table which maps identifiers to values), and a reference to the parent (which can be null).
For the code:
1let x = 10;
2let y = 20;
3
4function foo(z) {
5 let x = 100;
6 return x + y + z;
7}
8
9foo(30); // 150The environment structures of the global context, and a context of the foo function would look as follows:

Figure 6. An environment chain.
Logically this reminds us of the prototype chain which we've discussed above. And the rule for identifiers resolution is very similar: if a variable is not found in the own environment, there is an attempt to lookup it in the parent environment, in the parent of the parent, and so on — until the whole environment chain is considered.
ReferenceError.This explains why variable x is resolved to 100, but not to 10 — it is found directly in the own environment of foo; why we can access parameter z — it's also just stored on the activation environment; and also why we can access the variable y — it is found in the parent environment.
Similarly to prototypes, the same parent environment can be shared by several child environments: for example, two global functions share the same global environment.
Environment records differ by type. There are object environment records and declarative environment records. On top of the declarative record there are also function environment records, and module environment records. Each type of the record has specific only to it properties. However, the generic mechanism of the identifier resolution is common across all the environments, and doesn't depend on the type of a record.
An example of an object environment record can be the record of the global environment. Such record has also associated binding object, which may store some properties from the record, but not the others, and vice-versa. The binding object can also be provided as this value.
1// Legacy variables using `var`.
2var x = 10;
3
4// Modern variables using `let`.
5let y = 20;
6
7// Both are added to the environment record:
8console.log(
9 x, // 10
10 y, // 20
11);
12
13// But only `x` is added to the "binding object".
14// The binding object of the global environment
15// is the global object, and equals to `this`:
16
17console.log(
18 this.x, // 10
19 this.y, // undefined!
20);
21
22// Binding object can store a name which is not
23// added to the environment record, since it's
24// not a valid identifier:
25
26this['not valid ID'] = 30;
27
28console.log(
29 this['not valid ID'], // 30
30);This is depicted on the following figure:

Figure 7. A binding object.
Notice, the binding object exists to cover legacy constructs such as var-declarations, and with-statements, which also provide their object as a binding object. These are historical reasons when environments were represented as simple objects. Currently the environments model is much more optimized, however as a result we can't access binding as properties anymore.
We have already seen how environments are related via the parent link. Now we shall see how an environment can outlive the context which creates it. This is the basis for the mechanism of closures which we're about to discuss.
Closure
Functions in ECMAScript are first-class. This concept is fundamental to functional programming, which aspects are supported in JavaScript.
With the concept of first-class functions so called Funarg problem is related (or “A problem of a functional argument”). The problem arises when a function has to deal with free variables.
Let's take a look at the Funarg problem, and see how it's solved in ECMAScript. Consider the following code snippet:
1let x = 10;
2
3function foo() {
4 console.log(x);
5}
6
7function bar(funArg) {
8 let x = 20;
9 funArg(); // 10, not 20!
10}
11
12// Pass `foo` as an argument to `bar`.
13bar(foo);For the function foo the variable x is free. When the foo function is activated (via the funArg parameter) — where should it resolve the x binding? From the outer scope where the function was created, or from the caller scope, from where the function is called? As we see, the caller, that is the bar function, also provides the binding for x — with the value 20.
The use-case described above is known as the downwards funarg problem, i.e. an ambiguity at determining a correct environment of a binding: should it be an environment of the creation time, or environment of the call time?
This is solved by an agreement of using static scope, that is the scope of the creation time.
The static scope sometimes is also called lexical scope, hence the lexical environments naming.
Technically the static scope is implemented by capturing the environment where a function is created.
In our example, the environment captured by the foo function, is the global environment:

Figure 8. A closure.
We can see that an environment references a function, which in turn reference the environment back.
The second sub-type of the Funarg problem is known as the upwards funarg problem. The only difference here is that a capturing environment outlives the context which creates it.
Let's see the example:
1function foo() {
2 let x = 10;
3
4 // Closure, capturing environment of `foo`.
5 function bar() {
6 return x;
7 }
8
9 // Upward funarg.
10 return bar;
11}
12
13let x = 20;
14
15// Call to `foo` returns `bar` closure.
16let bar = foo();
17
18bar(); // 10, not 20!Again, technically it doesn't differ from the same exact mechanism of capturing the definition environment. Just in this case, hadn't we have the closure, the activation environment of foo would be destroyed. But we captured it, so it cannot be deallocated, and is preserved — to support static scope semantics.
Often there is an incomplete understanding of closures — usually developers think about closures only in terms of the upward funarg problem (and practically it really makes more sense). However, as we can see, the technical mechanism for the downwards and upwards funarg problem is exactly the same — and is the mechanism of the static scope.
As we mentioned above, similarly to prototypes, the same parent environment can be shared across several closures. This allows accessing and mutating the shared data:
1function createCounter() {
2 let count = 0;
3
4 return {
5 increment() { count++; return count; },
6 decrement() { count--; return count; },
7 };
8}
9
10let counter = createCounter();
11
12console.log(
13 counter.increment(), // 1
14 counter.decrement(), // 0
15 counter.increment(), // 1
16);Since both closures, increment and decrement, are created within the scope containing the count variable, they share this parent scope. That is, capturing always happens “by-reference” — meaning the reference to the whole parent environment is stored.
We can see this on the following picture:

Figure 9. A shared environment.
Some languages may capture by-value, making a copy of a captured variable, and do not allow changing it in the parent scopes. However in JS, to repeat, it is always the reference to the parent scope.
So all identifiers are statically scoped. There is however one value which is dynamically scoped in ECMAScript. It's the value of this.
This
The this value is a special object which is dynamically and implicitly passed to the code of a context. We can consider it as an implicit extra parameter, which we can access, but cannot mutate.
The purpose of the this value is to execute the same code for multiple objects.
The major use-case is the class-based OOP. An instance method (which is defined on the prototype) exists in one exemplar, but is shared across all the instances of this class.
1class Point {
2 constructor(x, y) {
3 this._x = x;
4 this._y = y;
5 }
6
7 getX() {
8 return this._x;
9 }
10
11 getY() {
12 return this._y;
13 }
14}
15
16let p1 = new Point(1, 2);
17let p2 = new Point(3, 4);
18
19// Can access `getX`, and `getY` from
20// both instances (they are passed as `this`).
21
22console.log(
23 p1.getX(), // 1
24 p2.getX(), // 3
25);When the getX method is activated, a new environment is created to store local variables and parameters. In addition, function environment record gets the [[ThisValue]] passed, which is bound dynamically depending how a function is called. When it's called with p1, the this value is exactly p1, and in the second case it's p2.
Another application of this, is generic interface functions, which can be used in mixins or traits.
In the following example, the Movable interface contains generic function move, which expects the users of this mixin to implement _x, and _y properties:
1// Generic Movable interface (mixin).
2let Movable = {
3
4 /**
5 * This function is generic, and works with any
6 * object, which provides `_x`, and `_y` properties,
7 * regardless of the class of this object.
8 */
9 move(x, y) {
10 this._x = x;
11 this._y = y;
12 },
13};
14
15let p1 = new Point(1, 2);
16
17// Make `p1` movable.
18Object.assign(p1, Movable);
19
20// Can access `move` method.
21p1.move(100, 200);
22
23console.log(p1.getX()); // 100As an alternative, a mixin can also be applied at prototype level instead of per-instance as we did in the example above.
Just to show the dynamic nature of this value, consider this example, which we leave to a reader as an exercise to solve:
1function foo() {
2 return this;
3}
4
5let bar = {
6 foo,
7
8 baz() {
9 return this;
10 },
11};
12
13// `foo`
14console.log(
15 foo(), // global or undefined
16
17 bar.foo(), // bar
18 (bar.foo)(), // bar
19
20 (bar.foo = bar.foo)(), // global
21);
22
23// `bar.baz`
24console.log(bar.baz()); // bar
25
26let savedBaz = bar.baz;
27console.log(savedBaz()); // globalSince only by looking at the source code of the foo function we cannot tell what value of this will it have in a particular call, we say that this value is dynamically scoped.
The arrow functions are special in terms of this value: their this is lexical (static), but not dynamic. I.e. their function environment record does not provide this value, and it's taken from the parent environment.
1var x = 10;
2
3let foo = {
4 x: 20,
5
6 // Dynamic `this`.
7 bar() {
8 return this.x;
9 },
10
11 // Lexical `this`.
12 baz: () => this.x,
13
14 qux() {
15 // Lexical this within the invocation.
16 let arrow = () => this.x;
17
18 return arrow();
19 },
20};
21
22console.log(
23 foo.bar(), // 20, from `foo`
24 foo.baz(), // 10, from global
25 foo.qux(), // 20, from `foo` and arrow
26);Like we said, in the global context the this value is the global object (the binding object of the global environment record). Previously there was only one global object. In current version of the spec there might be multiple global objects which are part of code realms. Let's discuss this structure.
Realm
Before it is evaluated, all ECMAScript code must be associated with a realm. Technically a realm just provides a global environment for a context.
When an execution context is created it's associated with a particular code realm, which provides the global environment for this context. This association further stays unchanged.
iframe element, which exactly provides a custom global environment. In Node.js it is close to the sandbox of the vm module.Logically though, each context from the stack is always associated with its realm:

Figure 10. A context and realm association.
Let's see the separate realms example, using the vm module:
1const vm = require('vm');
2
3// First realm, and its global:
4const realm1 = vm.createContext({x: 10, console});
5
6// Second realm, and its global:
7const realm2 = vm.createContext({x: 20, console});
8
9// Code to execute:
10const code = `console.log(x);`;
11
12vm.runInContext(code, realm1); // 10
13vm.runInContext(code, realm2); // 20Now we're getting closer to the bigger picture of the ECMAScript runtime. Yet however we still need to see the entry point to the code, and the initialization process. This is managed by the mechanism of jobs and job queues.
Job
Some operations can be postponed, and executed as soon as there is an available spot on the execution context stack.
Jobs are enqueued on the job queues, and in current spec version there are two job queues: ScriptJobs, and PromiseJobs.
And initial job on the ScriptJobs queue is the main entry point to our program — initial script which is loaded and evaluated: a realm is created, a global context is created and is associated with this realm, it's pushed onto the stack, and the global code is executed.
Further this context can execute other contexts, or enqueue other jobs. An example of a job which can be spawned and enqueued is a promise.
When there is no running execution context and the execution context stack is empty, the ECMAScript implementation removes the first pending job from a job queue, creates an execution context and starts its execution.
Example:
1// Enqueue a new promise on the PromiseJobs queue.
2new Promise(resolve => setTimeout(() => resolve(10), 0))
3 .then(value => console.log(value));
4
5// This log is executed earlier, since it's still a
6// running context, and job cannot start executing first
7console.log(20);
8
9// Output: 20, 10The async functions can await for promises, so they also enqueue promise jobs:
1async function later() {
2 return await Promise.resolve(10);
3}
4
5(async () => {
6 let data = await later();
7 console.log(data); // 10
8})();
9
10// Also happens earlier, since async execution
11// is queued on the PromiseJobs queue.
12console.log(20);
13
14// Output: 20, 10Agent
The concurrency and parallelism is implemented in ECMAScript using Agent pattern. The Agent pattern is very close to the Actor pattern — a lightweight process with message-passing style of communication.
Implementation dependent an agent can run on the same thread, or on a separate thread. The Worker agent in the browser environment is an example of the Agent concept.
The agents are state isolated from each other, and can communicate by sending messages. Some data can be shared though between agents, for example SharedArrayBuffers. Agents can also combine into agent clusters.
In the example below, the index.html calls the agent-smith.js worker, passing shared chunk of memory:
1// In the `index.html`:
2
3// Shared data between this agent, and another worker.
4let sharedHeap = new SharedArrayBuffer(16);
5
6// Our view of the data.
7let heapArray = new Int32Array(sharedHeap);
8
9// Create a new agent (worker).
10let agentSmith = new Worker('agent-smith.js');
11
12agentSmith.onmessage = (message) => {
13 // Agent sends the index of the data it modified.
14 let modifiedIndex = message.data;
15
16 // Check the data is modified:
17 console.log(heapArray[modifiedIndex]); // 100
18};
19
20// Send the shared data to the agent.
21agentSmith.postMessage(sharedHeap);And the worker code:
1// agent-smith.js
2
3/**
4 * Receive shared array buffer in this worker.
5 */
6onmessage = (message) => {
7 // Worker's view of the shared data.
8 let heapArray = new Int32Array(message.data);
9
10 let indexToModify = 1;
11 heapArray[indexToModify] = 100;
12
13 // Send the index as a message back.
14 postMessage(indexToModify);
15};So below is the picture of the ECMAScript runtime:

Figure 11. ECMAScript runtime.
And that is it; that's what happens under the hood of the ECMAScript engine!
Now we come to an end. This is the amount of information on JS core which we can cover within an overview article. Like we mentioned, JS code can be grouped into modules, properties of objects can be tracked by Proxy objects, etc, etc. — there are many user-level details which you can find in different documentations on JavaScript language.
Here though we tried to represent the logical structure of an ECMAScript program itself, and hopefully it clarified these details.