CodeTips#7: Spread operator: the slow beauty

Let's go learn how and when to use the beautiful spread operator

For a couple of years now, the JS community has been using the famous spread operator, AKA three dots (...), to copy values in arrays and objects, both on front and backend. This operator makes code easy to read however, many devs, including me, have never stopped themselves to think and ask: "What does this operator do?" and "What is the computational cost of it?".

We’ll find that out in the following example. We need to consume an API of all the cities on the planet (with more than 100,000 cities) that don’t have pagination, and we need to present this data in a table in the browser so that our end client will see all the countries with the number of cities in each of them, as in the figure below.

React Table rendering all countries and cities number

To render the table above using React.js we could have an implementation like this:

import dataset from '../cities.json';

function reduceWithSpread(predicate) {
    return dataset.reduce((curr, next) => {
        const country = predicate(next);
        const currentCountryCities = curr[country] ?? [];

        return {
            ...curr,
            [country]: [...currentCountryCities, next]
        };
    }, {});
}

export default function TableWithReduceSpread() {
    const cities = useMemo(() => {
        const result = reduceWithSpread(({ country }) => country);
        return Object.entries(result);
    }, [])

    return (
        <div>
            <h1>Render table with reduce with spread</h1>
            <div className='table'>
            <div className='table-header'>
                <b>Country</b>
                <b>Number of cities</b>
            </div>
            {cities.map(([country, cities]) => (
                <div className='table-row' key={country}>
                    <span>{country}</span>
                    <span>{cities.length}</span>
                </div>
            ))}
            </div>
        </div>
    )
}

You might think at first sight that the cost of rendering this would be O(n), since we’re just iterating over the array and returning new data for each element, right? Wrong! Because we have the fancy spread operator, which performs, oddly enough, a loop each time it’s called in the mapping of our function. Since a copy of a given data structure (array and object) is created, we actually have a higher computational cost, of O(n^2).

So, how can we reduce this computational cost? We can implement it in two ways: using reduce but without using the spread operator; or just using a classic for-loop. Let’s code!

The implementation using reduce but without the spread operator would look like this:

function reduceWithoutSpread(predicate) {
    return dataset.reduce((curr, next) => {
        const country = predicate(next);
        const currentCountryCities = curr[country] ?? [];

        currentCountryCities.push(next);

        curr[country] = currentCountryCities;

        return curr;
    }, {});
}

As you can see, instead of making a new copy via the spread operator of the collection of cities (currentCountryCities) at each turn of the reduce loop – allocating more memory space – in this implementation, we simply add the new city to the end of the collection of cities in the respective country and make an in-place assignment to the key of the same country as the cities are updated.

In the implementation without the use of reduce and the spread operator, we use the traditional loop (for), where we have a more imperative approach, but which at the same time is more performant compared to the first implementation that makes use of the spread operator. It is equivalent in terms of performance to reduceWithoutSpread since it doesn’t copy elements either.

function justLoop(predicate) {
  const group = {};

  for (let i = 0; i < dataset.length; i++) {
    const element = dataset[i];
    const groupKey = predicate(element);
    const groupValue = group[groupKey] ?? [];

    groupValue.push(element);

    group[groupKey] = groupValue;
  }

  return group;
}

In this code, we defined a const object called group that, at each turn from the loop, is updated with the key of a country with its cities collection.

Pros & cons of using a spread operator

Pros

  • Better readability for developers and future code maintainers;
  • Immutability, as it allows objects/arrays to be created/updated while retaining the original data.
  • Versatility, since, as can be seen, it can be used in structures such as arrays, objects, function arguments, strings, and maps.

Cons

  • Shallow Copy. While the spread operator is useful in most cases, it only performs a shallow copy of nested objects. This means that deeply nested structures will not be cloned deeply.
  • Performance, For very large arrays or objects, using the spread operator can impact performance due to memory use.

Benchmarks

As a first benchmark, we have a table comparing the average time taken by each implementation, using an array of 140,988 elements, to assemble the data structure needed to render the table of countries with the number of cities in each one. To get these results, we ran each algorithm 100 times to get a statistically relevant sample.

Implementation100 rounds execution time AVG (ms)
reduce with spread5030.25
reduce without spread6.9353
just loop6.2498

To make even clearer how problematic the use of the spread operator without taking into account the volume of data we are dealing with can be, the graph below shows that this use of the spread can negatively affect the user experience (UX) when it comes to the rendering time of a simple table that deals with a large volume of data.

spread benchmark

Now, as a second benchmark, we have 4 charts below that compare the use of the spread operator with other approaches that do not use it, in different amounts of elements, starting with 100 elements and growing to 1K, 10K, and 100K elements.

As we can see, using the spread operator with even a small number of elements (100) yields a greater computational cost in comparison with other approaches. But of course, to the end user, the time of processing is still very fast in some cases.

I created a repository on Github that you can use to run these benchmarks on your machine and check the differences between the approaches by yourself.

The bottom line is that, as developers, we have to weigh each approach and understand the trade-offs. We have to consider if having a more readable code is worth the cost in performance it brings. It all depends on the context we’re working in and the result we’re looking to achieve. We can use the spread operator for a simple TODO list or a shopping cart, and other approaches for other scenarios where we’re dealing with a large volume of data that requires the lowest possible computational cost.

References

https://community.appsmith.com/content/guide/javascript-spread-operator-what-are-those-three-dots-my-code

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax

We want to work with you. Check out our "What We Do" section!