From d31e5f3df7fc81ab02e18a2223f400921a8b682a Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 17 Jul 2026 09:45:27 +0200 Subject: [PATCH] feat(website): restructure the benchmark page into clearer sections Reorganise /benchmarks under Performance results, Code comparison, Inspect each workload, and Methodology and reproduction, without changing any measured data. - New opening copy and a workload-coverage line; the first stat card reads "fastest framework above JDBC". - Reword the network-overhead sentence and give the JDBC round-trip figure as a 155-172 us range across sessions; qualify the LOC comparison. - Add a Scope and limitations box and a closing call to action. - Each workload shows Storm by default, the other six libraries a selector away and the SQL a keyboard-operable Show SQL toggle away. --- .../src/components/tutorial/tutorialTheme.js | 9 +- website/src/pages/benchmarks.js | 248 ++++++++++++------ 2 files changed, 168 insertions(+), 89 deletions(-) diff --git a/website/src/components/tutorial/tutorialTheme.js b/website/src/components/tutorial/tutorialTheme.js index a34e564f8..e7b2f0bb5 100644 --- a/website/src/components/tutorial/tutorialTheme.js +++ b/website/src/components/tutorial/tutorialTheme.js @@ -102,7 +102,7 @@ export function editor({file, tag, code, sql, copy, variants}) { const hasVariantSql = variants && variants.some((v) => v.sql); const hasSql = Boolean(sql) || hasVariantSql; const sqlBtn = hasSql - ? `Show SQL` + ? `` : ''; const copyText = copy === true ? toPlain(variants ? variants[selectedIdx].code : code) : copy; @@ -204,10 +204,15 @@ export function wireSqlToggles() { const onClick = () => { const on = ed.classList.toggle('show-sql'); btn.classList.toggle('on', on); + btn.setAttribute('aria-expanded', on ? 'true' : 'false'); if (txt) txt.textContent = on ? 'Hide SQL' : 'Show SQL'; }; + const onKey = (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } + }; btn.addEventListener('click', onClick); - cleanups.push(() => btn.removeEventListener('click', onClick)); + btn.addEventListener('keydown', onKey); + cleanups.push(() => { btn.removeEventListener('click', onClick); btn.removeEventListener('keydown', onKey); }); } const copyBtn = ed.querySelector('.copybtn'); if (copyBtn) cleanups.push(attachCopy(copyBtn)); diff --git a/website/src/pages/benchmarks.js b/website/src/pages/benchmarks.js index d99f96ae0..390b050e0 100644 --- a/website/src/pages/benchmarks.js +++ b/website/src/pages/benchmarks.js @@ -10,7 +10,7 @@ const DESC = 'Reproducible JMH benchmarks of Storm against JDBC, Hibernate, jOOQ // Results from the reproducible suite: one tuned PostgreSQL 17 container over TCP, JMH, 2 forks, // 5x3s measured iterations, single thread. Values are mean us/op with the reported error. -// Rows are same-session comparisons; the raw JDBC single round trip measured ~155 us. +// Rows are same-session comparisons; the raw JDBC single round trip measured ~155-172 us across sessions. const LIBS = { jdbc: {name: 'JDBC', cls: 'jdbc'}, storm: {name: 'Storm', cls: 'storm'}, @@ -123,7 +123,7 @@ function modelLocHtml() { `).join(''); return `

Entities LOC

-

The entity or table definitions by the same counting rule, result DTOs and Storm's optimized write shape excluded. This is the code you write first and read forever.

+

The entity or table definitions by the same counting rule, result DTOs and Storm's optimized write shape excluded. This is the model code developers write and maintain.

${rows}

JDBC and jOOQ have no hand-written model: JDBC maps rows by hand and jOOQ generates its table classes, so their cost appears in the suite total above instead.

