Skip to content

Comparison benchmark

The same table of rows in Signy, React, Preact, Vue and Alpine.js.

Each library renders the table from the rows benchmark on its own page. The runner loads the pages one at a time in a frame and times every operation with the same code, so the only difference between the columns is the library.

Results depend on your machine, your browser and what else is running. Compare columns from the same run only. Close other tabs, and keep a laptop on power, for steadier numbers.

Run it

Open the benchmark on its own, which gives steadier numbers than the frame.

What it measures

The operations of the rows benchmark, from a click to the end of the layout it causes. A run starts once the library has updated the page:

  • Signy with flushAll()
  • React with flushSync()
  • Preact with options.debounceRendering set to render at once
  • Vue and Alpine.js after their next tick

How each library is written

Each page follows the docs of its library. The site has no build step for pages, so a few things differ from a typical app:

  • React and Preact use htm instead of JSX. Rows skip rendering when their data did not change, with memo() in React and shouldComponentUpdate() in Preact.
  • Vue's template is compiled when the site is built, as a Vue build step would, so the page needs no template compiler.
  • Alpine.js is its CSP build, since the site's Content Security Policy blocks eval. The CSP build parses expressions itself.
  • The libraries are bundled from npm and served by this site, so the benchmark makes no requests to a CDN.

Code

Every library page loads the same timing code:

Show the timing code
/**
 * Shared by the library pages of the comparison benchmark, so every library is
 * timed by the same code. A page renders the rows table its own way and passes
 * an adapter to Bench.start(). Each adapter method updates the DOM before it
 * returns, or before the promise it returns resolves.
 *
 * The comparison page loads the library pages in a frame and runs operations
 * with postMessage: { type: "run", operation } in, { type: "result" } out.
 */
