Stress testing Svelte until your browser breaks down crying

This was originally written for and posted to the 9elements blog


Svelte is one of the best bets on technology we have ever made. It’s fast, easy to learn and very ergonomic to use. It gives our teams a quick way to build responsive web apps that can handle a ton of data and complex interactions. Together with d3, it has become one of our go-to frameworks for data visualization. The new beta version of Svelte 5 already looks super interesting. Among the more recent and drastic changes is the introduction of runes, which claims to make your Svelte apps faster and more maintainable, among other things. Since a lot of our projects could benefit from those promises, we wanted to see if the hype was warranted. In all of our examples, we’ll basically be working with the following type. It’s a very simple object with a time and a value field:

type DataPoint = { time: Date; value: number };

The Svelte 4 component

As an example, we’re building a line chart with the time on the x-axis and the values on the y-axis. The initial component to render a basic chart looks like this:

<script>
  import { extent, max } from 'd3-array';
  import { scaleLinear, scaleUtc } from 'd3-scale';
  import { line } from 'd3-shape';

  /** @type {{time: Date, value: number}[]}} */
  export let dataPoints;
  export let now = new Date();
  export let mainChartWidth = 800;
  export let mainChartHeight = 100;

  // Calculate the limits of our input data
  $: maxValue = max(dataPoints, (point) => point.value);
  $: [minTime, maxTime] = extent(dataPoints, (point) => point.time);

  // Use the limits to create x and y scales
  $: x = scaleUtc()
    .domain([minTime ?? now, maxTime ?? now])
    .range([0, mainChartWidth]);

  $: y = scaleLinear()
    .domain([0, maxValue ?? 0])
    .range([mainChartHeight, 0]);

  // Use the scales to create a generator
  /** @type {import("d3-shape").Line<{time: Date, value: number}>} */
  $: lineGenerator = line(
    (point) => x(point.time),
    (point) => y(point.value)
  );

  // Use the generator to create the path commands for our chart
  $: d = lineGenerator(dataPoints);
</script>

<svg
  width={mainChartWidth}
  height={mainChartHeight}
  viewBox="0 0 {mainChartWidth} {mainChartHeight}"
>
  <path {d} />
</svg>

<style>
  svg {
    stroke: black;
    stroke-width: 0.1em;
    fill: none;
    stroke-linejoin: round;
    stroke-linecap: round;
  }
</style>

This component looks like a very familiar, Svelte component for rendering a simple chart. Nothing much to see here.

Sidenote: Quick introduction to runes

Runes, and the $state rune specifically, are the new way to declare reactivity within and outside Svelte components. In Svelte 4 a simple counter state variable would look like this:

<script>
  let count = 0;

  function increment() {
    count += 1;
  }
</script>

Rewriting the example using runes, it looks like this:

<script>
  let count = $state(0);

  function increment() {
    count += 1;
  }
</script>

It might look like a small change, but behind it lies a fundamental shift in the way Svelte updates its reactive variables. The $state rune turns our state into a signal which allows Svelte to track changes on a granular level and update the DOM in a performant way.

The Svelte 5 (beta) component

Let’s try this again with Svelte 5. The fifth version of the framework allows us to use reactive state outside the Svelte component context. All we have to do is create a *.svelte.js file, and we can use all the required runes. This gives us the opportunity to model our reactive data using classes. So we start by modeling our DataPoint in a new file called ./datapoint.svelte.js:

export class DataPoint {
  /** @type {Date} */
  time = $state(new Date());

  /** @type {number} */
  value = $state(0);

  /**
   * @param {Date} time
   * @param {number} value
   */
  constructor(time, value) {
    this.time = time;
    this.value = value;
  }
}

$state

This newly defined type has the same structure as our original one, but with the difference that it’s using the newly introduced $state rune. These so-called state fields will desugar to simple getters and setters that access the signals created behind the scenes:

export class DataPoint {
  #time = $.source($.proxy(new Date()));

