Skip to content

Signals benchmark

How fast the reactive core runs, with no DOM involved.

Each test runs the reactive core in a loop and reports operations per second. Nothing renders, so the numbers show the cost of the signals alone.

Results depend on your machine, your browser and what else is running. Compare runs on the same machine only. Close other tabs for steadier numbers.

Run it

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

What it measures

  • Create signals and computed values
  • Read and write a signal with no readers
  • Computed chain: a write that runs through 100 computed values
  • Effect fan out: one write that runs 1,000 effects
  • Batch: 1,000 writes in one batch() against 1,000 writes on their own

Code

Show the full 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>Signals benchmark - Signy</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
  </head>
  <body>
    <main class="container" id="app"></main>

    <script>
      const {
        Component, Signal, Computed, application, html, effect, batch, flushAll, createRoot,
      } = Signy;

      // Each test sets up inside a root, returns one operation to repeat, and is disposed after.
      // Nothing renders, so the numbers are the reactive core alone.
      const TESTS = [
        {
          id: "create",
          label: "Create a signal and a computed",
          setup: () => () => {
            const signal = new Signal(1);
            new Computed(() => signal.value * 2).value;
          },
        },
        {
          id: "read-write",
          label: "Write and read a signal with no readers",
          setup: () => {
            const signal = new Signal(0);
            let i = 0;
            return () => {
              signal.value = i++;
              return signal.value;
            };
          },
        },
        {
          id: "chain",
          label: "Write through a chain of 100 computed values",
          setup: () => {
            const head = new Signal(0);
            let last = head;
            for (let i = 0; i < 100; i++) {
              const previous = last;
              last = new Computed(() => previous.value + 1);
            }
            const tail = last;
            let seen = 0;
            effect(() => (seen = tail.value));
            flushAll();
            let i = 0;
            return () => {
              head.value = ++i;
              flushAll();
              return seen;
            };
          },
        },
        {
          id: "fan-out",
          label: "One write that runs 1,000 effects",
          setup: () => {
            const source = new Signal(0);
            let total = 0;
            for (let i = 0; i < 1000; i++) effect(() => (total += source.value));
            flushAll();
            let i = 0;
            return () => {
              source.value = ++i;
              flushAll();
              return total;
            };
          },
        },
        {
          id: "single-writes",
          label: "1,000 writes on their own",
          setup: () => {
            const signals = Array.from({ length: 1000 }, () => new Signal(0));
            let total = 0;
            for (const signal of signals) effect(() => (total += signal.value));
            flushAll();
            let i = 0;
            return () => {
              i++;
              for (const signal of signals) {
                signal.value = i;
                flushAll();
              }
              return total;
            };
          },
        },
        {
          id: "batched-writes",
          label: "1,000 writes in one batch()",
          setup: () => {
            const signals = Array.from({ length: 1000 }, () => new Signal(0));
            let total = 0;
            for (const signal of signals) effect(() => (total += signal.value));
            flushAll();
            let i = 0;
            return () => {
              i++;
              batch(() => {
                for (const signal of signals) signal.value = i;
              });
              flushAll();
              return total;
            };
          },
        },
      ];

      const pause = () => new Promise((resolve) => setTimeout(resolve, 0));
      const format = (n) => (n >= 1000 ? Math.round(n).toLocaleString("en") : n.toFixed(1));

      // Runs the operation in 50ms slices for about duration ms, pausing between
      // slices so the page stays responsive. Returns operations per second.
      async function measure(test, duration) {
        let operations = 0;
        let elapsed = 0;
        await createRoot(async (dispose) => {
          const operation = test.setup();
          operation();
          while (elapsed < duration) {
            const start = performance.now();
            let now = start;
            while (now - start < 50) {
              operation();
              operations++;
              now = performance.now();
            }
            elapsed += now - start;
            await pause();
          }
          dispose();
        });
        return (operations / elapsed) * 1000;
      }

      class SignalsBenchmark extends Component {
        results = new Signal({});
        running = new Signal(null);
        duration = new Signal(1000);

        async run(tests) {
          for (const test of tests) {
            this.running.value = test.id;
            const perSecond = await measure(test, this.duration.peek());
            this.results.set((results) => ({ ...results, [test.id]: perSecond }));
          }
          this.running.value = null;
        }

        render() {
          return html`
            <h1>Signals benchmark</h1>
            <p>Signy ${Signy.VERSION}. Each test repeats one operation for the time you pick and reports how many it managed per second.</p>
            <form class="cluster" onSubmit=${(e) => e.preventDefault()}>
              <label>
                Time per test
                <select onChange=${(e) => (this.duration.value = Number(e.currentTarget.value))}>
                  <option value="1000">1 second</option>
                  <option value="3000">3 seconds</option>
                </select>
              </label>
              <button class="primary" disabled=${() => this.running.value !== null}
                aria-busy=${() => String(this.running.value !== null)} onClick=${() => this.run(TESTS)}>Run all</button>
            </form>
            <table>
              <thead><tr><th scope="col">Test</th><th scope="col">Operations per second</th><th scope="col"></th></tr></thead>
              <tbody>
                ${TESTS.map((test) => html`
                  <tr data-test=${test.id}>
                    <th scope="row">${test.label}</th>
                    <td>${() => (this.running.value === test.id ? "Running" : this.results.value[test.id] ? format(this.results.value[test.id]) : "")}</td>
                    <td><button class="small outlined" disabled=${() => this.running.value !== null}
                      onClick=${() => this.run([test])}>Run</button></td>
                  </tr>
                `)}
              </tbody>
            </table>
          `;
        }
      }

      application(SignalsBenchmark).mount("#app");
    </script>
  </body>
</html>