const Bench = (() => {
  const A = ["pretty", "large", "big", "small", "tall", "short", "long", "handsome", "plain", "quaint", "clean", "elegant", "easy", "angry", "crazy", "helpful", "mushy", "odd", "unsightly", "adorable", "important", "inexpensive", "cheap", "expensive", "fancy"];
  const C = ["red", "yellow", "blue", "green", "pink", "brown", "purple", "brown", "white", "black", "orange"];
  const N = ["table", "chair", "house", "bbq", "desk", "car", "pony", "cookie", "sandwich", "burger", "pizza", "mouse", "keyboard"];
  const pick = (list) => list[Math.floor(Math.random() * list.length)];

  let nextId = 1;
  const buildData = (count) => Array.from({ length: count }, () => ({ id: nextId++, label: `${pick(A)} ${pick(C)} ${pick(N)}` }));

  const rows = () => document.querySelectorAll(".rows tbody tr");
  const rowId = (index) => Number(rows()[index].firstElementChild.textContent);

  async function fill(adapter, count) {
    if (rows().length !== count) await adapter.set(buildData(count));
    await adapter.select(null);
  }

  // prepare sets up the table and returns the input of run, untimed. run is timed.
  const OPERATIONS = [
    { id: "create1k", label: "Create 1,000 rows", prepare: async (a) => (await a.clear(), buildData(1000)), run: (a, data) => a.set(data) },
    { id: "create10k", label: "Create 10,000 rows", prepare: async (a) => (await a.clear(), buildData(10000)), run: (a, data) => a.set(data) },
    { id: "replace", label: "Replace all 1,000 rows", prepare: async (a) => (await fill(a, 1000), buildData(1000)), run: (a, data) => a.set(data) },
    { id: "update", label: "Update every 10th row", prepare: (a) => fill(a, 1000), run: (a) => a.update() },
    { id: "select", label: "Select a row", prepare: async (a) => (await fill(a, 1000), rowId(Math.floor(Math.random() * 1000))), run: (a, id) => a.select(id) },
    { id: "swap", label: "Swap rows 2 and 999", prepare: (a) => fill(a, 1000), run: (a) => a.swap() },
    { id: "remove", label: "Remove a row", prepare: async (a) => (await fill(a, 1000), rowId(500)), run: (a, id) => a.remove(id) },
    { id: "append", label: "Append 1,000 rows", prepare: async (a) => (await fill(a, 1000), buildData(1000)), run: (a, data) => a.append(data) },
    { id: "clear", label: "Clear 1,000 rows", prepare: (a) => fill(a, 1000), run: (a) => a.clear() },
  ];

  const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));

  // Times the operation and the layout it causes, like the rows benchmark
  async function measure(adapter, operation) {
    const input = await operation.prepare(adapter);
    await nextFrame();
    const start = performance.now();
    await operation.run(adapter, input);
    document.querySelector(".rows").offsetHeight;
    const time = performance.now() - start;
    await nextFrame();
    return time;
  }

  function start(adapter, library) {
    let queue = Promise.resolve();
    const run = (id) => {
      const operation = OPERATIONS.find((candidate) => candidate.id === id);
      const result = queue.then(() => measure(adapter, operation));
      queue = result.catch(() => {});
      return result;
    };

    const controls = document.getElementById("controls");
    const last = document.getElementById("last");
    const count = document.getElementById("row-count");
    for (const operation of OPERATIONS) {
      const button = document.createElement("button");
      button.className = "small";
      button.textContent = operation.label;
      button.addEventListener("click", async () => {
        const time = await run(operation.id);
        last.textContent = `${operation.label}: ${time.toFixed(1)} ms`;
        count.textContent = rows().length;
      });
      controls.append(button);
    }

    addEventListener("message", async (event) => {
      if (event.origin !== location.origin || event.data?.type !== "run") return;
      const time = await run(event.data.operation);
      const { label } = OPERATIONS.find((operation) => operation.id === event.data.operation);
      last.textContent = `${label}: ${time.toFixed(1)} ms`;
      count.textContent = rows().length;
      event.source.postMessage({ type: "result", operation: event.data.operation, time }, location.origin);
    });
    if (parent !== window) parent.postMessage({ type: "ready", ...library }, location.origin);
  }

  return { OPERATIONS, start };
})();
Show the Signy page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Signy rows - Signy comparison benchmark</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <link rel="stylesheet" href="/css/bench.css">
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
    <script src="/js/bench.js"></script>
  </head>
  <body>
    <main class="container">
      <h1>Signy <span id="version">1.x</span></h1>
      <div class="cluster" id="controls"></div>
      <p id="last">Press an operation to time it once.</p>
      <p><span id="row-count">0</span> rows in the table.</p>
      <div id="app"></div>
    </main>

    <script>
      const { Component, Signal, application, html, For, batch, flushAll, createSelector } = Signy;

      const rows = new Signal([]);
      const selected = new Signal(null);

      // Each label is its own signal, so updating a row touches that row only
      const toRow = ({ id, label }) => ({ id, label: new Signal(label) });

      class Rows extends Component {
        isSelected = createSelector(() => selected.value);

        render() {
          return html`
            <table class="rows">
              <tbody>
                ${For({
                  each: () => rows.value,
                  keyBy: "id",
                  children: (row) => html`
                    <tr class=${() => (this.isSelected(row.id).value ? "selected" : "")}>
                      <td>${row.id}</td>
                      <td><a href="#" onClick=${(e) => (e.preventDefault(), (selected.value = row.id))}>${() => row.label.value}</a></td>
                      <td><button class="small outlined" aria-label=${`Remove row ${row.id}`} onClick=${() => remove(row.id)}>×</button></td>
                    </tr>
                  `,
                })}
              </tbody>
            </table>
          `;
        }
      }

      const remove = (id) => rows.set((list) => list.filter((row) => row.id !== id));

      application(Rows).mount("#app");

      // flushAll() applies the DOM updates now instead of in the next microtask
      const now = (change) => (change(), flushAll());

      Bench.start(
        {
          set: (data) => now(() => rows.set(data.map(toRow))),
          append: (data) => now(() => rows.set((list) => [...list, ...data.map(toRow)])),
          update: () =>
            now(() =>
              batch(() => {
                const list = rows.peek();
                for (let i = 0; i < list.length; i += 10) list[i].label.set((label) => label + " !!!");
              }),
            ),
          select: (id) => now(() => (selected.value = id)),
          swap: () =>
            now(() =>
              rows.set((list) => {
                const next = list.slice();
                [next[1], next[998]] = [next[998], next[1]];
                return next;
              }),
            ),
          remove: (id) => now(() => remove(id)),
          clear: () => now(() => rows.set([])),
        },
        { name: "Signy", version: document.getElementById("version").textContent },
      );
    </script>
  </body>
