# Record and read the timeline

> Start a recording, choose layers, read a row, see it as tracks, export a session, and link to a view.

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](/guides/devtools/updates/).

## Start and stop recording

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.

## Choose layers

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](/guides/devtools/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.

```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;
  }
}
```

## Read a row

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.

An update marked `2×` was scheduled by the same component’s previous update, so it rendered twice for one change. A property set in `updated()` is the usual reason; the detail pane says so next to the cause.

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. An update you caused from the Components tab, with **Request update** or by editing or overriding a property, names the DevTools as its cause and has no line.

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.](/_astro/devtools-timeline-collapsed.light.Bmhyxo1P_Z2pzoe2.webp)![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.](/_astro/devtools-timeline-collapsed.dark.CRDu6dgH_Ih2KQ.webp)

Press **Raw** in the filter bar for one flat row per recorded event instead. Reach for it on ordering questions, and for [custom layers](/guides/devtools/custom-layers/), which have no pairs to collapse.

![The Timeline tab with Raw on: one row per recorded event, including custom Router events.](/_astro/devtools-timeline-raw.light.DRNnGqNw_ZcAjDa.webp)![The Timeline tab with Raw on: one row per recorded event, including custom Router events.](/_astro/devtools-timeline-raw.dark.DFHYdJrT_1yXPbw.webp)

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.](/_astro/devtools-timeline-row-details.light.D5xfhPJ-_1qryBH.webp)![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.](/_astro/devtools-timeline-row-details.dark.yeelANe3_wWi4g.webp)

An element row also carries two links. **filter** narrows the list and the tracks to that element. **inspect** opens the [Components tab](/guides/devtools/components/) on it, because both views use the same instance id.

## See it as tracks

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.](/_astro/devtools-timeline-tracks.light.DIweO2ho_m6GKj.webp)![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.](/_astro/devtools-timeline-tracks.dark.DmHAeqqw_Z1nGCSO.webp)

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.

### Select a time range

**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](#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.

## See it in Chrome’s Performance panel

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.](/_astro/devtools-chrome-performance-tracks.light.BfAKwBLM_X8weL.webp)![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.](/_astro/devtools-chrome-performance-tracks.dark.BKKsYzhP_e3POO.webp)

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](#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.

### In Firefox and older Chrome

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 a session for a bug report

**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.](/_astro/devtools-export-snapshot.light.BpRHWvFN_EWI4z.webp)![An exported snapshot opened as a static page: the same timeline, read-only.](/_astro/devtools-export-snapshot.dark.Bp5VZA0R_afHJX.webp)

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.

## Link to a view

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.

```text
#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.

```text
#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.

```text
#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.

The hash, not the query

The query string is where the DevTools handshake token lives, and that must not travel in a link you paste into an issue.

## Related

[CLI](/reference/cli/)Why lit-devtools build cannot export a snapshot for you.

[Find out why a component re-rendered](/guides/devtools/updates/)The same recording, read as updates instead of events.