`; @@ -923,8 +923,31 @@ const BM_CSS = ` .bm-loc .bm-note{margin-top:16px} .bm-loc .bm-name{white-space:normal} .bm-tag{display:inline-block;font-style:normal;font-size:9.5px;line-height:1;padding:3px 7px;margin-left:6px;border:1px solid var(--border);border-radius:999px;color:var(--muted);white-space:nowrap;vertical-align:1px} + .bm-limits{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;background:var(--panel-2);padding:16px 20px;margin:8px 0 24px} + .storm-tut .art .bm-limits h3{margin:0 0 6px;font-size:15px} + .bm-limits p{margin:0;color:var(--muted);font-size:13.5px;line-height:1.6} + .bm-cta{border:1px solid var(--border);border-radius:16px;background:var(--panel);padding:30px 32px;margin:46px 0 10px} + .storm-tut .art .bm-cta h2{margin:0 0 8px} + .bm-cta p{margin:0;color:var(--muted);max-width:640px} + .bm-cta .getit{margin-top:20px} `; +// One workload: Storm's implementation shows by default; the other six are a +// selector away and the SQL a toggle away, both living in the editor's own +// title bar, so a workload adds a single editor rather than extra panels or +// toggle links. Per-library SQL divergences ride on their variant. +function codeBlock({title, desc, file, storm, others, sql, sqlExtras}) { + const divergent = new Map(sqlExtras || []); + const variants = [ + {label: 'Storm', tag: 'Kotlin', code: storm, selected: true}, + ...others.map(({selected, ...v}) => (divergent.has(v.label) ? {...v, sql: divergent.get(v.label)} : v)), + ]; + return ` +

${title}

+ ${desc ? `

${desc}

` : ''} + ${editor({file, tag: 'Kotlin', sql, variants})}`; +} + function buildBody() { const charts = WORKLOADS.map(chartHtml).join('\n'); return ` @@ -932,19 +955,21 @@ ${navHtml('benchmarks')}

Concise by design.
Fast by measurement.

-

Storm set out to be the most enjoyable ORM to work with, entities as plain records, queries that read like the SQL they produce. This page shows that the same design keeps the hot path lean, measured against six alternatives on identical workloads, with the code behind every number.

+

Storm was designed around plain entities and queries that closely resemble the SQL they produce. These benchmarks show that the same design also keeps runtime overhead low.

+

Seven implementations run against the same PostgreSQL database, using the same schema, data and transaction boundaries. Every result includes a real TCP round trip, and the code behind every number is available to inspect and reproduce.

PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-16

-
5 of 8workloads where Storm is the fastest ORM.
-
5.6%is the most Storm trails the fastest ORM on any workload.
-
72% lessentity code than JPA: the five-table model is 29 lines in Storm, 105 as JPA entities.
+
5 of 8workloads where Storm is the fastest framework above JDBC.
+
5.6%is the most Storm trails the fastest framework on any workload.
+
72% lessentity code than JPA: 29 lines in Storm, 105 as JPA entities.
-

At a glance

+

Performance results

+

The workloads cover common data-access paths: point reads, joined entity hydration, projections, batch writes, change-aware updates and one-to-many object graphs.

Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the baseline.

${matrixHtml()} -

On most workloads Storm is the fastest framework; where it isn't, another library edges it narrowly: Hibernate on the single-row lookup and the read-modify-update, jOOQ on the batch insert, where its single multi-row statement beats the batched single-row insert the others send. Even then, Storm stays within about six percent of the fastest framework. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.

+

On most workloads Storm is the fastest framework; where it isn't, another library edges it narrowly: Hibernate on the single-row lookup and the read-modify-update, jOOQ on the batch insert, where its single multi-row statement beats the batched single-row insert the others send. Even then, Storm stays within about six percent of the fastest framework. Because every result includes a real network round trip, framework overhead represents only part of the reported latency. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.

Per-workload charts: the same numbers with their reported error @@ -954,97 +979,137 @@ ${charts}
-

The code being measured

-

Numbers without code invite tuned-benchmark suspicion, so here is what each workload runs in every library, trimmed of harness plumbing. Pick a library from the selector; toggle Show SQL to see the exact statement it puts on the wire. The full sources for all seven implementations are in the benchmark repository.

+

Code comparison

+