</html>
Show the React page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>React rows - Signy comparison benchmark</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <link rel="stylesheet" href="/css/bench.css">
    <script src="/vendor/react.js"></script>
    <script src="/js/bench.js"></script>
  </head>
  <body>
    <main class="container">
      <h1>React <span id="version">19.3.0</span></h1>
      <div class="cluster" id="controls"></div>
      <p id="last">Press an operation to time it once.</p>
      <p><span id="row-count">0</span> rows in the table.</p>
      <div id="app"></div>
    </main>

    <script>
      const { React, createRoot, flushSync, htm } = Vendor;
      // htm instead of JSX, since the page has no build step
      const html = htm.bind(React.createElement);

      // memo() keeps a select or an update from rendering all 1,000 rows
      const Row = React.memo(function Row({ row, selected, onSelect, onRemove }) {
        return html`
          <tr className=${selected ? "selected" : ""}>
            <td>${row.id}</td>
            <td><a href="#" onClick=${(e) => (e.preventDefault(), onSelect(row.id))}>${row.label}</a></td>
            <td><button className="small outlined" aria-label=${`Remove row ${row.id}`} onClick=${() => onRemove(row.id)}>×</button></td>
          </tr>
        `;
      });

      let state;

      function Rows() {
        const [rows, setRows] = React.useState([]);
        const [selected, setSelected] = React.useState(null);
        const remove = React.useCallback((id) => setRows((list) => list.filter((row) => row.id !== id)), []);
        state = { setRows, setSelected, remove };
        return html`
          <table className="rows">
            <tbody>
              ${rows.map((row) => html`
                <${Row} key=${row.id} row=${row} selected=${row.id === selected} onSelect=${setSelected} onRemove=${remove} />
              `)}
            </tbody>
          </table>
        `;
      }

      const root = createRoot(document.getElementById("app"));
      flushSync(() => root.render(html`<${Rows} />`));

      // flushSync() renders the update before it returns, instead of in a later task
      Bench.start(
        {
          set: (data) => flushSync(() => state.setRows(data)),
          append: (data) => flushSync(() => state.setRows((list) => [...list, ...data])),
          update: () =>
            flushSync(() => state.setRows((list) => list.map((row, i) => (i % 10 ? row : { ...row, label: row.label + " !!!" })))),
          select: (id) => flushSync(() => state.setSelected(id)),
          swap: () =>
            flushSync(() =>
              state.setRows((list) => {
                const next = list.slice();
                [next[1], next[998]] = [next[998], next[1]];
                return next;
              }),
            ),
          remove: (id) => flushSync(() => state.remove(id)),
          clear: () => flushSync(() => state.setRows([])),
        },
        { name: "React", version: React.version },
      );
    </script>
  </body>
</html>
Show the Preact page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Preact rows - Signy comparison benchmark</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <link rel="stylesheet" href="/css/bench.css">
    <script src="/vendor/preact.js"></script>
    <script src="/js/bench.js"></script>
  </head>
  <body>
    <main class="container">
      <h1>Preact <span id="version">11.0.1</span></h1>
      <div class="cluster" id="controls"></div>
      <p id="last">Press an operation to time it once.</p>
      <p><span id="row-count">0</span> rows in the table.</p>
      <div id="app"></div>
    </main>

    <script>
      const { h, render, options, Component, useState, useCallback, htm } = Vendor;
      // htm instead of JSX, since the page has no build step
      const html = htm.bind(h);

      // Render each update at once instead of in the next microtask
      options.debounceRendering = (renderNow) => renderNow();

      // Skips rows whose data and selection did not change, like memo() in React
      class Row extends Component {
        shouldComponentUpdate({ row, selected }) {
          return row !== this.props.row || selected !== this.props.selected;
        }

        render({ row, selected, onSelect, onRemove }) {
          return html`
            <tr class=${selected ? "selected" : ""}>
              <td>${row.id}</td>
              <td><a href="#" onClick=${(e) => (e.preventDefault(), onSelect(row.id))}>${row.label}</a></td>
              <td><button class="small outlined" aria-label=${`Remove row ${row.id}`} onClick=${() => onRemove(row.id)}>×</button></td>
            </tr>
          `;
        }
      }

      let state;

      function Rows() {
        const [rows, setRows] = useState([]);
        const [selected, setSelected] = useState(null);
        const remove = useCallback((id) => setRows((list) => list.filter((row) => row.id !== id)), []);
        state = { setRows, setSelected, remove };
        return html`
          <table class="rows">
            <tbody>
              ${rows.map((row) => html`
                <${Row} key=${row.id} row=${row} selected=${row.id === selected} onSelect=${setSelected} onRemove=${remove} />
              `)}
            </tbody>
          </table>
        `;
      }

      render(html`<${Rows} />`, document.getElementById("app"));

      Bench.start(
        {
          set: (data) => state.setRows(data),
          append: (data) => state.setRows((list) => [...list, ...data]),
          update: () => state.setRows((list) => list.map((row, i) => (i % 10 ? row : { ...row, label: row.label + " !!!" }))),
          select: (id) => state.setSelected(id),
          swap: () =>
            state.setRows((list) => {
              const next = list.slice();
              [next[1], next[998]] = [next[998], next[1]];
              return next;
            }),
          remove: (id) => state.remove(id),
          clear: () => state.setRows([]),
        },
        { name: "Preact", version: document.getElementById("version").textContent },
      );
    </script>
  </body>
