DEV Community

BlobKat
BlobKat

Posted on

JS prototypes are slow ... ?

On a project I'm working on, I came across a strange requirement (which I'll explain in a bit) and most importantly, it needed to be performant. After some testing I concluded that going with the obvious native faster solution wasn't actually the faster solution...

To best explain the requirement, let's bring in the traditional example of cars
car.png

class Car{
  constructor(topSpeed, color, name){
    this.position = 0
    this.topSpeed = topSpeed
    this.color = color
    this.name = name
  }
}
let firstCar = new Car(110, 'red', 'Honda Civic')
Enter fullscreen mode Exit fullscreen mode

Unfortunately, as simple and elegant as this looks, it may not be the best idea. Can you see why? Well when I want to implement logic, the Honda's engine may work differently from a Ford, and so I would be forced to write a really large switch statement for each car type.

No problem! This kind of problem has been addressed before, just create a HondaCivic class that extends the Car class.

class Car{
  constructor(topSpeed, color, name){
    this.position = 0
    this.topSpeed = topSpeed
    this.color = color
    this.name = name
  }
}
class HondaCivic extends Car{
  constructor(color){
    super(110, color, 'Honda Civic')
  }
  startUp(){
    //Logic to start our honda
  }
}
Enter fullscreen mode Exit fullscreen mode

Another problem arises, if we wanted to track 1000+ honda civics, we would have 1000+ times the name 'Honda Civic' in memory, as well as any other properties of the Honda civic family. If you've ever used function-style constructors before, you'd know there's a simple solution to that too:

class Car{
  constructor(color){
    this.position = 0
    this.color = color
  }
}
class HondaCivic extends Car{
  startUp(){
    //...
  }
}
//Instead of defining name & topSpeed on instances
//we define it on the prototype
HondaCivic.prototype.name = 'Honda Civic'
HondaCivic.prototype.topSpeed = 110

let firstCar = new HondaCivic('red')
console.log(firstCar.topSpeed == 110) //true
Enter fullscreen mode Exit fullscreen mode

The big issue

Now let's say we want to test our car around a track

function testCar(car){
  let seconds = 0
  //run car around a 100km track while counting seconds
  while(car.position < 100 * 1000){
    car.position += car.topSpeed
    seconds++
  }
  return seconds
}
Enter fullscreen mode Exit fullscreen mode

Now, don't get me wrong, this function works fine and performs about as good as you can get, or does it?
Let's time how fast js can execute this function

Image description

Neat! I'm sure no one would need to race their car 150 million times per second around a track
Let's try adding more cars

class FordMondeo extends Car{...}
FordMondeo.prototype.topSpeed = 120
FordMondeo.prototype.name = 'Ford Mondeo'
class BmwM4 extends Car{...}
...
Enter fullscreen mode Exit fullscreen mode

After we've added 4 cars and we've tested them all, we go back to our Honda civic and run the function again

Image description

What??

When we ran our test earlier, we got 156 million ops/s, now we only get 110. What happened?
Earlier, the testCar function was optimized to recieve exclusively HondaCivic objects. Now that we've tested a bunch of more cars, the Javascript engine can no longer be sure what car the function will recieve.
As such, it no longer knows for sure that the car we're testing has the same properties with the same type. But hang on, all the cars we tested did have the same properties and even the same types.
Well, that's the magic of optimizations, we never know for sure what's going on under the hood or why it does something that way. There may be a reason, or it may just be a silly mistake from V8's developers. ¯\(ツ)

The solution

Enough explaining and complaining, is there a way to get around this so fundamental issue?
As it turns out, yeah and it's really simple:

Avoid needing multiple classes: don't use the prototype

What if, instead of creating a new class with different properties & methods, we put them in an object, a CarDefinition that we can attach to each car?

class Car{
  constructor(definition, color){
    this.props = definition
    this.position = 0
    this.color = color
  }
}
const HondaCivic = {name: 'Honda Civic', topSpeed: 110}

let firstCar = new Car(HondaCivic, 'red')
console.log(firstCar.props.topSpeed == 110) //true
Enter fullscreen mode Exit fullscreen mode

Now that we only use 1 class Car for all types of cars, testCar will stay optimized, as it should.
But, you may complain, I don't want to have to access .props every time, I'm a programmer, and programmers are lazy
Don't worry, I got you covered. Let's add some getters for a shortcut

