Skip to content

Find out why a component re-rendered

Answer “which component re-renders too much” and “why did this one update” from one recording. Only the Lit Lifecycle layer needs to be on.

  1. Open the Timeline tab and press ▶ Record.
  2. Interact with the page, or just wait if something updates on its own.
  3. Switch to Updates.

Recording is shared state, so it does not matter which tab you start it from. Stopping keeps the recording. Starting again clears it, because the page re-zeroes its timeline clock each time.

The upper pane holds one row per component tag, slowest first.

The Updates tab table: one row per component with update counts and total time.The Updates tab table: one row per component with update counts and total time.
Column Meaning
Component · n The tag, with ×n when more than one instance of it updated. The header counts the components listed.
(unlabelled) The reactive properties that changed, most frequent first, then a badge for each thing to look at: n skipped for ticks shouldUpdate vetoed, ≡ and a count for equal-value reassignments, ⚠ and a count for updates that threw. A badge only appears when its count is not zero.
Updates How many update ticks were recorded, as n×.
Total Summed time in performUpdate across those ticks, with a bar scaled to the largest total in the table.
Slowest The slowest single update.

Rows group by tag rather than by instance on purpose. A list of 200 rows that each updated twice is one component’s problem, not two hundred.

Time comes from performUpdate alone. willUpdate, update and updated all run inside it, so adding the phases together would count the same milliseconds several times over. A tick whose end was not recorded, because it is still running or older than the retained window, counts toward Updates but adds nothing to Total.

Pick a component and the lower pane lists each of its update ticks: when it started and how long it took, what changed, what caused it, and the instance id, which links through to the Components tab. Above the list, the source link opens the component’s file in your editor. While a single instance has updated, a Rendered at link beside it opens the template tag that created that instance; with several instances there is no one place to name, so it is left out.

hmr-counter selected in the Updates tab: its source and Rendered at links above a list of its updates, each with its time, duration and changed keys.hmr-counter selected in the Updates tab: its source and Rendered at links above a list of its updates, each with its time, duration and changed keys.

The changed-property keys are the actual answer to “why did this re-render”. They come from Lit’s own changedProperties map, so they are what the element reacted to, not an inference.

A row marked skipped means the component’s shouldUpdate returned false: the properties listed changed, but nothing re-rendered. Skipped ticks are counted in the row’s skipped badge and not under Updates or Total, so those columns keep meaning renders. lit_update-summary returns skipped per component and skipped: true per cycle.

A row marked threw in update means that phase threw. The error’s name is shown, and its message is in the tooltip. The component table counts such updates under ⚠, and lit_update-summary returns errors per component and error per cycle, so an agent sees them too.

Failures that happen later are tied to the update that started them. A row marked rejected in updated means an async updated() (or willUpdate, firstUpdated) returned a promise that rejected with nothing handling it. A row marked task userTask failed means that @lit/task ended in its error state. An error thrown from a timer or an event listener the component set up has no such link, so it shows only in the browser console.

Turn on the Changed values layer in the Timeline tab before you record. Each update then lists prev → next for every property that changed, under its row. The previews use the same limits as the Components tab: two levels deep, eight items, 120 characters.

A badge reads new reference, same value when the property was assigned a different object, array or date with equal contents. That is the usual cause of a pointless re-render: an inline [] or {} in a template, or a list that is re-mapped on every change. Same reference means the property was not reassigned, for example a requestUpdate('items') after mutating in place. The component table marks a row with ≡ and a count when any of its updates reassigned an equal value, and the tooltip names the properties.

The layer is off by default because it serializes values on every update. At most 16 properties are recorded per update. Only the update phase is read, so a component that overrides update() without calling super reports nothing. Equality is checked on plain objects, arrays and dates, to six levels and 200 values. Anything else, such as a Map or a class instance, counts as different, and so does a value that exceeds the budget.

The same data is in lit_update-summary: changedDetail on each cycle and redundantChanges on each component.

In the playground fixture below, clicking the provider’s button bumps the marked counter. Both the provider and its consumer show a tick, and both name counter among the changed properties.

src/hmr-context.ts
import {LitElement, css, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {consume, provide} from '@lit/context';
import {counterContext} from './context-def.js';
/**
* Context API surface: a provider whose `@provide` value is also `@state`
* (bumping it pushes to subscribed consumers), and a consumer nested in
* its shadow DOM. The consumer sits in its own template literal so
* provider-header edits never rebuild the consumer element; the
* ContextProvider/ContextConsumer controllers live on the (untouched)
* instances and must keep working after either class is hot-patched.
*/
@customElement('hmr-ctx-provider')
export class HmrCtxProvider extends LitElement {
static override styles = css`
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
@provide({context: counterContext})
@state()
private counter = 0;
private renders = 0;
private renderHeader() {
return html`<h2>Context: HELLO</h2>`;
}
override render() {
return html`
${this.renderHeader()}
<button id="provide-increment" @click=${this.increment}>
Provided: ${this.counter}
</button>
<hmr-ctx-consumer></hmr-ctx-consumer>
<span class="badge" id="badge">renders: 0</span>
`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
const badge = this.renderRoot.querySelector('#badge');
if (badge !== null) {
badge.textContent = `renders: ${this.renders}`;
}
}
private increment() {
this.counter++;
}
}
@customElement('hmr-ctx-consumer')
export class HmrCtxConsumer extends LitElement {
static override styles = css`
#consumed {
font-weight: 600;
}
`;
@consume({context: counterContext, subscribe: true})
@state()
private counter?: number;
private renders = 0;
override render() {
return html`<p id="consumed">Consumed: ${this.counter}</p>`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
}
}

A component that overrides willUpdate or updated without calling super shadows the base implementation and does not report that phase. The tick still appears, because performUpdate is never overridden, but its changed keys can be missing.

The tab is addressable like the others. The element id selects the component row that instance belongs to, so the same id works whether you came from the Components tree or from an exported snapshot.

Terminal window
#tab=updates&component=7

The same derivation is available over MCP as lit_update-summary, which a coding agent should prefer over lit_recent-events for anything shaped like “why did this re-render”.