New: Master Frontend System Design! Read our brand new Meta-Guide and solve the Autocomplete practice challenge.Read Master Guide
FrontendPrep
Back to Guides
javascriptIntermediate25 min read

Modern JavaScript: From ES5 to ES6+ and Beyond

Master the evolution of JavaScript. Learn scoping difference (var vs let/const), lexical this, classes vs prototypes, modules, and async/await with clear side-by-side examples.

Arvind M
Arvind MLinkedIn

1. Introduction

JavaScript has undergone a massive transformation over the past two decades. For a long time, ECMAScript 5 (ES5), released in 2009, was the de facto standard of the web. It stabilized the language, introducing strict mode ('use strict'), standard JSON support, and foundational array utilities like map, filter, and reduce.

However, as web applications grew in complexity, the limitations of ES5—such as scope leakage, callback hell, and verbose prototype-based inheritance—became increasingly apparent.

In 2015, the release of ECMAScript 6 (ES6 / ES2015) marked a historic paradigm shift, introducing block-scoped variables, arrow functions, classes, native modules, and native promises. Today, JavaScript updates follow a yearly release cadence (ES2016 through ES2023+), bringing smaller but highly impactful features like optional chaining, nullish coalescing, and async iterators.

Understanding this evolution is not just about writing shorter code; it is about mastering the underlying engine execution models, avoiding architectural debt, and excelling in senior-level engineering discussions.


2. Why This Matters

As a senior engineer, your relationship with syntax must go deeper than code completion:

  • Compilation & Debugging: Modern codebases are written in ES6+ but are often compiled down to ES5 or older targets to support legacy environments. Understanding how features like async/await or classes compile helps you debug runtime errors and inspect polyfill overhead.
  • Engine Optimization: Modern JavaScript engines (like V8) are designed to heavily optimize native ES6+ structures. Writing modern syntax allows the engine to make better performance assumptions.
  • Interview Stature: Elite frontend engineering rounds regularly test your knowledge on scoping anomalies (var hoisting vs let temporal dead zone), prototypical inheritance translation to ES6 class syntax, and modules.

3. Prerequisites

Before moving forward, ensure you are comfortable with:


4. Mental Model

To transition from legacy ES5 patterns to modern ES6+, we must update our mental model of how the JavaScript engine establishes execution contexts, scopes, and memory layouts.

Scope Boundaries: Function vs. Block

In ES5, variable scoping was strictly bound by functions. Block structures like if statements or for loops did not create scope boundaries, leading to accidental global pollution and closure issues in loops. ES6 introduced block scoping through let and const. A block is defined by any pair of curly braces {}.

The Prototype Chain vs. Class Sugar

While ES6 introduced the class keyword, JavaScript remains a prototype-based language under the hood. The class syntax does not create a new object-oriented engine model; instead, it compiles directly to constructor functions and prototypical linkages.

Variable Lifecycle: Hoisting vs. Temporal Dead Zone (TDZ)

  • ES5 (var): The engine allocates memory for var variables during the creation phase of the execution context, initializing them to undefined.
  • ES6+ (let/const): The engine registers the variables during the creation phase but does not initialize them. They enter the Temporal Dead Zone (TDZ) and cannot be read or written to until the execution flow physically reaches the line of declaration.

5. How It Works

Let's dissect the core mechanical differences between ES5 and ES6+ features across five distinct categories.

5.1 Scoping and Declarations

In ES5, var was the only variable declaration keyword. ES6 introduced let and const.

  • Scoping: var is function-scoped. let and const are block-scoped.
  • Hoisting: var is hoisted and initialized to undefined. let and const are hoisted but uninitialized (they reside in the TDZ).
  • Reassignment: var and let allow reassignment. const creates a read-only reference (binding) that cannot be reassigned (though object properties can still be modified).
// ES5 Function Scoping
function es5Scope() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 (x leaked out of the if-block!)
}
 
// ES6 Block Scoping
function es6Scope() {
  if (true) {
    let y = 10;
  }
  console.log(y); // ReferenceError: y is not defined
}

5.2 Functions & Execution Contexts

Regular functions bind this dynamically depending on how they are invoked. ES6 Arrow Functions bind this lexically—they inherit this from their enclosing execution context.

  • Arrow Functions: Lack their own this, arguments object, super, and new.target. They cannot be used as constructors (calling them with new throws a TypeError).
  • Rest Parameters: Replace the verbose, non-array arguments object with a clean, native array containing the remaining arguments.