class Car{
  constructor(definition, color){
    this.props = definition
    this.position = 0
    this.color = color
  }
  get topSpeed(){return this.props.topSpeed}
  get name(){return this.props.name}
}
const HondaCivic = {name: 'Honda Civic', topSpeed: 110}

let firstCar = new Car(HondaCivic, 'red')
console.log(firstCar.topSpeed == 110) //true
Enter fullscreen mode Exit fullscreen mode

Now, we can use our object just as before, without having to worry about props

Performance of getters

Now our example seems a bit more complex, we've got an extra object props and every time we want to get a property, we'll actually be implicitly calling the getter trap. Shouldn't this make our code actually much slower than before? Well, turns out here most Javascript engines are actually smart and inline our function.
When a function is inlined, it's basically removed, and replaced with its contents. Javascript will essentially optimize our code to something like this:

class Car{
  constructor(definition, color){
    this.props = definition
    this.position = 0
    this.color = color
  }
  //These 2 functions are "inlined"
  //get topSpeed(){return this.props.topSpeed}
  //get name(){return this.props.name}
}
const HondaCivic = {name: 'Honda Civic', topSpeed: 110}

let firstCar = new Car(HondaCivic, 'red')

let _this = firstCar
//Note how our getter access was replaced with the actual function body
console.log((_this.props.topSpeed) == 110)
Enter fullscreen mode Exit fullscreen mode

Typically, the engine will decide whether or not a function can be inlined depending on how big it is, and what variables it accesses. After some testing, I could confirm that all major browsers will inline our little getter. 👍

Final test

Typical approach

class Car{
  constructor(color){
    this.position = 0
    this.color = color
  }
}
class HondaCivic extends Car{}
HondaCivic.prototype.topSpeed = 110
HondaCivic.prototype.name = 'Honda Civic'
class FordMondeo extends Car{}
FordMondeo.prototype.topSpeed = 120
FordMondeo.prototype.name = 'Ford Mondeo'
class BmwM4 extends Car{}
BmwM4.prototype.topSpeed = 130
BmwM4.prototype.name = 'BMW M4'
class Lamborghini extends Car{}
Lamborghini.prototype.topSpeed = 160
Lamborghini.prototype.name = 'Lamborghini'
class AnotherCar extends Car{}
AnotherCar.prototype.topSpeed = 999
AnotherCar.prototype.name = 'run out of ideas'

let firstCar = new HondaCivic('red')

function testCar(car, trackLength){
  let seconds = 0
  //run car around a 100km track while counting seconds
  while(car.position < trackLength){
    car.position += car.topSpeed
    seconds++
  }
  return seconds
}

testCar(firstCar, 100000)
testCar(new FordMondeo('grey'), 100000)
testCar(new BmwM4('green'), 100000)
testCar(new Lamborghini('blue'), 100000)
testCar(new AnotherCar('white'), 100000)
Enter fullscreen mode Exit fullscreen mode

Test case:

testCar(firstCar,100000)
Enter fullscreen mode Exit fullscreen mode

Result 110-111 million ops/s

Our solution

class Car{
  constructor(definition, color){
    this.props = definition
    this.position = 0
    this.color = color
  }
  get topSpeed(){return this.props.topSpeed}
  get name(){return this.props.name}
}
const HondaCivic = {topSpeed: 110, name: 'Honda Civic'}
const FordMondeo = {topSpeed: 120, name: 'Ford Mondeo'}
const BmwM4 = {topSpeed: 130, name: 'BMW M4'}
const Lamborghini = {topSpeed: 160, name: 'Lamborghini'}
const AnotherCar = {topSpeed: 999, name: 'run out of ideas'}


let firstCar = new Car(HondaCivic, 'red')

function testCar(car, trackLength){
  let seconds = 0
  //run car around a 100km track while counting seconds
  while(car.position < trackLength){
    car.position += car.topSpeed
    seconds++
  }
  return seconds
}

testCar(firstCar, 100000)
testCar(new Car(FordMondeo, 'grey'), 100000)
testCar(new Car(BmwM4, 'green'), 100000)
testCar(new Car(Lamborghini, 'blue'), 100000)
testCar(new Car(AnotherCar, 'white'), 100000)
Enter fullscreen mode Exit fullscreen mode

Test (same):

testCar(firstCar,100000)
Enter fullscreen mode Exit fullscreen mode

Result: 156 million ops/s

Top comments (0)