Rows benchmark
Create, update and remove thousands of table rows.
This is the classic set of operations from the js-framework-benchmark. Each run times the operation and the browser layout that follows it.
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 1,000 rows and create 10,000 rows into an empty table
- Replace all 1,000 rows with new ones
- Update every 10th row of 1,000
- Select a row, which highlights one row and clears the last one
- Swap rows 2 and 999
- Remove a row and clear the table
- Append 1,000 rows to 1,000
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>Rows benchmark - Signy</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<style>
.rows tr.selected td {
font-weight: 700;
background: var(--i-color-subtle-border);
}
.rows td {
padding-block: 0.25rem;
}
.rows button {
margin: 0;
}
</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, Show, batch, flushAll, createSelector, createRef } = Signy;
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;
// Each label is its own signal, so updating a row touches that row only
const buildRows = (count) =>
Array.from({ length: count }, () => ({ id: nextId++, label: new Signal(`${pick(A)} ${pick(C)} ${pick(N)}`) }));
// Each operation: prepare puts the table in its starting state, untimed. run is timed.
const OPERATIONS = [
{ id: "create1k", label: "Create 1,000 rows", prepare: (b) => b.clear(), run: (b) => b.rows.set(buildRows(1000)) },
{ id: "create10k", label: "Create 10,000 rows", prepare: (b) => b.clear(), run: (b) => b.rows.set(buildRows(10000)) },
{ id: "replace", label: "Replace all 1,000 rows", prepare: (b) => b.fill(1000), run: (b) => b.rows.set(buildRows(1000)) },
{
id: "update",
label: "Update every 10th row",
prepare: (b) => b.fill(1000),
run: (b) => batch(() => {
const rows = b.rows.peek();
for (let i = 0; i < rows.length; i += 10) rows[i].label.set((label) => label + " !!!");
}),
},
{ id: "select", label: "Select a row", prepare: (b) => b.fill(1000), run: (b) => (b.selected.value = b.rows.peek()[Math.floor(Math.random() * 1000)].id) },
{
id: "swap",
label: "Swap rows 2 and 999",
prepare: (b) => b.fill(1000),
run: (b) => b.rows.set((rows) => {
const next = rows.slice();
[next[1], next[998]] = [next[998], next[1]];
return next;
}),
},
{ id: "remove", label: "Remove a row", prepare: (b) => b.fill(1000), run: (b) => b.remove(b.rows.peek()[500].id) },
{ id: "append", label: "Append 1,000 rows", prepare: (b) => b.fill(1000), run: (b) => b.rows.set((rows) => [...rows, ...buildRows(1000)]) },
{ id: "clear", label: "Clear 1,000 rows", prepare: (b) => b.fill(1000), run: (b) => b.clear() },
];
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
};
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));
class RowsBenchmark extends Component {
rows = new Signal([]);
selected = new Signal(null);
isSelected = createSelector(() => this.selected.value);
results = new Signal({});
running = new Signal(false);
table = createRef();
clear() {
this.rows.set([]);
this.selected.value = null;
}
fill(count) {
if (this.rows.peek().length !== count) this.rows.set(buildRows(count));
this.selected.value = null;
}
remove(id) {
this.rows.set((rows) => rows.filter((row) => row.id !== id));
}
// Time the operation and the layout it causes. flushAll() applies the DOM updates
// at once instead of in the next microtask, and reading offsetHeight forces layout.
async measure(operation) {
operation.prepare(this);
flushAll();
await nextFrame();
const start = performance.now();
operation.run(this);
flushAll();
this.table.current.offsetHeight;
const time = performance.now() - start;
this.results.set((results) => {
const runs = [...(results[operation.id]?.runs ?? []), time];
return { ...results, [operation.id]: { runs } };
});
await nextFrame();
return time;
}
async run(operations, times) {
this.running.value = true;
try {
for (const operation of operations) {
for (let i = 0; i < times; i++) await this.measure(operation);
}
} finally {
this.running.value = false;
}
}
cell(id, read) {
return () => {
const runs = this.results.value[id]?.runs;
return runs ? `${read(runs).toFixed(1)} ms` : "";
};
}
render() {
return html`
<h1>Rows benchmark</h1>
<p>Signy <span id="signy-version">${Signy.VERSION}</span>. Press an operation to time it once, or run them all 5 times each.</p>
<div class="cluster">
${OPERATIONS.map((operation) => html`
<button class="small" disabled=${() => this.running.value} onClick=${() => this.run([operation], 1)}>${operation.label}</button>
`)}
<button class="small primary" disabled=${() => this.running.value} aria-busy=${() => String(this.running.value)}
onClick=${() => this.run(OPERATIONS, 5)}>Run all, 5 times each</button>
<button class="small secondary outlined" disabled=${() => this.running.value} onClick=${() => (this.results.value = {})}>Reset results</button>
</div>
<table class="results">
<thead><tr><th scope="col">Operation</th><th scope="col">Last</th><th scope="col">Median</th><th scope="col">Best</th><th scope="col">Runs</th></tr></thead>
<tbody>
${OPERATIONS.map((operation) => html`
<tr data-operation=${operation.id}>
<th scope="row">${operation.label}</th>
<td>${this.cell(operation.id, (runs) => runs.at(-1))}</td>
<td>${this.cell(operation.id, median)}</td>
<td>${this.cell(operation.id, (runs) => Math.min(...runs))}</td>
<td>${() => this.results.value[operation.id]?.runs.length ?? 0}</td>
</tr>
`)}
</tbody>
</table>
<p><span id="row-count">${() => this.rows.value.length}</span> rows in the table.</p>
<table class="rows" ref=${this.table}>
<tbody>
${For({
each: () => this.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(), (this.selected.value = row.id))}>${() => row.label.value}</a></td>
<td><button class="small outlined" aria-label=${`Remove row ${row.id}`} onClick=${() => this.remove(row.id)}>×</button></td>
</tr>
`,
})}
</tbody>
</table>
`;
}
}
application(RowsBenchmark).mount("#app");
</script>
</body>
</html>