// ES5 Context binding workaround
function ES5Counter() {
  var self = this;
  self.count = 0;
  
  setInterval(function() {
    self.count++; // Had to store 'self' to access outer execution context
  }, 1000);
}
 
// ES6 Lexical 'this' binding
function ES6Counter() {
  this.count = 0;
  setInterval(() => {
    this.count++; // Inherits 'this' lexically from ES6Counter instance
  }, 1000);
}

5.3 Object-Oriented Programming (OOP)

In ES5, OOP required defining a constructor function and attaching methods to its prototype. ES6 classes clean this up with syntactic sugar.

  • ES5 Prototypes: Involve manual prototype chaining and calling parent constructors using .call() or .apply().
  • ES6 Classes: Wrap this mechanism in a declarative class structure, introducing the constructor, extends, and super keywords.
// ES5 Inheritance
function Animal(name) {
  this.name = name;
}
Animal.prototype.speak = function() {
  return this.name + " makes a noise.";
};
 
function Dog(name, breed) {
  Animal.call(this, name); // Call parent constructor
  this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype); // Establish prototype link
Dog.prototype.constructor = Dog;
Dog.prototype.speak = function() {
  return this.name + " barks!";
};
// ES6 Classes
class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return `${this.name} makes a noise.`;
  }
}
 
class Dog extends Animal {
  constructor(name, breed) {
    super(name); // Call parent constructor
    this.breed = breed;
  }
  speak() {
    return `${this.name} barks!`;
  }
}

5.4 Asynchronous Programming

Asynchrony in ES5 was managed using callbacks, leading to deep nestings known as "Callback Hell". ES6 introduced native Promises, and ES2017 (ES8) introduced Async/Await, which allows writing asynchronous code that looks and behaves like synchronous code.

// ES5 Asynchronous Nested Callbacks
getUser(userId, function(user) {
  getOrders(user.id, function(orders) {
    getPaymentDetails(orders[0].id, function(payment) {
      console.log(payment);
    });
  });
});
 
// ES6+ Async/Await
async function getPaymentFlow(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const payment = await getPaymentDetails(orders[0].id);
    console.log(payment);
  } catch (error) {
    console.error("Failed to fetch payment details", error);
  }
}

5.5 Modules

Before ES6, JavaScript lacked native module support. Node.js popularized the CommonJS (CJS) module system, while browsers relied on script tags or loader libraries (AMD, RequireJS). ES6 introduced ES Modules (ESM) as a standardized, native syntax.

  • CommonJS (CJS): Evaluated dynamically at runtime, supports dynamic imports, and runs synchronously. Uses require() and module.exports.
  • ES Modules (ESM): Parsed and resolved statically during compile-time, enabling dead-code elimination (tree-shaking). Uses import and export.
// CommonJS (Node.js)
const math = require('./math');
module.exports = function(x) { return math.square(x); };
 
// ES Modules (Standard)
import { square } from './math.js';
export default function(x) { return square(x); }

6. Visual Diagram

The following diagram illustrates how the JavaScript Engine evaluates scopes and object links in both ES5 and modern ES6.

JS Scoping & Prototype LayoutsES5 Scopes & InheritanceFunction Scope Boundary Onlyfunction example() {var leakedVar = 'inside'; // leaks to function}Prototypal ChainDog.prototypeAnimal.prototypeprotoES6+ Scopes & Class SugarBlock Scope Bound if (true) {let blockVar = 'bound'; // trapped in block}Class Declarative Layerclass Dogclass AnimalextendsCompiles to proto linkage under the hood

7. Simple Example

Here are side-by-side examples showing the structural elegance and code reduction introduced in modern JavaScript.

Loop Scoping and Closures

In ES5, creating loops with asynchronous closures (like setTimeout) required an Immediately Invoked Function Expression (IIFE) to preserve index states. ES6 handles this natively with block-scoped variables.

// ❌ ES5 Variable Leakage Problem
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // Prints 3, 3, 3 (since 'i' is function-scoped)
  }, 100);
}
 
// 🟩 ES5 Solution: IIFE Workaround
for (var i = 0; i < 3; i++) {
  (function(capturedIndex) {
    setTimeout(function() {
      console.log(capturedIndex); // Prints 0, 1, 2
    }, 100);
  })(i);
}
 
// 🟩 ES6+ Block-Scope Solution
for (let j = 0; j < 3; j++) {
  setTimeout(() => {
    console.log(j); // Prints 0, 1, 2 (j is isolated per iteration block)
  }, 100);
}

Destructuring & Template Literals

ES6 provides array/object destructuring, rest/spread properties, and string template literals, simplifying how we manipulate data formats.