Numbers without code invite tuned-benchmark suspicion, so the counts below, and the workloads that follow, show exactly what each library runs, trimmed of harness plumbing. The full sources for all seven implementations are in the benchmark repository.

${modelLocHtml()} ${locHtml()} +

LOC is presented as an illustration of these benchmark implementations, rather than as a universal measure of framework complexity.

-

The model

-

Storm's model is plain data classes. Nullability, keys and relations live in the type: @FK val owner: Owner hydrates through a join, Ref<PetType> stays a lazy reference until asked. No proxies, no session lifecycle, nothing to configure. Switch the selector to compare it against the JPA entities, table objects and interfaces the other libraries declare for the same five tables.

- ${editor({variants: [ - {label: 'Storm', file: 'Entities.kt', tag: 'Kotlin', code: CODE_ENTITIES, selected: true}, - {label: 'JDBC', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JDBC}, - {label: 'Hibernate', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_HIBERNATE}, - {label: 'jOOQ', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JOOQ}, - {label: 'Exposed', file: 'Tables.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED}, - {label: 'Exposed DAO', file: 'Entities.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED_DAO}, - {label: 'Jimmer', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_JIMMER}, - ]})} +

Inspect each workload

+

Each workload shows Storm's implementation. Pick another library from the selector to compare it, or toggle Show SQL for the exact statement on the wire.

-

Primary key lookup

- ${editor({file: 'singleRowById', sql: SQL_SINGLE, variants: [ - {label: 'Storm', tag: 'Kotlin', code: CODE_SINGLE, selected: true}, - {label: 'JDBC', tag: 'Java', code: CODE_SINGLE_JDBC}, - {label: 'Hibernate', tag: 'Java', code: CODE_SINGLE_HIBERNATE}, - {label: 'jOOQ', tag: 'Java', code: CODE_SINGLE_JOOQ}, - {label: 'Exposed', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED}, - {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED_DAO}, - {label: 'Jimmer', tag: 'Java', code: CODE_SINGLE_JIMMER}, - ]})} + ${codeBlock({ + title: 'The model', + file: 'Entities.kt', + desc: `Storm's model is plain data classes. Nullability, keys and relations live in the type: @FK val owner: Owner hydrates through a join, Ref<PetType> stays a lazy reference until asked. No proxies, no session lifecycle, nothing to configure. Compare it against the JPA entities, table objects and interfaces the other libraries declare for the same five tables.`, + storm: CODE_ENTITIES, + others: [ + {label: 'JDBC', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JDBC, selected: true}, + {label: 'Hibernate', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_HIBERNATE}, + {label: 'jOOQ', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JOOQ}, + {label: 'Exposed', file: 'Tables.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED}, + {label: 'Exposed DAO', file: 'Entities.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED_DAO}, + {label: 'Jimmer', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_JIMMER}, + ], + })} -

Three-table join

-

No fetch joins to spell out and no N+1 to dodge: the entity graph declares what a Pet is, so selecting pets hydrates owner and city from one query.

- ${editor({file: 'joinWithMapping', sql: SQL_JOIN, variants: [ - {label: 'Storm', tag: 'Kotlin', code: CODE_JOIN, selected: true}, - {label: 'JDBC', tag: 'Java', code: CODE_JOIN_JDBC}, - {label: 'Hibernate', tag: 'Java', code: CODE_JOIN_HIBERNATE}, - {label: 'jOOQ', tag: 'Java', code: CODE_JOIN_JOOQ}, - {label: 'Exposed', tag: 'Kotlin', code: CODE_JOIN_EXPOSED}, - {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_JOIN_EXPOSED_DAO, sql: SQL_JOIN_EXPOSED_DAO}, - {label: 'Jimmer', tag: 'Java', code: CODE_JOIN_JIMMER, sql: SQL_JOIN_JIMMER}, - ]})} + ${codeBlock({ + title: 'Primary key lookup', + file: 'singleRowById', + storm: CODE_SINGLE, + sql: SQL_SINGLE, + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_SINGLE_JDBC, selected: true}, + {label: 'Hibernate', tag: 'Java', code: CODE_SINGLE_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_SINGLE_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_SINGLE_JIMMER}, + ], + })} -

