Skip to content

Stress benchmark

500 cells ticking every frame, and thousands of components built and thrown away.

The grid updates a random share of its cells on every animation frame and shows the frames per second it keeps. The second test mounts and unmounts components in a loop, so you can watch memory in the browser tools stay flat.

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

  • Market grid: 500 cells, each with its own signal, updated on requestAnimationFrame
  • Frames per second while the grid runs
  • Mount and unmount 1,000 components per cycle, with the time per cycle

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>Stress benchmark - Signy</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <style>
      .market {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr));
        gap: 2px;
        font-family: var(--i-font-mono);
        font-size: 0.75rem;
      }

      .market span {
        padding: 0.2rem;
        text-align: end;
        border-radius: 3px;
        background: var(--i-color-subtle-border);
      }

      .market .up {
        color: #fff;
        background: #047857;
      }

      .market .down {
        color: #fff;
        background: #b91c1c;
      }
    </style>
    <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, application, html, For, batch } = Signy;

      const CELLS = 500;
      const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));

      // One signal per cell: a tick touches only the cells that changed
      const createCell = (id) => ({ id, price: new Signal(100), trend: new Signal("") });

      class Market extends Component {
        cells = Array.from({ length: CELLS }, (_, i) => createCell(i));
        share = new Signal(0.1);
        running = new Signal(false);
        fps = new Signal(0);
        updates = new Signal(0);

        tick() {
          const count = Math.round(CELLS * this.share.peek());
          batch(() => {
            for (let i = 0; i < count; i++) {
              const cell = this.cells[Math.floor(Math.random() * CELLS)];
              const change = (Math.random() - 0.5) * 2;
              cell.price.set((price) => Math.max(1, price + change));
              cell.trend.value = change >= 0 ? "up" : "down";
            }
          });
          return count;
        }

        async start() {
          this.running.value = true;
          let frames = 0;
          let updated = 0;
          let since = performance.now();
          while (this.running.peek()) {
            updated += this.tick();
            frames++;
            const now = await nextFrame();
            if (now - since >= 500) {
              this.fps.value = Math.round((frames * 1000) / (now - since));
              this.updates.value = Math.round((updated * 1000) / (now - since));
              frames = 0;
              updated = 0;
              since = now;
            }
          }
        }

        setup() {
          this.onUnmount(() => (this.running.value = false));
        }

        render() {
          return html`
            <section>
              <h2>Market grid</h2>
              <form class="cluster" onSubmit=${(e) => e.preventDefault()}>
                <label>
                  Cells per frame
                  <select onChange=${(e) => (this.share.value = Number(e.currentTarget.value))}>
                    <option value="0.01">1%</option>
                    <option value="0.1" selected>10%</option>
                    <option value="0.5">50%</option>
                    <option value="1">100%</option>
                  </select>
                </label>
                <button class="primary" onClick=${() => (this.running.peek() ? (this.running.value = false) : this.start())}>
                  ${() => (this.running.value ? "Pause" : "Start")}
                </button>
                <p><strong id="fps">${() => this.fps.value}</strong> frames per second, <strong>${() => this.updates.value.toLocaleString("en")}</strong> cell updates per second</p>
              </form>
              <div class="market" role="img" aria-label="500 prices that change every frame">
                ${For({
                  each: this.cells,
                  keyBy: "id",
                  children: (cell) => html`<span class=${() => cell.trend.value}>${() => cell.price.value.toFixed(2)}</span>`,
                })}
              </div>
            </section>
          `;
        }
      }

      // A component with 1,000 bound rows, built and thrown away in a loop
      class Throwaway extends Component {
        items = Array.from({ length: 1000 }, (_, i) => new Signal(i));

        render() {
          return html`<ul>${For({ each: this.items, children: (item) => html`<li>${() => item.value}</li>` })}</ul>`;
        }
      }

      class Cycles extends Component {
        results = new Signal(null);
        running = new Signal(false);

        async run(cycles) {
          this.running.value = true;
          const host = document.createElement("div");
          host.hidden = true;
          document.body.append(host);
          const heap = () => performance.memory?.usedJSHeapSize;
          const before = heap();
          const times = [];
          for (let i = 0; i < cycles; i++) {
            const start = performance.now();
            const target = document.createElement("div");
            host.append(target);
            const app = application(Throwaway).mount(target);
            app.unmount();
            target.remove();
            times.push(performance.now() - start);
            if (i % 5 === 4) await nextFrame();
          }
          host.remove();
          const after = heap();
          this.results.value = {
            cycles,
            average: times.reduce((a, b) => a + b, 0) / cycles,
            best: Math.min(...times),
            heap: before && after ? (after - before) / 1048576 : null,
          };
          this.running.value = false;
        }

        render() {
          return html`
            <section>
              <h2>Mount and unmount</h2>
              <p>Each cycle mounts a component with 1,000 bound rows and unmounts it.</p>
              <div class="cluster">
                <button class="small" disabled=${() => this.running.value} onClick=${() => this.run(1)}>1 cycle</button>
                <button class="small primary" disabled=${() => this.running.value} aria-busy=${() => String(this.running.value)}
                  onClick=${() => this.run(50)}>50 cycles</button>
              </div>
              <p id="cycles-result">${() => {
                const r = this.results.value;
                if (!r) return "No run yet.";
                const heap = r.heap === null ? "" : ` Heap change after the run: ${r.heap.toFixed(1)} MB.`;
                return `${r.cycles} cycles, ${r.average.toFixed(1)} ms on average, ${r.best.toFixed(1)} ms at best.${heap}`;
              }}</p>
              <p><small>For memory, open the browser's performance tools and take a heap snapshot before and after a run.</small></p>
            </section>
          `;
        }
      }

      class Stress extends Component {
        render() {
          return html`
            <div>
              <h1>Stress benchmark</h1>
              <p>Signy ${Signy.VERSION}.</p>
              <${Market} />
              <${Cycles} />
            </div>
          `;
        }
      }

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