// ES5 Variable Extraction & Concat
var user = { id: 101, name: 'Alice', role: 'admin' };
var id = user.id;
var name = user.name;
var details = "User " + name + " (ID: " + id + ") is an admin.";
 
// ES6+ Destructuring & Interpolation
const { id, name, ...rest } = user;
const details = `User ${name} (ID: ${id}) is an ${rest.role}.`;

8. Real World Example

Let's build a real-world State Store utility that implements the Observer Pattern (Publish-Subscribe) with asynchronous state-update interceptors.

The ES5 Implementation

function ES5Store(initialState) {
  this.state = initialState || {};
  this.listeners = [];
}
 
ES5Store.prototype.subscribe = function(callback) {
  var self = this;
  self.listeners.push(callback);
  
  // Return unsubscribe function
  return function() {
    var index = self.listeners.indexOf(callback);
    if (index !== -1) {
      self.listeners.splice(index, 1);
    }
  };
};
 
ES5Store.prototype.updateState = function(newState, callback) {
  var self = this;
  
  // Asynchronous update emulation
  setTimeout(function() {
    // Basic property merge
    for (var key in newState) {
      if (newState.hasOwnProperty(key)) {
        self.state[key] = newState[key];
      }
    }
    
    // Notify observers
    for (var i = 0; i < self.listeners.length; i++) {
      self.listeners[i](self.state);
    }
    
    if (typeof callback === 'function') {
      callback(self.state);
    }
  }, 0);
};

The ES6+ Implementation

class ES6Store<T extends object> {
  private state: T;
  private listeners: Set<(state: T) => void>;
 
  constructor(initialState: T) {
    this.state = { ...initialState };
    this.listeners = new Set();
  }
 
  // Subscribe using a Set to prevent duplicate listeners
  public subscribe(callback: (state: T) => void): () => void {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
 
  // Asynchronous state updates returning Promises
  public async updateState(newState: Partial<T>): Promise<T> {
    return new Promise((resolve) => {
      // Yield to the Event Loop
      queueMicrotask(() => {
        this.state = { ...this.state, ...newState };
        
        // Notify using modern iteration
        this.listeners.forEach((listener) => listener(this.state));
        resolve(this.state);
      });
    });
  }
 
  public getState(): T {
    return { ...this.state };
  }
}

Why the ES6+ Store is Architecturally Superior

  1. Reference Safety: The ES6+ store returns shallow copies of the state using the spread operator { ...this.state }, preventing direct mutation of the internal store state by observers.
  2. O(1) Unsubscribe Performance: The legacy store uses Array.prototype.indexOf and splice for unsubscribing, which scales linearly $O(N)$. The ES6+ store utilizes a Set data structure, making both subscriptions and unsubscriptions instant $O(1)$ operations.
  3. Modern Microtask Scheduling: The ES6 implementation uses queueMicrotask instead of setTimeout to trigger callbacks. This schedules the notification execution contexts into the engine's Microtask Queue instead of the Macrotask Queue, yielding significantly faster execution schedules.
  4. Promise Integration: By returning a Promise, the updateState method can be awaited cleanly in asynchronous workflows (await store.updateState(updates)), eliminating nested success callbacks.

9. Common Mistakes

Engineers transition syntax easily but often run into deep-seated mechanical bugs:

Mistake #1: Using Arrow Functions for Object Methods

Arrow functions inherit their this lexically. If you assign an arrow function to an object's property, this will point to the outer context (e.g., the global window object or the module boundary) rather than the object itself.

const userProfile = {
  name: 'Bob',
  // ❌ Throws/Fails - 'this' does NOT point to userProfile
  greetArrow: () => {
    return `Hello, my name is ${this.name}`;
  },
  // 🟩 Works correctly
  greetMethod() {
    return `Hello, my name is ${this.name}`;
  }
};

Mistake #2: Accessing TDZ Variables inside Scopes

Even though hoisting occurs under the hood, writing references to variables before their initialization will crash your program.

function errorTDZ() {
  console.log(myVar);  // undefined (no error, just legacy var behavior)
  console.log(myLet);  // ReferenceError: Cannot access 'myLet' before initialization
  
  var myVar = 1;
  let myLet = 2;
}

Mistake #3: Destructuring Defaults vs. Falsy Values

Default parameters and destructuring defaults are only triggered if the value is strictly undefined. If the value is null or a empty string "", default values are ignored.

const { theme = 'dark' } = { theme: null };
console.log(theme); // null (does not fall back to 'dark'!)
 
// Use Nullish Coalescing for safer fallbacks
const selectedTheme = theme ?? 'dark';
console.log(selectedTheme); // 'dark'

10. Performance Considerations

Modern syntax is elegant, but transpilation to legacy targets is not free.

Transpilation & Polyfill Overhead

If your project targets old environments (like Internet Explorer or early Node.js versions), build tools like Babel transpile ES6 classes and async/await into complex ES5 helper modules.

