Skip to content

Record and read the timeline

Record what your components did and in what order, then scrub it. Use it for ordering questions; for “why did this re-render”, go to the Updates tab.

Open the Timeline tab and press ▶ Record. Interact with the page, then press ⏹ Stop. Stopping keeps what you captured.

Recording is shared state, so it does not matter which tab you start it from, and a coding agent can flip it too. Starting again clears the previous recording: the page re-zeroes its timeline clock each time, and events from the old clock would sit in the list on a different time origin.

Clear empties the list without stopping the recording.

The dev server keeps the latest 512 events of the recording. A panel that reloads, or one you open while recording is under way, lists those and carries on from there, so a long session comes back without its oldest events. The panel you recorded in keeps up to 5000 events until you reload it.

A strip of coloured pills under the toolbar turns each layer on or off. A layer is one category of recorded timeline events, with its own colour and toggle.

Layer What it captures Default
Lit Lifecycle connectedCallback, performUpdate, willUpdate, update, updated, firstUpdated, disconnectedCallback, with per-phase durations and changed-property keys, and an update skipped point event when shouldUpdate returns false on
Lit Render begin render and end render, from Lit’s built-in lit-debug events on
Lit Render (verbose) Per-binding lit-debug events (template updating, set part, commit text/node/attribute/property/…) — one per part on every render, so it’s high volume off
Changed values Adds the old and new value of each changed property to the update events. No events of its own, so it has no track; see Updates off
Mouse mousedown, mouseup, click, dblclick off
Keyboard keydown, keyup off
Lit warnings A warning:<code> point event for each warning Lit’s development build issues on
Custom events Events a component dispatches with this.dispatchEvent(...): the type, bubbles/composed/cancelable and a short preview of detail off

Your toggles last as long as the session: they go back to these defaults when the dev server restarts, or in the browser extension when DevTools closes.

A custom event row is attributed to the component that dispatched it, so it links to that component like any other row. Only an element’s own dispatchEvent call is recorded, not events the browser raises, and the detail preview is cut short like any value in the panel. One dispatched while its component is updating shares that update’s group.

An update skipped event means the component’s own shouldUpdate returned false. Lit still runs performUpdate but skips update and updated, so the event sits inside a performUpdate bar with nothing nested beneath it, and carries the property keys that were vetoed.

A warning:<code> event, such as warning:change-in-update, is a warning Lit’s development build issued, with its code and message in the event’s data. It is attributed to the element that was updating when Lit issued it, else to the tag the message names; one issued during an update shares that update’s group. Lit warns once per message, so a warning issued before you started recording, or while the Lit warnings layer was off, is added when you start or turn the layer on, marked replayed. Warning rows are amber with a ! mark, and selecting one shows the message, the code linked to its explanation on lit.dev, and whether it was replayed. A production build of Lit issues none.

A phase only reports if the component lets the base class run. The playground fixture below overrides every hook and calls super on the marked lines, so all of them show up.