</html>
Show the Vue page and its template
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Vue rows - Signy comparison benchmark</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <link rel="stylesheet" href="/css/bench.css">
    <!-- Vue with rows.template.vue compiled at build time, see scripts/vendor.mjs -->
    <script src="/vendor/vue.js"></script>
    <script src="/js/bench.js"></script>
  </head>
  <body>
    <main class="container">
      <h1>Vue <span id="version">3.5.43</span></h1>
      <div class="cluster" id="controls"></div>
      <p id="last">Press an operation to time it once.</p>
      <p><span id="row-count">0</span> rows in the table.</p>
      <div id="app"></div>
    </main>

    <script>
      const { createApp, shallowRef, nextTick, render, version } = Vendor;

      // shallowRef with new arrays and row objects, like Vue's entry in js-framework-benchmark
      const rows = shallowRef([]);
      const selected = shallowRef(null);
      const remove = (id) => (rows.value = rows.value.filter((row) => row.id !== id));

      createApp({ setup: () => ({ rows, selected, remove }), render }).mount("#app");

      // Vue patches the DOM in its next tick
      const now = (change) => (change(), nextTick());

      Bench.start(
        {
          set: (data) => now(() => (rows.value = data)),
          append: (data) => now(() => (rows.value = [...rows.value, ...data])),
          update: () => now(() => (rows.value = rows.value.map((row, i) => (i % 10 ? row : { ...row, label: row.label + " !!!" })))),
          select: (id) => now(() => (selected.value = id)),
          swap: () =>
            now(() => {
              const next = rows.value.slice();
              [next[1], next[998]] = [next[998], next[1]];
              rows.value = next;
            }),
          remove: (id) => now(() => remove(id)),
          clear: () => now(() => (rows.value = [])),
        },
        { name: "Vue", version },
      );
    </script>
  </body>
</html>
<table class="rows">
  <tbody>
    <tr v-for="row in rows" :key="row.id" :class="{ selected: row.id === selected }">
      <td>{{ row.id }}</td>
      <td><a href="#" @click.prevent="selected = row.id">{{ row.label }}</a></td>
      <td><button class="small outlined" :aria-label="`Remove row ${row.id}`" @click="remove(row.id)">×</button></td>
    </tr>
  </tbody>
</table>
Show the Alpine.js page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Alpine.js rows - Signy comparison benchmark</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <link rel="stylesheet" href="/css/bench.css">
    <!-- The CSP build of Alpine.js, which evaluates expressions without eval -->
    <script src="/vendor/alpine.js"></script>
    <script src="/js/bench.js"></script>
  </head>
  <body>
    <main class="container">
      <h1>Alpine.js <span id="version">3.17.4</span></h1>
      <div class="cluster" id="controls"></div>
      <p id="last">Press an operation to time it once.</p>
      <p><span id="row-count">0</span> rows in the table.</p>
      <div id="app" x-data="rows">
        <table class="rows">
          <tbody>
            <template x-for="row in rows" :key="row.id">
              <tr :class="row.id === selected ? 'selected' : ''">
                <td x-text="row.id"></td>
                <td><a href="#" @click.prevent="selected = row.id" x-text="row.label"></a></td>
                <td><button class="small outlined" :aria-label="'Remove row ' + row.id" @click="remove(row.id)">×</button></td>
              </tr>
            </template>
          </tbody>
        </table>
      </div>
    </main>

    <script>
      const { Alpine } = Vendor;

      let state;
      Alpine.data("rows", () => ({
        rows: [],
        selected: null,
        init() {
          state = this;
        },
        remove(id) {
          this.rows = this.rows.filter((row) => row.id !== id);
        },
      }));
      Alpine.start();

      // Alpine updates the DOM in its next tick
      const now = (change) => (change(), Alpine.nextTick());

      Bench.start(
        {
          set: (data) => now(() => (state.rows = data)),
          append: (data) => now(() => state.rows.push(...data)),
          update: () =>
            now(() => {
              for (let i = 0; i < state.rows.length; i += 10) state.rows[i].label += " !!!";
            }),
          select: (id) => now(() => (state.selected = id)),
          swap: () =>
            now(() => {
              const rows = state.rows;
              [rows[1], rows[998]] = [rows[998], rows[1]];
            }),
          remove: (id) => now(() => state.remove(id)),
          clear: () => now(() => (state.rows = [])),
        },
        { name: "Alpine.js", version: Alpine.version },
      );
    </script>
  </body>
</html>