Skip to content

Lit Performance

Lit is efficient by default, but real applications can still hit performance bottlenecks as they grow. This lesson covers the specific, practical techniques for keeping Lit components fast.

Where Lit Performance Problems Actually Come From

Because Lit already diffs efficiently at the template-expression level, most real performance issues in Lit apps come from one of a few specific sources: doing expensive computation directly inside render() on every update, rendering very large lists without virtualization, or triggering more re-renders than necessary through poor property/state design.

The right first step when a component feels slow is almost always profiling with the browser's DevTools Performance panel to find where time is actually being spent, rather than guessing — Lit's own update scheduling is rarely the actual bottleneck.

// Slow: expensive computation re-runs on every single render
render() {
  const sorted = [...this.items].sort(expensiveCompareFn); // re-sorts every render
  return html`${sorted.map((item) => html`<li>${item.name}</li>`)}`;
}

// Faster: only recompute when the actual dependency changes
#sortedItemsCache;
#lastItemsRef;
get #sortedItems() {
  if (this.items !== this.#lastItemsRef) {
    this.#sortedItemsCache = [...this.items].sort(expensiveCompareFn);
    this.#lastItemsRef = this.items;
  }
  return this.#sortedItemsCache;
}

Caching based on reference equality avoids re-sorting on every render when this.items itself hasn't actually changed — the guard() directive (below) formalizes this exact pattern.

The guard() Directive

import { guard } from 'lit/directives/guard.js';

html`
  ${guard([this.items], () => this.items.map((item) => html`<li>${item.name}</li>`))}
`;
  • guard(dependencies, valueFn) only re-runs valueFn() when one of the values in the dependencies array has changed since the last render.
  • If the dependencies are unchanged, guard() reuses the previously computed template result entirely, skipping the recomputation.
  • This is most valuable when valueFn does genuinely expensive work — sorting, filtering, or formatting a large dataset.
  • For cheap computations, guard()'s own bookkeeping overhead can outweigh the benefit — profile before adding it broadly.

Performance Techniques Cheat Sheet

The main tools for common Lit performance problems.

Problem Technique
Expensive computation re-runs every render guard() directive, or manual memoization
Reorderable list with unnecessary DOM churn repeat() with a stable key
Very large list (hundreds/thousands of items) Virtualization (@lit-labs/virtualizer)
Too many unrelated re-renders Split large components, use @state() more granularly
Slow initial page load with many components Lazy-load component definitions (next lesson)

Using shouldUpdate() to Skip Entire Renders

shouldUpdate(changedProperties) runs before render() and can return false to skip the update entirely — useful for components that should ignore certain property changes altogether under specific conditions, though this should be used sparingly since it bypasses Lit's normal update cycle for that render.

shouldUpdate(changedProperties) {
  if (this.isPaused && changedProperties.has('liveData')) {
    return false; // ignore incoming data updates entirely while paused
  }
  return true;
}

Overusing shouldUpdate to 'optimize' ordinary components usually adds complexity without a measurable benefit — reserve it for genuine, specific cases like this one.

Virtualizing Very Large Lists

For lists with hundreds or thousands of items, rendering every item's DOM node at once — even efficiently — is itself the bottleneck, not Lit's diffing. Virtualization renders only the items currently visible in the viewport (plus a small buffer), recycling DOM nodes as the user scrolls. The @lit-labs/virtualizer package provides a drop-in Lit-compatible virtualized list/grid.

import '@lit-labs/virtualizer';

html`
  <lit-virtualizer
    .items=${this.thousandsOfItems}
    .renderItem=${(item) => html`<div class="row">${item.name}</div>`}
  ></lit-virtualizer>
`;

Common Mistakes

  • Assuming Lit itself is 'slow' before profiling, when the actual bottleneck is expensive computation inside render() or an unvirtualized huge list.
  • Reaching for guard() or shouldUpdate() preemptively on every component, adding complexity where the default behavior was already fast enough.
  • Rendering thousands of list items directly without virtualization and being surprised by slow scrolling and initial render.
  • Storing derived, computable values as separate reactive properties instead of computing them inline (or with guard()) in render(), causing extra synchronization and re-renders.

Key Takeaways

  • Most real Lit performance problems come from expensive computation in render(), unvirtualized large lists, or excessive re-renders — not from Lit's own diffing.
  • guard(dependencies, valueFn) skips recomputing an expensive value when its dependencies haven't changed.
  • shouldUpdate() can skip an entire render cycle, but should be used sparingly and deliberately.
  • Virtualize genuinely large lists (@lit-labs/virtualizer) rather than rendering every item's DOM node upfront.

Pro Tip

Profile first, optimize second — open the browser DevTools Performance panel and record an interaction before reaching for guard(), shouldUpdate(), or virtualization. It's common to spend effort optimizing a component that was never actually the bottleneck.