  • An ES6 class with inheritance compiles to a large block of prototype helper routines.
  • async/await uses generator polyfills that significantly inflate build bundle sizes.

[!TIP] Ensure your build setups utilize different target builds for modern browsers (esnext or targeting browsers supporting ESM natively) to avoid forcing modern users to load bloated transpiled code.

Closures and Garbage Collection

Creating inline arrow functions in loops or nested React render hooks can lead to garbage collection thrashing:

// ❌ Allocates a new function instance on every single iteration
for (let i = 0; i < 10000; i++) {
  button.onClick = () => process(i);
}

If this loop executes repeatedly, thousands of short-lived function objects are created and immediately discarded, causing minor garbage collection pauses in the browser rendering thread.


11. Best Practices

To write highly performant, robust modern JavaScript code:

  • Immutable Bindings: Use const for all variable declarations by default. Only demote a variable to let if you explicitly plan to reassign its reference. Never use var.
  • Arrow Function Boundaries: Avoid arrow functions when defining constructor methods, object literal functions, or Event Listener callback scopes that rely on binding a dynamic target.
  • Modern Sugars: Replace complex nested ternary arrays and logical checks with Optional Chaining (?.) and Nullish Coalescing (??):
    // ❌ Legacy check
    const zipCode = user && user.address && user.address.zip;
     
    // 🟩 Modern check
    const zipCode = user?.address?.zip ?? '00000';
  • Prefer Spread over Mutation: Do not mutate existing array/object references. Build new states using spread operations to ensure compatibility with reactive architectures:
    const updatedItems = [...items, newItem];

12. Production Recommendations

When deploying JavaScript applications to production:

  • Implement "Module/Nomodule" Architecture: Deliver dual bundles to clients. Modern browsers receive native ESM builds (<script type="module" src="modern.js">) which are small and run native code. Legacy browsers receive fully transpiled polyfilled bundles.
  • Strict Lint Rules: Configure ESLint with strict plugins (eslint-config-next or custom configurations) to forbid legacy ES5 fallbacks:
    • no-var: true
    • prefer-const: true
    • no-unused-vars: true
  • Use Native ESM in Node.js: Set "type": "module" in your server-side package.json to leverage V8's static loader optimization for module caching, rather than Relying on CommonJS runtime resolutions.

13. Summary & Key Takeaways

The ES5 vs. ES6+ Comparison Matrix

FeatureES5 SpecificationES6+ Modern Specifications
Variable Declarationsvar (Function-scoped, Hoisted as undefined)let, const (Block-scoped, Hoisted in TDZ)
This BindingDynamic binding (Context shifts based on call location)Lexical binding in Arrow functions (Inherited)
Object OrientationConstructor Functions & Prototypesclass syntax sugar (Syntactic wrappers on prototypes)
Asynchronous LogicCallbacks (leads to nested Callback Hell)Native Promises and async/await syntax
Module SystemCustom specs (CommonJS, AMD)Standardized ES Modules (import/export)
Argument CollectionVerbose arguments pseudo-array objectNative Rest parameters (...args array)
Data ExtractionManual assignment from object pathsNative Object & Array Destructuring

Core Takeaways

  • Lexical Bindings simplify scopes: Modern scoping rules isolate execution environments inside block braces, preventing unintended global variable leaks.
  • Classes are prototypes under the hood: Classes make Object Oriented designs easy to write and read, but they still resolve inheritance dynamically via prototype delegation.
  • Async/Await increases maintainability: Promises and async functions abstract runtime call chains, removing spaghetti nesting and aligning code with direct execution routes.
  • ES Modules allow tree-shaking: ESM standardizes static imports, letting compilers analyze dependencies ahead of execution and strip dead code before delivery.

Share this Resource

Help other developers level up by sharing this study guide.

⚡ Weekly newsletter

Crack Your Next Frontend Interview.

Join senior engineers who receive practical, deep-dive frontend challenges, detailed concepts, and blueprints directly in their inbox.

  • Senior level React, JS, and CSS interview blueprints
  • System Design & performance optimization deep-dives
  • 100% free, zero spam, unsubscribe with one click

Join the Study Track

We value your privacy. Unsubscribe at any time.