Folks often hear that template-only components are faster than class-based components. But is it true?

Here is the setup,

  • the js-framework-benchmark app.

    • this benchmark focuses on testing the rendering of large tables, measuring CPU, Memory, etc while also measuring time to first render as well as time to update across certain common behaviors of these large tables. Normally, Large tables aren't something you want in a real app -- almost always you want pagination, and/or virtualized content. However, this is a decent stress test for "large amount of data rendered at once".
  • two copies of the code with the "row" content extracted to its own component.

    • the template-only version of the component looks like this
       const Row =
         <template>
           <tr class={{if @isSelected 'danger'}}>
             <td class='col-md-1'>{{@item.id}}</td>
             <td class='col-md-4'><a {{on 'click' @select}}>{{@item.label}}</a></td>
             <td class='col-md-1'><a {{on 'click' @remove}}><span
                   class='glyphicon glyphicon-remove'
                   aria-hidden='true'
                 ></span></a></td>
             <td class='col-md-6'></td>
           </tr>
         </template>;
    
    • the class-version of the component looks like this:
       class Row extends Component {
         <template>
           <tr class={{if @isSelected 'danger'}}>
             <td class='col-md-1'>{{@item.id}}</td>
             <td class='col-md-4'><a {{on 'click' @select}}>{{@item.label}}</a></td>
             <td class='col-md-1'><a {{on 'click' @remove}}><span
                   class='glyphicon glyphicon-remove'
                   aria-hidden='true'
                 ></span></a></td>
             <td class='col-md-6'></td>
           </tr>
         </template>
       }
    

The delta between the two components is this:

-  const Row =
+  class Row extends Component {
    <template>
      <tr class={{if @isSelected 'danger'}}>
        <td class='col-md-1'>{{@item.id}}</td>
        <td class='col-md-4'><a {{on 'click' @select}}>{{@item.label}}</a></td>
        <td class='col-md-1'><a {{on 'click' @remove}}><span
              class='glyphicon glyphicon-remove'
              aria-hidden='true'
            ></span></a></td>
        <td class='col-md-6'></td>
      </tr>
-    </template>;
+    </template>
+  }

Why?

When using TypeScript a question came up about the ergonomics of typing either of these components.

Here is what typing the template-only component looks like:

import { TOC } from '@ember/component/template-only';

interface Signature {
  Args: {
    item: { id: string; label: string };
    select: () => void;
    remove: () => void;
  }
}

const Row: TOC<Signature> = <template> ... </template>;

And here is what typing the class component looks like

interface Signature {
  Args: {
    item: { id: string; label: string };
    select: () => void;
    remove: () => void;
  }
}

// The Component was already imported from `@glimmer/component`
class Row extends Component<Signature> { ... };

This post isn't about the ergonomics of each of these, or which I prefer, but this is why I wanted to get actual hard data about the claims we've been hearing for years about template-only vs class-based.

But! having to make make a const for the default-export case, is extra work, as you must make a const. However, in a fully gjs/gts-using app, there does not need to be any default exports at as, as there would be no loose mode (classic, hbs files). Using gjs / gts in routes, your regular component files with one export could look like:

// app/components/my-component.gts
export const MyComponent: TOC<Signature> = <template>...</template>;

and then imported:

import Route from 'ember-route-template';
import { MyComponent } from 'my-app/components/my-component';

export default Route(
  <template>
      <MyComponent>
          {{outlet}}
      </MyComponent>
  </template>
);

But anyway, getting back to answering the question I've yet to mention,

if someone prefers empty class components (which are linted against) for usage in TypeScript, is there anything they're missing out on by not using template-only components?

The tl;dr: is yes. But actual results will vary based on your actual app and use cases.

template-only components are 7-26% faster

(for this particular benchmark and depending on which thing you're measuring).

As always, please test in your own apps. Tracerbench is a statistically sound tool that uses the built in performance.mark() apis.

This does not mean that this is always going to be the case. There has been increased activity in work happening in the VM to speed things up. For example, one such option that is being investigated is avoiding destruction callbacks on class components if the class didn't implement a destruction method (as well as removing the parent node before runnig destruction on child nodes (this would improve rendering brand new content or navigating to a new page)). Also at the time of writing, there already exists a good number of perf-related PRs, if anyone is interested: on the glimmer-vm repo

For the js-framework-benchmark, here are the actual results.

Image of the results (table below)

And the table of the results here:

Duration in milliseconds ± 95% confidence interval (Slowdown = Duration / Fastest)


Duration for...
template-only with classes
creating 1,000 rows (5 warmup runs).
87.13.3
(1.00)
97.22.3
(1.12)
updating all 1,000 rows (5 warmup runs).
107.91.5
(1.00)
125.02.0
(1.16)
updating every 10th row for 1,000 rows (3 warmup runs). 4 x CPU slowdown.
27.20.8
(1.00)
29.01.3
(1.07)
highlighting a selected row. (5 warmup runs). 4 x CPU slowdown.
26.40.5
(1.00)
31.81.1
(1.21)
swap 2 rows for table with 1,000 rows. (5 warmup runs). 4 x CPU slowdown.
34.91.5
(1.00)
37.82.4
(1.08)
removing one row. (5 warmup runs). 2 x CPU slowdown.
33.51.3
(1.00)
37.22.0
(1.11)
creating 10,000 rows. (5 warmup runs with 1k rows).
808.86.6
(1.00)
840.78.5
(1.04)
appending 1,000 to a table of 1,000 rows.
107.21.7
(1.00)
112.11.9
(1.05)
clearing a table with 1,000 rows. 4 x CPU slowdown. (5 warmup runs).
38.51.3
(1.00)
48.32.0
(1.26)
of all factors in the table 1.00 1.11
a pessimistic TTI - when the CPU and network are both definitely very idle. (no more CPU tasks over 50ms)
4,285.8
(1.00)
4,279.7
(1.00)
network transfer cost (post-compression) of all the resources loaded into the page.
573.1
(1.00)
573.1
(1.00)
of all factors in the table 1.00 1.00

Memory allocation in MBs ± 95% confidence interval

template-only with classes
Memory usage after page load.
6.8
(1.00)
6.8
(1.00)
Memory usage after adding 1,000 rows.
13.8
(1.00)
14.7
(1.07)
Memory usage after clicking update every 10th row 5 times
13.8
(1.00)
14.8
(1.07)
Memory usage after creating and clearing 1000 rows 5 times
8.1
(1.00)
8.6
(1.05)
Memory usage after adding 10,000 rows.
70.8
(1.00)
79.5
(1.12)
of all factors in the table 1.00 1.06

My system at the time of running the benchmark(s):

❯ google-chrome --version
Google Chrome 120.0.6099.109 

❯ uname -srp
Linux 6.2.0-39-generic x86_64


❯ lsb_release -csd
Ubuntu 23.04
lunar

info from screenfetch:

CPU: AMD Ryzen 9 7900X 12-Core @ 24x 4.7GHz
GPU: NVIDIA GeForce RTX 4080
RAM:  / 63438MiB