{{#let}} vs @cached Performance

Can anyone speak to the performance between using the {{#let}} helper to cache a component’s property versus using the @cached decorator on the property.

My hypothesis is the {{#let}} helper will have better performance because it doesn’t have to check if any tracked properties changed.

Below are code examples of what I’m talking about.

{{#let}} Helper

component

  get contact() {
    return this.allContacts.byId(this.args.contactId);
  }

template

{{#let this.contact as |contact|}}
  <LinkTo @route='contacts.contact' @model={{contact.id}}>{{contact.name}}</LinkTo>
  <div>
    {{contact.address1}}
  </div>
  <div>
    {{contact.city}}, {{contact.state}} {{contact.zip}}
  </div>
{{/let}}

   

@cached decorator

component

  @cached
  get contact() {
    return this.allContacts.byId(this.args.contactId);
  }

template

  <LinkTo @route='contacts.contact' @model={{this.contact.id}}>{{this.contact.name}}</LinkTo>
  <div>
    {{this.contact.address1}}
  </div>
  <div>
    {{this.contact.city}}, {{this.contact.state}} {{this.contact.zip}}
  </div>
2 Likes

Yeah, I think #let should be very slightly faster, and in a way that scales linearly with the number of uses in the template, because it’ll only need to check for tracked property value changes once—at the site of usage—rather than repeatedly asking for and receiving the cached value, which would have to check every time. (Helpers do in fact check if the tracked properties they consume change—they have to in order to recompute appropriately!—but things like helper invocations, component invocations, etc. all represent nodes in the reactivity tree which can “no-op” everything below them for recomputation when doing a new render pass. More on this below for folks who are reading this later.)

This would only show up in a meaningful way if you were re-using the cached value a lot, because in either case, the computation is extremely cheap. It’s not comparing/caching the actual values (like @computed did); it’s only comparing the integers representing the last “time” it was updated vs. the current “time” it was updated—where “time” here is in terms of the “clock” that the tracking system uses, which is just an integer which gets incremented any time you set a tracked property. Both @cached and passing a value to #let in the template will perform exactly the same check: for any tracked property the getter or the value used in the template used, has it changed.

But the reason you might see a meaningful difference emerge at large numbers of uses of the property in a template is that, as you note, re-invoking the cached property does still have to do the check every time, and even though the check is cheap, it’s not free. Even integer comparisons take time; more importantly, you slightly increase memory pressure with every use of @cached, and that adds up over time as well, including for things like GC pauses.

So a let is probably slightly faster in all cases, but it’s also a small enough difference that it likely doesn’t matter for most user code. My general rule of thumb would be:

  • default to not caching at all unless you’re doing something you know to be very expensive and/or where the caching semantics matter (like making an API call through a getter)
  • if you need to cache the value, start by reaching for whatever makes your code clearer and easier to follow over worrying about the cost of comparing integer values
  • if you’re doing something where you’re re-rendering the same value hundreds or thousands of times, then start caring about these kinds of low-level distinctions

These cases definitely do exist; I’m tracing out that heuristic primarily because I regularly see folks assume they need caching when in fact caching might cost them more than just running the code “naïvely” would.


I assume you already grok this, but for others reading along: if helpers didn’t check whether the tracked properties they use have changed, then code like this would never update:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class MyComponent extends Component {
  @tracked count = 0;

  get countPlusOne() {
    return this.count + 1;
  }

  increment = () => (this.count++);
}
// app/helpers/double.js
import { helper } from '@ember/component/helper';
export default helper(([a]) => a * 2);
<p>Value: {{double this.countPlusOne}}</p>
<button {{on "click" this.increment"}}>MOAR</button>

Any time you consume a property in a template, whether it’s rendering a value standalone, passing it to a helper, passing it to a modifier, or passing it to a component, that will connect it to the tracking system and trigger reevaluation when the tracked property changes. (Deep dive blog post here and even deeper dive walkthrough on YouTube here.) When you introduce @cached, you’re basically just letting a getter have the same level of wiring-into-the-reactivity-system that template invocations do. However, as the original question rightly suggests, it has to check for that divergence every time the decorated getter is invoked, rather than just once at the top of the {{#let ...}}, which would add up if used enough.

4 Likes

Thanks for the in-depth explanation @chriskrycho. From your response, I’m going to go with the @cached option since it’s the typical way to cache a value and {{#let}} would only be slightly faster

1 Like