Projection

-

A template picks three columns across the graph; the metamodel keeps every path compile-checked.

- ${editor({file: 'projection', sql: SQL_PROJECTION, variants: [ - {label: 'Storm', tag: 'Kotlin', code: CODE_PROJECTION, selected: true}, - {label: 'JDBC', tag: 'Java', code: CODE_PROJECTION_JDBC}, - {label: 'Hibernate', tag: 'Java', code: CODE_PROJECTION_HIBERNATE}, - {label: 'jOOQ', tag: 'Java', code: CODE_PROJECTION_JOOQ}, - {label: 'Exposed', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED}, - {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED_DAO}, - {label: 'Jimmer', tag: 'Java', code: CODE_PROJECTION_JIMMER}, - ]})} + ${codeBlock({ + title: 'Three-table join', + file: 'joinWithMapping', + desc: 'No fetch joins to spell out and no N+1 to dodge: the entity graph declares what a Pet is, so selecting pets hydrates owner and city from one query.', + storm: CODE_JOIN, + sql: SQL_JOIN, + sqlExtras: [['Exposed DAO', SQL_JOIN_EXPOSED_DAO], ['Jimmer', SQL_JOIN_JIMMER]], + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_JOIN_JDBC, selected: true}, + {label: 'Hibernate', tag: 'Java', code: CODE_JOIN_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_JOIN_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_JOIN_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_JOIN_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_JOIN_JIMMER}, + ], + })} -

Batch insert

- ${editor({file: 'batchInsert', variants: [ - {label: 'Storm', tag: 'Kotlin', code: CODE_BATCH, sql: SQL_BATCH, selected: true}, - {label: 'JDBC', tag: 'Java', code: CODE_BATCH_JDBC, sql: SQL_BATCH}, - {label: 'Hibernate', tag: 'Java', code: CODE_BATCH_HIBERNATE, sql: SQL_BATCH_HIBERNATE}, - {label: 'jOOQ', tag: 'Java', code: CODE_BATCH_JOOQ, sql: SQL_BATCH_JOOQ}, - {label: 'Exposed', tag: 'Kotlin', code: CODE_BATCH_EXPOSED, sql: SQL_BATCH}, - {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_BATCH_EXPOSED_DAO, sql: SQL_BATCH}, - {label: 'Jimmer', tag: 'Java', code: CODE_BATCH_JIMMER, sql: SQL_BATCH}, - ]})} + ${codeBlock({ + title: 'Projection', + file: 'projection', + desc: 'A template picks three columns across the graph; the metamodel keeps every path compile-checked.', + storm: CODE_PROJECTION, + sql: SQL_PROJECTION, + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_PROJECTION_JDBC, selected: true}, + {label: 'Hibernate', tag: 'Java', code: CODE_PROJECTION_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_PROJECTION_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_PROJECTION_JIMMER}, + ], + })} -

Read, modify, update

-

Storm's regular Owner is an aggregate: reading one loads its city through a join. Every other library declares that association lazy and reads the owner row alone, so to keep the read side of this workload identical for everyone, the benchmark uses a dedicated shape of the same table where city stays a lazy Ref. That shape is one record; declaring it is Storm's equivalent of the FetchType.LAZY the others put on their entities, and its ten lines are counted against Storm in the Queries LOC table above. On the write side, @DynamicUpdate(FIELD) writes only the column that changed. Entities are immutable; an update is a copy.