src/hmr-lifecycle.ts
import {LitElement, css, html, nothing, type PropertyValues} from 'lit';
import {customElement, state} from 'lit/decorators.js';
/**
* Exercises the full reactive update lifecycle so every phase shows up in the
* DevTools Timeline's `lit-lifecycle` layer:
*
* connectedCallback → (per update) performUpdate → willUpdate → update →
* firstUpdated (first only) → updated → … → disconnectedCallback
*
* Each hook is overridden and calls `super`, so the timeline (which wraps the
* shared ReactiveElement/LitElement base) reports it. The on-screen log shows
* the phase order of the last update for at-a-glance correlation; it's written
* straight to the DOM in `updated()` so it never triggers another update.
*/
@customElement('hmr-lifecycle-child')
export class HmrLifecycleChild extends LitElement {
static override styles = css`
:host {
display: block;
}
button:focus {
outline: 2px solid dodgerblue;
}
.log {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #888;
font-family: var(--font-mono, monospace);
}
`;
@state()
private count = 0;
/** Phases seen during the in-flight update cycle (reset each willUpdate). */
#phases: string[] = [];
override connectedCallback() {
super.connectedCallback();
this.setAttribute('data-connected', '');
}
override disconnectedCallback() {
super.disconnectedCallback();
// The element is leaving the DOM — nothing visible to update, but the
// wrapped base disconnectedCallback still reports the point event.
}
override willUpdate(changed: PropertyValues) {
this.#phases = ['willUpdate'];
super.willUpdate(changed);
}
override update(changed: PropertyValues) {
this.#phases.push('update');
super.update(changed); // runs render()
}
override firstUpdated(changed: PropertyValues) {
this.#phases.push('firstUpdated');
super.firstUpdated(changed);
}
override updated(changed: PropertyValues) {
this.#phases.push('updated');
super.updated(changed);
// Direct DOM write (no reactive property) so it doesn't loop.
const log = this.renderRoot.querySelector('.log');
if (log !== null) {
log.textContent = this.#phases.join(' → ');
}
}
override render() {
return html`
<button id="increment" @click=${() => this.count++}>
Trigger update — count: ${this.count}
</button>
<div class="log">connected</div>
`;
}
}
/**
* Wrapper that mounts/unmounts the child so its `connectedCallback` /
* `disconnectedCallback` fire on demand — they only run when an element
* actually enters or leaves the DOM, which a plain re-render won't do.
*/
@customElement('hmr-lifecycle')
export class HmrLifecycle extends LitElement {
static override styles = css`
button:focus {
outline: 2px solid dodgerblue;
}
.controls {
margin-bottom: 0.5rem;
}
`;
@state()
private mounted = true;
override render() {
return html`
<h2>Lifecycle</h2>
<div class="controls">
<button id="toggle" @click=${() => (this.mounted = !this.mounted)}>
${this.mounted ? 'Unmount child' : 'Mount child'}
</button>
</div>
${
this.mounted
? html`<hmr-lifecycle-child></hmr-lifecycle-child>`
: nothing
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'hmr-lifecycle': HmrLifecycle;
'hmr-lifecycle-child': HmrLifecycleChild;
}
}

Switch List | Tracks at the left of the toolbar to List to read the recording row by row. Rows are collapsed spans, not raw events: the capture layers emit a start and an end per phase, so each phase is one row with its duration, and a lifecycle row also lists the reactive properties that changed.

One component update is one row. The list shows the performUpdate row of each update, collapsed, with its duration, the properties the update changed, and a count of the rows folded into it (+2). A marker after the count flags an update with something to look at inside: a warning, an error, or a skipped update. Press the arrow at the left of the row to open it and see the willUpdate, update and updated phases, plus the events that belong to the same update: update skipped, Lit warnings, errors from a rejected updated, and, with the Custom events layer on, events the component dispatched while it was updating. With the arrow focused, the Right and Left arrow keys open and close it too, and Expand all and Collapse all in the filter bar open or close the phases of every update at once. Rows that are not part of an update stay as they are.

Rows stay in time order, and a column at the left of the list draws what caused each update, the way git log --graph draws branches. A line connects an update to the row that caused it: the update of a parent that set a property on it or created it, or the click, key or custom event whose handler requested it. The line runs past whatever happened in between, so a chain of updates reads top to bottom even when other rows sit inside it. A @lit/task run is a row of its own, from the update that started it to when it settled, and the re-render it asks for hangs from it. Each chain has its own colour, and hovering one of its rows fades the others. The cause is recorded at the requestUpdate call that scheduled the update, not guessed from timing. An update with no line is a timer, a signal or a stray requestUpdate(). A chain whose top has fallen out of the buffer starts at the first row still present. The details pane names the cause, with a show link that selects it.

Selecting a nested row from the tracks, a #event= link or the range summary opens its update first. A filter that hides a performUpdate but matches one of its phases keeps the performUpdate row, showing only the matching phases, so the match still has its context.

The Timeline list from the top of a recording: collapsed performUpdate rows for hmr-counter, hmr-properties and hmr-static-props, each with the properties it changed, its fold count and duration, between the separate render rows.The Timeline list from the top of a recording: collapsed performUpdate rows for hmr-counter, hmr-properties and hmr-static-props, each with the properties it changed, its fold count and duration, between the separate render rows.

Press Raw in the filter bar for one flat row per recorded event instead. Reach for it on ordering questions, and for custom layers, which have no pairs to collapse.

The Timeline tab with Raw on: one row per recorded event, including custom Router events.The Timeline tab with Raw on: one row per recorded event, including custom Router events.

Click any row to see the element tag name, its stable instance id, the duration, the changed properties, the source file and the raw event data. Click the file to open it in your editor. When the element was written in an html template or in index.html, a rendered at link opens the tag that created it instead.

An hmr-counter update expanded in the Timeline list, its willUpdate and update rows marked count, and the selected update row's details: the element and instance id, its source and rendered at links, and count under changed.An hmr-counter update expanded in the Timeline list, its willUpdate and update rows marked count, and the selected update row's details: the element and instance id, its source and rendered at links, and count under changed.

An element row also carries two links. filter narrows the list and the tracks to that element. inspect opens the Components tab on it, because both views use the same instance id.

The Timeline opens in Tracks: the recording as one horizontal lane per layer on a shared time axis, so you see what happened at the same time and how far apart things were. The list shows order and attribution instead. The panel remembers which view you left it in.

The Timeline in Tracks mode zoomed to about a millisecond: lanes for Lit Lifecycle, Lit Render, Lit warnings and Router, a Router navigate tick and, at the same moment, the hmr-custom-layer performUpdate span it caused, selected, with its phases beneath it and its details in the pane below.The Timeline in Tracks mode zoomed to about a millisecond: lanes for Lit Lifecycle, Lit Render, Lit warnings and Router, a Router navigate tick and, at the same moment, the hmr-custom-layer performUpdate span it caused, selected, with its phases beneath it and its details in the pane below.

Each mark is one span, placed by its start and end. Overlapping spans stack into rows, so an update tick reads as performUpdate on top with willUpdate, update and updated beneath it. A point event, or a span too short to show at the current zoom, draws as a thin tick. A span that has not ended yet runs to the end of the recording.

  • Wheel zooms around the pointer. Shift+wheel or a horizontal swipe pans.
  • Drag pans.
  • Double-click fits the whole recording again. While you record, a fitted view follows the newest events.

Click a mark to select it. The same detail pane opens under the tracks, and the selection carries over: switch back to List and it scrolls to that row.

The Tracks strip picks which lanes to draw. It is a view filter only. Hiding a track does not stop that layer being recorded, and the list is unaffected. Only layers you capture get a track, custom layers included.

The Element picker and the regex box above the list filter the tracks too, so a search you typed in one view still applies when you switch to the other.

Shift+drag on the tracks, or drag on the time ruler above them, shades a range of the recording and shows its bounds and length while you drag. A click on the ruler, or Esc, clears it. To pick the range of one span from the keyboard, focus its mark and press Shift+Enter.

Both edges of the range are handles you can adjust. Drag one with the mouse, or Tab to it (they are announced as Range start and Range end sliders) and press ← or → to move it one axis tick step, or hold Shift for five. An edge stops at the other edge and at the ends of the recording, and the view scrolls to keep it on screen.

Copy link in the summary copies a link that opens the Timeline on the same range; see Link to a view below.

With a range drawn and no span selected, the detail pane summarises it:

  • how long it is, and how many events each layer recorded in it;
  • each component that updated, with its update count and total performUpdate time. filter narrows the list and the tracks to that element, and inspect opens it in the Components tab.

A span belongs to the range when it starts inside it, so a long span that begins before the range is not counted, and two neighbouring ranges never count the same span twice. The summary covers the lanes you are showing.

Selecting a mark replaces the summary with that span’s detail, and drawing a range clears the span selection. The detail pane holds one or the other.

  • Zoom to range fits the view to the range, and stops following the newest events while you record.
  • Filter to range adds a Time chip to the filter bar, and the list and the tracks then show only that window. Click the chip to show everything again.

The panel’s tracks share a time axis with each other, not with the browser. To see Lit updates next to layout, paint, network and long tasks, mirror the timeline into Chrome DevTools’ Performance panel. Turn on performance tracks in the Settings tab, under Timeline, then record in Chrome as usual.

The recording gets a Lit track group with a Lifecycle and a Render track, plus Render (verbose) and Input when those layers are on. Lit warnings appear as markers on the Lifecycle track. Chrome lists it among the tracks as Lit — Custom, below Interactions and above Main, and starts it collapsed: click its arrow to open it. Each update tick is one entry named after its element, such as <hmr-counter> performUpdate, with willUpdate, update, updated and the render nested beneath it.

Chrome's Performance panel zoomed to one click: the expanded Lit — Custom group shows <hmr-counter> performUpdate with update nested under it on the Lifecycle track and <hmr-counter> render on the Render track, lined up above the Event: click task on Main.Chrome's Performance panel zoomed to one click: the expanded Lit — Custom group shows <hmr-counter> performUpdate with update nested under it on the Lifecycle track and <hmr-counter> render on the Render track, lined up above the Event: click task on Main.

An update takes well under a millisecond, so at the default zoom an entry is a sliver. Drag across the overview strip to select a stretch around an interaction, then zoom in with the wheel or W until the labels show. Entries shorter than a pixel, often willUpdate and updated, stay hidden until you zoom further. Traces captured with an agent through chrome-devtools-mcp contain the same entries, so an agent reading a trace sees Lit’s work too.

  • The mirror runs whether or not the Lit panel is recording. It follows the layers you picked in Choose layers, Lifecycle and Render by default.
  • Chrome only keeps the entries while a Performance recording runs. Outside one, each call costs well under a microsecond. With the setting off, the page makes no calls at all.
  • Entries are labels only. Chrome’s detail pane shows no changed properties and no source link; select the same tick in the Lit panel for those.
  • Custom layers from addTimelineEvent stay in the Lit panel.
  • Updates that ran before the runtime installed, such as the first render on page load, are not in the trace.

The tracks use console.timeStamp with its track arguments, which needs Chrome 134 or newer. Older Chrome versions, Firefox and Safari ignore those arguments, so there the same setting writes the entries as User Timing instead: each update tick is a measure, each input event a mark, named lit:<track> <label>, such as lit:Lifecycle <hmr-counter> performUpdate. The Settings tab says so under the switch.

In the Firefox Profiler they appear in the Marker Chart and Marker Table; type lit: in the search box to keep only Lit’s. The entries are cleared from the page’s own performance timeline as soon as they’re made, so performance.getEntriesByType('measure') doesn’t fill up with them.

  • The entries belong to the page’s process, not the browser’s. Start and capture the recording with Ctrl+Shift+1 and 2 (⌘ on macOS) while your page’s tab is active, and select its track, labelled with the site’s host. A recording started from about:profiling opens on that page’s track (Privileged Content), which has no Lit entries; pick your tab in the tab selector at the top left.
  • Firefox rounds performance.now() to whole milliseconds, and most updates take less. Their start and end land on the same tick, so most entries show as instants, and only updates longer than a millisecond show as bars.

Export snapshot freezes the current session into a self-contained directory: the recorded events, the component tree, the details of everything you opened, and any HMR incompatibilities. The dev server writes it, and the panel reports where.

An exported snapshot opened as a static page: the same timeline, read-only.An exported snapshot opened as a static page: the same timeline, read-only.

The result is a static site. Open index.html from any file server and the panel renders the recording with no dev server, no checkout of the app and no reproduction steps. The frozen panel is read-only: recording, picking and tree refresh are hidden, because there is no page behind it to command.

Two limits matter when you record something to report.

  • Only components you actually opened have their details baked in. Details are read from live DOM, so a component nobody inspected has nothing to freeze. Click through the ones that matter before exporting.
  • The export holds the dev server’s latest 512 events, not the whole session: a long one exports its tail, as a reloaded panel shows it.

lit-devtools build does not produce one of these. A fresh CLI process has no session to freeze, so export from the running server that recorded it.

The panel is addressable. Opened on its own, or from an exported snapshot, it reads its position from the URL hash and keeps it up to date as you click. Copying the address bar gives a link that reopens the same view.

Terminal window
#tab=components&component=7

tab is components, updates, timeline or settings. component is the element id from the Components tree; on the Updates tab it selects the component row that instance belongs to.

Terminal window
#tab=timeline&event=lq3x9-42

event is the id of a timeline event. Selecting a row in the Timeline writes the id of its start event into the hash, and opening the link selects the span that event belongs to and scrolls it into view. The id is stamped when the event reaches the dev server and is kept in an exported snapshot, so the link works against a snapshot someone sent you. If the event has scrolled out of the buffer, or the link came from another session, the Timeline opens with nothing selected.

Terminal window
#tab=timeline&range=500-700

range is a window of the recording, as start-end in milliseconds on the same clock as the times in the list and the axis. Drawing a range writes it into the hash, and Copy link in the range summary copies the whole address. Opening the link switches to Tracks, draws the range and zooms to it.

A range is only meaningful against the recording it was drawn on, because the clock restarts with each recording. So the link is applied once a span starts inside the window, and until then it is held: a snapshot that is still loading, or a live session still filling up, gets it as soon as its events arrive. It is dropped, never applied, if you press Clear (or start a new recording) first, and drawing your own range replaces it. A snapshot never re-zeroes, so a range link is the reliable way to point someone at a stretch of an exported session. When both event and range are present the range is drawn first and the event is then selected.