  get time() {
    return $.get(this.#time);
  }

  set time(value) {
    $.set(this.#time, $.proxy(value));
  }

  #value = $.source(0);

  get value() {
    return $.get(this.#value);
  }

  set value(value) {
    $.set(this.#value, $.proxy(value));
  }

  /**
   * @param {Date} time
   * @param {number} value
   */
  constructor(time, value) {
    this.#time.v = $.proxy(time);
    this.#value.v = $.proxy(value);
  }
}

This makes our DataPoint class behave like a regular class, but Svelte adds fine-grained reactivity behind the scenes that is fully within our control. We control which fields are reactive and which aren’t. Our new Svelte 5 component looks very similar but has small differences:

<script>
  import { extent, max } from 'd3-array';
  import { DataPoint } from './datapoint.svelte.js';
  import { scaleLinear, scaleUtc } from 'd3-scale';
  import { line } from 'd3-shape';

  /**
   * @type {{
   *  dataPoints: DataPoint[]
   *  mainChartWidth?: number
   *  mainChartHeight?: number
   *  now?: Date
   * }}
   */
  const { dataPoints, mainChartWidth = 800, mainChartHeight = 100, now = new Date() } = $props();

  // Calculate the limits of our input data
  const maxValue = $derived(max(dataPoints, (point) => point.value));
  const [minTime, maxTime] = $derived(extent(dataPoints, (point) => point.time));

  // Use the limits to create x and y scales
  const x = $derived(
    scaleUtc()
      .domain([minTime ?? now, maxTime ?? now])
      .range([0, mainChartWidth])
  );

  const y = $derived(
    scaleLinear()
      .domain([0, maxValue ?? 0])
      .range([mainChartHeight, 0])
  );

  // Use the scales to create a generator
  /** @type {import("d3-shape").Line<DataPoint>} */
  const lineGenerator = $derived(
    line(
      (point) => x(point.time),
      (point) => y(point.value)
    )
  );

  // Use the generator to create the path commands for our chart
  const d = $derived(lineGenerator(dataPoints));
</script>

<svg
  width={mainChartWidth}
  height={mainChartHeight}
  viewBox="0 0 {mainChartWidth} {mainChartHeight}"
>
  <path {d} />
</svg>

<style>
  svg {
    stroke: black;
    stroke-width: 2px;
    fill: none;
    stroke-linejoin: round;
    stroke-linecap: round;
  }
</style>

$props

Reading the component code from top to bottom, the first thing that pops into view is the new $props rune. Our export let statements are gone and have been replaced by a simple destructuring:

const {
  dataPoints,
  mainChartWidth = 800,
  mainChartHeight = 100,
  now = new Date(),
} = $props();

In contrast to the old style of using export let this allows us to fully type our component props in more ways than was previously possible.

$derived

Another thing we see, almost immediately, is the absence of $: reactive statements. They have been replaced by the $derived rune. The new rune works similar to the old reactive statements, with the main difference being, that the dependencies of the derived value don’t have to be visible to the compiler. That means that these two examples function the same way:

const counter = $state(0);

const doubled = $derived(counter * 2);
const counter = $state(0);

function double() {
  return counter * 2;
}

const doubled = $derived(double());

This is because Svelte 5 tracks dependencies at runtime instead of tracking them at compile time. Combined with the existence of *.svelte.js files, this means that we can refactor out reactive code into different files. We are no longer restricted to keeping reactive code in .svelte files.

The stress test

Go to https://stress-testing-svelte.vercel.app/ to try the examples for yourself. You can find the sources on GitHub. To stress test our examples and to get a feel for the improvements, we created a site that renders a variable number of charts with 1000 data points each. The number of charts is controllable by the user and can be adjusted in 10 point steps. We also added a button that “simulates” the mutation of the data points as fast as possible using requestAnimationFrame.

The result

The problem with the Svelte 4 component arise as soon, as too many instances of this chart are active on the current page (e.g. 100). At that point, the example dies spectacularly with the infamous ERR_SVELTE_TOO_MANY_UPDATES error. This is not an error per se, by the way. Svelte tries to shield you from having recursions in your reactive statements, which is absolutely desirable. But we don’t have any recursion in our line chart, and 100 charts with 1000 data points for each chart isn’t that contrived. Meanwhile, the Svelte 5 example, while getting slower, continues to work.

Conclusion

As requirements and expectations for data visualization evolve and get more complex, Svelte continues to keep up with the times while keeping its promise of speed and simplicity. The latest iteration of the framework has shown us this in particular. It will most likely accompany us for a long time.