- ${editor({file: 'updateById', sql: SQL_UPDATE, variants: [ - {label: 'Storm', tag: 'Kotlin', code: CODE_UPDATE, selected: true}, - {label: 'JDBC', tag: 'Java', code: CODE_UPDATE_JDBC}, - {label: 'Hibernate', tag: 'Java', code: CODE_UPDATE_HIBERNATE}, - {label: 'jOOQ', tag: 'Java', code: CODE_UPDATE_JOOQ}, - {label: 'Exposed', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED}, - {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED_DAO}, - {label: 'Jimmer', tag: 'Java', code: CODE_UPDATE_JIMMER}, - ]})} + ${codeBlock({ + title: 'Batch insert', + file: 'batchInsert', + storm: CODE_BATCH, + sql: SQL_BATCH, + sqlExtras: [['Hibernate', SQL_BATCH_HIBERNATE], ['jOOQ', SQL_BATCH_JOOQ]], + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_BATCH_JDBC, selected: true}, + {label: 'Hibernate', tag: 'Java', code: CODE_BATCH_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_BATCH_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_BATCH_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_BATCH_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_BATCH_JIMMER}, + ], + })} -

Object graph

-

One query, grouped during hydration. Repeated owners deduplicate to the same instance, so grouping is an identity operation, not a hash of every field. Switch the variant to see the code the other libraries needed for the same result.

- ${editor({file: 'objectGraph', sql: SQL_GRAPH, variants: [ - {label: 'Storm', tag: 'Kotlin', code: CODE_GRAPH_STORM, selected: true}, - {label: 'JDBC', tag: 'Java', code: CODE_GRAPH_JDBC}, - {label: 'Hibernate', tag: 'Java', code: CODE_GRAPH_HIBERNATE, sql: SQL_GRAPH_HIBERNATE}, - {label: 'jOOQ', tag: 'Java', code: CODE_GRAPH_JOOQ, sql: SQL_GRAPH_JOOQ}, - {label: 'Exposed', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED}, - {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED_DAO, sql: SQL_GRAPH_EXPOSED_DAO}, - {label: 'Jimmer', tag: 'Java', code: CODE_GRAPH_JIMMER, sql: SQL_GRAPH_JIMMER}, - ]})} + ${codeBlock({ + title: 'Read, modify, update', + file: 'updateById', + desc: `Storm's regular Owner is an aggregate: reading one loads its city through a join. Every other library declares that association lazy and reads the owner row alone, so to keep the read side of this workload identical for everyone, the benchmark uses a dedicated shape of the same table where city stays a lazy Ref. That shape is one record; declaring it is Storm's equivalent of the FetchType.LAZY the others put on their entities, and its ten lines are counted against Storm in the Queries LOC table above. On the write side, @DynamicUpdate(FIELD) writes only the column that changed. Entities are immutable; an update is a copy.`, + storm: CODE_UPDATE, + sql: SQL_UPDATE, + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_UPDATE_JDBC, selected: true}, + {label: 'Hibernate', tag: 'Java', code: CODE_UPDATE_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_UPDATE_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_UPDATE_JIMMER}, + ], + })} + + ${codeBlock({ + title: 'Object graph', + file: 'objectGraph', + desc: 'One query, grouped during hydration. Repeated owners deduplicate to the same instance, so grouping is an identity operation, not a hash of every field.', + storm: CODE_GRAPH_STORM, + sql: SQL_GRAPH, + sqlExtras: [['Hibernate', SQL_GRAPH_HIBERNATE], ['jOOQ', SQL_GRAPH_JOOQ], ['Exposed DAO', SQL_GRAPH_EXPOSED_DAO], ['Jimmer', SQL_GRAPH_JIMMER]], + others: [ + {label: 'JDBC', tag: 'Java', code: CODE_GRAPH_JDBC, selected: true}, + {label: 'Hibernate', tag: 'Java', code: CODE_GRAPH_HIBERNATE}, + {label: 'jOOQ', tag: 'Java', code: CODE_GRAPH_JOOQ}, + {label: 'Exposed', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED}, + {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED_DAO}, + {label: 'Jimmer', tag: 'Java', code: CODE_GRAPH_JIMMER}, + ], + })} + +

Methodology and reproduction

+ +
+

Scope and limitations

+

These benchmarks measure single-threaded operation latency on PostgreSQL. They do not measure application throughput, connection-pool contention, startup time, memory use, native-image performance or behaviour on other databases.

+
-

Method and fairness

The suite is built to be argued with. Everything below is enforced in code, not prose.