Free reference

The Unofficial Chrome Trace Event Manual

Chrome writes a performance trace in its own internal vocabulary and documents none of it. This is the reference: every event DevTools models, what each argument means, and a real example of each one.

Open a Chrome performance trace and you get a list of events with terse names and arguments nobody documents. There is no reference for them. Not on developer.chrome.com, not in the DevTools docs, not in the Chromium tree. The names are internal C++ and TypeScript identifiers that leaked into a file format people are expected to read.

So I wrote one. This manual covers all 169 events DevTools models, plus 20 that it does not model at all: what each event does, what every argument means, a real example of each, and what you can do with it.

Events explained
189
Fields explained
464
Categories
57

Loading and the network

Document parsing and the five events that make up every request.

HTMLDocumentParser::PumpTokenizerIfPossiblenot in DevTools recordingsnot in DevTools' model

Chrome tries to read another chunk of your HTML.

A DevTools Performance recording does not contain this event. Record blink with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Spec

Blink's HTML parser tests whether it is able to tokenize, and tokenizes when it is, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.parser-41.0%

Observed 145,565 times on 182 of 300 sites: 59,689 complete slices and 42,935 flow pairs, which carry no arguments.

Behaviour

Three events cover this work at three levels, nested inside one another:

PumpTokenizerIfPossible    the gate: may the parser run?          blink
  PumpTokenizer            the chunk loop: run until the budget   blink
    ParseHTML              the parse itself                       devtools.timeline

Chrome calls the outer gate whenever something has happened that might allow parsing to continue: more bytes have arrived from the network, a blocking script has finished running, document.write() has pushed markup into the stream, or the document is being brought to a close. Each of those is a reason to go and look, and looking is what this event records.

The answer is often no. The gate checks whether a blocking stylesheet has arrived, then tests the parser's state, and if the parser is stopped or paused it returns having consumed nothing at all. That is what IfPossible is doing in the name, and it is why a slice here is not evidence that any markup was read. A gate that contains no ParseHTML did nothing.

When the parser does run and then yields, having spent its budget, the gate schedules the next attempt before returning.

Derivations

Each attempt is flow-linked to the one it schedules, so following that chain from the first gives you every attempt on a single document in order: the ones that parsed, the ones that did nothing, and the time between them. DevTools will not draw that for you at any zoom level.

The transitions are where the value is. Find a run of gates that did nothing followed by one that parsed, and whatever completed in between is what unblocked the parser, usually a ResourceFinish for a script or a stylesheet. That is root-cause attribution for a stalled parse, from the trace rather than from inference.

Traps

All three events are on the blink category, which a DevTools Performance recording never asks for. The entire chain is invisible in the Performance panel, so this is a Perfetto or custom trace configuration job.

HTMLDocumentParser::PumpTokenizer, the middle of the three, is where endLine ends up for the ParseHTML inside it. See the traps on that entry.

Example event a local fixture page
{
  "name": "HTMLDocumentParser::PumpTokenizerIfPossible",
  "cat": "blink",
  "ph": "X",
  "ts": 1102933188,
  "dur": 6372,
  "tdur": 3193,
  "tts": 35974,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "parser": "0x1df400529100"
  }
}

See also ParseHTML · EvaluateScript

categories
blink
usage
high
duration
typical
references
html_document_parser.cc
ParseAuthorStyleSheet

Chrome reads a downloaded stylesheet and builds the CSSOM from it.

Spec

Blink's CSS parser decodes one fetched stylesheet and parses it into rules, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.data.styleSheetUrl-100.0%undeclared, always present

Observed 3,119 times on 182 of 300 sites.

Behaviour

"Author" is the CSS specification's word for the stylesheets a page supplies itself, as opposed to the browser's built-in defaults and any user stylesheet. This event covers those, and only the ones Chrome fetched: the function takes a CSSStyleSheetResource, so a <link rel=stylesheet> or an @import reaches it and an inline <style> does not. Inline CSS is parsed by a different function that writes nothing to the trace at all.

The slice is scoped to the whole function, so its duration includes decoding the downloaded bytes into text as well as parsing that text into rules. The URL is the only thing recorded: there is no size and no rule count.

Derivations

Chrome keeps a cache of already-parsed stylesheets, and a sheet served from it never reaches this function. So the stylesheet URLs that appear in the network events but not here are the ones that were reused rather than reparsed. That set is not visible anywhere else in a trace, and on a site serving the same CSS across navigations it is the difference between the parse cost you measured and the parse cost a first-time visitor pays.

Traps

A page that inlines all of its CSS produces none of these events, so an absence here is not evidence that stylesheet parsing was cheap.

The duration understates the work. Chrome parses these sheets with declaration-block parsing deferred, so the contents of each rule are not read until something first matches against them, and that cost appears later under style recalculation instead.

styleSheetUrl is the only argument and trace_engine does not declare it, so anything built on the package's types sees an event with no data on it.

Example event from https://www.bbc.com/news
{
  "name": "ParseAuthorStyleSheet",
  "cat": "blink,devtools.timeline",
  "ph": "X",
  "ts": 1109076924,
  "dur": 415,
  "tdur": 389,
  "tts": 45866,
  "pid": 8475,
  "tid": 8475,
  "args": {
    "data": {
      "styleSheetUrl": "https://cdn.privacy-mgmt.com/Notice.1c267.css"
    }
  }
}

See also ParseHTML · ResourceFinish

categories
blink, devtools.timeline
usage
moderate
duration
long
undeclared args
args.data.styleSheetUrl
references
style_sheet_contents.cc, inspector_trace_events.cc
ParseHTMLParse HTML

Chrome reads your HTML and builds the DOM from it.

Spec

Blink's HTMLDocumentParser runs its tokenizer and tree builder over part of the document, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.beginData.frameyes100.0%
args.beginData.sampleTraceIdopt100.0%
args.beginData.stackTrace[].columnNumber-89.4%
args.beginData.stackTrace[].functionName-89.4%
args.beginData.stackTrace[].lineNumber-89.4%
args.beginData.stackTrace[].scriptId-89.4%
args.beginData.stackTrace[].url-89.4%
args.beginData.startLineyes100.0%
args.beginData.urlyes100.0%
args.endDataopt0.0%declared, never observed
args.endData.endLineyes0.0%declared, never observed
args.parsed_bytes-100.0%undeclared, always present
args.parsed_tokens-100.0%undeclared, always present

Observed 58,996 times on 182 of 300 sites.

Behaviour

Chrome parses a document in bursts rather than in one continuous stretch, and each ParseHTML brackets a single burst. Chromium calls a burst a pump, which is where the sibling event HTMLDocumentParser::PumpTokenizerIfPossible takes its name.

A burst ends for one of two reasons. The parser reaches a synchronous <script> and has to wait while it is fetched and executed, or it spends its token budget: the first two bursts of a document are capped at 250 tokens each, after which the cap is lifted and the parser tries to consume everything remaining in one task. A page therefore produces a chain of these events separated by gaps, and those gaps have two quite different causes that the event alone does not tell apart.

Derivations

ts plus dur of one event, to ts of the next, is time the parser spent blocked rather than parsing, and the next event's startLine is the line of your HTML it was stuck at. Sum those gaps across a load and you have the total the parser spent waiting, located in your own source rather than inferred from where the <script> tags sit.

Short gaps are the parser rescheduling itself after spending its token budget; long ones are a script being fetched and executed. An EvaluateScript between the two events confirms which.

Traps

endLine is not on this event, though trace_engine declares it. Blink does emit it, but the begin and end pairs of ParseHTML and HTMLDocumentParser::PumpTokenizer interleave rather than nest, so each end closes the other's slice. endLine arrives on PumpTokenizer, and PumpTokenizer's own counters arrive here as parsed_bytes and parsed_tokens. Record the blink category if you want the end line.

A stackTrace means JavaScript was on the stack when the burst began, which is true of nearly every parse on a modern site. It does not identify innerHTML or document.write. The field is gated behind disabled-by-default-devtools.timeline.stack, which also gates sampleTraceId and makes every burst take a V8 CPU profiler sample it would not otherwise take.

parsed_bytes counts UTF-16 code units in the parser's buffer, which is not the number of bytes the network delivered.

Example event a local fixture page
{
  "name": "ParseHTML",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1102952623,
  "dur": 86,
  "tdur": 86,
  "tts": 47032,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "beginData": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "sampleTraceId": 6909218608594550,
      "stackTrace": [
        {
          "columnNumber": 20,
          "functionName": "",
          "lineNumber": 78,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "startLine": 0,
      "url": "http://127.0.0.1:8801/"
    },
    "parsed_bytes": 1960,
    "parsed_tokens": 420
  }
}

See also HTMLDocumentParser::PumpTokenizerIfPossible · EvaluateScript

categories
devtools.timeline
usage
moderate
duration
typical
undeclared args
args.parsed_bytes, args.parsed_tokens, args.beginData.stackTrace[].columnNumber, args.beginData.stackTrace[].functionName, args.beginData.stackTrace[].lineNumber, args.beginData.stackTrace[].scriptId, args.beginData.stackTrace[].url
references
inspector_trace_events.cc, html_document_parser.cc, source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc, segmented_string.h
ResourceFinishFinish loading

This event tells you a resource request has finished.

Spec

Blink closes out a request, on the thread that owned the fetch.

Arguments

pathdeclaredobserved
args.data.decodedBodyLengthyes100.0%
args.data.didFailyes100.0%
args.data.encodedDataLengthyes100.0%
args.data.finishTimeyes87.9%
args.data.requestIdyes100.0%

Observed 36,088 times on 182 of 300 sites.

Behaviour

The terminal event of a request's trace record, and the only one that says whether the request failed.

Two call sites write the same event name through one payload builder. The success path forwards real values. The failure path hardcodes a null timestamp, didFail: true, and zero for both lengths, so an abort that moved bytes reports none: one capture emitted six data events totalling 35 bytes and then closed with encodedDataLength: 0.

A request blocked before a loader exists, by CSP or a mixed-content check, emits no finish event at all.

Derivations

Whether a request failed. didFail appears nowhere else in the lifecycle.

The cache tier, given a success: a ResourceMarkAsCached on the same request id means Blink's memory cache; fromCache: true on the response with no such marker means the HTTP disk cache; neither means the network.

Body size on the wire, on network loads only: this event's encodedDataLength minus the response event's. Verified to within a few bytes of content-length on HTTP/3. It is meaningless on cache hits, on 304s, and wherever the response reports -1.

How busy the thread was when the request landed: ts minus finishTime is the emitting thread's queueing lag. In one trace the six largest gaps, about 88 ms each, all fell inside a single 87.5 ms script task.

Traps

decodedBodyLength / encodedDataLength is not a compression ratio. The denominator includes the header block, so an uncompressed file reads below 1.0, and by more the smaller it is. This entry's own example event is the trap: 3,880 over 4,073 reads as 0.95 "compression" for a fixture file that is exactly 3,880 bytes on disk and is served with no compression of any kind. The field that would make the ratio honest exists in Chromium, is carried all the way into Blink, and is never written to the trace. The ratio is undefined on cache hits, 0/0 on failures, and 0.0 on a 304.

encodedDataLength: 0 means one of four things: a genuine disk-cache hit, a hardcoded zero for a memory-cache hit, a hardcoded zero for a failure that may have transferred plenty, or an empty body. Summing it undercounts every aborted request by its full size.

A missing finishTime is not a failure signal. Across eight traces, 117 absences split 93 memory-cache hits and 24 failures and nothing else. A present finishTime does not mean bytes came off the wire either, because disk-cache hits have one.

ts is not when the download ended. It is when the probe ran, which waits for the current task on that thread. Use finishTime for duration.

The payload carries no url and no frame, so it cannot be attributed to an iframe without joining on the request id. There is no error code, no error text and no distinction between failed, cancelled and aborted.

trace_engine types finishTime as required. It is absent on roughly one event in eight. Guard it.

Example event a local fixture page
{
  "name": "ResourceFinish",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102939672,
  "tts": 39266,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "decodedBodyLength": 3880,
      "didFail": false,
      "encodedDataLength": 4073,
      "finishTime": 1102.922565,
      "requestId": "F19F977AB13E669964965F7427E6F7B2"
    }
  }
}

See also ResourceSendRequest · ResourceReceivedData · ResourceMarkAsCached

categories
devtools.timeline
usage
moderate
references
url_loader_completion_status.h, inspector_trace_events.cc, resource_loader.cc, url_loader.cc, NetworkRequestsHandler.ts
ResourceMarkAsCached

Chrome already had the file in memory and never asked for it.

Spec

Blink satisfies a subresource request out of the renderer process's own memory, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.data.requestIdyes100.0%

Observed 2,959 times on 175 of 300 sites.

Behaviour

Not the HTTP cache. The guard is Blink's own memory cache, which covers two things: an entry in the process-wide MemoryCache, and a resource Blink materialised in-process such as a data: URI or an MHTML subresource. The second kind fires on first use, before any cache could have mattered.

The marker sits at a fixed slot inside a burst of five events that Blink manufactures for a request that never left the process: send, this marker, response, data, finish. The whole burst is typically 20 to 150 microseconds.

It only fires the first time a given document takes a url out of the process-wide cache. A second request for the same url in the same document emits nothing, so the event counts distinct memory-satisfiable urls per document, not reuses. Frames only; workers never emit it.

Derivations

Join on the request id to ResourceSendRequest for the url, frame and priority. This event carries none of them, so any rule phrased over "the url of this event" cannot be implemented.

An http or https scheme marker means another document in the same renderer process already held those bytes. Usually an earlier navigation, but a same-origin iframe in the same page load does it too.

On the matching ResourceFinish: no finishTime, encodedDataLength of zero, and didFail false. Do not add decodedBodyLength > 0 to that fingerprint, because it is zero for every data: URI.

Traps

It is not a repeat-view signal. Across eight cold loads with fresh profiles, 38 of these fired and 35 were inline data: URIs. One site produced 18 on a cold profile.

It is not the opposite of fromCache either. The two are independent and co-occur routinely, because a memory-cache entry replays the response it was stored with: if that response came from the disk cache, both appear on the same request. Measured at 6 of 8 in a warm-profile capture, and on a cold load of reddit.com.

The replayed ResourceReceiveResponse looks like a real network response, with a status code, protocol and socket id all carried over from the original fetch, and a timing block pointing backwards.

Whether the event appears at all can depend on your recorder. Chrome's two hard-coded 1x1 transparent placeholder GIFs emit the full burst when a CDP client is attached and nothing under --trace-startup, so the same page produces a different population under Puppeteer than under Perfetto.

A deferred resource does not emit it. An inline data: font without rel=preload, or an inline data: image with loading="lazy", loads normally and produces four events with no marker.

Example event from https://www.theguardian.com
{
  "name": "ResourceMarkAsCached",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1119408173,
  "tts": 531693,
  "pid": 8591,
  "tid": 8591,
  "s": "t",
  "args": {
    "data": {
      "requestId": "8591.369"
    }
  }
}

See also ResourceReceiveResponse · ResourceFinish · ResourceSendRequest

categories
devtools.timeline
usage
moderate
references
inspector_trace_events.cc
ResourceReceivedDataReceive data

Chrome hands part of a downloaded file to the page.

Spec

Blink hands a span of response-body bytes to the loader's observer, on the thread that owns the fetch.

Arguments

pathdeclaredobserved
args.data.encodedDataLengthyes100.0%
args.data.frameyes100.0%
args.data.requestIdyes100.0%

Observed 73,689 times on 182 of 300 sites.

Behaviour

This is a delivery notification, not an arrival notification. The bytes were already in the renderer process, and on two of the four code paths that emit it the whole body had finished downloading before the first event was written.

Four emitters reach it: document subresources, worker fetches, the main document itself, and a synthetic single event replaying a memory-cache hit. Each builds the span differently, which is why one event is neither one network chunk nor one fixed-size read.

Some requests carry a full body and emit nothing at all. A 204 or a 304 has no body; a fetch() resolved to a blob, a stream read by script, or a streamed script all have their body drained by someone else before the loader reads it. Those bytes may reappear later under a different request id.

Derivations

Summing over one request id gives the decompressed bytes received so far. It is the only byte figure available for a request that was still in flight when the recording stopped, and the only place an aborted request's bytes are visible at all, since ResourceFinish reports zeros for a failure.

Gaps between consecutive events on one request, lined up against main-thread tasks, show the renderer starving its own download. The reads happen in renderer tasks, so a blocked thread pushes them later with no change on the network: 11 to 44 microseconds from response to first data on quiet requests in one trace, against 41,802 microseconds on a busy one in the same load.

Traps

encodedDataLength here is decoded body bytes, not wire bytes. A gzipped stylesheet that was 255 bytes on the wire reports 200,020 here and 473 on ResourceFinish: a 424-fold gap between two fields with the same name. "Encoded" is Blink's raw-bytes-versus-text axis, not HTTP's content encoding. Summing these gives decoded size and never transfer size.

The timestamp is not a network timestamp. On the background-processed path the entire response, data and finish sequence can land in a burst of about 100 microseconds after the body already downloaded and V8 already compiled it off-thread.

Event count is not a streaming signal in either direction. One event can mean a small body, a busy main thread, a pre-assembled buffer or a cache replay. One or two is normal: 16 of 21 body-bearing requests in one trace emitted exactly one, and a 2 MiB stylesheet produced six.

Chunk sizes are buffer artefacts. The only real constant is a roughly 1 MiB per-task budget, and it binds only bodies the loader reads itself: a fetch() body pulled by JavaScript produced a single 1,114,112-byte event.

A memory-cache hit emits one event carrying the whole resource, and looks exactly like a fast network response unless you notice the ResourceMarkAsCached beside it.

DevTools collects these events and never reads them. Every byte figure in the Performance panel comes from ResourceFinish.

Example event a local fixture page
{
  "name": "ResourceReceivedData",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102926911,
  "tts": 30693,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "encodedDataLength": 3880,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "requestId": "F19F977AB13E669964965F7427E6F7B2"
    }
  }
}

See also ResourceReceiveResponse · ResourceFinish

categories
devtools.timeline
usage
moderate
references
inspector_trace_events.cc, resource_loader.cc
ResourceReceiveResponseReceive response

Chrome has the response headers for a request.

Spec

Blink takes delivery of a response head, on the thread that owns the fetch.

Arguments

pathdeclaredobserved
args.data.cacheStorageCacheName-0.0%
args.data.connectionIdyes100.0%
args.data.connectionReusedyes100.0%
args.data.encodedDataLengthyes100.0%
args.data.frameyes100.0%
args.data.fromCacheyes100.0%
args.data.fromServiceWorkeryes100.0%
args.data.headers.nameyes0.0%declared, never observed
args.data.headers.valueyes0.0%declared, never observed
args.data.headers[].name-99.1%undeclared, always present
args.data.headers[].value-99.1%undeclared, always present
args.data.mimeTypeyes100.0%
args.data.protocolyes100.0%
args.data.requestIdyes100.0%
args.data.responseTimeyes95.5%
args.data.serviceWorkerResponseSource-0.5%
args.data.staticRoutingInfo.matchedSourceType-0.4%
args.data.staticRoutingInfo.ruleIdMatched-0.4%
args.data.statusCodeyes100.0%
args.data.timing.connectEnd-95.3%
args.data.timing.connectStart-95.3%
args.data.timing.dnsEnd-95.3%
args.data.timing.dnsStart-95.3%
args.data.timing.proxyEnd-95.3%
args.data.timing.proxyStart-95.3%
args.data.timing.pushEnd-95.3%
args.data.timing.pushStart-95.3%
args.data.timing.receiveHeadersEnd-95.3%
args.data.timing.receiveHeadersStart-95.3%
args.data.timing.requestTime-95.3%
args.data.timing.sendEnd-95.3%
args.data.timing.sendStart-95.3%
args.data.timing.sslEnd-95.3%
args.data.timing.sslStart-95.3%
args.data.timing.workerReady-95.3%
args.data.timing.workerStart-95.3%

Observed 34,142 times on 182 of 300 sites.

Behaviour

One event per request, and the only one carrying the status code, MIME type, protocol, headers, cache flags, socket identity and the network timing block. Nothing else in the lifecycle repeats any of it.

Redirect hops do not produce one. Only the final response does.

It fires for Blink memory-cache hits too, where it replays a stored response object rather than describing a fetch. In that case ResourceMarkAsCached fires first on the same request id, and everything in the payload describes the earlier load.

Derivations

The timing block is absolutely placeable, because requestTime is seconds on the same monotonic clock as ts. Every other offset in the block is milliseconds relative to it, so any phase lands at requestTime 1e6 + offset 1e3 in trace units.

That gives you when the headers really arrived, and subtracting it from ts gives you how long Blink took to pick them up: a main-thread congestion signal you cannot get anywhere else.

Server think time is receiveHeadersStart - sendEnd, which is what DevTools computes.

Connection setup is dnsEnd - dnsStart, connectEnd - connectStart, sslEnd - sslStart. These are blocking times, not occurrence times, so a zero means the request did not wait, as with a preconnected socket.

Traps

encodedDataLength here is five different numbers. On a fresh network response it is the response header bytes as they went over the wire, so on h2 and h3 it is the compressed HEADERS frame and lands far below the header text: a 204 with around 809 bytes of headers reported 57. It is 0 on an unvalidated disk-cache hit and on data:, and -1 on blob:, chrome:// and chrome-extension:. On a memory-cache replay it is the entire wire transfer of the original load, body included: one stylesheet reported 189 on the fetch that got it and 5,214 on the replay.

fromCache is not a flag. It is derived from two timestamps, true whenever the response predates the request, which is why HTTP/2 pushed resources and service-worker responses report it. A 304 revalidation reports false, and reports its status as 200, because the 304's headers are merged into the stored entry.

fromCache: true and ResourceMarkAsCached are not mutually exclusive. A memory-cache entry replays whatever response it was stored with, so one that originally came off the disk cache sets both. DevTools marks such a request as disk-cached and memory-cached at once, deliberately.

On a memory-cache replay the timing block belongs to the earlier fetch, measured 1.3 seconds stale in one capture and 2.5 seconds in another, and connectionId is a closed socket. There is no flag on this event to tell you; you have to join to ResourceMarkAsCached. How much of a trace is affected depends entirely on how you recorded: a single navigation has almost none, a two-navigation recording can be three quarters stale.

ts is not when the headers arrived. No timestamp is passed to the trace macro, so it is when the Blink probe ran, always at or after the headers completed and by tens of milliseconds on a busy thread.

pushStart and pushEnd are absolute seconds with a 0 sentinel, sitting among fifteen millisecond offsets that use -1.

protocol can be a url scheme. Filter data, blob, chrome and chrome-extension out of any protocol histogram.

Server think time understates on any origin sending 103 Early Hints, because receiveHeadersStart can be the 1xx, and the field that would correct it is recorded by Blink and never written to the trace.

Example event from https://web.dev
{
  "name": "ResourceReceiveResponse",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1236061260,
  "tts": 450196,
  "pid": 10309,
  "tid": 10309,
  "s": "t",
  "args": {
    "data": {
      "cacheStorageCacheName": "devsite.pwa_RUNTIME_v2.1",
      "connectionId": 0,
      "connectionReused": false,
      "encodedDataLength": 0,
      "frame": "614FC5655E48B642B0235ABCA128B0C6",
      "fromCache": true,
      "fromServiceWorker": true,
      "headers": [
        {
          "name": "content-encoding",
          "value": "gzip"
        },
        {
          "name": "age",
          "value": "161242"
        },
        {
          "name": "report-to",
          "value": "{\"group\":\"devrel-devsite\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/devrel-devsite\"}]}"
        },
        "... [16 more items]"
      ],
      "mimeType": "image/svg+xml",
      "protocol": "h2",
      "requestId": "10309.250",
      "responseTime": 1789291418105.672,
      "serviceWorkerResponseSource": "cacheStorage",
      "statusCode": 200,
      "timing": {
        "connectEnd": -1,
        "connectStart": -1,
        "dnsEnd": -1,
        "dnsStart": -1,
        "proxyEnd": -1,
        "proxyStart": -1,
        "pushEnd": 0,
        "pushStart": 0,
        "receiveHeadersEnd": 2.19,
        "receiveHeadersStart": 2.19,
        "requestTime": 1236.054677,
        "sendEnd": 0.002,
        "sendStart": 0.002,
        "sslEnd": -1,
        "sslStart": -1,
        "workerReady": 0.118,
        "workerStart": 0.002
      }
    }
  }
}

See also ResourceSendRequest · ResourceReceivedData · ResourceFinish · ResourceMarkAsCached

categories
devtools.timeline
usage
moderate
undeclared args
args.data.timing.connectEnd, args.data.timing.connectStart, args.data.timing.dnsEnd, args.data.timing.dnsStart, args.data.timing.proxyEnd, args.data.timing.proxyStart, args.data.timing.pushEnd, args.data.timing.pushStart and 13 more
references
load_timing_info.h, resource_load_timing.cc, inspector_trace_events.cc, web_url_response.cc, url_loader_util.cc, url_loader.cc and 3 more
ResourceSendRequestSend request

Chrome asks for a file.

Spec

Blink hands a request down to be fetched, on the thread that owns it.

Arguments

pathdeclaredobserved
args.data.fetchPriorityHintopt100.0%
args.data.frameyes100.0%
args.data.initiator.columnNumber-46.1%
args.data.initiator.fetchType-97.2%
args.data.initiator.lineNumber-46.1%
args.data.initiator.type-97.2%
args.data.initiator.url-51.3%
args.data.isLinkPreloadopt97.2%
args.data.priorityyes100.0%
args.data.renderBlockingopt45.2%
args.data.requestIdyes100.0%
args.data.requestMethodopt100.0%
args.data.resourceTypeopt100.0%
args.data.sampleTraceId-100.0%undeclared, always present
args.data.stackTrace[].columnNumber-42.0%
args.data.stackTrace[].functionName-42.0%
args.data.stackTrace[].lineNumber-42.0%
args.data.stackTrace[].scriptId-42.0%
args.data.stackTrace[].url-42.0%
args.data.urlyes100.0%

Observed 34,467 times on 182 of 300 sites.

Behaviour

For a subresource this is Blink's fetcher passing the request on, before the network service, before DNS, before the socket. The queueing between this moment and the wire is why ResourceReceiveResponse carries a timing block at all.

For the document it is something else entirely. Blink writes it at navigation commit, from DocumentLoader, long after the browser sent the request and after the response headers have arrived. Chromium's own comment at that line reads "The fetch has already started in the browser". Measured on a real site, the renderer's event landed 110 ms after the browser's ResourceWillSendRequest and 0.16 ms before the response.

The payload is the richest of the six and none of the later events repeat it: url, method, resource type, priority, and where the request came from. Redirects emit one event per hop on a shared request id.

Derivations

Attribution. initiator.url falls back to stackTrace[0].url, which is exactly what DevTools does before matching the result against other requests to build a dependency tree rather than a flat waterfall.

A render-blocking audit: filter renderBlocking for blocking and in_body_parser_blocking to get the requests that actually held up first paint.

Queueing for a subresource: requestTime on the response event, minus this event's ts.

Traps

Never compute document time to first byte from this event. It is a commit timestamp, and the answer comes out around 0.1 ms.

Stack depth measures your recorder, not the page. V8 captures one frame unless a client has the Runtime domain enabled, and then up to 200. The same fetch() from an eleven-frame call chain gave eleven frames under Puppeteer and one frame under --enable-tracing. Never compare stack depth across traces made with different tools.

The stack itself is gated on disabled-by-default-devtools.timeline.stack. Without that category stackTrace and sampleTraceId are simply gone, and nothing in the output distinguishes that from no stack being available.

renderBlocking is not a property of the resource. It is whatever the code path that triggered this particular request handed over, so the same font url carries non_blocking on a cold load and loses the field entirely on a memory-cache hit. Absent is not a third value.

isLinkPreload means an explicit rel=preload directive, not "Chrome preloaded this". Anything the preload scanner found speculatively is false.

priority is the priority at send time. A separate ResourceChangePriority event can supersede it.

fetchPriorityHint is present on every event because auto is written unconditionally. Only low and high carry author intent.

On a redirecting navigation the earlier events' urls are not reliably the intermediate hops. Count the events for the hop count; do not reconstruct the chain from them.

Example event from https://web.dev
{
  "name": "ResourceSendRequest",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1236185423,
  "tts": 104095,
  "pid": 10389,
  "tid": 10389,
  "s": "t",
  "args": {
    "data": {
      "fetchPriorityHint": "auto",
      "frame": "7EC3978477DE6DAE91460AF9D4ECB53A",
      "initiator": {
        "columnNumber": 13,
        "fetchType": "css",
        "lineNumber": 258,
        "type": "parser",
        "url": "https://www.google.com/recaptcha/api2/anchor?ar=1&k=6Lfqf9YdAAAAAKUVvj6y53E_tMMlnj8dt1fpo-FV&co=aHR0cHM6Ly93ZWItZG90LWRldnNpdGUtdjItcHJvZC0zcC5hcHBzcG90LmNvbTo0... [98 more chars]"
      },
      "isLinkPreload": false,
      "priority": "VeryHigh",
      "renderBlocking": "non_blocking",
      "requestId": "10389.14",
      "requestMethod": "GET",
      "resourceType": "Font",
      "sampleTraceId": 4395473783086954,
      "stackTrace": [
        {
          "columnNumber": 81,
          "functionName": "",
          "lineNumber": 736,
          "scriptId": "5",
          "url": "https://www.gstatic.com/recaptcha/releases/BnqMGSY_YP4cCmbNINHpJPkd/recaptcha__en.js"
        },
        {
          "columnNumber": 413,
          "functionName": "",
          "lineNumber": 613,
          "scriptId": "5",
          "url": "https://www.gstatic.com/recaptcha/releases/BnqMGSY_YP4cCmbNINHpJPkd/recaptcha__en.js"
        },
        {
          "columnNumber": 49,
          "functionName": "LX.Bl",
          "lineNumber": 1223,
          "scriptId": "5",
          "url": "https://www.gstatic.com/recaptcha/releases/BnqMGSY_YP4cCmbNINHpJPkd/recaptcha__en.js"
        },
        "... [13 more items]"
      ],
      "url": "https://fonts.gstatic.com/s/roboto/v48/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2"
    }
  }
}

See also ResourceWillSendRequest · ResourceReceiveResponse · ResourceFinish

categories
devtools.timeline
usage
moderate
undeclared args
args.data.initiator.fetchType, args.data.initiator.type, args.data.initiator.url, args.data.sampleTraceId, args.data.initiator.columnNumber, args.data.initiator.lineNumber, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName and 3 more
references
inspector_trace_events.cc, inspector_network_agent.cc, image_loader.cc, preload_helper.cc, preload_request.cc, resource_fetcher.cc and 7 more
ResourceWillSendRequestWill send request

Chrome is about to request a page or an iframe.

Spec

The browser process marks a navigation request as it is about to be sent, on the browser's main thread.

Arguments

pathdeclaredobserved
args.data.requestIdyes100.0%

Observed 952 times on 182 of 300 sites.

Behaviour

This is the only one of the six request events the browser process writes. The other five come from the renderer, which is visible in the trace itself: this event is process-scoped, "s": "p", while the others are thread-scoped, "s": "t".

It is written for cross-document navigations only, never for a subresource. A page, an iframe and a data: frame all get one; a back-forward cache restore, a prerender activation, a same-document navigation and about:blank do not. Chrome writes it before the URL loader exists and before any service worker sees the request, so a navigation answered offline by respondWith() still produces one.

Redirects produce one event per hop, all sharing a request id. N events means N-1 redirects.

Derivations

This is where a navigation actually started. The renderer's ResourceSendRequest for the same document cannot tell you, because Blink writes that one at commit, once the response is already in hand: measured at 0.014 to 0.092 ms before the response event across six captures.

So honest time to first byte for the main document runs from this event to requestTime + receiveHeadersEnd on ResourceReceiveResponse. In one capture the browser sent at 0 ms, the request reached the server at 92.60 ms after two redirects, headers completed at 129.40 ms, and the renderer wrote its ResourceSendRequest at 132.93 ms.

Consecutive timestamps on one request id give the cost of each redirect hop as the browser experienced it. A hop of a couple of milliseconds is an internal redirect, an HTTP to HTTPS upgrade rather than a round trip. DevTools reads these timestamps in preference to the renderer's.

A request id with this event and nothing else is a navigation that never committed: hung, superseded, a 204, or a download.

Traps

The gap to ResourceSendRequest is not network time. It contains browser dispatch, redirects, the wire, IPC and the renderer's commit. The non-network residual measured 2.7 to 5.0 ms on a warm headless machine and grows when a renderer has to be spawned.

The event does not mean bytes went on the wire. Service-worker-answered navigations and data: frames emit it with no network activity at all.

A failed navigation is not an orphan. A DNS failure still commits an error page, and the renderer emits the full set with the url chrome-error://chromewebdata/.

DevTools will not show you an orphan either: it drops any request with no send event, so you have to read the raw trace.

The request id is the navigation token, 32 uppercase hex characters, shared by every hop and by the renderer's four events for the same document. Subresource ids look nothing like it.

Example event a local fixture page
{
  "name": "ResourceWillSendRequest",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102919608,
  "pid": 8178,
  "tid": 8178,
  "s": "p",
  "args": {
    "data": {
      "requestId": "F19F977AB13E669964965F7427E6F7B2"
    }
  }
}

See also ResourceSendRequest · ResourceReceiveResponse

categories
devtools.timeline
usage
low
references
devtools_instrumentation.cc

Style, layout and paint

The rendering pipeline, from style recalculation to pixels.

AnimationAnimation

Chrome plays an animation or a transition on the page.

Spec

Blink records the play-state changes of one animation object, a CSS animation, a CSS transition or an element.animate() animation, as an async span on the renderer's main thread, with instants for state changes and for a failed handover to the compositor.

Arguments

pathdeclaredobserved
args.data.compositeFailedopt15.0%
args.data.displayNameopt32.1%
args.data.idopt32.1%
args.data.nameopt32.1%
args.data.nodeIdopt32.1%
args.data.nodeNameopt32.1%
args.data.stateopt63.6%
args.data.unsupportedProperties[]-11.0%
args.endData.state-21.3%

Observed 18,728 times on 180 of 300 sites.

Behaviour

Every event one animation writes shares an async id, id2.local, so a single animation reads as one span.

The span follows the play state, not the frames. Blink writes the begin when an animation goes from idle, paused or finished to playing, and the end when it goes back. Any other change of state is an instant carrying nothing but state. Iterations write nothing, so a width animation with two iterations produced one begin, one state instant and one end. A pause() followed by play() closes the span and opens a new one on the same id.

A second kind of instant reports a decision about the compositor, the thread that can run an animation without the main thread once it has been handed over. Blink makes that decision in PreCommit, for an animation that is playing and is being started on the compositor. If the handover fails, it writes an instant with compositeFailed, a bitmask of reasons; the reason that stands for an unsupported CSS property also fills unsupportedProperties with the property names. If the handover succeeds, it writes nothing at all. Captures on Chrome 153 from two independent runs agree on which properties fail: width, top, left, margin-left, margin-bottom and box-shadow each wrote a failure, while transform, opacity and background-color wrote none. background-color is in that second group because Chrome paints it through a native paint worklet the compositor can drive; box-shadow has the same machinery, unfinished and off by default.

Only the begin identifies the animation. displayName is the animation's id if script gave it one, otherwise the CSS animation-name, otherwise the transitioned property, and nodeId and nodeName identify the element. Every later event on the span carries a state or a compositor result and nothing else.

The four categories form one group, and recording any of them is enough. A devtools.timeline recording on its own produced the full set.

Derivations

Which animations ran on the main thread, and why. Decode compositeFailed against FailureReason in Chromium's compositor_animations.h and join to the begin on id2.local for the element. Bit 13 is an unsupported CSS property, and it is the only reason that fills unsupportedProperties. Bit 5, the target not being in a compositable state, usually rides along with it, because an element with nothing else worth compositing carries no direct compositing reason on its paint property nodes; the same transition on an element that also runs a composited transform reports bit 13 alone. Read the mask as a set of reasons, not as a number to recognise.

The span is the window in which that animation costs main-thread time. While a width animation was running, the renderer's main thread ran UpdateLayoutTree, Layout and PrePaint once per frame for the whole span.

How the animation ended. endData.state is finished when it ran out, paused after pause(), and idle after cancel().

Traps

endData is on the begin event, not the end. Chrome's JSON exporter rebuilds each span as one slice and then splits it back into a begin and an end, copying the arguments onto the begin and clearing them from the end, so every e event has empty args. DevTools' own event types do not declare endData at all. A begin carries endData exactly when its animation ended inside the recording; a begin without it was still running when you stopped.

Start recording before the animation starts. An animation whose begin falls outside the recording has no span in it, and no end either: an end with no begin never becomes a slice, so it is never written. A 3 second width animation that was already running when tracing began, and finished well inside a 2.5 second recording, left no Animation event at all, while Layout ran on every frame throughout. Instants are exported one at a time and do survive, so such an animation can still drop a lone state or compositeFailed instant into the trace with no begin to attach it to. The compositor result has a second gate: Blink only keeps the list of reasons if the category was enabled when the animation last became pending.

No compositeFailed does not prove the animation was composited. Success writes nothing. Neither does an animation Blink never offered to the compositor: one in a hidden page, one that is not playing, or one whose reason list was never allocated. Nor does an animation whose begin predates the recording, which has no span to hang a result on. The absence tells you something only when the begin is in your trace, the animation started inside the recording, and the page was visible.

Not every compositeFailed is a performance problem. Bit 17 means the animation has no visible change, which Chromium's source attributes to constant keyframe values or an off-screen animation it optimised out. It writes the instant with no unsupportedProperties, and it repeats: Blink re-marks such an animation compositor-pending every time that verdict flips, and each re-check writes another instant. In captures it appeared once on a quiet page and once per frame on a page that was also running composited animations.

transform and opacity can fail too. An animation of either on an inline element, or under will-change: contents, was rejected with no property named at all.

unsupportedProperties is absent, not empty, when nothing in the failure was a property, at least in an exported JSON trace.

state on the begin reads running before the animation is running. Blink decides between begin, instant and end using an extra pending state that exists only for DevTools, but it writes the play state from the Web Animations spec, which has no pending. The first instant with state: running is when the animation actually got its start time, tens of milliseconds after the begin at most in these captures. In the same way, an instant with state: paused is the pause being requested, and the end that follows is the pause taking effect.

id is not the animation's id. It is a counter Blink increments for every animation it creates in the renderer process, so a recording that caught two renderers has two animations numbered 1. The id a script sets is name. And element.animate() starts the animation before it applies the id option, so the first begin of such an animation has an empty name and displayName; only a later restart carries it.

DevTools gives a still-running animation zero duration. trace_engine pairs each begin with its end, and keeps a begin with no end only if it has an instant, at dur 0. An infinite spinner looks as if it lasted no time at all, and instants whose begin came before the recording are dropped.

The layout shift culprits DevTools reports count instants, not animations, and ignore which property failed. CLSCulprits turns every instant with a non-zero compositeFailed into a failure, bit 17 included, and attributes it to a shift only if the shift lands in the first PrePaint after the animation's end, or after its begin if it had not ended. A constant animation can fill that list, and a non-composited animation that moves content while it runs is not named for those shifts.

Example event from https://www.bbc.com/news
{
  "name": "Animation",
  "cat": "blink.animations,devtools.timeline,benchmark,rail",
  "ph": "b",
  "ts": 1109196689,
  "pid": 8475,
  "tid": 8475,
  "id2": {
    "local": "0x38f4005fc1d8"
  },
  "scope": "blink.animations,devtools.timeline,benchmark,rail",
  "args": {
    "data": {
      "displayName": "margin-bottom",
      "id": "1",
      "name": "",
      "nodeId": 40,
      "nodeName": "BUTTON class='message-component message-button no-children focusable button sp_choice_type_11'",
      "state": "running"
    },
    "endData": {
      "state": "finished"
    }
  }
}

See also Layout · UpdateLayoutTree · Paint · LayoutShift · PrePaint

categories
blink.animations, devtools.timeline, benchmark, rail
usage
moderate
undeclared args
args.endData.state
references
animation.cc, inspector_animation_agent.cc
BeginFrameFrame start

The compositor starts a new frame on a display refresh.

Spec

Chromium's compositor, cc, starts an impl frame for one display refresh signalled by viz, Chrome's display compositor, or writes the same event afterwards for a refresh it missed, on the thread that drives that layer tree.

Arguments

pathdeclaredobserved
args.frameSeqIdyes100.0%
args.layerTreeIdyes100.0%

Observed 46,944 times on 182 of 300 sites.

Behaviour

Once per display refresh, viz sends a begin-frame signal to every compositor that has asked for one. When a renderer's compositor receives it, it starts an impl frame, cc's name for the work its own thread does each frame, and writes this event. It is an instant on the renderer's Compositor thread, not the main thread.

A compositor only asks for these signals while it has something to do: a requestAnimationFrame callback, a running animation, a pending redraw. A page with nothing to do writes none. In one capture a static page wrote no BeginFrames in four seconds, a single requestAnimationFrame wrote two, and a CSS animation or a requestAnimationFrame loop wrote one per refresh for as long as it ran. NeedsBeginFrameChanged marks each time a compositor starts and stops asking.

Every widget has its own compositor and its own layer tree: the main frame, and each out-of-process iframe in its own renderer. An animating cross-site iframe ticks its own layer tree and leaves the parent's silent.

A second code path writes the same event for refreshes that never reached the compositor. As each impl frame begins, cc's frame reporting controller compares the new sequence number with the previous one, sets aside the refreshes viz held back on purpose, and writes one event for every refresh in between, in the same task and just before the real one. A gap of n refreshes writes n minus one events, and a gap of more than a hundred writes none at all, so the longest stalls are the ones that leave nothing behind. The timestamps are arithmetic, the previous frame's time plus one interval each, rather than times viz recorded.

The browser draws its own tabs, toolbar and window chrome with a compositor of its own, and the first code path skips it. The second does not. Every BeginFrame on the browser's CrBrowserMain thread is therefore a refresh its interface missed, and the refreshes it handled write nothing.

Derivations

Which part of the page never goes idle. Group these events by pid and layerTreeId, and map each pair to a frame through SetLayerTreeId, which carries both the layer tree id and the frame id. A layer tree with an unbroken run of BeginFrames through a stretch where nothing should be moving has something requesting a frame every refresh: a spinner, a requestAnimationFrame polling loop, an ad iframe. That compositor is waking at the display's rate for as long as the run lasts.

One frame grid across processes. frameSeqId is numbered by viz, not by the renderer, so the same number in two processes is the same refresh and carries an identical ts. That lines the main frame, its out-of-process iframes and the browser UI up refresh by refresh.

Traps

ts is not when the event was written. Chrome passes the frame's start time from the begin-frame signal to the trace macro explicitly, so ts is the moment viz stamped the refresh, earlier than the compositor task that wrote it: a median of 0.15 ms headless and 0.46 ms headful in two Linux captures, and whole refresh intervals for the backfilled events. A trace sorted by ts puts the event ahead of the message that delivered it. tts is the thread time at write, so on this event the two do not describe the same moment.

Not every BeginFrame is a frame that started. The backfilled ones describe refreshes the compositor missed. They are written in a single task, so the signature is a run of consecutive events on one thread whose thread time barely moves while ts steps one refresh interval at a time. In a renderer the last event of such a run is the real frame. On the browser's interface compositor there is no real one, because only the backfill path runs there.

The count is not a frame rate. The compositor thread starts frames whether or not the main thread can supply them: a page blocking its main thread for 300 ms at a time still produced one BeginFrame per refresh. Nor is every impl frame drawn. A frame that finds nothing damaged abandons its draw, so on one real site a single layer tree had 309 BeginFrames, 23 DrawFrames and 80 DroppedFrames, while a page running one CSS animation had a DrawFrame for every BeginFrame. DroppedFrame is written only when the drop costs smoothness, so a BeginFrame with neither partner is usually a frame with nothing to redraw rather than a loss.

layerTreeId is not unique in a trace. It is a counter local to each process, starting at 1, so the browser and every renderer reuse the same small numbers. Key on pid and layerTreeId together. It is not fixed for a frame either, and the main frame is not always on the first tree its renderer created: read the number from SetLayerTreeId, which carries the layer tree id and the frame id together.

DevTools can merge an iframe's frames into the page's. Its frame model picks the main frame's layerTreeId and then accepts any BeginFrame or DrawFrame carrying that number, from any process, although it does check the process id on main-thread events. Two iframes from the same site usually share a renderer and get layer trees 1 and 2, which collides whenever the main frame is on 2. In one capture a page that repainted twice a second, with two same-site iframes animating, came out as 240 frames in four seconds; with the iframes on different sites it was 8.

frameSeqId joins BeginFrame to DrawFrame, not to Commit. DrawFrame carries the number of the impl frame it drew and DroppedFrame the number of the one it dropped, so both pair with BeginFrame inside one process and layer tree. Commit carries the number of the BeginFrame that asked the main thread for work, which is a different frame from the one that draws the result whenever the main thread is late. BeginMainThreadFrame has no frameSeqId at all, and DevTools' frame model never reads Commit's.

The numbering counts refreshes, not frames. It keeps advancing while a compositor is not listening, so a break in the run usually marks the moment that compositor stopped asking, which NeedsBeginFrameChanged confirms. While it is listening the backfill closes the gaps, unless the stall ran past a hundred refreshes. Chromium's own comment warns the sequence can break when the begin-frame source changes, and the source id is not in the trace.

The category is off by default. These events live on disabled-by-default-devtools.timeline.frame. DevTools puts it in every Performance recording, but a CDP or Perfetto session you configure yourself records nothing unless you ask for it.

Your harness can create these events. Puppeteer's waitForFunction polls with requestAnimationFrame by default, and that alone made a static page write one BeginFrame per refresh. Anything in a test script that polls through requestAnimationFrame does the same.

Example event a local fixture page
{
  "name": "BeginFrame",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1102922548,
  "tts": 1503,
  "pid": 8257,
  "tid": 8278,
  "s": "t",
  "args": {
    "frameSeqId": 6,
    "layerTreeId": 1
  }
}

See also DrawFrame · BeginMainThreadFrame · Commit · NeedsBeginFrameChanged · DroppedFrame · SetLayerTreeId

categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, layer_tree_host_impl.cc, compositor_frame_reporting_controller.cc
BeginMainThreadFrameFrame start (main thread)

The main thread starts its part of a new frame.

Spec

Chromium's compositor, cc, marks the start of a main frame on the renderer's main thread: the rendering update in which Blink runs frame-aligned input and events, requestAnimationFrame callbacks, style, layout and paint before handing the result to the compositor thread.

Arguments

pathdeclaredobserved
args.data.frameIdopt100.0%
args.layerTreeIdyes100.0%

Observed 27,016 times on 182 of 300 sites.

Behaviour

Chrome produces a frame on two threads. The compositor thread decides a frame is due and posts a task to the main thread, writing RequestMainThreadFrame as it does. When that task runs, cc checks that the page is visible and that rendering is not being held back, applies the scroll offsets and pinch-zoom deltas the compositor thread has accumulated, writes this event, and then hands control to Blink for the rendering update. It is an instant with no duration, a few microseconds into the task; the work it announces follows it in the same task, in this order:

BeginMainThreadFrame
  input held for the frame     mousemove, wheel, touchmove
  events                       resize, scroll, media queries, animations
  requestAnimationFrame        FireAnimationFrame
  style, layout, paint         UpdateLayoutTree, Layout, PrePaint, Paint
Commit                         layer update and handoff to the compositor

Chrome only asks for a main frame when something needs one: a requestAnimationFrame, a DOM or style change, an animation that has to tick on the main thread. An idle page writes none. An animation the compositor runs by itself writes almost none: in one capture a transform animation produced 2 main frames in 800 ms, and a width animation on the same element produced 37.

There are three situations in which Chrome writes nothing even though a frame was asked for. A hidden page aborts the main frame before this event. So does a page whose main frame updates are deferred, which is how a loading document holds off rendering: in one capture a script in <head> requested an animation frame and no main frame ran for the 2.5 seconds until the <body> arrived. And the browser's own UI compositor is excluded at the call site, so every one of these belongs to a renderer.

Each compositor has its own sequence. A page has one for its main frame, and each out-of-process iframe has one in its own renderer. frameId counts that compositor's main frames from 0.

Derivations

Waiting versus working. RequestMainThreadFrame is written the moment the compositor thread posts the task, so the gap from it to this event is how long the frame queued behind other main-thread work. The gap from this event to the start of the next Commit on the same thread is how long the rendering update itself took. Together they split a late frame into its two causes. A 300 ms timer task put the best part of 300 ms into the first gap and about half a millisecond into the second, while the compositor thread wrote 17 DroppedFrame events. A requestAnimationFrame callback doing 40 ms of work did the reverse: 40 ms of work and under 0.1 ms of waiting. The second gap stops where Commit opens, so cc's own commit work is outside it; that slice runs in tens of microseconds and stays under a millisecond even at the tail, but it is main-thread time too.

Which vsync the main frame answered. This event carries no frameSeqId, but the Commit that follows it does, and that number is the BeginFrame the main frame was started for. After the 300 ms task above, the Commit named a BeginFrame issued nearly 300 ms earlier, not the current one.

Which main frames changed anything. frameId reappears as frameId on ActivateLayerTree when the layer tree that main frame committed goes live on the compositor thread. A frameId with no matching ActivateLayerTree is a main frame that did its work and had nothing to commit. In two captures that also recorded cc's own abort events, the match was exact in both directions. Both events are in DevTools' default recording, so a trace exported from the Performance panel carries them, though neither is drawn in the panel itself.

Traps

The span to Commit is not a frame budget. It excludes the time the frame waited to start, which is where a long task puts its cost. A short span with dropped frames does not clear the main thread: in the capture above the span was about half a millisecond and 17 frames were lost to a main-thread task. Check the gap from RequestMainThreadFrame before you look at raster or the GPU. DevTools agrees with this reading: its own frame model dates a frame from the start of the task, not from this event.

frameId is not a frame. It is neither the frame token other events use to name a document nor the frameSeqId that BeginFrame, Commit, DrawFrame and DroppedFrame share. It is a counter of this compositor's main frames, advanced when one reaches the commit step. No field on this event joins it to those four; pair it with the next Commit on the same thread, or through ActivateLayerTree.

DevTools records this event but never draws it. Its timeline style carries a hidden flag, as do RequestMainThreadFrame, BeginFrame and DrawFrame, and ActivateLayerTree has no style at all, so none of them appear in the flame chart. Commit does. Turn on "Show all events" in the panel's settings to see them, or read the exported trace JSON.

The two events you want are on different categories. This one is written only on disabled-by-default-devtools.timeline.frame; Commit is written only on disabled-by-default-devtools.timeline. A DevTools recording asks for both, but a hand-written trace configuration that takes one and not the other cannot pair them at all.

Two trace events are called Commit, and a Performance recording contains both. The one this event pairs with is a complete event on the renderer's main thread carrying layerTreeId and frameSeqId. The other is an argument-less async pair on the compositor thread, category cc,benchmark,disabled-by-default-devtools.timeline.frame, written by cc's frame reporter as it walks a compositor frame through its stages. Match on thread and phase, not on the name.

layerTreeId is only unique within a process, because it comes from a per-process counter. In one trace the browser process wrote Commit events with layerTreeId: 1 and an out-of-process iframe's renderer used 1 for its own tree. Key on pid and layerTreeId together, or a pairing with "the next Commit" will pick up the browser's.

A Commit after this event does not mean anything was committed. cc opens the Commit slice before it checks for changes and closes it on the way out when there were none, so every main frame in every capture had one, including those that changed nothing on screen.

It is also possible, from source, for a main frame to run and then skip the commit altogether, while paint holding defers commits on a loading page, or on an animate-only frame. That leaves this event with no Commit after it and the next main frame repeating its frameId. It did not happen in any of the traces checked for this entry.

Style and layout are not confined to main frames. Script that reads layout after changing the DOM forces them in whatever task it is running, outside any BeginMainThreadFrame. Adding up the work between these events and Commit misses it.

Your recorder can create these events. Puppeteer's page.waitForFunction() polls with requestAnimationFrame by default, which requests a main frame every vsync for as long as it waits. A page that was idle produced none with a fixed sleep and one every 16.7 ms with waitForFunction.

trace_engine types data.frameId as optional. Chrome writes it on every event. DevTools' frame model tests it for truth rather than for presence, so the one main frame it never labels is the one with frameId 0, the first of every layer tree.

Example event a local fixture page
{
  "name": "BeginMainThreadFrame",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1102922990,
  "tts": 28292,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frameId": 2
    },
    "layerTreeId": 1
  }
}

See also RequestMainThreadFrame · Commit · ActivateLayerTree · BeginFrame · DroppedFrame · DrawFrame

categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, layer_tree_host.cc, layer_tree_host.h
CommitCommit

The main thread hands the page's updated layers over to the compositor thread.

Spec

Chromium's compositor library, cc, turns the frame Blink has just painted into layer updates and hands them to the compositor thread to be committed, on the main thread of the process that owns the layer tree.

Arguments

pathdeclaredobserved
args.frameSeqIdyes37.0%
args.layerTreeIdyes37.0%

Observed 186,094 times on 182 of 300 sites.

Behaviour

Three different pieces of Chromium write an event called Commit, and they are easy to tell apart. The one DevTools draws, and the one this entry is about, is a slice with a duration on disabled-by-default-devtools.timeline, carrying layerTreeId and frameSeqId. The other two are async b/e pairs with empty args: one on cc,benchmark,disabled-by-default-devtools.timeline.frame, nested inside a PipelineReporter, and one on cc,benchmark,input,input.scrolling, nested inside an EventLatency. Those two are the same measurement, the compositor thread's own commit action, recorded as one stage in a per-frame breakdown and written after the fact, when the frame is finished with. They are why args is missing from so many Commit records. A Performance panel recording contains the first two and never the third, because DevTools records the timeline.frame category group and not input.

The main-thread slice lives inside ProxyMain::BeginMainFrame, cc's handler for a main-thread frame. By the time it opens, Blink has already run style, layout and paint. The slice begins with cc updating its layers, which for a Blink page mostly means copying Blink's display lists into cc's own. It then takes a snapshot of the changed state, posts that to the compositor thread and carries on, running Blink's post-lifecycle steps: accessibility updates and IntersectionObserver computation. It closes only when BeginMainFrame returns. So the handoff is somewhere in the middle of the slice, not at either end of it.

No Commit is written when a frame never gets that far: the page is hidden, updates or commits are being deferred, or the frame only needed to run animations. A page that is not changing produces none.

When cc updates its layers and finds nothing changed, the slice is still written. It contains an EarlyOut_NoUpdates and a MainFrameAborted whose args.main_frame_pipeline.aborted_on_main_reason is NO_UPDATE, nothing was committed, and no stage appears in the frame's breakdown. Both of those events are on the cc,benchmark category, which DevTools does not record, so in the panel such a commit looks like any other.

The browser process writes this event too. Chrome's own UI is drawn by a cc compositor that runs on the browser's main thread, with no compositor thread to hand to, so that slice contains the entire commit, LayerTreeHostImpl::FinishCommit included. On a static test page the browser process wrote more of them than the renderer did.

Derivations

What happened to the frame afterwards. Join this event's pid, layerTreeId and frameSeqId to the PipelineReporter in the same process whose args.frame_reporter has the same layer_tree_host_id and frame_sequence. That record tells you whether the frame was presented with this commit's content, presented with older content, or dropped, and breaks its path to the screen into timed stages. The join matched every renderer commit in a test capture, and PipelineReporter is one of the categories DevTools records, so it works on a panel recording. DrawFrame with the same number is not a substitute: a draw is not presentation, and it does not tell you which commit's content was drawn.

Traps

Filter on more than the name. Counting every event called Commit counts each commit two or three times. Keep the ones with ph X and a frameSeqId, which is exactly what trace_engine's isCommit does. Its comment blames old traces, but frameSeqId was added to this event well before it was renamed from CompositeLayers, so what the check really excludes is the two async stage copies.

layerTreeId is not unique across a trace. It comes from a per-process counter that starts at 1, so the browser's UI compositor and a renderer can both report layerTreeId: 1, with overlapping frameSeqId values. In one renderer the id moved from 1 to 2 when the page navigated away from the initial about:blank. Always key on pid as well. A SetLayerTreeId event maps a frame to its tree.

The async stage cannot be keyed back to a frame. In a JSON trace the exporter gives many PipelineReporter tracks the same id2.local, and the reporters overlap in time, so neither the id nor a begin/end stack will tell you which frame a stage belongs to. Use the main-thread Commit and its arguments for that.

Much of a long Commit may be IntersectionObserver work. In a fixture observing 400 elements, IntersectionObserverController::computeIntersections accounted for most of the time inside Commit. In the Performance panel none of it is drawn: Blink writes the child under that long name, trace_engine only knows the name ComputeIntersections, and the timeline hides event names it has no style for. Every other child of Commit is on the cc or cc,benchmark categories, which DevTools does not record at all, so a Commit bar in the panel has nothing underneath it. That is from reading the source and from a capture made with DevTools' own category list; the panel itself was not opened.

Commit is not a line between main thread and compositor. The compositor thread's own commit stage starts a fraction of a millisecond into the main-thread slice and almost always finishes before the main thread closes it.

The duration can include waiting. On a frame a few milliseconds after a click, the main thread was seen stopping inside Commit until the compositor thread finished. cc has a flag that makes it wait, and the only function that sets it is ProxyMain::SetInputResponsePending, whose comment says the main thread pauses when the next frame contains the response to input. Nothing in Chromium's source calls that function. The wait is measured; what sets the flag is unverified.

The reverse is also true: a main-thread wait for a commit that happens outside BeginMainFrame is not in any Commit. Chromium's own TODO at the call site says so. LayerTreeHost::WaitForCommitCompletion on the cc category is not a good way to find those, though. It fires about once per commit whether or not anything is waited on, because the same call is used to reset a flag after the compositor has already finished, and those take a microsecond or two. Only a long one is a real wait.

trace_engine types this event as an instant, but every main-thread Commit has a dur.

The frameSeqId values have gaps. Each is the number of the BeginFrame that started the main-thread frame, and a BeginFrame that produced no commit leaves its number unused. On a page animating every frame there are no gaps at all. DevTools does not read frameSeqId from this event; it only uses layerTreeId to mark the pending frame as committed.

Example event a local fixture page
{
  "name": "Commit",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102923583,
  "dur": 59,
  "tdur": 53,
  "tts": 28651,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "frameSeqId": 6,
    "layerTreeId": 1
  }
}

See also BeginMainThreadFrame · BeginFrame · ActivateLayerTree · DrawFrame · PipelineReporter · ComputeIntersections · CompositeLayers · SetLayerTreeId

categories
disabled-by-default-devtools.timeline, cc, benchmark, disabled-by-default-devtools.timeline.frame, input, input.scrolling
usage
high
duration
typical
references
devtools_instrumentation.h, proxy_main.cc, single_thread_proxy.cc, compositor_frame_reporter.cc, event_latency_tracing_recorder.cc
CompositeLayersComposite layersno longer emitted

The old name for Chrome handing the page's layers from the main thread to the compositor thread.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Spec

Chromium's compositor, cc, blocks the thread that owns a layer tree while the compositor thread copies that tree into its own, recorded under this name from Chrome 42 until Chrome 111 renamed it Commit.

Arguments

pathdeclaredobserved
args.layerTreeIdyesdeclared, never observed

Behaviour

Chrome's compositor keeps a page's layer tree on two threads. The main thread builds its copy through style, layout and paint; the compositor thread keeps its own, and rasterises and draws from that. A commit is the handover between them, and this event was the owning thread's side of it.

That thread does not do the copy. It posts the commit to the compositor thread, which cc calls the impl thread, and blocks until the compositor's scheduler has run it, so the slice is the wait, the scheduler's own delay and the copy together. It stayed open a little longer than that: after the wait came Blink's post-commit work, which is why an IntersectionObserverController::computeIntersections sits inside every one written by a renderer.

The slice opened after the layers had been updated and after cc had packaged the commit, and it opened before cc checked whether anything had changed, so a main frame with nothing to commit still produced a short one. Where a layer tree has no compositor thread, which includes the browser's own interface, the same code did the copy inline and the slice is the copy.

You got one per main frame that reached the commit stage. A frame the main thread never runs, such as one driven entirely by a compositor animation, wrote nothing, and so did a main frame that returned early because the page was not visible or commits were deferred.

Chrome no longer writes the name. Blink's DevTools agent wrote CompositeLayers around each commit until February 2015, when the event moved into cc itself as ScopedCommitTrace and shipped in Chrome 42. Chrome 94 added frameSeqId alongside layerTreeId. Commit e8a4175e91, first shipped in Chrome 111, changed the name to Commit and nothing else: the same struct, call sites, category and arguments. The same release gave the layerization step its own event, Layerize. No Chromium source contains the old string today, so you will only meet this event in a trace recorded by Chrome 110 or earlier, or in a tool written to read one.

Derivations

dur minus tdur is the part of the slice in which the thread was not running. Under threaded compositing that is the block on the compositor thread, so a long commit with little thread time points at a compositor that was slow to pick the work up rather than at an expensive copy. For a layer tree with no compositor thread the two are within a microsecond of each other, which is one way to tell which kind of compositor wrote an event you are looking at.

Traps

The name says composite, but the work is the commit. Nothing inside this slice rasterises, draws or composites anything to the screen. That happens afterwards on the compositor thread, marked by ActivateLayerTree and DrawFrame.

Commit is not the same measurement under a new name. Chrome 111 moved layer updating inside the span five days before it renamed the event, in commit 54d0b36333, so both changes reached users in the same release. Every stable build that wrote CompositeLayers opened the slice after that work; every build that writes Commit includes it, single-threaded compositors aside. A jump between an old trace and a new one can be the definition changing rather than the page.

Chrome emitted it as a begin and end pair, but what lands in a trace file is one complete event carrying dur, tdur and the arguments. trace_engine types it as an instant event with layerTreeId alone, which is wrong about the phase and about the arguments; from Chrome 94 the event also carried frameSeqId. Commit is typed as an instant there too.

Not every one belongs to your page. layerTreeId identifies which compositor committed, and every compositor built on cc writes the event, including the browser's own interface, which in a recording of a single page can write more of them than the page does. DevTools only counts events whose layerTreeId matches the SetLayerTreeId for the main frame. Filter the same way, and filter by process as well, because the id comes from a counter that is private to each process.

Do not normalise an old trace by renaming these events to Commit. DevTools rejects a Commit with no frameSeqId, and accepts a CompositeLayers without one, so a rename silently drops every event recorded before Chrome 94 from the frame model. It also collides: in a current trace three different events are called Commit, on three different categories, and only the one on disabled-by-default-devtools.timeline is this event.

Lighthouse still groups CompositeLayers under Rendering and does not list Commit at all, so the replacement takes its parent task's group instead, which in a current trace is Other. The same work moves from one bucket to the other in Lighthouse's main-thread breakdown between an old trace and a new one.

A short slice does not mean a cheap commit. cc opened it before deciding whether there was anything to commit, so an empty main frame produced one too, with the EarlyOut_NoUpdates marker inside it.

The category is disabled by default. DevTools turns disabled-by-default-devtools.timeline on for a Performance recording, but a tracing session you configure yourself has to ask for it.

Example event

Renamed to Commit in Chrome 111, and the collection recorded Commit. DevTools still accepts both names so that old traces keep importing.

See also Commit · BeginMainThreadFrame · SetLayerTreeId · ActivateLayerTree · DrawFrame

references
devtools_instrumentation.cc
ComputeIntersectionsCompute intersectionsnever written by Chrome

DevTools' name for Chrome working out how far each element watched by an IntersectionObserver overlaps its root, which is the viewport unless the observer named an element.

No Chrome build writes this name into a trace file. It exists in the model, not on the wire.

Spec

ComputeIntersections is the name DevTools declares for Blink's IntersectionObserverController updating a document's IntersectionObserver observations on the renderer's main thread, work that Chrome writes to the trace only as IntersectionObserverController::computeIntersections.

Behaviour

Chrome never writes this name. The work it stands for reaches the trace as IntersectionObserverController::computeIntersections, fully qualified and with a lower-case c, on blink,devtools.timeline and with no arguments. Everything below describes that event.

The controller is a per-document object that holds every observation made in that document. A document gets one the first time something in it calls observe(), and keeps it: disconnect() removes the observations but not the controller, and the event carries on afterwards.

From then on, each main-frame update runs the controller twice for that document, both times inside ProxyMain::BeginMainFrame. The first pass comes near the end of the lifecycle update, after pre-paint and before paint, and serves only observers that must deliver before paint, such as the one Blink uses for content-visibility: auto. Anything else is skipped there without measuring geometry. The second pass runs inside the Commit slice, after the main thread has handed the frame to the compositor, and computes everything that delivers later: the page's own observers, and Blink's own loading=lazy watcher. Which pass an observation lands in is decided by its delivery behaviour, not by whether the page or the browser created it.

The count therefore follows frames and documents rather than observers: two per document per main-frame update, four when a same-origin iframe on the same thread also observes something, and more when the first pass makes the lifecycle repeat, which content-visibility: auto does when a section changes state. A parent's slice and a child frame's slice are siblings, not nested.

The slice is written whether or not anything needed updating. Each observation is only recomputed when something has marked it out of date, but that check happens inside the slice, so the controller still walks every observation on frames where nothing moved.

Not every controller is yours. Native loading=lazy images and content-visibility: auto sections both produced the event on pages with no script observer at all. Nor is the callback in the slice: a JavaScript observer's entries are queued, and a task the controller posts runs the callback later.

The naming has a history on both sides. Chromium wrote IntersectionObserverController::computeTrackedIntersectionObservations until Chrome 78, and wrote it on the blink category alone until Chrome 92, when commit bab2cb7b98, "DevTools: Expose computeIntersections trace event", added devtools.timeline. DevTools gained ComputeIntersections in the same release, as a key mapped to the real name, and labelled the event "Compute intersections". The newer trace engine declared the key with itself as the value. When a March 2024 refactor, first shipped in Chrome 124's DevTools, moved the Performance panel onto that table, the label stopped matching anything Chrome writes.

Derivations

Summed per frame, these slices are the main-thread cost of intersection observation, and in a DevTools recording nothing else measures it. Blink.IntersectionObservation.UpdateTime covers the second pass too, but only on the blink category, which the Performance panel does not record.

Traps

Searching a trace for ComputeIntersections finds nothing, from any Chrome version. Search for IntersectionObserverController::computeIntersections. Code built on trace_engine has the same problem: Types.Events.Name.COMPUTE_INTERSECTION holds the bare string and never matches a real event.

The Performance panel hides the real event. DevTools draws a main-thread event only if it has a style, and the only style is keyed on the bare name. The event is recorded and sits in the saved JSON, but the flame chart leaves it out unless the "Show all events" setting, timeline-show-all-events, is on. Its time is folded into whatever visible slice encloses it: Commit for the second pass, the plain task for the first.

A DevTools recording from Chrome 91 or earlier does not contain the event at all, because it was on blink only. Before Chrome 78 it had a different name.

There are no arguments, so the trace cannot tell you which observer did the work, or even whether it was one of yours. Chrome counts internal and JavaScript observations separately inside this function and sends both counts to its metrics, not to the trace.

The number of events tells you nothing about observers, callbacks or intersections. A page with one observer and a page with thousands produce the same count, and a page that disconnected its only observer keeps producing them.

For the page's own observers the cost is in the second slice of each pair, not the first; for content-visibility: auto it is the other way round. In one headless Chrome 153 capture with 2,000 observed paragraphs, the first pass had medians of 26 to 47 microseconds, the second 67 to 102 on frames with no animation and 573 during a requestAnimationFrame loop that moved an unrelated element with left; the single largest slice was 1,158. With one observer, every median stayed under 5. Read the pair per frame, never one slice alone, and do not expect milliseconds.

The slice is not the whole cost of an observer. The callback runs in a separate task and appears as its own FunctionCall.

Example event

Chrome never writes this name. A fixture page running an IntersectionObserver produced the real one, IntersectionObserverController::computeIntersections, fully qualified and with a lower case c.

See also Commit · PrePaint · BeginMainThreadFrame · FunctionCall

references
TraceEvents.ts
Decode ImageImage decode

Chrome decodes an image's compressed bytes into pixels.

Spec

One of Blink's image decoders turns one frame of an image, or as much of it as has downloaded so far, into pixels, on whichever renderer thread asked for them.

Arguments

pathdeclaredobserved
args.imageTypeyes100.0%

Observed 4,611 times on 153 of 300 sites.

Behaviour

Two places in Chromium write this name. ImageDecoder::DecodeFrameBufferAtIndex writes it for every format, and only if the frame it was asked for is not already fully decoded in that decoder. JPEGImageDecoder::DecodeToYUV writes it for JPEG decoded to YUV, the separate brightness and colour planes a JPEG actually stores, before they are converted to RGB.

For images on the page, <img> and CSS backgrounds alike, you will find it on a worker thread, ThreadPoolForegroundWorker, not the main thread. Paint only records that an image goes somewhere; the compositor schedules the decode as a task, so the event sits inside ImageDecodeTask and Decode LazyPixelRef:

ImageDecodeTask            the compositor's decode task       worker thread
  Decode LazyPixelRef      Blink is asked for the pixels
    Decode Image           the decoder runs

Which cache runs that task depends on how Chrome rasterizes. With GPU rasterization it is GpuImageDecodeCache, otherwise SoftwareImageDecodeCache, and the difference changes what this event shows (see traps). decoding="sync" does not move the work to the main thread. The attribute is a hint to the compositor about whether it may present a frame without the image, not a choice of thread, and images marked sync decoded on a worker in both raster modes.

The main thread gets one when script asks for pixels. A canvas 2D drawImage decodes when the canvas frame is finalized, WebGL texImage2D with an image decodes inside the call, and createImageBitmap on an <img> decodes the file again from scratch, even when the page has already decoded it. createImageBitmap on a Blob and the WebCodecs ImageDecoder decode on a worker, with no Decode LazyPixelRef around them.

One image can produce several of these. While an image is still downloading, Blink paints what it has, so each repaint with more bytes decodes again: an 800x600 baseline JPEG and an 800x600 PNG, each delivered in eight chunks, produced three and four events. An animated image gets one per frame the first time round. Once decoded pixels are in the compositor's decode cache, drawing them again writes nothing: a three-frame GIF animating for two seconds produced three, against twenty-one decode tasks.

The frame waits for the decode. Chrome builds each tile's raster task with the decodes of the images in that tile as dependencies, so the frame that first shows an image cannot be presented until its decode has finished. The exception is decoding="async", below.

Derivations

Decode time inside an image LCP. When the LCP element is an image, decoding is not async and a Decode Image exists for it, the candidate cannot be presented until that event has ended, and on GPU raster until the ImageUploadTask that follows it has ended too. Five runs per case on a 4000x3000 JPEG inserted after it had fully loaded, timed from the insert: under software raster the decode ended at 78 to 88 ms and the LCP candidate landed at 100 to 109 ms; under GPU raster the decode ended at 50 to 62 ms and the candidate landed at 108 to 122 ms. That is render delay no main-thread view of the trace shows, because none of it ran on the main thread.

Main-thread decodes. Every Decode Image on CrRendererMain in these captures, and none at all in a DevTools recording made on a different machine, was script asking for pixels through canvas, WebGL or createImageBitmap, and each one occupied the main thread for its full duration. Page images decoded on a worker in both raster modes and on both machines. So filtering this event to the main thread gives you the decode cost your own script put in the way of input, which is the cost createImageBitmap on a Blob would move to a worker.

Traps

A GPU-rastered trace does not show WebP or AVIF decodes to YUV at all. Only the JPEG decoder's YUV path writes this event; the WebP and AVIF decoders' do not. You see a Decode LazyPixelRef with nothing inside it, or ImageFrameGenerator::decodeToYUV if you recorded blink. One page of five images produced four events under software raster and two under GPU raster, losing the WebP and the AVIF. Chrome uses YUV only for a static image whose bytes had all arrived before its first decode, so whether an image appears here depends on the raster mode, the format and the network. Never count these events as "images decoded", and never compare counts between a headless crawl and a DevTools recording made on a machine with a GPU.

imageType is the decoder's file extension, from sniffing the bytes, not the URL or the Content-Type. It is jpg, not jpeg, and on the YUV path it is uppercase JPEG, so any JPEG breakdown needs both. A JPEG served as photo.png with image/png reports jpg.

There is no URL on this event, and the one DevTools shows can belong to another image. DevTools takes the most recent PaintImage on the same thread, or failing that the most recent Decode LazyPixelRef on the same thread, without checking that either is related. A main-thread canvas decode reporting imageType: "png" was labelled with the .jpg URL of an unrelated <img> painted earlier, and a createImageBitmap decode on a worker was labelled with the JPEG that worker had decoded before. Your own join is safer: the enclosing Decode LazyPixelRef carries the image's id, and the Draw LazyPixelRef written when an element painted that image carries the same one. An event with no enclosing Decode LazyPixelRef cannot be tied to an image at all.

A canvas decode is not under the drawImage call. It runs at CanvasRenderingContext2D::FinalizeFrame, which is a later task with no script on the stack unless something such as getImageData forces the canvas to flush first.

dur covers the decoder's own work and nothing else. Colour conversion runs after it, and the copy to the GPU is a separate ImageUploadTask. AVIF is given two decoder threads, so its tdur came out well below its dur, between a third and a half of it across captures, while every other format matched: for AVIF, dur is how long this thread waited and tdur undercounts the CPU.

With decoding="async" the LCP can be stamped before the pixels reach the screen. The compositor may present frames with the image's box still blank and fill it in later, and the LCP candidate is recorded on one of those frames. Across ten runs with screenshots, LCP was reported 76 to 138 ms before any presented frame contained a 4000x3000 photo. Chrome does this only for an image marked async that is static, was already fully downloaded when it was first drawn, and is at least a megabyte of decoded pixels on desktop, or half that on Android with GPU raster. So a cold first load is not affected: the hero is first drawn while still arriving and Chrome declines to defer it. A repeat view from the HTTP cache, or a preloaded hero held back by slow CSS, is affected, and there the reported LCP came in 15 to 29 ms ahead of the frame that actually showed a 1920x1080 hero. Do not read an LCP timestamp as proof that the image's pixels were on screen.

Several events for one image while it loads are separate partial decodes, not one slow one. This is about partial data, not progressive encoding: a baseline JPEG and a PNG both did it. Match them by the enclosing Decode LazyPixelRef id before adding durations up.

Example event from https://www.bbc.com/news
{
  "name": "Decode Image",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1108423719,
  "dur": 99,
  "tdur": 99,
  "tts": 929,
  "pid": 8407,
  "tid": 8471,
  "args": {
    "imageType": "png"
  }
}

See also Decode LazyPixelRef · ImageDecodeTask · ImageUploadTask · PaintImage · largestContentfulPaint::Candidate

categories
disabled-by-default-devtools.timeline
usage
moderate
duration
long
references
image_decoder.cc, jpeg_image_decoder.cc
Document::UpdateStyleAndLayoutnot in DevTools recordingsnot in DevTools' model

Chrome makes sure a document's style and layout are up to date, because some code needs to read them right now.

A DevTools Performance recording does not contain this event. Record blink with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Spec

Blink's Document::UpdateStyleAndLayout brings one document's style and layout up to date for a caller that needs current geometry, updating any parent document first, on the renderer's main thread.

Behaviour

This is the on-demand path, not the scheduled one. Anything in Blink that is about to read geometry calls it first: offsetHeight, getBoundingClientRect(), a layout-dependent property read through getComputedStyle(), elementFromPoint(), scrollIntoView(), innerText, focus changes, editing, selection and printing. The rendering update Chrome runs in each frame does not come through here. It calls LocalFrameView::UpdateStyleAndLayout directly, so an animating page with no script produces frame after frame of Layout and not one of these.

The event is written before Blink checks whether anything is dirty, so every call produces one, whether or not there was work to do. A hundred consecutive offsetHeight reads with nothing changed in between produced a hundred events of about five microseconds each, and not one of them contained a Layout. The count tells you how often something asked for geometry, not how often Chrome laid out. Reads against a detached node or a document that was never put on screen do not reach the function at all, and produce nothing.

When the document is a same-origin iframe, Blink updates the parent document first, inside the child's slice. One read in the iframe therefore produces two nested events, and a dirty parent gets laid out on the child's behalf. It does not work the other way round: reads in the parent left a dirty iframe alone. A cross-process iframe has no local owner, and nothing crosses.

Your script is not the only caller. Blink forces one after the load event for the main frame and for visible iframes, and Chrome's own features add more around DOMContentLoaded and load. The one that will surprise you is selection: once a focused frame holds a selection, Chrome's input-method bookkeeping calls this function during every main frame, at the start of the frame to report the selection bounds to the browser, and again afterwards to update text input state. The trigger is the selection itself, not the click that usually creates it. Setting one from script, with no input at all, produced the same events a real click did, and clearing it took them back to none. It costs nothing on an idle page, because an idle page produces no frames; on a page that is animating, scrolling or running script it is every frame. Reporting the bounds is skipped on Android, where the compositor sends them instead, but the text input state half runs everywhere.

Almost every call opens a Blink.ForcedStyleAndLayout.UpdateTime slice inside this one, and this function is the only place that slice is opened. It is skipped for a document with no view, for a re-entrant call on a view already inside one, and on a machine with no high-resolution clock. Blink decides whether to charge a call as forced from a C++ DocumentUpdateReason that is never written to the trace, and no caller in Blink passes it the one reason that would exempt it.

Derivations

Layout thrashing, counted. Within one task, the events that contain a Layout are the layouts your code forced, and the ones that contain nothing were reads against a tree that was already clean. A loop of a hundred style writes each followed by an offsetHeight read produced a hundred of these with a Layout inside every one; the same hundred reads without the writes produced a hundred with nothing inside.

Who forced it. When one of these did real work, the UpdateLayoutTree and Layout nested inside it carry beginData.stackTrace, if you recorded disabled-by-default-devtools.timeline.stack, and that stack points at the line of JavaScript that asked. A nested Layout under this event with no stack was forced by Chrome rather than by script: in one capture, every such layout sat under selection bookkeeping or the end of parsing. Record the renderer category and the selection callers appear by name, as WidgetBase::UpdateSelectionBounds and WidgetBase::UpdateTextInputStateInternal.

Traps

Not every forced update appears here. Reading a property that does not depend on layout, such as getComputedStyle(el).color after a style write, recalculates style through a different function and produces an UpdateLayoutTree with none of these around it. For a layout-dependent property the style recalculation runs first, before this slice opens, so only the layout lands inside.

elementFromPoint() is a caller like any other. It opens one of these before it hit tests, to make sure the scrollbars it is about to measure against are current, and if the tree is dirty the style and layout happen inside it. What runs afterwards under LayoutView::HitTest is the rest of the lifecycle, prepaint included, and that part is outside the slice.

Style and layout inside a frame may not be script's fault. Once a selection exists, the selection-bounds call at the start of each frame is the first thing that needs layout, so the frame's pending work, a CSS animation's style and layout for example, runs inside this event ahead of requestAnimationFrame callbacks and the rendering update, and is charged as forced. Do not read a slice here as script-forced without a stack or a script event around it.

The population depends on how you recorded. A scripted click, of the kind a crawler sends to provoke input events, leaves a caret selection behind, and that selection multiplies these on every frame for the rest of the recording.

Durations nest across iframes. The parent document's event sits inside the iframe's, so summing dur over all of these counts the parent's work twice.

The event is on the blink category. A DevTools Performance recording neither captures it nor draws it unless the timeline-show-all-events setting is on: that one setting adds every category to the recording and lets an event DevTools has no model entry for be drawn at all. With it on, the slice appears under the System colour with its raw C++ name, above UpdateLayoutTree and Layout, and not as the Layout block, which DevTools draws around the Layout event two levels below.

DevTools' own forced-reflow warning never looks at this event. It counts Layout and style recalculation nested inside JavaScript, and only reports a task whose total crosses thirty milliseconds, so a page can be full of these and be flagged for none of them.

Example event a local fixture page
{
  "name": "Document::UpdateStyleAndLayout",
  "cat": "blink",
  "ph": "X",
  "ts": 1102948044,
  "dur": 8,
  "tdur": 5,
  "tts": 44136,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also Blink.ForcedStyleAndLayout.UpdateTime · LocalFrameView::UpdateStyleAndLayout · Layout · UpdateLayoutTree

categories
blink
usage
very high
duration
short
references
document.cc
DrawFrameDraw frame

The compositor has finished a frame and handed it to Chrome's display compositor.

Spec

A renderer's compositor turns its active layer tree into a compositor frame and submits it to Viz, the GPU-process service that puts frames on screen, on the renderer's compositor thread.

Arguments

pathdeclaredobserved
args.frameSeqIdyes100.0%
args.layerTreeIdyes100.0%

Observed 21,196 times on 182 of 300 sites.

Behaviour

In Chromium's compositor, draw does not mean pixels. LayerTreeHostImpl::DrawLayers builds a compositor frame, a list of already-rasterised tiles and quads to be placed on screen, and hands it to the pipe to Viz. DrawFrame is written just after that hand-off, before anything has been drawn. Viz then combines the frame with the browser UI and every other renderer's frames, draws and swaps in the GPU process, and only then is anything visible. In software-only fixture captures on one machine the frame reached the screen a median of about 4 ms later; in a DevTools recording of a real page load the gap ran to tens of milliseconds once the page had settled and into the hundreds during the load, spent waiting for and doing the draw inside Viz.

Chrome writes one only when there is something to draw. Each BeginFrame gives the compositor a chance to produce a frame, and one where the active layer tree has no damage takes an early exit before this line, leaving a cc event called EarlyOut_NoDamage behind. An idle page therefore writes almost none and an animating page writes one per display refresh, so how far the DrawFrame count falls short of the BeginFrame count is a statement about the page, not about traces in general.

The main thread does not have to take part. The compositor waits for the main thread's update until a deadline inside the frame, then draws without it, carrying compositor-driven animations and scrolling forward on the last content it was given. During a 600 ms busy loop in one capture the compositor kept writing DrawFrame for a CSS animation, with no BeginMainThreadFrame and no Commit at all, and each of those draws landed about 16 ms into its frame rather than the usual 1 ms.

The browser's own UI compositor is excluded by an explicit check, so a trace can hold BeginFrames and PipelineReporters from the browser process and no DrawFrames. Every renderer compositor writes them, which means the main frame and each cross-site iframe running in its own process, each with its own layerTreeId.

Derivations

When a drawn frame actually reached the screen, or whether it ever did. Join to PipelineReporter on process, layerTreeId = layer_tree_host_id and frameSeqId = frame_sequence: the reporter ends at the presentation timestamp, and its state says whether the frame was presented, partially presented or dropped. DrawFrame to that end is the part of a slow frame that happened after the renderer had finished, in Viz and the GPU, which separates a busy renderer from a busy display pipeline. The join is not one to one: a frame can carry a second reporter, marked frame_type: FORKED, which follows the main thread's half of that frame and can end dropped while the compositor's frame was presented.

Frame production for compositors DevTools does not show. Counting DrawFrame per process and layerTreeId gives the submitted frame rate of each cross-site iframe, such as an animating ad, which the Frames track leaves out.

Traps

DrawFrame is not presentation. It is the hand-off to Viz, and a handed-off frame can still be thrown away: when Viz presents a later frame first, every earlier one still waiting is recorded as not presented. Anything measuring "time to pixels" from this event is measuring time to hand-off.

A BeginFrame without a DrawFrame is usually not a dropped frame. Most often nothing on the active layer tree had changed, and Chrome reports the frame as no update desired. It reports one as dropped instead when the frame was skipped for any other reason, so the reporter's state is what tells you which, never the absence of the event.

A dropped frame may have a DrawFrame of its own. It has one exactly when the compositor had something to draw and only the main thread's update went missing: during a blocked main thread with a CSS animation running, every frame Chrome flagged as dropped also wrote a DrawFrame. On a page whose animation runs on the main thread, the same block produced dropped frames with no DrawFrame at all. hasPartialUpdate on a DroppedFrame does not mean anything was drawn either. DevTools takes its dropped and partial flags from DroppedFrame and never from a missing DrawFrame.

A steady stream of DrawFrame does not mean the page is responsive. Compositor animations and scrolling keep producing them while the main thread is completely stuck.

layerTreeId is not unique in a trace. It is a counter local to each process that starts at 1, so two renderers in one capture reported 1, and one frame switched from 1 to 2 during load. Join on process as well, always.

frameSeqId is shared, not yours. The number comes from the display's begin-frame source and goes to every client, so the main frame, a cross-site iframe and the browser UI all use the same numbers at the same moments. It also counts from browser start rather than trace start, so a recording opens in the hundreds or thousands and there is no frame 1 to look for.

frameSeqId is the BeginFrame during which the frame was drawn, not the one in which any main-thread work for it began. Content that took three refreshes to produce carries the last one's number.

ts is when the event was written. BeginFrame's timestamp is the frame's scheduled start instead, so the gap between the two on one frameSeqId is how far into the frame the draw happened, not how long the compositor worked.

The Frames track matches a layer tree number, not a process. DevTools takes the main frame's layerTreeId and accepts any frame event carrying it, whatever process wrote it. A cross-site iframe animating at 60 fps on a different number wrote 180 DrawFrames in three seconds and DevTools built two frames; one that landed on the same number instead fed the same model, and its requests for main-thread frames held the gate shut and suppressed the page's own frames. Within the track, DrawFrame only decides which queued BeginFrames become frames: a frame starts at its BeginFrame and ends where the next one starts.

The first DrawFrame of a recording can have no BeginFrame, because that BeginFrame happened before tracing started.

The whole frame family lives in disabled-by-default-devtools.timeline.frame. A recording without that category has none of it.

trace_engine ignores DrawFrame unless it is an instant event. Its own comment says older Chrome builds wrote an async begin and end pair, so a very old trace yields no frames.

Example event a local fixture page
{
  "name": "DrawFrame",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1102924416,
  "tts": 2021,
  "pid": 8257,
  "tid": 8278,
  "s": "t",
  "args": {
    "frameSeqId": 6,
    "layerTreeId": 1
  }
}

See also BeginFrame · BeginMainThreadFrame · Commit · DroppedFrame · PipelineReporter

categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, layer_tree_host_impl.cc
firstContentfulPaintFirst Contentful Paint

This event tells you when the first content on the page - text, an image, a video, a canvas or an SVG shape - reached the screen.

Spec

Blink's PaintTiming records the presentation time of the first compositor frame that carried contentful content for one document, written on that document's renderer main thread once the compositor reports that frame presented.

Arguments

pathdeclaredobserved
args.data.navigationIdyes100.0%
args.frameyes100.0%

Observed 398 times on 180 of 300 sites.

Behaviour

Painting does not write this mark. A painter that draws something contentful raises a flag on Blink's PaintController, and at the end of the paint Blink bundles the flags raised during that rendering update and asks the compositor for a presentation promise on the frame being produced. The mark is written when the feedback for that frame comes back, on that document's renderer main thread, and it is stamped with the presentation timestamp that came with it: the moment the pixels were on screen, not the moment they were painted. The timestamp is handed to the trace macro rather than taken from the clock, so the record is written later than the instant it claims.

What counts as contentful is a short list with sharp edges. Text that is not all whitespace. An image being drawn, where an image means a real image resource: an <img>, a background-image pointing at a file, an SVG <image>. A <video> element's layer being prepared for the compositor. A <canvas> that has been drawn into. The fill or the stroke of an SVG shape. Text, images and video raise a flag that the end of the frame reads; canvas, SVG shapes and SVG images call the marking function directly.

The edges are where a reader goes wrong. A CSS gradient is a generated image rather than a contentful one, and Blink hands it a do-not-report flag, so a page whose hero is a gradient gets a first paint and nothing here. An empty <canvas> has not been drawn into and does not count, even with a 2D context on it; a canvas filled with white does, because the test is whether anything was drawn and never what colour it was. An <svg> with no shapes in it invokes no painter. Anything painted under opacity: 0 raises nothing, so a page whose content starts transparent gets its mark from the paint after the opacity changes.

PaintTiming belongs to a document, and a CHECK in Chromium makes a second mark for the same document impossible. One document, at most one mark, and nothing further for a soft navigation or a restore from the back/forward cache. But every document gets its own: a same-origin iframe writes its own pair of paint marks on the same thread as its parent, a cross-site iframe writes them in its own renderer process, and a main frame that paints nothing contentful writes none at all while its iframes do.

Derivations

FCP itself is this ts minus the ts of the navigationStart carrying the same navigationId, which is exactly what DevTools computes before classifying it against 1.8 and 3.0 seconds.

The gap from firstPaint in the same frame and navigation is how long the page was on screen with nothing on it: painted, laid out, and showing only a background. When the first paint was already contentful the two marks carry the same timestamp, so a gap exists only when there was really one.

Per-iframe FCP. Each frame's mark stands against that frame's own navigationStart, so the trace gives you a first contentful paint for an embedded document, including a cross-origin one that no script on the page can reach: a document's performance entries only ever describe that document.

Traps

This is not the number your RUM script reports. The mark carries the raw presentation timestamp, because Chromium does not coarsen timestamps it does not expose to the web. The PerformancePaintTiming entry that a field script reads carries the same instant rounded up to the next multiple of 4 ms, unless the document is cross-origin isolated. DevTools uses the raw ts, so the Performance panel and a beacon from the same load can differ by up to that. Measured on a one-paragraph page: the trace said 16.008 ms and the entry said 20. Serve the same page with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy set and the coarsening stops, and the entry reports the same fractional millisecond the trace does.

The paint-side timestamp is not in the trace. Blink carries it alongside the presentation time all the way into the callback that writes this mark, and writes only the presentation time. The web-exposed entry does expose it, as paintTime, but it is not when painting finished: it is taken near the top of the rendering update, before the paint phase runs. So presentationTime - paintTime covers the paint, the commit, the raster and the swap together, and this event will not tell you how much of that was which.

The mark can precede anything you could see. A <video> marks it when its layer is prepared for the compositor, not when a frame of video appears: a fixture whose video source never delivered a byte still produced the mark on a blank screen, and Chromium says so at the call site. Text still inside a font-display: block period marks it too. A page whose only content is a paragraph in a web font that never arrives reports a first contentful paint and then stays blank; the font-readiness test in Blink's text painter guards the LCP detector, not this flag.

The record is behind its own timestamp. In every capture the mark's ts fell in a gap between two tasks on its own thread, with the next task starting anywhere from a fraction of a millisecond to thirteen milliseconds later. Nothing was running at the instant the mark claims. Do not read the enclosing task as the work that produced it, and do not assume marks reach the file in timestamp order.

Nothing on the event says which frame this is. There is no isMainFrame or isOutermostMainFrame here, unlike an LCP candidate or a navigationStart. Join args.frame to the frame tree, or you will read an iframe's FCP as the page's. DevTools does the join and draws the marker for the main frame only, so an iframe's mark is in the trace and not on the ruler.

The frame id is at args.frame, not args.data.frame, which is where MarkDOMContent and MarkLoad keep theirs.

A frame id does not separate two loads. Reload a page and the second mark has the same frame id as the first; only navigationId tells them apart. Join on it rather than on the nearest navigationStart, because one load can leave three of those in a trace, across two processes, and picking the wrong one moves the answer by milliseconds. DevTools throws rather than guess when the id is missing.

A soft navigation produces no mark here. The document already used its one FCP. DevTools manufactures a soft FCP from a field inside SoftNavigationStart instead, so a soft-navigation FCP on the timeline has nothing to do with this event.

It is phase R, a mark, with no duration; a consumer filtering for instants (I) will not see it. And firstPaint can carry exactly the same timestamp, because a first paint that is contentful backdates the first-paint stamp to its own. Which of the two is written first is the iteration order of a hash set, not a fact about the page.

Example event a local fixture page
{
  "name": "firstContentfulPaint",
  "cat": "loading,rail,devtools.timeline",
  "ph": "R",
  "ts": 1102971047,
  "tts": 133797,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "navigationId": "F19F977AB13E669964965F7427E6F7B2"
    },
    "frame": "14A831DCB73EB1205B67CDB89FC6EBD7"
  }
}

See also firstPaint · navigationStart · largestContentfulPaint::Candidate · SoftNavigationStart

categories
loading, rail, devtools.timeline
usage
rare
references
inspector_trace_events.cc, paint_timing.cc
firstPaintFirst Paint

This event tells you when the browser first painted something of the page's own, rather than the blank background it starts from.

Spec

Blink's PaintTiming marks the presentation of the first frame in which a document drew anything other than its default background, timestamped with the presentation time the display compositor reported for that frame, on the renderer main thread that owns the document.

Arguments

pathdeclaredobserved
args.data.navigationIdyes100.0%
args.frameyes100.0%

Observed 400 times on 180 of 300 sites.

Behaviour

Blink decides what counts while it paints, one document at a time. A stack object, FramePaintTiming, brackets each document's paint; when it goes out of scope it asks the paint controller, the object that records the drawing operations for a frame, whether anything was drawn, and hands the answer to PaintTiming. The paint controller says yes at the first recorded drawing operation that actually puts pixels down, with one exception: the document's own background is skipped there, because at that point nothing knows whether it is the default one. The root background gets in through a separate door in ViewPainter, which raises the flag by hand only when the colour it is about to fill with differs from the frame's base background colour.

The working test is therefore whether the page drew anything of its own, and the line falls in some surprising places. A border counts, including the border Chrome puts on an <iframe> for you. So do a box shadow and an empty form control. So does a white box on a white page, which nobody can see but which is still a fill. A background counts only when it is not the colour the frame started with, so a page whose only styling is background: #fff writes nothing at all. That base colour is a variable, not a constant: under color-scheme: dark it is dark, and the same background: #fff then does count while an unstyled page still does not. A body background is part of the document background only while it transfers to the canvas, which stops as soon as the root element has a background of its own; after that it is an ordinary fill and counts like any other box. A box with size but no decoration draws nothing, and neither does anything under opacity: 0 or visibility: hidden. Painting for printing or for a paint preview runs inside a scope that suppresses paint timing, under a feature that ships on.

Raising the flag does not write the event. It puts kFirstPaint into a set of pending paint events; after the lifecycle update the document asks the compositor to call back when that frame has been presented, and only in that callback does Blink write the mark, stamped with the presentation time rather than with the time of the callback. So the event sits in the trace a little earlier than the task that produced it, and later than the Paint slice that did the drawing.

One per document, ever. The mark and its timestamp are each guarded by a null check on the value they set, and there is no soft-navigation copy of the timing to reset. A second full navigation in the same tab is a second document and writes a second event. A same-document navigation, however much of the page it rewrites, writes none. A page restored from the back/forward cache records its first paint through a different function, which writes nothing to the trace.

Every document writes its own, so an iframe gets one for itself, from its own renderer process when it is cross-origin. The parent does not inherit it, but it is not left out either: the iframe's border is the parent's own paint. Take the border away and a page whose body holds nothing else has drawn nothing of its own, and writes no event, while its child writes one.

First contentful paint marks first paint on its way past, so a document that reaches firstContentfulPaint always has a firstPaint at or before it. Usually it is the same frame, and the two events share a timestamp. Raising the flag is also one of the two things that release paint holding after a navigation, the other being first contentful paint.

Derivations

The interval from this event to firstContentfulPaint in the same document is how long the viewport was styled but empty: painted, so not the browser's blank canvas, and with nothing in it yet. A fixture that set a root background immediately and injected its text a second later put firstPaint at 23 ms and firstContentfulPaint at 1,023 ms. When the page's first drawing is also its first content, which is the ordinary case, both events land in the same frame and the interval is exactly zero: it was zero in every capture made for this entry that had both, and in every trace already on disk.

An absolute number needs the navigationStart whose navigationId matches, not the first one in the trace.

Traps

Its absence does not mean the page did not paint. A fixture whose only styling was a white background over a white base produced Paint, Commit, ActivateLayerTree and DrawFrame and no firstPaint, in Chrome 141 and 153 alike. A page whose body holds only a borderless iframe produces none either, and neither does one whose content is entirely under opacity: 0. Treating the mark as "the screen was blank until here" is wrong in both directions.

args.frame is not a document. It is the frame the document lives in, and a frame outlives its documents: two navigations in one tab wrote two marks with the same frame id, and a trace already in this collection does the same across three renderer processes. args.data.navigationId, the id of the document's loader, is the only field that separates them, and it is the one to join on. It can also name a navigation that is not in the trace at all, when recording started after it; trace_engine drops the metric when that happens.

ts is a presentation time, not a paint time. It is the timestamp the display compositor reported for the frame, so on a real display it is tens of milliseconds after the main-thread Paint slice that drew the content, and it lands on a display refresh. Do not line it up with the paint work; that is not what it measures. Chrome 141 still wrote a thread timestamp on this mark, which shows the backdating directly: firstContentfulPaint and firstPaint shared a ts to the microsecond while their tts values were 64 microseconds apart.

It is not the number the page can read. performance.getEntriesByType('paint') rounds first-paint up to the next multiple of 4 milliseconds unless the page is cross-origin isolated. The trace keeps the uncoarsened value, so the two will not match, and the trace is the more precise of the two.

Chrome 153 writes no tts on this event. Chrome 141 did. In a 153 capture the events written with an explicit timestamp, this one and navigationStart, have no thread time, while RunTask, Paint and Commit still do. Both headless, same trace config, one machine.

DevTools does not show it. The Performance panel builds its Timings markers from a fixed list of navigationStart, soft navigation start, LCP candidates, FCP, DCL and Load, and this event is not on it; the Timings track itself appends only user timings and extension markers, and the event's flame-chart style is declared hidden on top of that. The panel still has a "First Paint" label, an "FP" abbreviation and a green marker colour that nothing reads, and the trace engine still parses the event into a metric score, with no good or poor rating attached. Read from devtools-frontend source; the panel itself was not opened.

In the file it comes after firstContentfulPaint. When both fire from the same frame they share a timestamp, and the write order follows the iteration order of a hash set, not the order of events. Sort on ts and expect ties.

Example event a local fixture page
{
  "name": "firstPaint",
  "cat": "loading,rail,devtools.timeline",
  "ph": "R",
  "ts": 1102971047,
  "tts": 133866,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "navigationId": "F19F977AB13E669964965F7427E6F7B2"
    },
    "frame": "14A831DCB73EB1205B67CDB89FC6EBD7"
  }
}

See also firstContentfulPaint · largestContentfulPaint::Candidate · navigationStart · Paint · Commit · DrawFrame

categories
loading, rail, devtools.timeline
usage
rare
references
inspector_trace_events.cc, paint_timing.cc
HitTestHit test

Blink works out what is at a given point on the page.

Spec

LayoutView::HitTestNoLifecycleUpdate searches one frame's paint layers for whatever lies at a point, or inside a rectangle, on the main thread of the renderer that owns the frame.

Arguments

pathdeclaredobserved
args.endData.listBased-0.0%
args.endData.move-39.7%
args.endData.nodeId-100.0%undeclared, always present
args.endData.nodeName-100.0%undeclared, always present
args.endData.rect-0.0%
args.endData.rectilinear-100.0%undeclared, always present
args.endData.x-100.0%undeclared, always present
args.endData.y-100.0%undeclared, always present

Observed 10,373 times on 174 of 300 sites.

Behaviour

Two unrelated pieces of Chromium write an event called HitTest. The one this entry is about is a slice with a duration on blink,devtools.timeline, carrying an endData object. The other is an instant on the compositor thread, on the input category, whose only argument is disposition: before a touchstart is sent anywhere, the compositor tests it against its own copy of the layer data to decide whether the main thread needs it, and disposition is that answer. A Performance recording never contains the second one, because DevTools does not record input.

The main-thread slice is Blink asking a paint layer tree, the structure it paints a frame from, what lies at a point or inside a rectangle. It is written whether the answer was computed or served from the per-document hit test cache, which holds two entries and is thrown away by any style recalculation, any layout and any DOM change.

What the count follows is callers and frames. Every mouse move, press, release, context menu and drag update makes one, and a point over nested same-process frames makes one per frame level it passes through. A single click makes several: the move that precedes it, the press, the release and the targeting that dispatch does, all but the first usually served from the cache in a microsecond or two. A tap asks twice over, a rectangle of candidates for touch adjustment and then the adjusted point, and that pair repeats across the touch and gesture events. document.elementFromPoint and elementsFromPoint each make one per call, under a FunctionCall, so they are attributable to your script.

The largest source on a busy page is none of those. After every layout, and at the end of every scroll, Blink marks the hover state dirty, and at the top of the next main-thread frame WebFrameWidgetImpl::BeginMainFrame clears that flag by dispatching a synthetic mouse move at the pointer's last known position. That is a full hit test, with move set, and no user input is involved. So a page that lays out every frame hit tests every frame, and a page being wheel-scrolled hit tests once per scroll rather than once per frame: scrolling itself does no main-thread hit test, it just keeps moving the page under a stationary pointer. The whole mechanism is gated on the pointer having moved at least once. In one capture an animating page whose mouse was never touched produced none of these in five seconds; the same page, with a single mouse move at the start, produced one in each of its three hundred frames.

IntersectionObserver produces them only with trackVisibility: true. That option is the v2 occlusion check, Blink refuses it unless you also pass a delay of at least a hundred milliseconds, and that delay is what paces the tests: each update hit tests each intersecting target's rectangle, inside IntersectionObserverController::computeIntersections. Six hundred plain observers produced none.

Blink hit tests your page on its own account too. An ad detector tests the centre of the viewport, at most once a second, looking for an overlay interstitial; in Chrome 153 a second one tests nine-tenths of the way down, looking for a sticky ad. Both run inside Commit, on every page, ads or not, but only after the first contentful paint and only while the page is still producing main frames. A tab that has gone quiet produces neither: a static page with nothing animating and nobody pointing at it wrote no hit tests at all in five seconds.

Derivations

The element, without any correlation work. nodeId is a DOMNodeId, which is the same number CDP calls a backendNodeId: DOM.describeNode or DOM.resolveNode will turn it into a node while that renderer is still alive. In a saved trace you still have nodeName, which names the node and, for an element, carries its id and class attributes.

The main-thread cost of hit testing for one interaction. Sum dur over the events in an input task and you have work that sits directly in the interaction's critical path and that nothing else in a trace measures. Discount the nesting first (see traps).

Traps

Most of what a hit test costs is outside the slice. Except when a caller has already guaranteed a clean lifecycle, a hit test first runs style, layout, compositing inputs and pre-paint, and all of that happens in LayoutView::HitTest, the enclosing function, before the traced slice opens. Chromium's own comment at the call site says "LayoutView::hitTest causes a layout". In one capture of a :hover rule that changes height, the enclosing call was 1,308 microseconds, of which the hit test was 20: 63 in UpdateLayoutTree, 471 in Layout, 712 in PrePaint. Even with nothing dirty the enclosing call ran about twice the slice. DevTools does not connect the two, and it will not call it a forced reflow either: its warning only fires for a layout with a JavaScript call on the stack, and this one has none.

Two emitters, one name. Keep the compositor's instant out of any count: it has no dur, no endData, and describes a decision about a touch, not a search of the page.

Filtering on ph of X silently drops the cheapest ones. A hit test served from the cache can take zero nanoseconds, and Perfetto's JSON exporter writes a zero-duration slice as an instant with no dur. The endData is all there.

Every field but x and y is present-or-absent. None of them is ever written as false, so endData.move === false is never true and absence is the only negative.

A one-microsecond hit test is a cache hit, and its flags belong to an earlier test. The slice opens before the cache is consulted, and a hit replaces the whole result, the request that produced it included. The payload is then built from that request, so move and touch describe whichever test filled the cache rather than the one you are looking at. This is why a mousedown, whose own request never sets the flag, routinely reports move: true a microsecond after the mouse move that preceded it.

move does not mean the user moved the pointer. It is the flag Blink sets for the mouse-move code path, which the synthetic hover update also goes through. The complement is not "a click": it includes both ad detectors, every elementFromPoint and every IntersectionObserver occlusion test.

rectilinear tells you nothing. Every point-based test sets it, by construction, and so does every rectangle. Only a quad-based test can leave it out.

There is no node on a list-based test. elementsFromPoint and touch adjustment ask for a list rather than a target, and the payload builder writes listBased instead of the node, never both. A test that hit nothing has no node either.

Nested same-process frames write several events for one point, and the durations nest. A gesture that descends through three same-origin iframes wrote four slices, each enclosing the next, with x,y stepping into each frame's own coordinates and all four naming the innermost node. Pointer moves over the same page wrote three, this time as siblings, each naming its own frame's node. Adding dur up across a page with iframes double counts; a single point producing several events is a frame count, not a symptom.

nodeName is Node::DebugName(), not a tag name. For an element it is the tag name followed by the id and every class, quoted: DIV id='b7' class='b'. For text it is #text, because the node a hit test resolves is a node, not an element. Treat it as a label, not as anything to parse or select with.

The coordinates are the frame's own, and a rectangle reports its centre. Scroll offset is not applied, so a point that stays under a stationary pointer keeps the same coordinates through a four-thousand-pixel scroll, and an event from a subframe is in that subframe's space. Page scale is divided out, so with a fallback layout viewport the numbers are not the device's CSS pixels: a tap in the middle of a 390-pixel-wide emulated screen with no viewport meta tag reported x of 490, half of the 980-pixel layout viewport. On a rect-based test, x and y are the centre of the rectangle, not a corner.

DevTools draws the bar and shows none of this. There is no detail handling for the event, so the summary pane offers nothing beyond the timings, and the node is never linked, because the code that resolves node ids to elements reads args.data.nodeId for a fixed list of event names and this one is neither on the list nor in that shape. Everything useful is in the trace JSON.

Only two of the callers survive a default recording. A hit test inside Commit is one of Blink's ad detectors; one inside FunctionCall is your own script. LayoutView::HitTest, EventHandler::handleMouseMoveEvent and WebFrameWidgetImpl::BeginMainFrame are all on categories the Performance panel does not record, so every other hit test is an unattributed bar. Turning on "Show all events" replaces DevTools' category filter with a wildcard and records them.

Depth is not what makes one expensive; count and positioning are. Across one set of runs on one build and machine, 40 pointer moves over 600 boxes took a median of 34 microseconds each. Nesting those boxes 200 levels deep took it to 47. Adding 20,000 plain in-flow boxes took it to 544. Positioning those 20,000 took it to about 3.5 milliseconds, per pointer move, and neither pointer-events: none nor putting them far off screen changed that. Why positioning costs so much is not established here, only measured.

Example event a local fixture page
{
  "name": "HitTest",
  "cat": "blink,devtools.timeline",
  "ph": "X",
  "ts": 1104756124,
  "dur": 13,
  "tdur": 12,
  "tts": 207556,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "endData": {
      "move": true,
      "nodeId": 8,
      "nodeName": "DIV class='box'",
      "rectilinear": true,
      "x": 400,
      "y": 300
    }
  }
}

See also EventDispatch · Layout · UpdateLayoutTree · PrePaint · ComputeIntersections · Commit · EventTiming

categories
blink, devtools.timeline, input
usage
moderate
duration
typical
undeclared args
args.endData.nodeId, args.endData.nodeName, args.endData.rectilinear, args.endData.x, args.endData.y, args.endData.move, args.endData.rect, args.endData.listBased
references
layout_view.cc, hit_test_request.h, event_handler.cc, hit_test_location.cc, input_handler_proxy.cc
InlineNode::ShapeTextIncludingFirstLinenot in DevTools recordingsnot in DevTools' model

Chrome turns all the text in one block into positioned glyphs.

A DevTools Performance recording does not contain this event. Record blink with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Spec

Blink shapes the text of one inline formatting context into glyphs with HarfBuzz, plus a second pass for ::first-line when that style needs different glyphs, on the renderer's main thread.

Behaviour

One event is one inline formatting context: one block box that has inline-level content in it. A <p>, a table cell, a list item, an SVG <text>, the inner editor of an <input> or a <textarea>, or the anonymous block Chrome wraps around loose text that sits beside a block sibling. One event covers the whole of it, however the text is marked up inside. In local captures a paragraph containing an <em>, a <strong>, a link and a <span> produced one event, and so did a paragraph set in three fonts, a paragraph mixing English and Arabic, and a paragraph wrapping to dozens of lines. It is not one event per line, per font, per text node, per element or per shaping run.

Text is not required, only inline content. A block whose single child is an <img>, a <br>, an <input> or an empty <span> still has inline items to prepare and still gets an event. A block with no inline content at all gets none: an empty <p>, an empty <div>, a <p> holding only collapsible whitespace. Lists are the case that catches people out. With markers outside, which is the default, every marker is a block of its own, so a <ul> of three items gave six events and three empty <li> gave six as well, while list-style: none and list-style-position: inside each gave three.

Inside the slice Blink walks the block's inline items and coalesces adjacent text into as few HarfBuzz calls as it can, shaping straight across element boundaries wherever the font, the resolved direction and the run segment agree. It breaks the run at a control character, at a zero-width non-joiner, and at an inline box carrying an inline-start padding, margin or border or a vertical-align off the baseline (inline_node.cc:471-527, 1622-1660). None of those runs has an event of its own.

Chrome prepares a block again only when a flag on it, NeedsCollectInlines, has been set, and PrepareLayout clears that flag as soon as it has run (inline_node.cc:621-665), so one invalidation is one event however many times the block is laid out afterwards. Three things set it. A text change, through LayoutText::TextDidChange (layout_text.cc:1083). A web font arriving, through LayoutText::InvalidateSubtreeLayoutForFontUpdates (layout_text.cc:1086-1094), which also invalidates the block's cached inline items, so the reshape is a full one. And a style change that Blink classes as needing a reshape, which is a narrower thing than a style change that needs a layout: ComputedStyle::DiffNeedsReshape sets diff.needs_reshape, and only LayoutText::StyleDidChange and LayoutInline::StyleDidChange act on it (layout_text.cc:233-235, layout_inline.cc:172-173).

That one rule predicts the whole matrix. On a local fixture, font-size, font-family, font-weight, font-kerning, font-variant-caps, letter-spacing, word-spacing, text-transform, white-space, direction, writing-mode and -webkit-text-security each produced exactly one event, as did every edit to a text node, appending a block, and restoring one from display: none. Halving the container, resizing the window, and changing padding, margin, color, background, line-height, text-align, text-indent, tab-size, outline and vertical-align produced none, although every one of those laid the page out again. A border is the odd one: putting a border on an inline box reshapes the block, because it breaks the shaping run there, and changing the width of a border it already had does not. (Chrome 153, Linux, headless, one profile.)

A web font finishing its download invalidates every block set in it, and re-shapes exactly those. In one capture the chain ran RemoteFontLoaded, StyleEngine::InvalidateStyleAndLayoutForFontUpdates, Layout, and then three of these events for the three paragraphs in the late font, with the two in the fallback stack untouched.

One path does not go through layout at all. When script edits part of a text node rather than replacing it, InlineNode::SetTextWithOffset re-shapes the block on the spot (inline_node.cc:1031); in a capture that event sat inside the timer task that called replaceData, with no Layout slice above it.

Derivations

How much of a layout was text. The layout-path events nest inside the Layout slice DevTools already draws, and summing them within one Layout gives the share of it that was shaping, which is the only breakdown of a layout slice Chrome offers outside SVG text. It takes a recording with both categories in it: Layout is devtools.timeline and the shapes are blink, so a blink-only trace has the shapes and nothing to nest them in. On local fixtures that share ran from 8 to 39 percent, the high end being the relayout after a web font arrived.

What a font swap cost, and which blocks paid it. The events that follow RemoteFontLoaded are the reshaping that font-display: swap bought: the count is the number of text blocks that used the font and the sum is the shaping time spent on them. Neither number appears anywhere else, and the layout shift the swap causes tells you nothing about either.

Whether your text is being shaped once or repeatedly. Every event after the first layout is a block whose glyphs Chrome threw away and recomputed, and the load-time count is the page's total of blocks with inline content to measure it against.

Traps

A default DevTools Performance recording cannot contain this event. The panel records a fixed list of categories that does not include blink (TraceEvents.ts:3301), and the fixture that gave one event per paragraph on the blink category gave none under that list. There are two ways round it. Record blink yourself, through the Perfetto UI, chrome://tracing, or Tracing.start over the DevTools protocol. Or turn on Settings > Performance > Show all events, which switches the panel's category filter from - to and draws events it has no model for under their raw name (TimelineController.ts:424-428, ThreadAppender.ts:376). The second is expensive: the same page recorded several times as many events as the default configuration, where recording blink on its own got the same events in a far smaller trace. It also costs you the context: Layout is on devtools.timeline, so a blink-only trace has every shape and no layout slice to attribute it to.

There are no arguments. TRACE_EVENT0 writes a name and a duration and nothing else (inline_node.cc:1859): no element, no url, no character count. Nothing in the trace says which block a slow shape belongs to, so you get a total and a count, and attribution comes from bisecting the page.

The fastest shapes are not phase X. A slice whose duration rounds to zero is exported as "ph": "I" with no dur at all (export_json.cc:886-890), and short shapes round to zero readily. Filtering on ph === "X", or on the presence of dur, drops them silently, and how many it drops depends on the machine and on how short the text is: a fixture of 1,500 one-character paragraphs put a different slice of them in the instant bucket on every run.

A ::first-line rule that changes the font makes each event cover two full shapes of the whole block, not of the first line. Line breaking has not run when the shaping happens, so Blink cannot know which characters are on the first line and re-shapes every item in the node with nothing to reuse, whenever the first-line font differs from the block font or a first-line text-transform rewrites the string (inline_node.cc:1785-1853). The event count does not move, so the cost hides unless you compare durations: on a 40-paragraph fixture, total shaping went from 571 us with no rule to 1,374 us for a font-family rule and 1,574 us for font-weight, with text-transform at 1,083 us. A color rule stayed level, because it needs no reshape at all. Medians of five loads each.

Width does not reshape, but a width that changes the font does. Glyphs and advances survive re-layout at a new width, so resizing the window emits nothing. Size the text in vw, or switch font-family at a breakpoint, and every block in the page reshapes on every resize, through the font rather than through the width.

Not every event is inside a Layout. The SetTextWithOffset path fires during the DOM mutation, so do not assume a parent slice when attributing these to a frame.

The count is a block count, not a measure of how much text there is. The same 24,000 characters split into 20 blocks and into 2,000 blocks gave 20 events and 2,000 events, and about twice the total time, not a hundred times.

The slice is the shaping only. Collecting the block's inline items and segmenting the text into script runs happen immediately before it in the same PrepareLayout and write nothing to the trace, so inline layout costs more than the sum of these events.

Example event a local fixture page
{
  "name": "InlineNode::ShapeTextIncludingFirstLine",
  "cat": "blink",
  "ph": "X",
  "ts": 1102943300,
  "dur": 249,
  "tdur": 153,
  "tts": 40884,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also Layout · RemoteFontLoaded · LocalFrameView::UpdateStyleAndLayout

categories
blink
usage
high
duration
short
references
inline_node.cc
InvalidateLayoutInvalidate Layout

Something has made layout out of date, and Chrome has scheduled the page to be laid out again.

Spec

Blink's LocalFrameView schedules a relayout, after a dirty layout bit has propagated up the box tree as far as a layout root, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.nodeIdyes100.0%
args.data.sampleTraceId-100.0%undeclared, always present
args.data.stackTrace[].columnNumber-46.3%
args.data.stackTrace[].functionName-46.3%
args.data.stackTrace[].lineNumber-46.3%
args.data.stackTrace[].scriptId-46.3%
args.data.stackTrace[].url-46.3%

Observed 31,873 times on 182 of 300 sites.

Behaviour

Marking an element dirty is not what writes this event. Blink sets a bit on the element, then walks up its container chain setting a bit on each ancestor, and it abandons that walk at the first ancestor that already carries one. Only a walk that runs the whole way to a layout root ends in a call to LocalFrameView::ScheduleRelayout or ScheduleRelayoutOfSubtree, and those two functions are the only places the event is written. A layout root is either the frame's LayoutView, which stands for the whole document, or a box Blink is able to lay out without touching anything above it.

So the event marks a transition from clean to scheduled, once per layout root, rather than one dirtied element. In one capture, twenty inline-style writes to a single element produced one event; five removeChild calls in one task produced one, whose stack named the enclosing function and not any particular removal; two separately contained subtrees dirtied in the same task produced two, one each. Per-element dirtying, with a reason string and the element's name attached, is a different event on a different category: LayoutInvalidationTracking. There are far more of those.

That also makes nodeId the layout root and never the element you changed. On the frame-wide path Blink passes the LayoutView's node, which is the document, so every frame-wide invalidation in a frame reports the same id. On the subtree path it passes the boundary box, which is an element you will recognise, and the Layout that follows carries beginData.partialLayout: true.

Whether a box can be a boundary is a long list of conditions, and the ones that catch people out are the ones that disqualify it. It needs a layer of its own to paint into, it cannot be fragmented across columns or pages, and it cannot be a table part, a flex item, or anything inside a grid, whatever else is set on it: contain: strict on a flex item is not a boundary, and neither is the same box inside a grid. Past those, what qualifies a box is layout and size containment together, or being an SVG root, or having a fixed width and height and either clipping its overflow on both axes or being a text control. A fixed-size input with position: relative is a boundary; the same input with no layer of its own is not.

Nothing about any of this is specific to script. An image finishing its download, a web font arriving and a change of viewport size each schedule layout the same way, and a font can dirty the whole tree.

A frame-wide invalidation does not stop a second one arriving in the same rendering update. Blink tracks a full relayout and a simplified one, the kind a moved out-of-flow box needs, with separate bits, so the second walk can travel a chain the first has already marked and reach the document again. Changing a normal box and moving an absolutely positioned box in one task gave two events with the same nodeId and one Layout after them.

Three situations produce no event at all. Dirtying something while layout is already running is suppressed outright. The frame-wide path is also gated on Document::ShouldScheduleLayout, which holds until render-blocking resources have loaded and a body exists, so early parsing produces none. And a caller can mark an object without marking its container chain, which never schedules anything. A fourth is easy to hit by accident: a change inside a content-visibility: hidden subtree writes nothing, not this event and not the per-element one.

Derivations

Whether your containment is doing anything. Read the nodeId values between two Layout events, and read what they are rather than how many there are. One that resolves to the document is a relayout of the whole frame; anything else is a box Blink could lay out on its own, and the matching Layout will say partialLayout: true. A page that means to confine its work and keeps reporting the document is not confining it. No other event in the trace names the boxes layout was actually scheduled from.

How long a scheduled layout waited. The gap between an invalidation and the Layout that follows it on the same frame is the delay between something going out of date and Blink doing anything about it: microseconds when the invalidation happened inside the style recalculation of the update that is already running, a frame or more when it happened in a task of its own. This is the number DevTools prints as "Pending for" when you select a Layout.

The difference between "no script did this" and "no stack was captured". Record disabled-by-default-v8.cpu_profiler as well, and sampleTraceId resolves against cpuProfile.trace_ids inside ProfileChunk, which points at a node in the profile's call tree. In a DevTools recording of a real page, every one of the events resolved, and every event without a stackTrace landed on (program): V8 was not executing JavaScript at that instant. That is a positive answer, where a missing field on its own is not.

Traps

A stackTrace names whatever scheduled the layout, which is often not what changed the page. Writing to style, toggling a class and appending an element all leave layout alone at the time; the layout bit is set later, inside the style recalculation that runs in the next rendering update, and no script is on the stack by then. Removing a node or replacing textContent destroys layout objects there and then, so the walk reaches the root inside your task and you do get a stack. A forced read such as offsetHeight pulls the style recalculation back into the same task, which is why layout thrashing always leaves one. Recording the stack category and finding no stack is evidence about when layout was scheduled, not about whether script was responsible.

Stack depth measures your recorder, not the page. V8 captures one frame unless a client has the Runtime domain enabled, and then up to 200. A loop three functions deep gave a single frame under Chrome's own --enable-tracing and more than one under Puppeteer. Never compare depth across traces made with different tools. The frames run top first, so stackTrace[0] is the line that ran, and its line and column numbers count from one.

stackTrace and sampleTraceId are both gated on disabled-by-default-devtools.timeline.stack. Without it they are simply absent, and nothing in the output distinguishes that from no stack being available. With it, every invalidation also takes a V8 CPU profiler sample it would not otherwise take.

nodeId has no name beside it, and the id belongs to one renderer process. LayoutInvalidationTracking carries nodeName and this event does not, so resolving it means CDP or a trace that has both categories, and a default DevTools recording has neither: the per-element category is not one it records. Worse, backend node ids are handed out per process, so an id written by another process resolves against your document to a real element that has nothing to do with it. In one capture, three events from a second renderer carried the same id as the page's own head. Check args.data.frame before you resolve anything.

The Performance panel does not draw this event. DevTools marks it hidden, so it never reaches the flame chart unless Show all events is on, and an initiator arrow needs both of its ends drawn. What you usually see pointing at a Layout is a ScheduleStyleRecalculation, which DevTools substitutes when the invalidation happened inside a style recalculation that had an initiator of its own. The event does appear in the Layout's details pane, as the stack under "First layout invalidation", an "Initiated by" link and "Pending for". Either way DevTools keeps only the last invalidation for the frame and forgets it once a Layout has used it, so when several layout roots were invalidated the earlier ones are dropped.

LayoutInvalidationTracking is not a superset of this event. Only the call that marks an object as needing layout itself writes one. Marking an ancestor, or marking a box for the simplified layout that a moved out-of-flow element needs, goes straight to the container walk. Moving an absolutely positioned box by changing top produced an InvalidateLayout and no per-element event at all.

scriptId inside stackTrace is a string, not a number.

Chrome's own internal documents write these too. A six second headless recording of one trivial page carried events from six frames across four renderer processes, with stacks from Chrome's own interface code. Filter on args.data.frame.

Example event a local fixture page
{
  "name": "InvalidateLayout",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102950855,
  "tts": 45968,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "nodeId": 3,
      "sampleTraceId": 6909218608594504,
      "stackTrace": [
        {
          "columnNumber": 81,
          "functionName": "note",
          "lineNumber": 24,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        },
        {
          "columnNumber": 5,
          "functionName": "",
          "lineNumber": 47,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ]
    }
  }
}

See also LayoutInvalidationTracking · Layout · UpdateLayoutTree · ScheduleStyleRecalculation · Blink.ForcedStyleAndLayout.UpdateTime

categories
disabled-by-default-devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc, local_frame_view.cc
largestContentfulPaint::CandidateLargest Contentful Paint

An element has reached the screen that is larger than every contentful element shown before it, which makes it the page's Largest Contentful Paint so far.

Spec

Blink's LargestContentfulPaintCalculator names a new Largest Contentful Paint for one document, written on the renderer's main thread when a frame reaches the screen carrying a contentful element whose visible area beats every candidate that document has already reported.

Arguments

pathdeclaredobserved
args.data.candidateIndexyes100.0%
args.data.imageDiscoveryTime-43.6%
args.data.imageLoadEnd-43.6%
args.data.imageLoadStart-43.6%
args.data.isMainFrameyes100.0%
args.data.isOutermostMainFrameyes100.0%
args.data.loadingAttryes29.7%
args.data.navigationIdyes100.0%
args.data.nodeIdyes100.0%
args.data.nodeNameopt100.0%
args.data.performanceTimelineNavigationId-100.0%undeclared, always present
args.data.size-100.0%undeclared, always present
args.data.typeopt100.0%
args.frameyes100.0%

Observed 619 times on 179 of 300 sites.

Behaviour

Blink keeps two records for each document: the largest text it has painted, and the largest image. When the display pipeline reports back that a frame has been presented, the calculator takes the larger of the two and, if it beats the size of the last candidate it reported, writes this mark and hands the same value to the page's PerformanceObserver. The test is against the last size reported, not against the runner-up, so the candidates only ever climb, and an element that merely ties writes nothing.

The mark is backdated. It is written while the presentation callback runs, but its ts is the raw presentation timestamp the display pipeline gave for that frame, which is earlier. Where the event sits on the timeline is when the element reached the screen, not when Chrome worked out that it had.

Size is what Chrome saw, not what the page declared. size is the area of the element's box clipped to the viewport, in CSS pixels, and then, if the box is larger than an image's natural size, divided by the factor it was stretched by. A 400x300 image displayed at 800x600 reported 120000, its natural area; an 800x600 image with 160 px below the fold reported 352000.

Three rules take an image out of the running completely: one with no viewport intersection at all is never recorded, so an image appended below the fold produces nothing; one whose clipped area reaches the viewport's own area is discarded, so a hero sized to 100vw by 100vh never becomes a candidate while the same hero at 99vh does; and one carrying less than 0.05 bits per displayed pixel is judged too sparse to be content. All three belong to images, because the function that applies them is called from the image detector alone. Text is judged on one thing, that its clipped area is not zero, which is why a block of text measured at exactly the viewport's area was a candidate where the image was not. Fully transparent text, with no shadow and no stroke, is never recorded. An element removed from the page between painting and presenting is dropped before the comparison; one removed after its candidate was written simply stands.

Several candidates are normal, because they are a record of what the largest thing on screen was at each point in the load. A heading usually paints before the hero image finishes, so the common shape is a text candidate and then an image one. Content that arrives later adds one more each time it is the largest yet.

Every document runs its own calculator. A cross-origin iframe writes its own stream, in its own process, with its own candidateIndex starting at 1 and its own navigationId, and none of it counts towards the top-level page's LCP. A soft navigation writes largestContentfulPaint::CandidateForSoftNavigation from this same call site instead.

The stream ends at the first real interaction and is never closed. PaintTiming::NotifyInputEvent drops the calculator for any input that reaches the main thread except a mouse move, a mouse enter or leave, a key up and a pinch gesture, and NotifyScroll drops it for any user or compositor scroll. On a fixture whose hero grew afterwards, a click and a key down each ended the stream in every run and a mouse move never did. A wheel is the case to watch: on a page too short to scroll and with no wheel listener the stream survived every run, because the compositor handled the wheel by itself and the renderer never saw it, but one passive wheel listener on the same page ended it every run.

Derivations

LCP itself, to a precision the page cannot see. Subtract the ts of the navigationStart sharing this event's navigationId from the ts of the last candidate for that navigation with isOutermostMainFrame true. The trace carries the presentation timestamp raw, while PerformanceObserver rounds startTime up to the next multiple of 4 ms on any page that is not cross-origin isolated. Across 60 paired observations every API value was an exact multiple of 4 ms and landed 0.02 to 3.98 ms after the trace's, never before.

The image half of the LCP breakdown, from this event alone. imageDiscoveryTime is when the fetch was queued, imageLoadStart when the request went out, imageLoadEnd when the last byte arrived, and ts minus imageLoadEnd is the element render delay that followed, once the two are in the same units. No network event needs to be matched up, which matters because matching one to an element is the awkward part. DevTools does not use them: its LCP breakdown insight finds the network request instead, by way of the image URL on LargestImagePaint::Candidate.

Traps

Not every candidate is the LCP, and the last one in the trace often is not either. Group by args.frame and navigationId first and take the highest candidateIndex in that group. On a page with a cross-origin iframe, the iframe's candidate was both larger and later than the page's own, and the page's PerformanceObserver correctly ignored it. isOutermostMainFrame is the filter.

Two candidates can share a timestamp. Presentation callbacks queue up and are drained together, so a text and an image candidate were written with identical ts and with candidateIndex 1 and 2. Order by candidateIndex, never by ts alone.

candidateIndex counts what was written, not what happened. The counter lives inside the payload builder, which the trace macro only runs when the category group is enabled. A recording started a second into a load whose page had already produced two candidates showed one event, numbered 1, while the page's own PerformanceObserver still reported three entries. Nothing in the trace says the earlier ones existed.

This is not the number CrUX reports. The field metric travels a separate path out of the same class. It is computed from different members, taking an image that has painted but not finished loading, and an animated image's first frame time rather than its paint time. The browser then merges the page's value with its subframes' and keeps it only if the paint happened while the tab was in the foreground. Its trace events are LargestImagePaint::Candidate and LargestTextPaint::Candidate, on the loading category, each with a candidateIndex counted separately from this one. For a static image or a text LCP the two agree; for an animated image, a video, or a page whose largest content is inside an iframe, expect them not to.

There is no URL on this event. DevTools gets one by looking up LargestImagePaint::Candidate on the same nodeId in the same process and reading its imageUrl. That event is on the loading category alone, so a trace recorded with only devtools.timeline has the LCP and no way to name the image.

nodeId is a DevTools backend node id, unique only inside one renderer process. A main frame and its iframe both used nodeId: 4 in one capture, so any lookup has to be keyed on the process as well. It resolves to an element only against a live page; in a saved trace, nodeName (the tag plus its class or id) is all the identification there is.

loadingAttr tells you what kind of element this is, not just what the attribute said. The key is written only when the candidate is an <img>. An <img> with no loading attribute gets an empty string, so the value you see most is "". The key missing altogether means the candidate is not an <img>: text, a CSS background image, a video poster or first frame, an SVG <image>, an <input type=image>. All of those still report type: "image" apart from text, so type will not tell them apart.

The three image times are milliseconds since that frame's own time origin, as floats, while ts is the trace clock in microseconds. In an iframe they are relative to the iframe document's origin, not the top document's.

They describe the resource, not this element. When a second, larger element used an image the page had already downloaded, its candidate repeated the original download's discovery, start and end times, while the PerformanceObserver entry for the same paint reported a loadTime 800 ms later. If you want when this element's image became available, the trace event is not where it is.

An image served without Content-Length can drop out of LCP entirely. The minimum-entropy check takes the response's expected content length, or the bytes received so far when there is no such header, and divides its bits by the displayed area. It is computed once, when the image's record is created at its first paint, and never revisited for that record. So the number that decides it is the bytes in hand at first paint against the displayed area times 0.00625, and a hero first painted on a few kilobytes of a streamed response stays below the threshold forever. A progressive JPEG shown at 1280x755, so needing 6040 bytes, and dribbled out in 1 KB chunks: with the header the image was the LCP in three runs of three, without it the LCP stayed on the <h1> in three of three and the image produced no candidate at all. The same page in 8 KB chunks, which put enough bytes in hand before the first paint, kept the image as the LCP in two runs of three even without the header. Unthrottled, and on a repeat view from the HTTP cache, where the bytes arrive at once, it always counts, so the same page can report a different LCP element on a first and a second visit.

With decoding="async" the candidate can be stamped before the pixels are on screen. The compositor may present a frame with a large image's box still blank and fill it in later, and the LCP is recorded on one of those frames. On an ordinary page the reported LCP came in 15 to 29 ms ahead of the frame that actually showed a 1920x1080 hero, and 76 to 138 ms ahead for a 4000x3000 one. It needs an image that is static, fully downloaded when it was first drawn and at least a megabyte of decoded pixels, so a cold first load is unaffected and a repeat view or a preloaded hero behind slow CSS is not.

trace_engine does not declare size, performanceTimelineNavigationId or any of the three image times, so anything typed on that package sees an event without them. It does declare loadingAttr as always present, which it is not.

The candidate stream is not a closed set. If a trace stops before the page does, a larger element may have arrived afterwards and there is no marker saying the value was final. largestContentfulPaint::Invalidate, which withdrew a candidate, was removed in Chrome 88; a candidate whose element is later removed from the page simply stands.

Example event from https://www.bbc.com/news
{
  "name": "largestContentfulPaint::Candidate",
  "cat": "loading,rail,devtools.timeline",
  "ph": "R",
  "ts": 1108451601,
  "tts": 119589,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "candidateIndex": 1,
      "imageDiscoveryTime": 70.19999999995343,
      "imageLoadEnd": 135.9000000001397,
      "imageLoadStart": 117,
      "isMainFrame": true,
      "isOutermostMainFrame": true,
      "loadingAttr": "eager",
      "navigationId": "BAAF482071D29F77645D385F984F23B2",
      "nodeId": 274,
      "nodeName": "IMG class='Image-styles__ImageStyled-sc-8c99a12b-0 cVsHni'",
      "performanceTimelineNavigationId": 4052,
      "size": 213752,
      "type": "image"
    },
    "frame": "2EA8810E92C12C680C55B2941251AA98"
  }
}

See also largestContentfulPaint::CandidateForSoftNavigation · largestContentfulPaint::Invalidate · LargestImagePaint::Candidate · firstContentfulPaint · navigationStart · Decode Image

categories
loading, rail, devtools.timeline
usage
low
undeclared args
args.data.imageDiscoveryTime, args.data.imageLoadEnd, args.data.imageLoadStart, args.data.performanceTimelineNavigationId, args.data.size
references
image_resource.cc, largest_contentful_paint_calculator.cc, resource_fetcher.cc, performance.cc, window_performance.h, paint_timing_record.h
largestContentfulPaint::CandidateForSoftNavigationSoft Largest Contentful Paint

Chrome records the largest text or image painted in the page's response to a single user interaction, separately from the document's own largest contentful paint.

Spec

Blink's LargestContentfulPaintCalculator has a new largest contentful paint for one SoftNavigationContext -- the record Chrome keeps of what a single user interaction changed -- and marks it on the renderer's main thread at the presentation time of the frame that painted it.

Arguments

pathdeclaredobserved
args.data.candidateIndexyes100.0%
args.data.imageDiscoveryTime-44.4%
args.data.imageLoadEnd-44.4%
args.data.imageLoadStart-44.4%
args.data.isMainFrameyes100.0%
args.data.isOutermostMainFrameyes100.0%
args.data.loadingAttryes38.9%
args.data.nodeIdyes100.0%
args.data.nodeNameopt100.0%
args.data.performanceTimelineNavigationIdyes100.0%
args.data.size-100.0%undeclared, always present
args.data.typeopt100.0%
args.frameyes100.0%

Observed 18 times on 15 of 300 sites.

Behaviour

Blink runs a second largest-contentful-paint calculation alongside the document's own. Both are driven by the same class, LargestContentfulPaintCalculator, and each one asks its owner a single question: is this a hard navigation? The document's owner says yes and gets largestContentfulPaint::Candidate; a soft navigation context says no and gets this name. Both names are written by the same two functions, so the payloads are identical but for one field, which is the only thing that reliably tells them apart (see traps) (largest_contentful_paint_calculator.cc:218-227 and 259-266).

A soft navigation context is Chrome's record of one user interaction and everything that interaction went on to change. Chrome creates one for every event that counts as an interaction -- a click, a tap, a key press -- while the event is being dispatched, long before it knows whether a route change is coming (event_timing.cc:144-146, soft_navigation_heuristics.cc:674-698). Each context builds its own calculator (soft_navigation_context.cc:31-33), and that calculator writes this event.

A paint belongs to a context when the interaction's own code modified the painted node, or an ancestor of it. Chrome follows the interaction through promises, timers and continuations to decide that, and when two interactions have touched the same node the most recent one wins (soft_navigation_paint_attribution_tracker.h:19-73). After every presented frame, Chrome sorts the frame's text and image paints into the contexts they belong to and hands each context its own largest (soft_navigation_heuristics.cc:516-530). The timestamp is the presentation time of that frame (paint_timing.cc:607-623).

Nothing in that path asks whether a soft navigation happened. The heuristics -- the URL changed, the DOM was modified, enough area was painted -- gate SoftNavigationStart and the performance entries, not this event. A click that swaps in a paragraph and changes no URL produces one of these; so does a click that appends a single character. In a 300-site crawl that clicked once on each page, the corpus contains these events and not one SoftNavigationStart to go with them.

candidateIndex counts up within one interaction and starts again at one for the next, because the counter belongs to that context's calculator. It is not a position in the document's candidate stream.

The stream ends the way hard LCP does. Once a context has painted something, the next click, key press or user scroll freezes it, and later paints attributed to that interaction are no longer reported (paint_timing.cc:962-989, soft_navigation_context.cc:174-187). An interaction that has not painted yet is not frozen by further input, and a newer interaction never ends an older one, which is how a context can outlive several later ones.

Derivations

Soft LCP. Pair the event with the SoftNavigationStart whose performanceTimelineNavigationId matches, and the gap between the two timestamps is how long the route change took to render, measured from the interaction that started it -- SoftNavigationStart is stamped at the interaction's own start time (soft_navigation_context.cc:65-67, 256-260). That is the number DevTools computes (PageLoadMetricsHandler.ts:207) for the marker it draws in the Timings track as "Soft Largest Contentful Paint", and nothing in the page's own LCP reporting will match it: a soft navigation produces no largest-contentful-paint entry at all, so a script watching for LCP never sees the route change, and the interaction-contentful-paint entry that does carry the paint starts its clock at the interaction rather than at the paint.

The image sub-phases. When the candidate is an image, imageDiscoveryTime, imageLoadStart and imageLoadEnd split the wait into discovery, download and render delay, exactly as they do for a hard navigation's candidate, and every image candidate carries all three. They are measured from the document's time origin, so subtract the soft navigation's own start time first; what is left tells you whether the route change's hero image was requested late or merely arrived slowly.

Traps

Most of these are not soft navigations. The event is written for any interaction whose paints Chrome can attribute, whether or not the URL changed and whether or not enough of the page was repainted. Counting them, or reading each one as an SPA route change, overcounts badly: a crawl that clicks once per page collects them from sites that never soft-navigated at all. Only an event you can pair with a SoftNavigationStart describes a soft navigation.

performanceTimelineNavigationId is the window's current navigation id, not the interaction's. Chrome reads it off the performance timeline at the moment it writes the event (largest_contentful_paint_calculator.cc:499-501), and the timeline's id only advances when some context commits a soft navigation. So an interaction that never became one carries the hard navigation's id, and joining candidates to navigations on that field alone drops an interaction's paint into the initial page load. Worse, an interaction whose paint is still pending is not frozen by the click that starts the next one, and a context that no longer owns the URL can never commit -- so its later paints are stamped with whatever soft navigation is current by then. A fixture where one button changes the URL and paints a second later, while a second button changes the URL and paints at once, reliably produced two candidates from two different interactions under one navigation id.

candidateIndex is per interaction, so two events in one trace can both be candidate one. In the case above, both were. Anything that assumes the index orders a navigation's candidates, or that a higher index is a later candidate, is reading a different counter than it thinks. DevTools only replaces a stored candidate when the index is strictly higher (PageLoadMetricsHandler.ts:229-239), which is what stops the stray candidate above from becoming the reported soft LCP; a hand-rolled "last candidate wins" join has no such protection.

There is no navigationId, and that absence is the only reliable way to tell the two candidate events apart from their payloads. performanceTimelineNavigationId is on both. Chrome writes the loader-id navigationId only for a hard navigation, deliberately: the commit that introduced this name removed it rather than keep the sentinel value the old code used.

size is missing from trace_engine's declaration of the event and loadingAttr is declared as always present, so code built on those types cannot see the LCP size and will be surprised by the candidates that have no loadingAttr. loadingAttr is written whenever the element is an <img>, and it is written as an empty string when the attribute is absent, so an empty value means "no attribute", not "not an image"; a candidate with no loadingAttr at all was a CSS background image, a video poster or an SVG image.

The image timings are on a different clock from ts. ts is the raw monotonic presentation timestamp in microseconds; imageDiscoveryTime, imageLoadStart and imageLoadEnd are DOMHighResTimeStamp milliseconds from the document's time origin (largest_contentful_paint_calculator.cc:464-474). Subtracting one from the other produces nonsense.

Some image paints can never be candidates, soft or hard. An image whose visual rect fills the viewport is rejected outright, and so is one whose encoded bytes come to less than 0.05 bits per painted pixel (largest_contentful_paint_calculator.cc:648-669, 677-755). A flat-colour placeholder swapped in by a click paints, is attributed to the interaction, and still produces no candidate. Text is judged on painted area alone (largest_contentful_paint_calculator.cc:671-674).

DevTools discards the ones it cannot place. If the trace holds no SoftNavigationStart with a matching id -- because the interaction never became a soft navigation, or because the soft navigation happened before recording started -- the Performance panel drops the event silently (PageLoadMetricsHandler.ts:284-291). Turning off the "Enable soft navigation performance monitoring" setting drops every one of them, along with SoftNavigationStart (PageLoadMetricsHandler.ts:82-87, LiveMetrics.ts:17-22). Only the winning candidate per soft navigation is ever drawn.

The kill switch does not switch the whole event off. SoftNavigationTraceEvents is on by default and can be disabled from the command line, but it guards only the image branch and SoftNavigationStart; the text branch has no such check. Running with it off leaves you with text candidates and nothing to pair them to.

A trace from a Chrome before 145 has no events with this name. Soft navigation candidates were written as largestContentfulPaint::Candidate then, carrying the fixed sentinel navigationId deadbeef-dead-beef-dead-beefdeadbeef, which is what an old trace has to be filtered on and what a current trace does not.

Example event

No trace in the collection produced this event.

See also largestContentfulPaint::Candidate · SoftNavigationStart · SoftNavigationContextCreated · EventTiming · firstContentfulPaint

categories
loading, rail, devtools.timeline
usage
rare
undeclared args
args.data.imageDiscoveryTime, args.data.imageLoadEnd, args.data.imageLoadStart, args.data.size
references
largest_contentful_paint_calculator.cc
largestContentfulPaint::Invalidateno longer emitted

Chrome threw away the LCP candidate it was holding, and had no other contentful element left to replace it with.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Spec

Blink's LargestContentfulPaintCalculator was left holding neither a largest-text record nor a largest-image record, and wrote this instant on the renderer's main thread to say the document had no largest-contentful-paint candidate any more; Chrome wrote it from version 78 until version 88 removed it.

Behaviour

Blink works out LCP in two layers. Two detectors, one for text and one for images, each keep a record of the largest thing that has painted so far. A calculator, LargestContentfulPaintCalculator, takes both records after a paint and decides what to report to the page and to the trace. This event came from one branch of the calculator: if a held record had just changed, and the calculator was then holding neither a text record nor an image record, it wrote this instant and returned (largest_contentful_paint_calculator.cc:86-94 at 87.0.4280.141).

Neither, not either. A record went away when the element's layout object was destroyed, which covers being removed from the document and being given display: none, and losing the largest image while a text record was still held did not produce this event: it fell through to the size comparison below and produced a largestContentfulPaint::Candidate for the text instead. The event meant the document had run out of candidates altogether, not that a bigger candidate had been replaced by a smaller one.

It did not fire on input. The first qualifying input or scroll runs PaintTimingDetector::OnInputOrScroll, which stops both detectors (paint_timing_detector.cc:179-198); from then on the text side is not recording, and a stopped image detector ignores removals (image_paint_timing_detector.cc:154-175). No held record can disappear after that, so the branch cannot be entered. Finalisation is the end of the candidate stream, not an invalidation of it.

The payload was candidateIndex, isMainFrame and, from Chrome 79 on, navigationId, under args.data, with the frame id alongside in args.frame. No node, no size, no type: the event said a candidate was gone, not which one. candidateIndex came from count_candidates_, the same counter largestContentfulPaint::Candidate increments, so an invalidation consumed an index of its own.

The name was one of a pair added in August 2019 and first shipped in Chrome 78, by commit e7a0a11a0e6f, "Add renderer trace events for LCP.". Its stated reason was that DevTools could not easily consume the two browser-process events that already carried these names. Commit a5484e6310a3, "[LargestContentfulPaint] Allow removed content by default", deleted it in October 2020 for Chrome 88. That change made the web-exposed LCP monotonic: the calculator now keeps largest_reported_size_ and reports a new entry only for a candidate strictly larger than the last one it reported, and content that is removed keeps the entry it already produced. Remove every contentful element from a page on a current Chrome and the last candidate still stands. The condition this event reported can no longer arise, and nothing took its place.

Derivations

In a trace recorded on Chrome 78 through 87, this event is the only signal in the renderer stream that the largestContentfulPaint::Candidate before it is void. Chrome of that era reported whatever the detectors currently held, so LCP could go down: after an invalidation the next candidate may be smaller than the one before it. Take the last Candidate in such a trace and you may be reporting an element Chrome had already discarded; do what DevTools and Lighthouse both do, and let the highest candidateIndex for a navigation decide, discarding the result if it is an invalidation. From Chrome 88 the sequence of candidate sizes never decreases, so no correction is needed and the last candidate is the answer.

Traps

A live event has almost the same name and means something else. NavStartToLargestContentfulPaint::Invalidate::AllFrames::UKM is in current traces, on the loading category, and it is not this event withdrawing anything. It is written in the browser process by UkmPageLoadMetricsObserver::OnTimingUpdate as the plain else-branch of "is there a valid merged LCP time right now", so it means no LCP value at this timing update, which is the ordinary state early in a load. It carries main_frame_tree_node_id and nothing else, and it is older than the renderer event: the commit that added this one gave those browser-side events as the reason. There is a matching NavStartToExperimentalLargestContentfulPaint::Invalidate::AllFrames::UKM beside it.

DevTools never drew this event, but it did act on it. TimelineFlameChartDataProvider._appendPageMetrics kept, per navigation, the LCP event with the highest candidateIndex, and appended it to the Timings track only if it was a candidate. An invalidation at the end of the stream therefore removed the LCP marker from the Timings track altogether, and that was the only sign of it in the panel. As a marker in the overview ruler it was invisible: markerStyleForEvent had no case for the name, so it took the default non-tall style, createEventDivider sets a background colour only on a tall marker, .resources-event-divider declares none of its own, and its container carries pointer-events: none.

Today's DevTools ignores it. The name survives in devtools-frontend as Types.Events.Name.MARK_LCP_INVALIDATE, with no interface, no style, no handler, and no place in the marker list, so an old trace loaded into a current Performance panel shows the withdrawn candidate as the LCP and says nothing. Lighthouse is the exception: it still accepts the name as an LCP event, and a trace whose last LCP event is an invalidation comes back with lcpInvalidated set instead of an LCP.

Its timestamp is not a paint time. Candidate is a mark backdated to the element's paint time; this was an instant stamped when the calculator ran, at the end of a paint. Comparing the two as if they were the same clock overstates how long the page held a candidate.

Candidate numbering has gaps in an old trace, because invalidations took indices from the same counter. A jump in candidateIndex across two Candidate events is not a dropped or lost event.

LargestImagePaint::NoCandidate is live and sounds like a successor. It is not. It comes from LargestContentfulPaintCalculator::ReportNoMetricsImageCandidateToTrace on the metrics side, beside LargestImagePaint and LargestTextPaint, and it means the image candidate is not reportable yet, not that one was withdrawn.

Example event

Removed in Chrome 88, when LCP stopped withdrawing a candidate whose element left the page. The collection recorded largestContentfulPaint::Candidate instead.

See also largestContentfulPaint::Candidate · largestContentfulPaint::CandidateForSoftNavigation

references
largest_contentful_paint_calculator.cc
LayerizeLayerize

Blink turns the page it has just painted into the layers the compositor will draw from.

Spec

Blink's PaintArtifactCompositor groups the paint chunks the renderer has just painted into cc::Layers and builds cc's property trees from them, on the renderer's main thread, after paint and before the commit.

Arguments

pathdeclaredobserved
args.data.frame-100.0%undeclared, always present
args.data.isMainFrame-100.0%undeclared, always present
args.data.isOutermostMainFrame-100.0%undeclared, always present
args.data.page-100.0%undeclared, always present

Observed 26,989 times on 182 of 300 sites.

Behaviour

Blink paints a document once, into a single display list cut into paint chunks: runs of drawing commands that share one transform, one clip and one effect node. Deciding which of those chunks get their own compositor layer is a separate pass over the finished list, and that pass is this event. PaintArtifactCompositor::Layerizer opens a pending layer for every chunk, then tries to merge each new one backwards into the ones before it, stopping at the first whose bounds in screen space might overlap. What survives becomes the layer list, and a PropertyTreeManager turns the paint property nodes those layers reference into cc's transform, clip, effect and scroll trees. Chromium's own note on the algorithm puts its cost at O(pqd), where p is the number of paint chunks, q the number of merge attempts and d the depth of the property trees.

Chromium calls that ordering CompositeAfterPaint. Before it, layerization came first: a lifecycle phase named compositing assignments picked the layers, and painting then went into them one layer at a time. That phase was still in local_frame_view.cc in Chrome 98, behind RuntimeEnabledFeatures::CompositeAfterPaintEnabled(), and was gone from it in Chrome 99. The trace event is younger than the rearrangement: Layerize first appears in that file in Chrome 111, the release that also renamed CompositeLayers to Commit. In Chrome 110 the same code ran and was marked only by a blink-category event that no Performance recording asks for, so in a trace from 110 or earlier this work is simply not there.

It runs in the same main-thread task as the rest of the rendering update, between the last Paint and the Commit:

BeginMainThreadFrame
  UpdateLayoutTree, Layout, PrePaint, Paint
  Layerize                     paint chunks -> cc layers and property trees
Commit                         handoff to the compositor thread

It is never inside Commit. cc runs Blink's whole lifecycle before it opens that slice, and the "update layers" step cc does inside it is its own, copying Blink's display lists into cc's.

One is written per rendering update per local root: the top document of a renderer, or an out-of-process iframe in its own renderer. Same-process iframes have no local root of their own and are layerized inside their parent's event. Across five fixtures the count matched BeginMainThreadFrame exactly, every time. Blink writes it, so unlike Commit the browser process never does.

Most of them do almost nothing. PaintArtifactCompositor records what kind of update it owes, and the event covers the cheap answers as well as the expensive one. If nothing was painted, the function returns at once and the slice is a couple of microseconds. If something was repainted but no chunk changed its identity, its bounds or its count, and no paint property value changed, the repaint fast path runs: it pushes the new drawing commands into the layers that already exist and re-issues raster invalidation, without deciding anything. Only the third case re-runs the layerizer. On a fixture of 800 one-pixel divs each carrying opacity: 0.99, so each got its own chunk, those three cost about 2, 218 and 362 microseconds respectively. A full re-layerization is forced by a chunk being added or removed, by a chunk's bounds changing, or by a paint property value changing. Changing one element's colour every frame stayed on the repaint path; changing its width did not.

Derivations

Whether a Layerize actually re-layerized. Only the full path calls PaintArtifactCompositor::Update, which writes an event of that name on the blink category. A Layerize with that child made compositing decisions; one without it took a fast path. blink is not a category DevTools records, so this needs a trace configuration of your own. In a Performance recording the duration is the only tell, and it is a good one: the fast paths and the full path differ by two orders of magnitude on the same page.

Which document paid. args.data.frame is the local root of the renderer that did the work. The same token appears on FrameCommittedInBrowser and CommitLoad next to a url, so layerization time on a page full of third-party iframes can be attributed to the frame that caused it rather than to a process id.

How many compositor layers a page actually has, and what each one is. Record disabled-by-default-devtools.timeline.layers and cc writes LayerTreeHostImpl:snapshot, whose args.snapshot.active_tree.layers is the real layer list with a layer_name on each entry naming the element. That is the only way to check a guess about promotion against what Blink decided, and it is worth the trip: a fixture of 800 elements carrying opacity: 0.99 turns out to produce four layers, and the same 800 carrying will-change: transform produce 804. The catch is in the traps below: that category also makes this event slower, so read the counts from such a recording and the durations from an ordinary one.

Traps

It has arguments. Four of them, on every event: frame, isMainFrame, isOutermostMainFrame and page. Nothing in the payload builder is conditional. trace_engine declares none of them and has no type for the event at all, only the name, which is probably where the belief that it carries nothing comes from.

page is a copy of frame. Blink fills it with the local frame root of the frame it was given, and the frame it was given is always the local frame root, so the two are the same string on every event, in an ordinary page as much as in an iframe. Chromium's own comment next to that line says page does not work with out-of-process iframes and that callers should use frame instead.

The name says layers, but the cost is chunks. It is not the number of composited layers that drives a full run; it is the number of paint chunks the layerizer has to walk and try to merge, and an element gets its own chunk as soon as it has its own transform, clip or effect node, whether or not that node composites anything. On a fixture of 800 elements with opacity: 0.99, which between them add no compositor layer at all, a full run cost about 350 microseconds a frame. That page ends up with four compositor layers, the same four an empty page has. Promoting the same 800 with will-change: transform, which does produce 804 layers, took it to about 500. Promotion adds to a bill the chunk already ran up. A non-composited transform cost more than either, because it also deepens the property trees.

Off-screen content is free. Blink does not paint far outside the cull rect, so content there produces no chunks and no layers. Spread 200, 400 and then 800 promoted boxes down a 28,800-pixel document and the page ends up with the same 140 compositor layers every time, and the same cost; pack them into one screen and the count and the cost both triple. Counting will-change declarations in a stylesheet tells you nothing about what a frame will pay.

It is the driver of an animation that decides, not the promotion. A transform animation written in CSS or through element.animate() re-layerizes once and then never, however many frames it runs for. The same movement written as a per-frame style.transform from JavaScript forces a full re-layerization on nearly every frame, while painting almost nothing, and will-change: transform on the element does not change that at all: promoted, unpromoted, or already carrying a transform, the three behave identically. Blink has a direct transform update that skips layerization, and its own comment says it can only be used when an animation is present, because that is the case in which overlap testing can be assumed away. Both look like "an animated transform" in a screen recording; one of them costs a full layerization a frame.

Recording it changes it. Ticking Enable advanced paint instrumentation in the Performance panel adds disabled-by-default-devtools.timeline.layers, and that category is one of the three that turn on Blink's paint debug info. The debug info is built inside this slice: a name for every composited layer, plus a list of compositing reasons for each on a full run. On an 800-layer page that added about half again to every full-path Layerize. Having the Layers panel open does the same thing, for the same reason, without any recording setting. A default Performance recording does not do this, and nothing else about the event inflates itself either: its four arguments are four dictionary writes, with no traversal behind them. While you are in there, treat the compositing reasons that debug info carries with care: Chromium's comment above the function that produces them says they are informative for tracing and partly heuristic, not the decision Blink actually made.

Averaging the durations is meaningless. The distribution is not one population but three, and the cheap ones outnumber the expensive ones on any page that is not changing much. Sum the long ones, or count them; a mean sits in a gap where no frame lives.

The bar has nothing under it. Every child this event has with a duration is on blink or on cc, and a Performance recording asks for neither. Layerize itself is on plain devtools.timeline, so the panel always has the slice and never has its contents. The one child on a category DevTools does record, Layer:created, is an instant, and there are far fewer of them than there are layers: a page that built 804 compositor layers wrote two.

DevTools files it under Rendering. Paint, immediately before it, and Commit, immediately after it, are both Painting. In the panel's summary the three steps of one handover land in two different buckets.

Lighthouse does not know the name. Its task groups list CompositeLayers, Paint, PrePaint, UpdateLayer and UpdateLayerTree, and neither Layerize nor Commit. An unlisted event inherits its parent task's group, and the parent here is an ungrouped task, so layerization time is reported as Other.

No Layerize does not mean a cheap frame. A main frame on a hidden or render-throttled page never reaches the paint phase, so it writes no Layerize and no Paint either, and neither does a frame taken while anything is printing or producing a paint preview. The slice also opens some way into the function that writes it, after the returns for a frame with no page and for a renderer with accelerated compositing switched off, so a call can happen and leave nothing behind. In the other direction, Chromium can run the lifecycle and then skip the commit, which would leave a Layerize with no Commit after it; that is from the source, and it did not happen in any trace captured for this entry. One more path writes a Layerize outside any main frame, when a local root becomes render-throttled; also unobserved.

The same span is measured twice more in a trace. Blink.CompositingCommit.UpdateTime, on the blink category, wraps the same code and is this event's parent rather than its child. So does composite_commit_us inside args.send_begin_mainframe_to_commit_breakdown on a PipelineReporter's SendBeginMainFrameToCommit stage, which is on a category DevTools does record. The UMA timer opens a few lines earlier and its value is rounded and accumulated per main frame, so it tracks the slice closely without equalling it: of the pairs checked in two captures, none matched exactly and most were within two microseconds. The sibling update_layers_us in that same breakdown is cc's own layer update inside Commit, which is a different step.

Example event a local fixture page
{
  "name": "Layerize",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1102923357,
  "dur": 208,
  "tdur": 104,
  "tts": 28532,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "isMainFrame": true,
      "isOutermostMainFrame": true,
      "page": "14A831DCB73EB1205B67CDB89FC6EBD7"
    }
  }
}

See also Paint · PrePaint · Commit · CompositeLayers · BeginMainThreadFrame · UpdateLayerTree · UpdateLayer · RasterTask · DrawFrame · PipelineReporter

categories
devtools.timeline
usage
moderate
duration
typical
undeclared args
args.data.frame, args.data.isMainFrame, args.data.isOutermostMainFrame, args.data.page
references
inspector_trace_events.cc, document.cc, local_dom_window.cc, local_frame_view.cc
LayoutLayout

Chrome works out the size and position of the parts of one document that are out of date.

Spec

Blink's LocalFrameView::UpdateLayout lays out one document, either from the whole layout tree or from the subtree roots registered since the last pass, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.beginData.dirtyObjectsyes100.0%
args.beginData.frameyes100.0%
args.beginData.partialLayoutyes100.0%
args.beginData.sampleTraceIdopt100.0%
args.beginData.stackTrace[].columnNumber-42.5%
args.beginData.stackTrace[].functionName-42.5%
args.beginData.stackTrace[].lineNumber-42.5%
args.beginData.stackTrace[].scriptId-42.5%
args.beginData.stackTrace[].url-42.5%
args.beginData.totalObjectsyes100.0%
args.endDataopt0.0%declared, never observed
args.endData.layoutRootsyes0.0%declared, never observed
args.endData.layoutRoots.depthyes0.0%declared, never observed
args.endData.layoutRoots.nodeIdyes0.0%declared, never observed
args.endData.layoutRoots.quadsyes0.0%declared, never observed

Observed 25,372 times on 182 of 300 sites.

Behaviour

One event is one call to LocalFrameView::UpdateLayout, and Blink only makes that call when something is actually dirty. So the event means layout ran: there is no empty Layout the way there is an empty forced-update slice. It covers one document. A same-process iframe is laid out by its own call, and the two calls never overlap: the scheduled update lays out this document and then walks into its children, and a read inside an iframe brings the embedding document up to date before the iframe's own layout starts. Either way you get two events side by side rather than one inside the other, and beginData.frame is what tells them apart. One rendering update can still produce several, because the update repeats style and layout while ResizeObserver callbacks and the first observation of a content-visibility: auto subtree keep dirtying things.

Most of the time the pass starts at the layout tree root, Blink's tree of boxes and text runs that mirrors the rendered part of the DOM. It can instead start lower. When an element's layout is invalidated, Blink walks up marking its ancestors, and if it meets a relayout boundary on the way it stops there and registers that box as a subtree root instead. A box qualifies if laying it out cannot change anything around it: contain: layout size is the explicit route, and the implicit one is a fixed width and height, overflow clipped on both axes, and a paint layer of its own, on a box that is not a flex or grid item. The near misses are easy to write by accident. contain: layout on its own does not qualify, nor does a fixed size with hidden overflow on a box that has no layer of its own, and an absolutely positioned box is excluded while it still depends on its static position, so one with top set and left left alone does not qualify either. partialLayout says whether any subtree root was registered, and endData.layoutRoots names them, deepest first.

The frame's own layout and a layout your code forced look identical on the event itself. What separates them is what they sit inside, and beginData.stackTrace. The stack is only written when JavaScript was on the V8 stack at the moment the pass opened, and then its top frame is the line that forced the pass. That is usually a geometry read, but focus(), scrollIntoView(), the selection APIs and innerText all put their own line there instead. Blink forces layouts of its own, at the end of parsing, after the load event, for hit testing and for selection bookkeeping, and those carry no stack at all, so an event without one is not necessarily the frame's own work.

beginData is written before Blink lays anything out, so its counts describe the work about to be done. endData is written afterwards, but its roots were snapshotted before the pass as well, and when there were none the layout tree root is substituted for them. A full layout therefore always reports one root, the document.

Some of the events in a trace belong to documents you did not write. Every SVG image has its own page and document, and it runs its lifecycle from inside the host document's paint, so one of its layouts sits under Paint, on the host's own thread. Such a document has a full set of loading events of its own, with an empty document URL and no CommitLoad and no request, which is the surest way to recognise it. Tracing is browser-wide, so Chrome's own WebUI pages and extension pages produce them too.

Derivations

Which line forced a layout. When the stack category is recorded, the top frame of beginData.stackTrace is the JavaScript that asked, and sampleTraceId gets you the rest: DevTools resolves that id against the CPU profile to rebuild the whole JS stack above the event. This is the only place that line appears, and it is the difference between knowing that a task forced eleven layouts and knowing which function did it.

Whether your containment is working. Scoping is a property of the box, not of the change: partialLayout flips to true only once the invalidated element has an ancestor Blink is willing to treat as a relayout boundary. Adding contain: layout size to a widget that updates constantly, then checking whether its layouts now report partialLayout: true with that element in layoutRoots, is a direct before-and-after measurement of the containment, and layoutRoots[].nodeId is a backend node id that DevTools links straight to the element.

Traps

The duration is not the cost of laying out. Producing dirtyObjects and totalObjects means walking the layout tree in pre-order, and that walk only happens while devtools.timeline is being recorded, inside the slice it is describing. On one machine it cost roughly four nanoseconds per layout object. That is invisible on a small page and not on a large one: on a page of 24,000 divs, two hundred forced layouts of a single box took about 2ms with nothing recording at all and about 44ms with devtools.timeline on, and the median Layout event read 205us for a layout that costs about ten. That the walk is the whole difference can be shown on the same page: put those 24,000 divs inside a content-visibility: hidden container, which the walk skips and layout does not care about, and the measurement falls back to about 4ms with dur at 9us. Untraced, the same incremental layout did not grow with the tree at all between 500 and 24,000 divs. Blink.Layout.UpdateTime wraps the same call and is the number to read instead, but only in a recording that leaves devtools.timeline out, because the walk happens inside that timer too.

endData goes missing, and it depends on your category list. The end of the Layout slice and the end of the enclosing LocalFrameView::layout slice are written in the wrong order in Blink, so when blink or benchmark is recorded the endData argument lands on LocalFrameView::layout and Layout ends with nothing. Record neither and it stays where it belongs. A third slice, LocalFrameView::performLayout, sits inside Layout and closes itself, so it takes nothing away; it is written on rail, which DevTools does record by default, while blink and benchmark are not. That is why a Performance panel trace has both that slice and its endData, and why the Show all events setting, which asks for every category, takes endData away, and with it the Layout root link in the details pane, because that link is built from endData.layoutRoots. devtools-frontend's own type for this event carries the comment "endData is not reliably populated. Why? TBD."

The ratio reads backwards. dirtyObjects close to totalObjects is not the whole-document relayout it looks like. On a partial layout both numbers describe only the subtree, so a well-scoped layout is the case that reads near one: two objects dirty out of three was one 300x200 box being laid out on its own. A small fraction is the ordinary full pass: four dirty objects out of four hundred is one element being changed on a page where nothing else moved. The reading the ratio does support is a document whose layout tree really is all dirty, which is what the first layout of every document and every iframe is. What separates that from a scoped layout is partialLayout, not the ratio.

totalObjects is not the size of your DOM. It counts layout objects, and an element with a text node inside it is two of them, so it runs to about twice the element count. It is also only the part of the tree the walk could reach: children of a display-locked subtree are skipped, so a content-visibility: hidden container holding 500 divs took a document of 1006 objects down to 6, and an off-screen content-visibility: auto container did the same. Elements with display: none have no layout object to count at all. And when partialLayout is true, totalObjects is the size of the subtree, not of the document.

dirtyObjects counts ancestors too. An object is dirty if it needs layout itself or if any descendant does, which is how Blink propagates the flag, so changing the width of one div near the root of a large page reported four: the layout tree root, html, body and the div. It is a count of marked objects, not of elements you changed.

partialLayout and layoutRoots are promises made before the work. Both are read before Blink lays anything out. PerformLayout can still throw the subtree roots away and lay out the whole view if the layout tree root turned out to be dirty too, and nothing in the trace records that it did. On a full layout, layoutRoots is always a single entry for the document, with the viewport as its quad, so it is an attribution path back to real elements only when partialLayout is true.

The stack is shallower than you think, and how deep depends on the recorder. Blink asks V8 for a non-full stack, which is one frame unless a client with the Runtime agent enabled is attached to the renderer. The same page, the same three-deep call chain, gave [inner] under Chrome's own --trace-startup and inner < middle < outer < (anonymous) through puppeteer. A DevTools panel recording has such a client, so it gets the deep version. The line and column numbers are 1-based, unlike the ones in a CPU profile's call frames, and DevTools subtracts one before showing them.

Filter on the frame before you add anything up. A trace holds layouts from documents that have nothing to do with the page you are looking at: an SVG image's own document, laid out inside Paint, plus Chrome's WebUI and any extension's pages, each in its own process. Summing dur across a trace without checking pid and beginData.frame counts all of them.

DevTools' two warnings about this event have gates you will not guess. The forced reflow warning counts only Layout and UpdateLayoutTree nested inside a JavaScript event, and reports nothing unless one task's total of those crosses thirty milliseconds. The DOM size insight wants a single Layout of at least forty milliseconds whose dirtyObjects is over a hundred, on the main frame's own main thread. Both are thresholds on dirty objects and on durations that, per the first trap, include the recording's own tree walk.

Example event a local fixture page
{
  "name": "Layout",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103040878,
  "dur": 138,
  "tdur": 135,
  "tts": 131619,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "beginData": {
      "dirtyObjects": 6,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "partialLayout": false,
      "sampleTraceId": 6909218608593003,
      "stackTrace": [
        {
          "columnNumber": 39,
          "functionName": "",
          "lineNumber": 85,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "totalObjects": 10
    }
  }
}

See also InvalidateLayout · LayoutInvalidationTracking · UpdateLayoutTree · Document::UpdateStyleAndLayout · Blink.ForcedStyleAndLayout.UpdateTime · LocalFrameView::UpdateStyleAndLayout · PrePaint

categories
devtools.timeline
usage
moderate
duration
typical
undeclared args
args.beginData.stackTrace[].columnNumber, args.beginData.stackTrace[].functionName, args.beginData.stackTrace[].lineNumber, args.beginData.stackTrace[].scriptId, args.beginData.stackTrace[].url
references
local_frame_view.cc, layout_subtree_root_list.cc, source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc
LocalFrameView::ScheduleAnimationnot in DevTools recordingsnot in DevTools' model

Blink code has asked for the next rendering update to run for this frame.

A DevTools Performance recording does not contain this event. Record cc with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Spec

LocalFrameView::ScheduleAnimation records one request from Blink C++ code for a main-thread frame on behalf of one document's frame, written on that renderer's main thread just before the request is passed to the ChromeClient and on to the compositor's LayerTreeHost.

Arguments

pathdeclaredobserved
args.delay-100.0%undeclared, always present
args.frame.document.url-100.0%undeclared, always present
args.frame.is_cross_origin_to_outermost_main_frame-100.0%undeclared, always present
args.frame.is_cross_origin_to_parent-100.0%undeclared, always present
args.frame.is_main_frame-100.0%undeclared, always present
args.frame.is_outermost_main_frame-100.0%undeclared, always present
args.location.file_name-100.0%undeclared, always present
args.location.function_name-100.0%undeclared, always present
args.location.line_number-100.0%undeclared, always present

Observed 125,001 times on 182 of 300 sites.

Behaviour

Blink has more than one way to ask the compositor for a new main-thread frame, and this event records one of them. Code that holds a LocalFrameView (Blink's object for one frame's rendering state) and calls its ScheduleAnimation method writes the event, then hands the request to the ChromeClient, which finds the widget for the frame's local root and asks cc, the compositor, to schedule a main frame.

Most of what you will see goes through one function, ScriptedAnimationController::ScheduleAnimationIfNeeded: requestAnimationFrame, queued scroll, scrollend and resize events, media query listeners, video frame callbacks, and the re-arm Chrome performs after running a frame's rAF callbacks if more are queued. Animations add their own. Starting a CSS animation, a CSS transition or a Web Animation writes one from PendingAnimations::Add, and an animation that runs on the main thread, such as a transition on width, writes one from AnimationTimeline::ScheduleServiceOnNextFrame for every frame it runs. A composited animation, once started, writes none. IntersectionObserver.observe() and ResizeObserver.observe() write one each, and page load writes a few from lifecycle code such as BeginLifecycleUpdates and, in an iframe, WasShown.

There is one event per call, and nothing merges requests before the event is written. Merging happens afterwards, inside cc: a request that arrives while one is already pending is folded into it, unless it is marked urgent or needs to lift frame throttling. Call requestAnimationFrame ten times in one task and you get ten of these, one ProxyMain::SetNeedsAnimate (the cc instant that marks a request actually being posted) and one BeginMainThreadFrame. A single rAF loop writes two per frame, one from the requestAnimationFrame call inside your callback and one from the re-arm after it, with identical arguments.

The event is written before the request goes anywhere, so it appears even when the request is then ignored, for example for a frame that does not have a widget yet. Each renderer writes its own: with site isolation a cross-site iframe writes it in its own process, while a same-site iframe writes it in the page's renderer with is_main_frame false.

Traps

Style and layout changes request their frame without this event. Writing el.style.width, toggling a class, changing display or content-visibility or appending DOM nodes asks for a frame through PageAnimator::ScheduleVisualUpdate, which goes straight to the ChromeClient. So do main-thread scroll animations, the visual viewport and autoscroll. In a capture each of these produced a posted frame request and a BeginMainThreadFrame, and none of these events. The exception is a style change that starts a transition or animation: the animation code then writes it, as Add and ScheduleServiceOnNextFrame. Counting these events does not count the frames a page asked for.

location is Blink's C++ caller, not your code. It is filled by a default argument evaluated where ScheduleAnimation is called, so it names the Blink function one level up, with no class name and a line number that moves between Chrome builds. A few Blink wrappers are themselves called ScheduleAnimation, and then that is what you see. To find the script that called requestAnimationFrame, use the RequestAnimationFrame event and its stack trace.

ScheduleAnimationIfNeeded does not mean page script asked. Every rAF, queued scroll or resize event and media query listener shows that same line in scripted_animation_controller.cc. Scroll events are queued whether or not anything listens for them, so on a page with no scroll listener at all, a smooth scroll still wrote one per frame.

A count of these is a count of calls, not frames. Ten in a row may be one frame; a steady rAF loop gives two per frame; and, from the first trap, frames also run with none. Events written while a frame runs its rAF callbacks ask for the next frame, not the current one. Nothing here says whether a frame followed, or whether it drew anything.

delay is always 0. No Blink code passes a delay to this function.

The reason is not recorded. The function receives a BeginMainFrameReason (rAF, animation, CSS animation, and so on) and an urgent flag, and writes neither.

frame is the frame that asked, not the one that draws. A same-site iframe's request goes to its parent's compositor and can be merged with the parent's. frame.document.url is an empty string for events written before the frame's first navigation commits, and about:blank for a fresh iframe.

Record cc, and expect DevTools to hide it. cc is not in the categories the Performance panel records unless its "Show all events" setting is on, and even in a trace that contains the event, DevTools draws it only with that setting on, because it has no style for the name. A trace of the whole browser can also contain these events from Chrome's own interface pages: in new headless Chrome 153 a process named WebUI Top Renderer wrote them for chrome://omnibox-popup.top-chrome/. Filter on process or frame.document.url.

Example event a local fixture page
{
  "name": "LocalFrameView::ScheduleAnimation",
  "cat": "cc",
  "ph": "X",
  "ts": 1102925982,
  "dur": 9,
  "tdur": 8,
  "tts": 30078,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "delay": 0,
    "frame": {
      "document": {
        "url": "http://127.0.0.1:8801/"
      },
      "is_cross_origin_to_outermost_main_frame": false,
      "is_cross_origin_to_parent": false,
      "is_main_frame": true,
      "is_outermost_main_frame": true
    },
    "location": {
      "file_name": "third_party/blink/renderer/core/dom/scripted_animation_controller.cc",
      "function_name": "ScheduleAnimationIfNeeded",
      "line_number": 248
    }
  }
}

See also RequestAnimationFrame · FireAnimationFrame · ScheduleStyleRecalculation · BeginMainThreadFrame

categories
cc
usage
high
duration
short
references
scripted_animation_controller.cc, frame.h, location.cc, document.cc, local_frame_view.cc, traced_value_support.h and 2 more
LocalFrameView::UpdateStyleAndLayoutnot in DevTools recordingsnot in DevTools' model

Chrome brings one frame's style and layout up to date.

A DevTools Performance recording does not contain this event. Record blink with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Spec

Blink's LocalFrameView::UpdateStyleAndLayout recalculates style and runs layout for a single frame's document, doing only what is dirty, on the renderer's main thread.

Behaviour

Every frame on the page, the main document and each iframe, has its own view in Blink, the object that owns that frame's layout, and this is the view's update function. It has two main callers, and they put it in two different places in the trace.

The rendering update Chrome runs before painting calls it under LocalFrameView::RunStyleAndLayoutLifecyclePhases, once for every frame that process is rendering, parent first and then each child in turn, and again for all of them if choosing the root scroller dirtied layout. When something needs geometry sooner, such as script reading offsetHeight, the request goes through Document::UpdateStyleAndLayout, which calls this function inside its own slice, normally with a Blink.ForcedStyleAndLayout.UpdateTime slice in between. The Forced slice is left out when the request starts inside another one in the same frame, so the dependable sign of the forced path is the enclosing Document::UpdateStyleAndLayout. In no capture did one of these contain another, not even for iframes: in the rendering update each child frame's call follows its parent's, and on the forced path an iframe's document brings its parent up to date first, so the parent's call is an earlier sibling of the child's.

The function only does what is out of date. It recalculates style, which writes UpdateLayoutTree, then runs layout, which writes Layout, and each runs only if something is dirty. It then updates CSS counters, runs an extra sizing loop for the rare frame whose size follows its content, and makes one more pass if layout is still dirty. When anything was laid out, post-layout tasks such as geometry updates run before the slice closes. When nothing was dirty, the slice closes a microsecond or two after it opened, with nothing inside. That is by far the most common outcome, because the forced path calls it on every request whether or not anything changed: a hundred offsetHeight reads with no writes in between produced a hundred of these, all empty. Across the crawled corpus most of them come from the forced path, for the selection reason explained under Document::UpdateStyleAndLayout, so a trace full of these is mostly a trace full of clean reads.

The function begins with a guard that refuses the update for a frame whose rendering is throttled, a document that is not active, a frame that has not committed yet, a view already in the middle of layout, or a document whose lifecycle is postponed. The guard sits inside the traced scope, so a refused call is also a slice. Both main callers check most of those conditions before calling, which leaves the guard little to catch. A throttled iframe, such as one that is offscreen and cross-origin, is skipped before the call and so produces fewer of these, not more: in one headless capture, an offscreen cross-site iframe running a requestAnimationFrame animation for two and a half seconds wrote three, and the same iframe on screen wrote over a hundred and fifty.

Derivations

Which layouts did work. A slice that contains Layout or UpdateLayoutTree did real style or layout for that frame; an empty one did nothing. The working slice's duration covers that work plus the post-layout tasks that follow it, and one slice can hold more than one style and layout pass. Combined with the enclosing slice, this sorts every layout on the main thread, and the style recalculations that ran as part of one, into forced work (inside Document::UpdateStyleAndLayout) and rendering-update work (inside LocalFrameView::RunStyleAndLayoutLifecyclePhases), per frame through the beginData.frame on the nested events. Style recalculations that needed no layout, such as getComputedStyle() for a colour, run outside this event and are not covered.

Traps

A short slice is not a refused update. The guard returns inside the traced scope, but so does a call that simply found nothing dirty, and the trace cannot tell the two apart. Both are a slice of a microsecond or so with no children. Count children, not durations.

The fastest ones are not slices. Chrome's trace clock ticks in whole microseconds, so a call that opens and closes within the same tick has a duration of exactly zero. JSON traces recorded over the DevTools protocol, which is how Puppeteer and most tools record, are written by Perfetto's JSON exporter, and it writes a zero-length slice as an instant event: "ph": "I" with "s": "t" and no dur. They are the same event, just the cheapest instances of it. A parser that only reads "X" records undercounts the empty calls, and one that expects dur on every record will choke on them. The odd "ph": "B" record with no matching end is a slice that was still open when the recording stopped, usually a forced update caught mid-flight, with Document::UpdateStyleAndLayout and Blink.ForcedStyleAndLayout.UpdateTime open around it.

Summing durations double counts. Each forced one sits inside a Document::UpdateStyleAndLayout, usually with a Blink.ForcedStyleAndLayout.UpdateTime in between, and its own duration includes the UpdateLayoutTree and Layout inside it. Add up one level only. When blink is recorded, the direct children of a working slice are the timer slices Blink.Style.UpdateTime and Blink.Layout.UpdateTime, with UpdateLayoutTree and Layout one level below them, so look for descendants rather than children.

There are no arguments, so an empty slice does not say which frame it was for. The Layout and UpdateLayoutTree inside a working one carry beginData.frame; for the rest you have the process and thread.

Not all of them are the page. A trace that records every renderer process picks up Chrome's own interface pages as well. Under headless Chrome, a process named WebUI Top Renderer contributed a handful per capture, some directly under LocalFrameView::HandleLoadCompleted, the caller that runs this once more at load completion for a frame whose size follows its content. Filter by process before counting.

The event is on blink, which a DevTools Performance recording does not capture unless the timeline-show-all-events setting is on, and DevTools has no model entry for it, so it appears under its raw C++ name in the System category. DevTools' Layout block is drawn from the Layout event nested inside, not from this one.

Example event a local fixture page
{
  "name": "LocalFrameView::UpdateStyleAndLayout",
  "cat": "blink",
  "ph": "X",
  "ts": 1102923242,
  "dur": 4,
  "tdur": 4,
  "tts": 28430,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also Document::UpdateStyleAndLayout · Blink.ForcedStyleAndLayout.UpdateTime · Layout · UpdateLayoutTree · InvalidateLayout

categories
blink
usage
very high
duration
very short
references
local_frame_view.cc
PaintPaint

Chrome records the drawing commands for the parts of a page that need to be drawn again.

Spec

Blink's paint phase walks the PaintLayer tree of one local frame tree and records drawing commands into a display list for any content that needs repainting, on the renderer's main thread; nested Paint events mark individual PaintLayers that were repainted.

Arguments

pathdeclaredobserved
args.data.clip[]-100.0%undeclared, always present
args.data.frameyes100.0%
args.data.layerIdyes100.0%
args.data.nodeIdopt100.0%
args.data.nodeNameopt100.0%
args.data.sampleTraceId-100.0%undeclared, always present

Observed 70,939 times on 182 of 300 sites.

Behaviour

Paint records drawing commands; it does not produce pixels. What Blink writes is a display list, which cc later rasterizes in RasterTask, so a long Paint is time spent walking the page and recording, never time spent filling pixels.

The event comes in two shapes, one inside the other. A PaintLayer is Blink's own unit of paint. The document has one, and so does every element that is positioned or forms a stacking context, which includes anything with a transform, an opacity below 1 or a filter, and always includes the root html element. A PaintLayer is not a compositor layer, and most never become one.

Paint   #document          the root: the whole paint walk for this frame tree
  Paint HTML               a layer that needed repainting
  Paint DIV id='menu'      another one

The outer event is written for the top PaintLayer every time the paint phase runs, and it covers the whole walk, including the cheap work of copying the recorded output of layers that did not change. Blink's comment says it is there "to cover the cost of tree walk and cache copying of non-repainted contents". Same-process iframes are painted inside their parent's walk; an out-of-process iframe gets a root event of its own in its own renderer.

Inside it, a nested Paint is written for a PaintLayer that itself needed repainting, but only when the enclosing layer being reported did not also need it. If a whole stacking context changed, you get one event for it, not one for each layer inside. Layers that paint beyond what is on screen, a scroller or an element with a composited transform, are reported whenever they repaint. Nesting follows paint order, not the DOM: a positioned child of a positioned parent that does not form a stacking context is painted by the stacking context above both, and its event sits beside its parent's rather than inside it.

A changed element with no PaintLayer of its own is reported as the layer it paints into, and on most pages that is the root element's, named HTML: a colour change on an ordinary div shows up as #document with HTML inside it. On a page's first paint every layer needs painting, the root itself included, so the nested events are suppressed and there is a single #document.

The paint phase only runs when something needs repainting. A rendering update where nothing does writes no Paint at all, even though style, layout, PrePaint and Layerize all ran. A JavaScript style.transform animation on a promoted element produced a full rendering update every frame and not one Paint across 29 frames; the same loop changing a background colour produced two Paints a frame. A CSS transform animation produced no main-thread frames at all.

This is the third event to carry this name. Before CompositeAfterPaint, Chromium's rearrangement that paints first and decides compositor layers afterwards, there was a timed Paint for each composited layer, carrying that layer's id, beside one for the frame. From Chrome 97, when CompositeAfterPaint became the default, to Chrome 132 there were two kinds: a timed Paint per frame with a layerId of 0, and an instant Paint for each compositor layer that had damage, whose clip was the damaged rect in screen space and whose layerId was real. (Chrome 96 already had the code, behind a flag that was off by default.) Chrome 133 removed both and replaced them with the events described here. Articles and tools written before 2025 describe the older events.

Derivations

Which layers were actually repainted. The nested events inside a root Paint name the PaintLayers that needed repainting in that rendering update, by nodeName. That is the nearest a default Performance recording gets to "what repainted", and it is enough to tell a repaint confined to one component from one that dragged in the whole root layer. Its granularity is the PaintLayer: an element without one is reported as whatever layer it paints into.

Whether an animation is paint-free. Line the rendering updates up against the Paint events. A BeginMainThreadFrame whose PrePaint is not followed by a Paint on the same thread was a frame in which nothing needed repainting. An animation that is supposed to be paint-free and still produces a Paint every frame is not.

Traps

layerId is always 0. Blink writes a literal zero, with the comment "For backward compatibility". It has not been a compositor layer id since Chrome 133, and nothing in the event links it to one.

clip is not what was repainted. It is the layer's cull rect, the area Blink is willing to paint for that layer: the visible area, expanded along each axis the content can scroll by up to 4000 pixels (2000 on Android, and multiplied by the device pixel ratio), never past the scrollable content. Content that scrolls both ways gets half the expansion on each axis. A page that does not scroll gets no expansion at all, so its clip is exactly the viewport, and a small scroller gets its box plus 1024 pixels of scroll. On a page of 20-pixel boxes, each box's event carried a clip of 1000 by 924 pixels, the same size as the root's. It is in the layer's own local coordinates, so a nested event's rect usually starts at negative numbers, and the root's moves with the scroll position: scrolled to 8000 in an 800-pixel viewport, the root's clip ran from 4000 to 12800. DevTools' own type definition calls the field unreliable and slated for removal. The damage-rect meaning comes from the instant events of Chrome 96 to 132.

nodeId names the layer, not the element you changed. DevTools labels it "Layer root" for exactly that reason. Expect #document and HTML most of the time. nodeName is only present from Chrome 137. The id is a DOM node id allocated per renderer process, so an out-of-process iframe's #document can carry the same number as an element in the page.

frame is the frame that owns the reported layer, which is not always where the change happened. Blink carries an iframe's need to repaint up to the iframe element's own PaintLayer, and when that layer does not paint itself, which is the case for an unpositioned iframe, the layer above it is marked as needing repaint instead: usually the parent's HTML. That suppresses every nested event inside the iframe, so a repaint in a same-process iframe shows up as the parent frame's HTML, with the parent's frame id. Give the iframe position: relative and the nested event carries the iframe's own frame id and names the layer inside it.

Adding up Paint durations counts the same time twice. The nested events run inside the root one. Sum the root events, or use self time.

DevTools does almost nothing with this event beyond drawing it. LayerTreeHandler skips every Paint whose layerId is 0, which is all of them, so for a trace from Chrome 133 or later the frame model never attaches a paint to a frame and the picture preview never appears. Older traces, with real layer ids, still get both. InvalidationsHandler does not attach invalidations to Paint either: it uses a Paint only as the point after which a frame's pending invalidations are thrown away, and it credits them to UpdateLayoutTree and Layout. Nothing in DevTools answers "why did this repaint" from this event. DevTools' type definition also still says nodeId is absent under CompositeAfterPaint; it is on every event.

stackTrace never appears. Script is forbidden while Blink paints, so there is no JavaScript on the stack to capture. sampleTraceId does appear when disabled-by-default-devtools.timeline.stack is recorded, which a DevTools recording does, and then every Paint, however tiny, also takes a V8 CPU profiler sample.

Advanced paint instrumentation repaints everything once. Recording disabled-by-default-devtools.timeline.layers (the Performance panel's advanced paint instrumentation setting records it), or having the Layers panel open, switches on Blink's paint debug info, and the first paint after it is switched on forces a full repaint of every frame. The first Paint in such a recording is not representative.

The root Paint is most of the paint phase, not all of it. The cull rect update runs before it, and committing the display list and Layerize run after it closes.

Example event a local fixture page
{
  "name": "Paint",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1102944950,
  "dur": 444,
  "tdur": 287,
  "tts": 41633,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "clip": [0, 0, 1280, 0, 1280, 1100, 0, 1100],
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "layerId": 0,
      "nodeId": 3,
      "nodeName": "#document",
      "sampleTraceId": 6909218608594407
    }
  }
}

See also PrePaint · Layerize · Commit · RasterTask · PaintImage · PaintSetup · BeginMainThreadFrame · UpdateLayoutTree · Layout

categories
devtools.timeline
usage
moderate
duration
short
undeclared args
args.data.sampleTraceId
references
paint_layer_painter.cc, node.cc
PaintImagePaint image

Blink records one image being drawn into a page's paint.

Spec

Blink writes PaintImage on the renderer's main thread while it records the drawing of one image into a frame's display list: an <img> or other image box, a CSS background, or a border-image, with the painted rectangle and the source rectangle it draws from.

Arguments

pathdeclaredobserved
args.data.frameopt97.5%
args.data.heightyes97.5%
args.data.isCSSyes97.5%
args.data.isPictureopt35.5%
args.data.loadingAttributeopt35.5%
args.data.nodeIdopt100.0%
args.data.nodeNameopt100.0%
args.data.srcHeightyes97.5%
args.data.srcWidthyes97.5%
args.data.srcsetAttributeopt35.5%
args.data.urlopt53.8%
args.data.widthyes97.5%
args.data.xyes97.5%
args.data.yyes97.5%

Observed 56,965 times on 147 of 300 sites.

Behaviour

Painting in Blink is recording, not drawing pixels. It builds a display list, a list of drawing operations per element, and the compositor turns that into pixels later, on other threads. PaintImage marks the recording of one image operation, and there are three places that write it: ImagePainter for an <img> (and anything else laid out as an image box, such as a CSS content: url() image), the background painter for background-image, and the border painter for border-image.

Blink keeps each element's recorded drawing and reuses it until something invalidates it, and a reused drawing writes nothing. So the event follows re-recordings, not images. An image can be recorded more than once during load, as layout settles around it or as a progressive file arrives in pieces. After load, scrolling the window or an overflow container wrote nothing for images already painted, a style change on a neighbouring element wrote nothing, and adding an outline to an image wrote one more event for that image alone.

Blink records more than what is on screen: it paints everything inside its cull rect, an area that extends well beyond the viewport. An eager image 6000px down the page was recorded on the first scroll, before it came into view, and in one configuration during load; one at 30000px was not recorded at all. A PaintImage for an element does not mean the user saw it.

There is a fourth writer that paints nothing. During PrePaint, the step before paint, Blink works out whether each box's background will be completely hidden by what sits on top of it, so it can skip painting that background. When the answer depends on an image, either an <img> with a background of its own or an <img> that fills a box with a background, Blink asks whether the image is opaque, and that question writes a PaintImage named after the <img>. These events sit inside PrePaint, not Paint, and carry nodeId, nodeName and url only, with no rectangle and no frame. The check runs again whenever that background changes, so it can write an event for an image whose own drawing is reused and gets no regular PaintImage at all.

For a bitmap, the duration is the recording and nothing more. Decoding happens later, at raster, on a worker thread, with software and GPU rasterization alike: a 4000x4000 JPEG whose decode took around 30 ms on a worker had PaintImage events of a few microseconds. An SVG image is different. It is a whole document with its own lifecycle, which Blink runs from inside the host page's paint. When the SVG is drawn at a size it has not been drawn at before, its own Layout and PrePaint sit inside its PaintImage; a repaint at the same size carries only the PrePaint.

Derivations

Oversized <img> files. srcWidth times srcHeight is the number of pixels the file decodes to, and width times height is the area it was painted over, so their ratio is how much of the file the display could use. DevTools' Image delivery insight does exactly this: per image URL it takes the paint with the largest area, divides width and height by the device pixel ratio of the machine that recorded (see traps), and compares. Do it per URL, not per event, skip the events that have no rectangle, and use only bitmap <img> elements; the traps below say why the rest mislead.

Which element an image request became. url plus nodeId joins a network request to the element that painted it, and nodeId is a backend node id DevTools resolves to the DOM node. Join on the URL the request started with, because url is the pre-redirect one. A requested image with no PaintImage at all was downloaded and never painted while you were recording, which is what a preload for the wrong candidate, or an image hidden on this viewport, looks like.

Traps

width, height, x and y are device pixels of the machine that recorded, not CSS pixels. A 400x300 CSS <img> reported width: 400 at device scale factor 1 and width: 800 with the display at 2x, with software and GPU rasterization alike. DevTools' device emulation does not change them: emulating a 2x phone on a 1x desktop still reported 400. DevTools corrects for this using the host DPR it stores in the trace metadata, and warns that without it the results may be very misleading. A trace from chrome://tracing or puppeteer has no such metadata, so divide by the recording machine's DPR yourself.

srcWidth and srcHeight are not naturalWidth and naturalHeight. For an <img> they are the pixels of the part of the file being drawn; for a CSS image they are always the whole image.

  • A srcset candidate chosen by its w descriptor has a naturalWidth corrected for density; srcWidth is not. srcset="big.jpg 1600w" sizes="400px" gave naturalWidth 400 and srcWidth 1600.
  • Under object-fit: cover the source is cropped to the box but the painted rectangle is not. A 1600x1200 file in a 300x300 box reported srcWidth 1200 and width 400 at x -42, so the ratio says 3 where the file is more than 5 times the box width. With overflow: visible on the image nothing is cropped and srcWidth is the full 1600.
  • They are in the file's stored orientation. A JPEG with an EXIF rotation displayed 300x400 reported srcWidth 1600 and srcHeight 1200: compare width to height and the ratio is wrong on both axes.
  • An SVG is drawn at whatever size it is painted, so its source size equals its painted size and the ratio is always 1. The same holds for a CSS gradient.

CSS backgrounds cannot be judged by the ratio. For a repeating background, width and height are the whole painted area while srcWidth is the image that makes up one tile, and for a sprite, srcWidth is the whole sheet while width is the little box showing one icon: a 50px box on a 1600px sheet reads as 32 times oversized. DevTools skips CSS images for this reason. isCSS is true for every background and border-image, and also for an image made by the CSS content property; it is false for an <img>.

Counting events is not counting images. One image writes an event every time its drawing is re-recorded, so group by url and nodeId.

Events without a rectangle break size arithmetic, DevTools' included. The background check described above writes events with a url but no width, height, srcWidth, srcHeight or frame; filter them out first. DevTools does not. Its Image delivery insight picks the largest paint per URL with a comparison that a missing size wins when it comes last, and then has nothing to measure: of three identical oversized JPEGs, the one whose wrapper changed background colour after load dropped out of the insight, while the other two were flagged.

loadingAttribute and srcsetAttribute are the <img> element's own attributes as written, an empty string when absent, and they exist only when the element is an <img>. loadingAttribute: "" means no attribute, not eager loading by decision. In a <picture>, the candidates live on <source> elements and are not reported: srcsetAttribute is empty even though a candidate was chosen, and isPicture (whether the <img>'s parent is a <picture>) is the only sign. url is the candidate that was actually picked.

url is the URL the image was requested with, before any redirect: an <img> whose URL answered with a 302 reported its own URL, not the one it was sent to. It is missing whenever there is no fetched resource, which is every CSS gradient. A URL longer than 1024 characters, typically a data: URI, is cut to its first 511 and last 510 characters around ..., so it will not match anything exactly.

x and y are in the local coordinate space of whatever the element paints into. For ordinary content that is the document, but an image under transform reported 0, 0, and one inside a scrolling container was positioned relative to the container's contents. Do not treat them as page or viewport coordinates.

The event is on disabled-by-default-devtools.timeline, which a DevTools Performance recording includes and most other tracing setups do not.

Example event from https://www.bbc.com/news
{
  "name": "PaintImage",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1108423745,
  "dur": 121,
  "tdur": 120,
  "tts": 91874,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "data": {
      "frame": "2EA8810E92C12C680C55B2941251AA98",
      "height": 346.5,
      "isCSS": false,
      "isPicture": false,
      "loadingAttribute": "eager",
      "nodeId": 274,
      "nodeName": "IMG class='Image-styles__ImageStyled-sc-8c99a12b-0 cVsHni'",
      "srcHeight": 360,
      "srcWidth": 640,
      "srcsetAttribute": "https://ichef.bbci.co.uk/news/240/cpsprodpb/09f1/live/aaa670e0-af38-11f1-a540-61c3f7fc4e6c.jpg.webp 240w,https://ichef.bbci.co.uk/news/320/cpsprodpb/09f1/live/a... [578 more chars]",
      "url": "https://ichef.bbci.co.uk/news/640/cpsprodpb/09f1/live/aaa670e0-af38-11f1-a540-61c3f7fc4e6c.jpg.webp",
      "width": 616,
      "x": 332,
      "y": 529
    }
  }
}

See also Paint · Decode Image · Decode LazyPixelRef · Draw LazyPixelRef · ImageDecodeTask · PrePaint · LayoutImageUnsized · largestContentfulPaint::Candidate

categories
disabled-by-default-devtools.timeline
usage
moderate
duration
typical
references
inspector_trace_events.cc, image_painter.cc
PaintSetupPaint setupno longer emitted

Chrome set up a layer for painting, before painting into it.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Spec

Chromium's compositor, cc, timed its BitmapContentLayerUpdater allocating a new backing bitmap and canvas for a layer whose area to repaint had changed size, on cc's main thread, immediately before painting into it; the code shipped from Chrome 29 to Chrome 44.

Behaviour

Before impl-side painting, cc painted a layer's contents straight into a bitmap and uploaded that bitmap as a texture. The layer type that worked this way was ContentLayer, and its painter was BitmapContentLayerUpdater. On each update, cc worked out the rectangle to repaint (the union of the layer's dirty tiles) and handed it to the updater, which compared its size with the previous one. If the size differed, it allocated a new bitmap and a canvas over it, and that allocation is all this event covered. So it fired on a layer's first paint and whenever the invalidated area changed size, not only when the layer itself was resized; a repaint of an area the same size as last time reused the bitmap and wrote nothing.

It was a begin and end pair on cc's main thread, which for a page is the renderer main thread, carrying one argument, layerId, the cc layer being painted. The category was cc,devtools up to Chrome 36 and disabled-by-default-devtools.timeline from Chrome 37. From Chrome 33 the span closed before the paint began, so the paint sits next to it. In Chrome 29 to 32, cc also wrapped the whole update in a PaintLayer span, so there PaintSetup and the paint are both nested inside that.

Chrome no longer writes it. Impl-side painting, where a layer's drawing commands are recorded first and rasterised into tiles later, uses a different layer type and never reaches this code. With default flags it was already on for Android when the event was added, became the default on Linux and Chrome OS by Chrome 38, and on Windows and Mac in Chrome 39. After that a page's renderer only reached this code if Chrome was started with --disable-impl-side-painting. Chrome's own interface on Windows, Linux and Chrome OS kept painting this way by default through Chrome 44. In June 2015 commit d23f2953ca removed ContentLayer and commit 4dac85e885 removed BitmapContentLayerUpdater, both before Chrome 45, so Chrome 44 is the last release that contains the code.

The event was added in May 2013 together with a Blink change that did not write a trace event of its own. DevTools' old record-based Timeline listened for cc's PaintSetup, and Blink turned it into a Timeline record with the DOM node that owned the layer attached, placed just before the Paint record. Blink deleted that record-based Timeline in February 2015; the trace-based Timeline that replaced it read cc's event directly. Either way it was cc's measurement of the same allocation.

The name survives in three places: an unused kPaintSetup constant in cc/base/devtools_instrumentation.h, the DevTools Performance panel, which still labels it "Paint setup" and colours it as painting, and Lighthouse, which still counts it under Rendering. You will only meet the event itself in a trace recorded by Chrome 44 or earlier.

Traps

Nothing replaced it. PrePaint is Blink's lifecycle step before paint in a current trace, but it does different work and is not this event under a new name. A trace from after Chrome 44 that has no PaintSetup has not lost anything you could look for elsewhere.

It measured allocation, not drawing. A long one means cc allocated a large bitmap because the area to repaint changed size; it says nothing about how expensive the layer's contents were to paint. That cost is in the paint that follows (or, in Chrome 29 to 32, in the enclosing PaintLayer, which contains both).

layerId is a cc layer id, not a DOM node id, and trace_engine declares no arguments for this event. DevTools still assigns it a "Layer root" label in its code, but it resolves no node for PaintSetup, so that row never appears.

In a trace from Chrome 39 to 44 with default flags, a page's renderer did not write it. A PaintSetup there came from the browser process drawing Chrome's own interface, or from a renderer started with --disable-impl-side-painting. Check which process wrote it before blaming the page. On Android, a page never wrote it with default flags.

Example event

Chrome has not written this since Chrome 45, when its last emitter in cc was deleted. The name survives only as an unused constant in cc and an entry in DevTools.

See also Paint · PrePaint · UpdateLayer · RasterTask · CompositeLayers

references
devtools_instrumentation.h, bitmap_content_layer_updater.cc
PrePaintPre-paint

Blink gets the page ready to be painted: it works out the transforms, clips and effects each part is drawn with, and which parts need drawing again.

Spec

Blink runs the compositing-inputs and pre-paint lifecycle phases for one local frame root, walking the layout tree with PrePaintTreeWalk to update the paint property trees and invalidate what must be repainted, on the renderer's main thread between layout and paint.

Arguments

pathdeclaredobserved
args.data.frame-100.0%undeclared, always present
args.data.isMainFrame-100.0%undeclared, always present
args.data.isOutermostMainFrame-100.0%undeclared, always present
args.data.page-100.0%undeclared, always present

Observed 48,090 times on 182 of 300 sites.

Behaviour

Before Blink can record drawing commands it needs to know, for every element, which transform, clip, effect and scroll offset it is drawn under, and whether what it drew last time is still valid. Chromium keeps the first answer in paint property trees, one node per element that introduces a transform, clip, effect or scroll container, and works both answers out in a single walk of the layout tree called PrePaintTreeWalk. The same walk updates the data the compositor uses to hit-test touch and wheel input (touch-action, blocking wheel listeners) and skips subtrees with nothing dirty. The slice also covers a smaller phase Chromium calls compositing inputs, which updates some per-layer flags and commits the pending text selection. The two are separate phases in the code, and the slice covers both on purpose: Chromium's comment above it says the difference "is not relevant to web developers, so include them both under PrePaint."

In a rendering update it runs in the same main-thread task as the rest of the pipeline:

BeginMainThreadFrame
  UpdateLayoutTree, Layout
  PrePaint          compositing inputs, then the pre-paint walk
  Paint, Layerize
Commit

Each rendering update writes at least one per local root: the top document of a renderer, or an out-of-process iframe in its own renderer. It can write more. After PrePaint, Blink delivers ResizeObserver callbacks and a few other post-layout steps (the first IntersectionObserver check for content-visibility: auto, container-query changes, view transitions); if any of them dirties style or layout, the whole sequence runs again inside the same update. A ResizeObserver that resized an element produced Layout, PrePaint, Layout, PrePaint, Paint in one update.

The walk starts at the local root and crosses into every same-process iframe, so a same-origin iframe never gets a PrePaint of its own; its work is inside its parent's. A cross-site iframe that site isolation puts in its own process writes its own stream.

It is written whether or not anything changed. A page with nothing scheduled writes none, because no rendering update runs. A page with an empty requestAnimationFrame loop writes one on every frame, each a few microseconds long, while Paint never appears.

A rendering update is not the only way in. Every hit test brings the lifecycle up to this phase first, and writes a PrePaint whether or not anything was dirty, with no paint after it. document.elementFromPoint called three times in a row wrote three. Chrome's own hit test for mouse movement does the same, so under a moving mouse a frame holds two: one under the mousemove handling, then the frame's own. A forced layout stops short of it: getBoundingClientRect after a style change ran style and layout and returned, because Blink stops before this phase when layout is all the caller asked for. Printing and prerendering's dry run also stop after it.

No PrePaint is written for a frame Blink is currently render-throttling, unless an IntersectionObserver needs a fresh answer from it, for a document that is not active, or while DevTools has the lifecycle postponed.

The event carried a different name until Chrome 102. crrev.com/c/3519012 renamed UpdateLayerTree to PrePaint, in the same place in the code, and added three of the four arguments; UpdateLayerTree had only frame. Current Chrome does not write UpdateLayerTree at all.

Derivations

Which renderer did the work. args.data.frame is the frame id of the local root whose lifecycle ran. The same id appears on FrameCommittedInBrowser and in the frames list on TracingStartedInBrowser, next to a URL, so pre-paint time on a page with cross-site iframes can be split by document rather than by process id. isMainFrame alone separates the top-level stream from the out-of-process iframe streams, without needing that map.

Which lifecycle update a layout shift belongs to. Blink normally writes LayoutShift from inside this slice, at the end of each frame's pre-paint walk, when LayoutShiftTracker adds up the region that moved and scores it. The PrePaint that contains a LayoutShift is the update in which the shift was detected, and whatever dirtied layout before it is the candidate cause. DevTools uses it that way: LayoutShiftsHandler only collects and sorts PrePaint events, and the CLS culprits insight maps each shift to the PrePaint containing it, then credits a web font or a non-composited animation that finished within half a second before that PrePaint, an iframe created before it, or an image with no dimensions painted in the next PaintImage after it.

Traps

It has arguments. Four, on every event, none conditional: frame, isMainFrame, isOutermostMainFrame and page. trace_engine declares none of them.

page is a copy of frame. Blink fills it with the local frame root of the frame it was given, and the frame it was given is always the local root, so the two are the same string on every event. In the top renderer that happens to be the page; in an out-of-process iframe it is the iframe. Chromium's comment on that line says page does not work with out-of-process iframes and callers should use frame.

frame is not the frame whose content changed. It names the local root. Work for every same-process iframe is inside the parent's slice, under the parent's id. The LayoutShift events inside it carry their own frame, which can be a same-origin iframe: in one capture, a shift in a same-origin iframe sat inside the main document's PrePaint with the iframe's id on it.

isMainFrame and isOutermostMainFrame are not about the main thread. isMainFrame is true when the frame has no parent and false when an out-of-process iframe's renderer did the work. isOutermostMainFrame additionally turns false for the root of a fenced frame, which is the only case in which the two disagree.

Counting PrePaints does not count frames. It is written on every rendering update that gets past layout, including those with nothing dirty, more than once when a ResizeObserver or a similar step sends the lifecycle round again, and once per hit test, including every mouse move. A test harness that polls with requestAnimationFrame (puppeteer's waitForFunction does by default) keeps a page producing them. Sum the long ones instead.

Not every PrePaint belongs to a frame. A hit test, including your own elementFromPoint, runs it on demand inside the task that asked, dirty or not, and it is on that task's critical path. In DevTools it sits directly before a Hit test slice under the calling script or input event.

Not every LayoutShift is inside one, and not every one inside is from a frame. A shift that happens while a pointer or mouse button is held down is held back and written when the pointer is released, from input handling, outside any PrePaint; these carry had_recent_input and DevTools ignores them. A shift can also be detected by a hit test's PrePaint, which puts the LayoutShift in the middle of a script task.

The bar has nothing under it. The two phase slices inside it, LocalFrameView::RunCompositingInputsLifecyclePhase and LocalFrameView::RunPrePaintLifecyclePhase, are on blink,benchmark, which a Performance recording does not ask for. The only child DevTools shows is LayoutShift, an instant.

Old traces say UpdateLayerTree, and it was not always the same slice. A parser that spans Chrome versions needs both names; Lighthouse's task groups list both for that reason. From Chrome 97 to 101 UpdateLayerTree wraps the same work as today's PrePaint. Up to Chrome 96, where CompositeAfterPaint was still off by default, the same slice also ran the old compositing update, compositing inputs and compositing assignments, which modern Chrome does in Layerize after paint, so those durations are not comparable with a modern PrePaint. DevTools' CLS culprits insight matches only the name PrePaint, so on a trace older than Chrome 102 it has nothing to anchor root causes to.

DevTools pairs shifts with PrePaint by time alone. The culprits insight checks whether a shift's timestamp falls inside a PrePaint, and picks "the next PrePaint" after a font or animation, with no check of process, frame or kind. On a page with cross-site iframes, where two renderers run their lifecycles concurrently, a shift can in principle be attributed through the wrong renderer's PrePaint, and a hit-test or iframe PrePaint that happens to come first can leave a real culprit uncredited. That is from the source; it was not reproduced.

Example event a local fixture page
{
  "name": "PrePaint",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1102923261,
  "dur": 32,
  "tdur": 27,
  "tts": 28446,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "isMainFrame": true,
      "isOutermostMainFrame": true,
      "page": "14A831DCB73EB1205B67CDB89FC6EBD7"
    }
  }
}

See also UpdateLayerTree · Layout · Paint · Layerize · LayoutShift · HitTest · BeginMainThreadFrame · PaintImage

categories
devtools.timeline
usage
moderate
duration
typical
undeclared args
args.data.frame, args.data.isMainFrame, args.data.isOutermostMainFrame, args.data.page
references
inspector_trace_events.cc, document.cc, local_dom_window.cc, local_frame_view.cc
RasterTaskRasterize paint

Chrome's compositor rasterizes one tile of a layer: it plays back the drawing commands recorded for that tile.

Spec

The compositor's TileManager writes RasterTask around the playback of one tile's recorded display list into that tile's raster buffer, on a raster worker thread: drawing the pixels there under software raster, or serializing the drawing commands for the GPU process under GPU raster.

Arguments

pathdeclaredobserved
args.tileData.layerIdyes100.0%
args.tileData.sourceFrameNumberyes100.0%
args.tileData.tileId.id_refyes100.0%
args.tileData.tileResolutionyes100.0%

Observed 140,457 times on 182 of 300 sites.

Behaviour

Paint only records drawing commands. The compositor, cc, cuts each compositor layer into tiles, rectangles it can rasterize and cache independently, and schedules one raster task per tile that needs pixels: tiles that are new, that scrolled into the area cc keeps ready, or whose content was invalidated. RasterTask wraps exactly one thing inside that task, the playback of the tile's recording through a raster buffer. Its parent, RasterizerTaskImpl::RunOnWorkerThread, is the whole task.

What that playback does depends on how Chrome is rasterizing, and it changes the event completely.

Software raster. Used when there is no usable GPU: headless Chrome with default flags, --disable-gpu, --disable-gpu-rasterization, or a blocklisted GPU. The renderer draws the pixels itself, and you see DisplayItemList::Raster nested inside. Several tasks run in parallel on different workers; four or five ran side by side in these captures. Tiles come from a fixed default size, 256 by 256 pixels here.

GPU raster. The normal case on a desktop or phone with a working GPU, and out of process: the renderer does not draw. The task serializes the tile's drawing commands into the command buffer for the GPU process (RasterImplementation::RasterCHROMIUM nested inside), and the GPU process turns them into pixels later in RasterDecoderImpl::DoRasterCHROMIUM, on its own main thread, CrGpuMain. cc runs these tasks strictly one at a time. Tiles are sized from the viewport instead: as wide as it, and a quarter of its height rounded up, never less than 256 pixels tall (1216 by 256 on a 1200 by 900 viewport). The same page produced about a quarter as many tasks. On one fixture page the renderer's RasterTasks added up to 5 ms under GPU raster and 276 ms under software raster on the same machine, and the GPU process spent 7 ms on the pixels: more than all the RasterTasks together.

In the renderer the tasks run on Chrome's shared thread pool, on threads named ThreadPoolForegroundWorker. The browser process writes RasterTask as well, on a thread named CompositorTileWorker1, while it rasterizes Chrome's own interface.

The event is written as a begin and an end. Chrome's JSON export turns a finished pair into one X event with a duration, and writes a lone B for a task that was still running when recording stopped; stopping a trace in the middle of heavy raster produced one B per busy worker.

Derivations

Which compositor layer costs raster time, under software raster. Sum dur per layerId within the renderer process. With disabled-by-default-devtools.timeline.layers recorded, the trace also carries LayerTreeHostImpl:snapshot events whose active_tree.layers give each layer_id a layer_name, its compositing_reasons and an owner_node, the backend DOM node id of the element that owns the layer. Expect most of the time to land on one layer: everything that is not separately composited paints into the root scrolling layer, whose owner is the document.

Traps

Under GPU raster, RasterTask does not contain the rasterization. It covers packing the drawing commands into the command buffer; the pixels are made in the GPU process in RasterDecoderImpl::DoRasterCHROMIUM, on the gpu category, which neither a DevTools recording nor puppeteer's defaults include. Short RasterTasks on a real device do not mean the drawing was cheap.

Numbers from headless Chrome are software raster numbers. Headless Chrome with default flags reported "Software only" for rasterization, and --enable-gpu-rasterization --ignore-gpu-blocklist did not change that. A headless crawl measures raster the way a machine without a GPU would: more tasks, smaller tiles, running in parallel, and far more renderer time than the same page on a device with a GPU.

It is not on a thread called "Raster". The renderer's tasks run on ThreadPoolForegroundWorker threads, and the DevTools Performance panel shows them under "Thread pool", not "Raster". CompositorTileWorker1, which DevTools labels as a rasterizer thread, belongs to the browser process drawing Chrome's own interface, so filter by process before adding anything up.

tileResolution is not a low-resolution flag. Its values are HIGH_RESOLUTION and NON_IDEAL_RESOLUTION, and there is no low-resolution value. A tiling is non-ideal when its scale is no longer the one the layer wants, after a zoom or a scale change. cc takes raster work from the high-resolution tiling, with one transitional exception: tiles of an active-tree tiling that has gone non-ideal while its pending-tree twin is high resolution. So NON_IDEAL_RESOLUTION can appear, but pinch-zoom, page-scale and scale-transform captures all produced only HIGH_RESOLUTION.

tileId.id_ref is a memory address, not an id. It is the address of the tile object, and a new tile reuses the address of a freed one: 129 tasks in one capture shared 40 ids, with different sourceFrameNumber values on the same id. Pair it with sourceFrameNumber before counting re-rasters of "the same tile".

sourceFrameNumber is when the tile was created, not when it was rastered. It is the compositor commit number in effect when cc created the tile object, counted from 0 per compositor, so it repeats across processes. A tile that survives later commits keeps its number while the recording it plays back is the layer's current one, so the number can be older than the content drawn, and it says nothing about which displayed frame the pixels reached.

layerId does not lead to an element in DevTools. It is a compositor layer id, not a DOM node id, and LayerTreeHandler never reads RasterTask. What DevTools does instead is show a paint preview for a selected RasterTask by looking up tileId and sourceFrameNumber in a layer tree snapshot, which exists only with advanced paint instrumentation, and it matches that snapshot by the name cc::LayerTreeHostImpl, while Chrome 153 writes it as LayerTreeHostImpl:snapshot. Expect no preview from a current recording. The manual route through owner_node is in the derivations, and needs the page still open to turn that node id into an element.

A B event has no duration. It is a task that had not finished when recording stopped. Drop it, or treat its time as unknown, rather than reading it as zero.

The event is on both cc and disabled-by-default-devtools.timeline, and either one records it: a DevTools Performance recording, puppeteer's default categories, and a trace of cc alone all have it.

Example event a local fixture page
{
  "name": "RasterTask",
  "cat": "cc,disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102928322,
  "dur": 35,
  "tdur": 33,
  "tts": 1420,
  "pid": 8178,
  "tid": 8209,
  "args": {
    "tileData": {
      "layerId": 37,
      "sourceFrameNumber": 1,
      "tileId": {
        "id_ref": "0x13f4017a89a0"
      },
      "tileResolution": "HIGH_RESOLUTION"
    }
  }
}

See also RasterizerTaskImpl::RunOnWorkerThread · ZeroCopyRasterBuffer::Playback · DisplayItemList::Raster · GPUTask · ImageDecodeTask · Paint · cc::LayerTreeHostImpl · ActivateLayerTree · DrawFrame

categories
cc, disabled-by-default-devtools.timeline
usage
high
duration
typical
references
frame_viewer_instrumentation.cc, tile_manager.cc, picture_layer_impl.cc, devtools_instrumentation.h, picture_layer.cc, layer_tree_host.h and 3 more
RequestMainThreadFrameRequest main thread frame

The compositor thread sends the main thread the task in which it will do its part of a frame.

Spec

Chromium's compositor, cc, posts a BeginMainFrame task from a renderer's compositor thread to its main thread, the task in which Blink will run requestAnimationFrame callbacks, style, layout and paint, and writes this instant on the compositor thread immediately after posting it.

Arguments

pathdeclaredobserved
args.layerTreeIdyes100.0%

Observed 27,083 times on 182 of 300 sites.

Behaviour

Something has to ask for a main frame first, and usually it is the page. A requestAnimationFrame call, a DOM or style change or a main-thread animation tells cc that the main thread needs a frame. The compositor thread can ask too: when it scrolls the page itself it requests a main frame to hand the new scroll offset to the main thread, and it does the same when a composited animation has start or finish events to deliver. The need is a single flag, not a queue: twenty requestAnimationFrame calls and twenty style writes in one task produced one RequestMainThreadFrame. An idle page writes none, and neither does a running composited transform animation, which the compositor ticks and draws without the main thread.

The compositor thread's scheduler then decides when to act on it, and everything it holds back happens before this event. It sends a main frame only inside an impl frame, cc's name for the compositor's own work on one display refresh, at most one per refresh, and never while the previous main frame is still running on the main thread. It also holds off while the page is hidden, while main frame updates are deferred during loading, and while it is throttling. What that means in practice depends on what the page was doing:

  • After idle, viz hands the compositor the most recent refresh the moment it starts listening again, so the event followed a requestAnimationFrame call by about half a millisecond in one capture.
  • In a steady requestAnimationFrame loop it waits for the next refresh, a whole interval after the call.
  • In a loop whose callbacks outlast a refresh, it goes out a few hundredths of a millisecond after the previous main frame's Commit starts, wherever that falls in the refresh.

When it does send, cc posts the task and writes this event. It is an instant on the renderer's Compositor thread. A compositor without its own thread has no call site: the browser draws its own UI that way, so every one of these belongs to a renderer, and a renderer run without a compositor thread, as in web tests, writes BeginMainThreadFrame with no RequestMainThreadFrame before it.

Because the scheduler will not send another main frame until the main thread has finished with the last one, this event and BeginMainThreadFrame strictly alternate for each layer tree: one request, then the start of that main frame on the main thread, then the next request. There are two ways to get a request with no start after it. The task can run and abort before BeginMainThreadFrame, because the page became hidden or rendering was deferred between the send and the run; one capture had this on the initial about:blank layer tree at the moment the page navigated away from it. Or the trace can end while the task is still waiting behind a long task.

Derivations

How long a main frame waited, split in two. When a requestAnimationFrame call was the trigger, the first RequestAnimationFrame (on devtools.timeline) after the previous main frame to this event is how long the compositor held the request: up to a refresh in a steady loop, which is the normal wait for vsync, or the whole previous main frame when frames run long. This event to the next BeginMainThreadFrame on the same process and layer tree is how long the task sat in the main thread's queue. A frame that started late for the first reason needs shorter frames; one that started late for the second needs shorter tasks, or fewer of them queued at once. Both events are in a Performance panel recording. A request that came from a style change or from the compositor writes no RequestAnimationFrame, so only the second half is measurable.

Traps

The gap to BeginMainThreadFrame is not only the main thread being busy. It is the time the task spent in Blink's compositor task queue, and Blink's scheduler decides the order. A task already running is never interrupted: a rAF followed by a 300 ms busy loop gave a 299.6 ms gap. But the queue is also ordered by priority. The compositor queue normally has the same priority as timers, so work queued earlier runs first: five 10 ms timers queued during a steady rAF loop all ran before the main frame, which started 50 ms after the previous one ended. Once a task finishes 100 ms or more after the last main frame task ended, the scheduler raises the queue above timers and most other work, though still below input and render-blocking loading tasks, until the next main frame runs. With twenty 20 ms timers queued instead of five, the main frame started 100.1 ms after the previous one and jumped the remaining timers, and each of the next few main frames waited again, 100 to 120 ms apart, until the timers ran out. After an idle stretch the page already counts as starved, so the same twenty timers waited behind the main frame instead of in front of it. A run of gaps close to 100 ms is that rule at work, not a coincidence in your task lengths. From source, the queue is also raised to top priority after a click or key press until the next frame, and lowered below timers while the compositor thread is handling a scroll gesture, unless rendering is already starved; neither was captured for this entry.

The compositor's own delays are not in the gap. Waiting for the next refresh, waiting for the previous main frame, throttling and paint holding all happen before this event is written. Neither is it a vsync marker: after idle it is written almost immediately, and in a heavy loop it lands mid-refresh.

Pair on process and layerTreeId, not thread. This event is on the Compositor thread and BeginMainThreadFrame is on the main thread. layerTreeId is the id of the compositor's layer tree host, a counter local to each process that starts at 1, so an out-of-process iframe's renderer uses the same small numbers as the page's: in one capture the page's tree was 2 and the iframe's, in its own process, 1. It also changes across a navigation: an initial about:blank tree was 1 and the loaded page 2 in one trace. SetLayerTreeId carries the layer tree id and the frame id together. Take the next BeginMainThreadFrame on the same pair only if no second request comes first, which skips the aborted ones.

DevTools uses it as a switch, not a timestamp. The event is hidden in the flame chart unless "Show all events" is on in the Performance panel's settings. The frame model never reads its ts. It sets a flag that stops DrawFrame from closing frames until the main frame's Commit arrives, and it matches on layerTreeId alone, so a request from another process with the same number sets the flag for the page's frames too. On one capture, removing every RequestMainThreadFrame changed DevTools' frame count from 837 to 840.

Category. It is written only on disabled-by-default-devtools.timeline.frame, which DevTools records but a hand-written trace configuration has to ask for. SendBeginMainFrame, on cc,benchmark, is written a few microseconds earlier at the same moment and carries the begin-frame sequence number. A default Performance recording does not include it; with "Show all events" on, DevTools records every category that is not disabled by default, and it is there.

Your harness can create these. Puppeteer's page.waitForFunction() polls with requestAnimationFrame by default, so a page that is waiting idle requests a main frame every refresh for as long as the test waits.

Example event a local fixture page
{
  "name": "RequestMainThreadFrame",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1102922942,
  "tts": 1523,
  "pid": 8257,
  "tid": 8278,
  "s": "t",
  "args": {
    "layerTreeId": 1
  }
}

See also BeginMainThreadFrame · RequestAnimationFrame · BeginFrame · Commit · DrawFrame · DroppedFrame · MainFrameAborted · SetLayerTreeId · SendBeginMainFrame

categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, proxy_impl.cc
ScheduleStyleRecalculationSchedule style recalculation

Chrome notes that a page's styles have gone out of date and schedules a recalculation.

Spec

Blink's Document::ScheduleLayoutTreeUpdate moves a document from clean to waiting for a style and layout tree update, the moment the first out-of-date style is recorded, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.nodeIdopt0.0%declared, never observed
args.data.reasonopt0.0%declared, never observed
args.data.sampleTraceId-100.0%undeclared, always present
args.data.stackTrace[].columnNumber-50.8%
args.data.stackTrace[].functionName-50.8%
args.data.stackTrace[].lineNumber-50.8%
args.data.stackTrace[].scriptId-50.8%
args.data.stackTrace[].url-50.8%
args.data.subtreeopt0.0%declared, never observed

Observed 44,757 times on 182 of 300 sites.

Behaviour

Blink records a style change in two steps. First it marks the element involved and walks up its ancestors setting a bit on each: that happens for inline style writes, class or attribute changes that match a selector, and inserted nodes. At the end of that walk, unless the parent was already marked, it calls Document::ScheduleLayoutTreeUpdateIfNeeded, and that function returns straight away if the document already has an update pending. Only the call that finds no update pending gets through to ScheduleLayoutTreeUpdate, which moves the document's lifecycle to kVisualUpdatePending, Blink's state for "an update has been asked for and has not started", and writes this event.

So the event marks a transition, once per document, and not one invalidation. In one capture, twenty writes to one element's style in a single task produced one event, and toggling a class on five elements produced one. Everything after the first is silent until the recalculation runs. The recalculation is UpdateLayoutTree, and running it takes the document out of the pending state, so the next change writes a new event. That is why a loop that writes a style and then reads offsetHeight leaves one of these per iteration, each followed microseconds later by an UpdateLayoutTree inside the same task. Without the read, the recalculation waits for the next rendering update.

The per-element record, with a reason string and the element's name, is a different event on a different category: StyleRecalcInvalidationTracking, on disabled-by-default-devtools.timeline.invalidationTracking. Those twenty writes produced twenty of them.

Script is only one source. The HTML parser inserting nodes, a stylesheet finishing its download, a web font arriving, a change of viewport size and each main-thread tick of a CSS animation all make style out of date the same way. The trigger is also wider than style: anything that needs the layout tree rebuilt goes through the same call, including SVG <use> shadow trees, elements leaving the top layer and CSS counters. Changes inside a content-visibility: hidden subtree are the exception in the other direction: Blink postpones marking a hidden subtree until it is shown, and in a test capture neither an inline style write nor a class toggle inside one wrote an event.

Blink suppresses the event while style is already being recalculated and while layout is running, and writes none for an inactive document. It does not suppress it when no rendering update can follow: in a throttled frame, such as an off-screen cross-origin iframe, or before the document's render-blocking resources have loaded, the event is written but no frame is requested. In a capture with a slow stylesheet in the head, the parser wrote one event, the document then sat in the pending state until the sheet arrived 1.5 seconds later, and a script style write during that wait wrote nothing, because an update was already pending.

The payload is frame, the id of the document's frame, and nothing else unless disabled-by-default-devtools.timeline.stack is recorded. With that category on, every event gets a sampleTraceId, and it gets a stackTrace only if V8 was running JavaScript at that instant. That is the split you see in the arguments table: events started by script carry a stack, and events started by the parser, the network or the animation clock do not.

Derivations

Which line of JavaScript first made style dirty. stackTrace[0] is the call that took a clean document to pending, so in a trace full of forced recalculations each event names the write half of a write-then-read pair, and the UpdateLayoutTree right after it is the read. With disabled-by-default-v8.cpu_profiler recorded as well, sampleTraceId resolves against cpuProfile.trace_ids in ProfileChunk to a node in the profile's call tree, which is how DevTools rebuilds the full JavaScript stack above the event. In one capture every stackless event that resolved landed on (program): nothing was executing JavaScript, so Blink started that recalculation on its own. That is a positive answer where a missing stackTrace on its own is not.

Traps

reason, subtree and nodeId are never written. trace_engine declares all three on this event and Blink has no code that writes them: the payload builder takes only the frame, as it already did in 2015. They are the fields of StyleRecalcInvalidationTracking. If you want to know which element changed and why, record disabled-by-default-devtools.timeline.invalidationTracking and read those events instead.

The stack names the first change, not the expensive one. Only the invalidation that found no update pending writes an event, so a cheap style.width write followed by a class toggle on body in the same task is attributed entirely to the width write. The class toggle, which is the one that makes the recalculation large, leaves no trace here at all.

DevTools pairs by frame and by order, and never forgets. InitiatorsHandler keeps the last ScheduleStyleRecalculation for each frame id and links it to every UpdateLayoutTree that starts on that frame afterwards; it overwrites the entry when a new one arrives but never clears it after use, and there is no time limit. That link fills the recalculation's "Initiated by" row, its "First invalidated" stack and its "Pending for" time. It is right whenever a fresh event comes before the recalculation. It goes wrong when the event is written inside the recalculation, and a forced style update in script does that while CSS animations are running: an outdated animation is enough to make the forced update run, and the update services animations only after its UpdateLayoutTree has begun, so the animation's change writes a ScheduleStyleRecalculation a few microseconds into the slice. DevTools then links that recalculation to an older event, tens of milliseconds old in the real trace where this was seen, and credits the new one to the next recalculation. The test for a wrong link is structural: a ScheduleStyleRecalculation on the same frame falls inside the previous UpdateLayoutTree, or none falls between two of them. A long "Pending for" on its own proves nothing, because a recalculation held back by a render-blocking stylesheet is linked correctly and can show a wait of more than a second.

This event can also be what a Layout points at. When a layout invalidation happened inside a style recalculation that had an initiator, DevTools substitutes that initiator, so the arrow into a Layout can start here rather than at InvalidateLayout.

Record the right categories, or there is nothing to pair. This event is on disabled-by-default-devtools.timeline, and UpdateLayoutTree is on devtools.timeline, which is on by default. A trace made without the disabled category has every recalculation and none of these, and no recalculation in it has an initiator. sampleTraceId and stackTrace need disabled-by-default-devtools.timeline.stack as well, and with it on each event also takes a V8 CPU profiler sample it would not otherwise take.

Stack depth measures your recorder, not the page. V8 captures one frame unless a client has the Runtime domain enabled, and then up to 200. The same five-deep call chain came out as seven frames under Puppeteer and as one frame in a trace recorded from the command line with --trace-startup. The frames run top first, lineNumber and columnNumber count from one, and scriptId is a string.

An event does not promise a recalculation soon. In a throttled frame or before render-blocking resources have loaded, the event is written and no frame is requested, so the matching UpdateLayoutTree may be a long way off.

Filter on args.data.frame. Every document writes these, including iframes, about:blank pages and Chrome's own pages in other renderer processes, and DevTools' Frames track treats any of them on the main thread as a sign that a main-thread frame has begun.

Example event a local fixture page
{
  "name": "ScheduleStyleRecalculation",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102950872,
  "tts": 45984,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "sampleTraceId": 6909218608594505,
      "stackTrace": [
        {
          "columnNumber": 81,
          "functionName": "note",
          "lineNumber": 24,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        },
        {
          "columnNumber": 5,
          "functionName": "",
          "lineNumber": 47,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ]
    }
  }
}

See also UpdateLayoutTree · StyleRecalcInvalidationTracking · ScheduleStyleInvalidationTracking · InvalidateLayout · Layout · Document::UpdateStyleAndLayout

categories
disabled-by-default-devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc, document.cc
ScrollLayerScroll

Blink records that the scroll position of the page, or of a scrollable element, has changed.

Spec

PaintLayerScrollableArea::UpdateScrollOffset applies a new scroll offset to one of Blink's scrollers, the viewport or an overflow box, on the renderer's main thread, whatever produced the change.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.nodeIdopt100.0%
args.data.nodeName-100.0%undeclared, always present

Observed 1,043 times on 101 of 300 sites.

Behaviour

Every scroller Blink knows about keeps its own copy of its scroll offset, and ScrollLayer is written each time that copy actually changes. An offset set to the value it already has writes nothing. The name is older than tracing itself: DevTools' original Timeline had a ScrollLayer record, and the trace event kept it. The layer is Blink's own layer, today called the paint layer, the structure a scroller belongs to, not a compositor layer.

Most scrolling in Chrome is not applied on the main thread. A wheel, trackpad, keyboard or smooth-scroll animation is applied by the compositor thread, which moves the page on screen without waiting for Blink. At the start of each main-thread frame, LayerTreeHost::ApplyCompositorChanges hands Blink the offsets the compositor has reached since the last one, and each scroller that moved writes a ScrollLayer. So an ordinary threaded scroll produces one per frame, as the first thing in that frame's task, followed by BeginMainThreadFrame and the scroll event. A 600-pixel gesture on an idle page wrote one in each of its 32 frames.

Script writes them too, and on a different schedule. scrollTo, scrollBy, setting scrollTop, scrollIntoView with the default instant behaviour, and focus() on an element outside the viewport write one synchronously, inside the JavaScript that called them. With behavior: 'smooth' the call only starts an animation, which is handed to the compositor and comes back one frame at a time, exactly like a user scroll. Navigating to a #fragment, scroll anchoring (Blink shifting the offset to keep content still when something is inserted above it) and clamping (pulling the offset back when the content shrinks below it) each write one inside the next frame's rendering update. A scroll the main thread performs itself on behalf of the user would write one as well; that path is in the source, but no capture here could make Chrome take it.

The visual viewport, the part of the page you see after a pinch zoom, is not one of these scrollers. Panning it around the zoomed page writes nothing. Once the pan reaches the edge of the layout viewport and starts moving the page itself, the page's scroller changes and ScrollLayer on #document appears as usual.

Derivations

Which code scrolled the page. An instant script scroll writes its ScrollLayer inside the JavaScript that caused it, nested under that code's FunctionCall, so the enclosing FunctionCall names the function and its script and nodeName names the scroller. That is the quickest way to find the code behind an unexpected jump, a scroll restoration fight, a focus() that drags the page or a carousel setting scrollLeft. A ScrollLayer at the head of a frame, with no script above it, is the compositor reporting back and says nothing about who started the scroll. Smooth scrolls from script do not nest either: the call only starts the animation.

Traps

It cannot tell you whether scrolling is threaded. A compositor scroll, a script scroll and a main-thread scroll all write the same event with the same fields. One per frame during a scroll is the normal signature of a healthy threaded scroll, not of a fallback to the main thread.

The kind of scroll is recorded next to it, not on it. Blink writes it three times around each ScrollLayer: a Type instant on the blink category, another on input, and a SetScrollOffset instant on disabled-by-default-blink.debug whose type reads kCompositor, kProgrammatic, kAnchoring and so on. A default Performance recording captures none of them. Turning on the Performance panel's CSS selector stats setting adds disabled-by-default-blink.debug, so the SetScrollOffset instant then lands in the saved trace. It is also written for script scrolls that turn out to change nothing, so pair it with a ScrollLayer by timestamp rather than counting them.

It does not say where the scroller went. There is no offset and no delta on the event. The new x and y are on PaintLayerScrollableArea::UpdateScrollOffset (blink) and ScrollableArea::scrollOffsetChanged (input), neither of which a Performance recording captures.

Its timestamp is when Blink heard about the scroll, not when the user saw it. With the main thread blocked for 800 milliseconds, a threaded gesture scrolled the page for half a second on the compositor while the main thread wrote nothing, and the whole scroll then arrived as two ScrollLayers the moment the block ended. A gap in ScrollLayer during a scroll can mean the scroll was working fine.

A non-passive wheel listener looks the same. Given the same 800-millisecond block and a wheel listener without passive: true, the compositor could not start scrolling until the main thread had run the listener, so the page genuinely froze. The ScrollLayers landed at almost the same moment as in the threaded case. What differs is on the compositor thread, where the scroll updates are handled, in the input category, which DevTools does not record. Such a listener delays the start of a compositor scroll; it does not move the scroll onto the main thread, and neither does a scroll listener, which runs after the offset has already changed.

The root scroller is #document. When the page itself scrolls, nodeName is #document and nodeId is the document, not <html> or <body>. For an overflow box nodeName is Node::DebugName(): the tag followed by the id and every class, as in DIV id='box' class='scroller main'. Use it as a label, not a selector.

nodeId is only good for the live page. It is a DOMNodeId, the same number CDP calls a backendNodeId, handed out on first use: the document was 2 in one capture and 1 in another. It will not line up across two loads of the same page.

frame is the scroller's own frame. A scroll inside an iframe carries the iframe's frame id, not the page's.

dur is bookkeeping, not the cost of scrolling. The slice covers what Blink does right after taking the new offset: marking paint and the compositor's copy dirty and queueing the scroll event. That is tens of microseconds. Your scroll handlers run later in the frame, outside it.

DevTools calls the node "Layer root". The Summary pane links nodeId under that label, shared with Paint. It is the element that scrolled.

Example event a local fixture page
{
  "name": "ScrollLayer",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1104989335,
  "dur": 190,
  "tdur": 85,
  "tts": 219835,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "nodeId": 3,
      "nodeName": "#document"
    }
  }
}

See also BeginMainThreadFrame · EventDispatch · Commit · Paint

categories
devtools.timeline
usage
low
duration
typical
undeclared args
args.data.nodeName
references
node.cc, paint_layer_scrollable_area.cc
StyleResolver::ResolveStyleneeds Enable CSS selector stats (slow) or Invalidation trackingnot in DevTools' model

Chrome has just worked out the computed style of one element.

A DevTools Performance recording contains this event only with Enable CSS selector stats (slow) or Invalidation tracking turned on, which records disabled-by-default-devtools.timeline.invalidationTracking.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Spec

Blink's StyleResolver::ResolveStyle marks each style it finishes computing for one element or pseudo-element, the unit that the enclosing UpdateLayoutTree counts as elementCount, with an instant on the renderer's main thread, written only while disabled-by-default-devtools.timeline.invalidationTracking is recorded.

Arguments

pathdeclaredobserved
args.data.nodeId-100.0%undeclared, always present
args.data.parentNodeId-100.0%undeclared, always present
args.data.pseudoId-100.0%undeclared, always present

Observed 3,114,328 times on 182 of 300 sites.

Behaviour

StyleResolver::ResolveStyle is the function that turns CSS into a computed style for one element: it matches rules and runs the cascade, or reuses a cached result, then layers any running animation values on top. When it has finished, it adds one to a per-document counter and, if the invalidation-tracking category is being recorded, writes this instant on the very next line. UpdateLayoutTree, the style recalculation slice, reports how far that counter moved while it ran as elementCount. The two have been kept in step deliberately since Chrome 138, so give each instant to the innermost UpdateLayoutTree on the same thread and the number matches elementCount exactly. It matched in every recalculation of every trace captured for this entry, synthetic pages and real ones. The word innermost matters: a child frame's document can be parsed, loaded and styled while the parent document's recalculation is still running, so its UpdateLayoutTree sits nested inside the parent's, with a different beginData.frame, and its instants fall inside both.

What both numbers count is resolutions, not elements. One element can appear more than once in a single recalculation. Every page's root <html> is resolved three times in its first recalculation, once for itself and once each for the ::spelling-error and ::grammar-error rules in Chrome's own stylesheet; an element with a ::selection rule gets a second one the same way; an element inserted with an @starting-style rule is resolved twice, once for its starting style and once for its real one; and an element with a styled scrollbar is resolved again for each scrollbar part, sometimes more than once per part. Pseudo-elements that Blink creates as nodes, ::before, ::after, ::marker, ::first-letter and ::backdrop, get events of their own, so an element with a ::before and an ::after adds three.

Some style changes write nothing at all. A handful of inherited properties are independent, meaning nothing else in a style depends on them: color, visibility, cursor, pointer-events, text-transform, interactivity, -webkit-rtl-ordering and a few table and list properties. When a parent changes only those, Blink copies each child's old style and writes the new values in without calling ResolveStyle. It does not take that shortcut for a child that is animating or transitioning, uses custom properties, sits inside a link, has text decorations or needs recalculating for its own reasons; those are resolved normally. In one capture, setting color on a parent of twenty children wrote one event and an elementCount of one, while setting font-size on it wrote twenty-one. Text nodes are never resolved this way, a text field's inner editor builds its style by hand, and a pseudo-element request that matches no rules returns before the counter and the event.

Some resolutions happen outside any UpdateLayoutTree, and those are written without being counted anywhere. getComputedStyle on an element inside a display: none subtree, or on a pseudo-element that has no box, resolves it on the spot, inside your script's task. Size container queries and anchor-positioning fallbacks resolve the affected elements again during layout, so their events sit inside a Layout slice. Custom scrollbar parts turn up in both places: resolved in the recalculation, then again in the layout that follows.

Anything that marks an element for style recalculation leads to one of these on the next update: an inline style write, a class change, a selector newly matching. Writing the value an element already has changes nothing and writes nothing. A CSS animation or transition on a property the main thread has to animate re-resolves its element on every frame: a width animation and a width transition each did, with no script on the page, and a font-size animation on a parent took every child with it. A transform animation or an opacity transition running on the compositor does not ask the main thread for frames, and on an otherwise idle page its element was not re-resolved frame by frame. Give the page any other reason to produce frames, even an empty requestAnimationFrame loop, and the same transform animation re-resolves its element on every one of them.

Derivations

Which elements a style recalculation actually resolved. UpdateLayoutTree gives you a number; these give you the nodes behind it, one per unit of that number. nodeId is the same id the DevTools Protocol calls backendNodeId, so DOM.describeNode or DOM.pushNodesByBackendIdsToFrontend turns it into an element while the page is still open.

Where a restyle started. Within one UpdateLayoutTree, an event whose parentNodeId was not itself resolved in the same recalculation is the top of a restyled subtree. In a capture that changed font-size on one parent of fifty children every frame, all fifty-one elements came out with the same per-node count, and this test picked out the parent alone in every frame. This is what the argument was added for: accounting for every element a subtree invalidation touched. The test names the place, not the reason: a CSS animation on the element gives the same root as a script writing to it. And Blink sometimes restyles pseudo-elements without their element, after a change near the root, so ::before, ::after and ::marker nodes, and pseudo requests on an element that was not itself resolved, can come out as roots. Resolve the ids before you trust one.

Style work done during layout. An event inside a Layout slice rather than an UpdateLayoutTree is a container query, an anchor-positioning fallback or a custom scrollbar part being resolved mid-layout. No other event names those elements, and no elementCount includes them.

Traps

pseudoId: 0 does not mean an ordinary element. ::before, ::after, ::marker and ::backdrop are resolved as nodes in their own right and write pseudoId 0, with nodeId set to the pseudo-element and parentNodeId to the element it belongs to. So do elements in a browser-built shadow tree, such as an input's placeholder. A non-zero value appears when Blink resolves a pseudo style through the originating element instead, and then nodeId is that element: ::first-line, ::selection and the other highlight pseudos, and each part of a custom scrollbar. getComputedStyle(el, '::before') on an element whose ::before has no box does the same, and that is the one way a non-zero ::before, ::after or ::marker value turns up. ::first-letter is the odd one out, non-zero with its own node. Resolve the id before you decide what it is.

pseudoId numbers belong to one Chrome build. The value is a position in Blink's PseudoId enum, and new pseudo-elements are inserted into the middle of it: ::selection is 9 in Chrome 135 to 140, 10 in 141 to 147, 11 in 148 to 150 and 12 in 151 to 153. Read it against computed_style_constants.h for the build that wrote the trace.

A count per nodeId names the element, not the cause. A script writing a style in requestAnimationFrame and a CSS animation on a main-thread property leave exactly the same one-event-per-frame pattern, and a single inherited change on a parent gives every descendant the same count as the parent. The event has no stack and no reason. For the cause, read StyleRecalcInvalidationTracking from the same category: for the same element it says Animation on an animation tick, and Inline CSS style declaration was mutated, with a stack, on a script write. ScheduleStyleRecalculation carries the stack of the first write too, once disabled-by-default-devtools.timeline.stack is recorded. And because ids are handed out per node object, a component that replaces its elements on every render never builds up a count at all: each new node gets a new id.

Your recorder can restyle the page. A composited transform or opacity animation is re-resolved every frame only while something else is producing frames. Puppeteer's waitForFunction polls with requestAnimationFrame by default, so a capture that waits that way shows those animations resolving on every frame when the same page left alone does not. In headless mode Puppeteer also passes --hide-scrollbars, and then custom scrollbar styles are never resolved at all.

The count is not the cost. There is no duration, and a style served from Blink's matched-properties cache, or an animation frame that reuses a cached base style, writes the same event as a full cascade. Use the UpdateLayoutTree duration for time, and CSS selector stats for where it went.

elementCount, and so this event, misses styles that changed without being resolved. The independent-property shortcut can restyle a whole subtree and report one element. Recording blink or blink_style adds counters to the end of Document::updateStyle, among them stylesChanged and independentInheritedStylesPropagated, which show the gap.

There is no frame id. Filter on the process and thread, and take the document from the enclosing UpdateLayoutTree's beginData.frame. Events outside any UpdateLayoutTree have no document you can read off the trace, and parentNodeId is 0 for the root element of every document, a frame's as well as the page's. In a trace recorded across the whole browser, other renderer processes, Chrome's own WebUI pages among them, write these too; nodeId comes from a counter each renderer process keeps for itself, so an id is meaningless outside the process that wrote it.

Recording it. DevTools adds the category when Invalidation tracking or Enable CSS selector stats is on. Nothing in DevTools reads the event, and the Performance panel draws it only with Show all events on. It is one event per resolved style, so a page's first recalculation alone can write thousands.

Older traces differ. Chrome 134 wrote this name with a single args.elementId. Chrome 134 to 137 wrote it before the early return for unmatched pseudo styles, so the events there could outnumber elementCount; Chrome 135 brought the current data fields. Until Chrome 139, nodeId was the element passed in: for content cloned into an SVG <use> that is the original rather than the clone, and for ::first-letter it is the element the letter belongs to. parentNodeId was the plain parent, 0 for anything at the top of a shadow tree.

Example event a local fixture page
{
  "name": "StyleResolver::ResolveStyle",
  "cat": "disabled-by-default-devtools.timeline.invalidationTracking",
  "ph": "I",
  "ts": 1102940676,
  "tts": 40052,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "nodeId": 2,
      "parentNodeId": 0,
      "pseudoId": 0
    }
  }
}

See also UpdateLayoutTree · StyleRecalcInvalidationTracking · ScheduleStyleRecalculation · SelectorStats · Layout · Animation

categories
disabled-by-default-devtools.timeline.invalidationTracking
usage
very high
references
style_resolver.cc, inspector_trace_events.cc, computed_style_constants.h
UpdateLayerUpdate layer

Chrome's compositor brings one of its layers up to date with the content most recently painted for it.

Spec

cc's PictureLayer::Update brings one picture layer up to date during the compositor's update-layers step, handing over the region invalidated since the last frame and, if the layer was invalidated or resized, taking the display list its content client supplies and checking whether it paints a single solid colour, on the main thread of the process that owns the layer tree.

Arguments

pathdeclaredobserved
args.layerIdyes100.0%
args.layerTreeIdyes100.0%

Observed 534,568 times on 182 of 300 sites.

Behaviour

The name belongs to cc, Chromium's compositor library, not to Blink. It is defined in cc's DevTools instrumentation header and written from exactly one place, PictureLayer::Update, as a begin and end pair carrying the layer's id and the id of the layer tree it belongs to. A picture layer is cc's layer type for content that is drawn from a recorded list of drawing commands, a display list, as against a texture, a video frame, an embedded surface or a single solid colour.

cc writes it during its update-layers step, which walks the layer tree and calls Update on every layer that is drawn, has content of its own and has a size. Whether the layer changed is not a condition: an untouched layer gets its UpdateLayer in every frame that reaches this step, and returns within a microsecond or so. Layers that exist only to hold a transform or a scroll position get none, and neither do cc's other layer types. That includes a composited layer whose paint is one flat colour: Blink hands cc a solid-colour layer for it, not a picture layer. Frames that were only asked to run animations or requestAnimationFrame callbacks, and changed nothing, never reach the step and write none.

In a renderer the step runs on the main thread, inside the Commit slice that ProxyMain::BeginMainFrame opens once Blink has finished its lifecycle:

BeginMainThreadFrame                instant: the main-thread frame starts
UpdateLayoutTree, Layout, PrePaint, Paint
Layerize                            Blink builds cc's display lists here
Commit
  LayerTreeHost::DoUpdateLayers     only with the cc category
    PictureLayer::Update            only with disabled-by-default-cc.debug
      UpdateLayer                   one per picture layer

In a Performance recording the two cc slices are not recorded, so UpdateLayer sits directly under Commit.

By then there is nothing left to paint. Blink converted its paint into a cc display list for each layer during Layerize. Inside the slice cc first takes the region Blink invalidated on the layer since the last frame, to send to the compositor thread with the commit. If the layer was neither invalidated nor resized, that is the whole call. Otherwise cc asks Blink's content client for the display list and gets back the one built during Layerize; if it is new, cc widens the invalidation when the recorded area changed, works out whether the list amounts to one solid colour, and emits a snapshot of it if a paint-instrumentation category is being recorded. Chromium's own comment on that step says that for Blink-backed layers, painting "amounts to copying the Blink display list".

The browser process writes it too. Chrome's own interface is drawn by a cc compositor running on the browser's main thread, outside any Commit slice, and there the content client really does paint: it records the interface's drawing inside the call. Those are the long UpdateLayer events. A recording of a page includes them, because DevTools adds the browser process to every page recording, and they appear whether Chrome runs headless or with a window. On a test page that stopped changing after load, and on a real article page during load and scroll, the browser's UpdateLayer events outnumbered the page's.

The event dates from June 2014, when the trace-based Timeline was being built. Its commit says it was added "so we can easily bind pictures to layers and related blink-side paint events". At that time PictureLayer::Update returned before opening the scope when nothing had changed, and inside the scope it asked WebKit to paint the layer's picture there and then. Reading it as a layer being painted describes that arrangement, not a web page in current Chrome.

Derivations

How many picture layers the page draws, from an ordinary recording. Count the UpdateLayer events inside one renderer Commit, on the same pid and thread. That is the number of picture layers with content in that frame. On a fixture with three promoted boxes and text in the page body it was four every frame, the three boxes and the document's scrolling background, while the layers that only hold a transform or scroll were left out. Three conditions. Solid-colour layers are not counted: on a second fixture whose page background was plain white, the document's background layer drew content but was a solid-colour layer and wrote no UpdateLayer. A Commit that holds none is a frame that never reached update-layers, not a page without layers, so take the count from a Commit that has any. And an out-of-process iframe has its own renderer, tree and Commits. The layer list itself, with a name for each layer, is only in LayerTreeHostImpl:snapshot, which needs a category DevTools records only with advanced paint instrumentation on; UpdateLayer is in every Performance recording.

Which layers took new content in a frame. An unchanged layer returns almost at once and one with a new display list does more work, so in bulk the long ones are the changed layers; the two ranges overlap by a few microseconds, so a single duration proves nothing. To be certain, record the cc category and look for RecordingSource::FinishDisplayItemListUpdate inside the slice. The same layerId then turns up in RasterTask's args.tileData.layerId in the same process, which is how to tie a raster task to the frame and layer that caused it.

Traps

It is not Blink, and it is not paint. In a renderer, the painting of a layer happened earlier, in Paint, and the conversion into cc's display list in Layerize. Adding up UpdateLayer durations does not give you paint cost. A long one in a renderer is a layer that took a new display list, or, if advanced paint instrumentation was on, mostly the cost of recording it (see below).

Many of them are Chrome's own UI. Filter on pid. The browser process has its own layer tree, every page recording includes it, and its UpdateLayer events are the ones that actually record drawing, so they are also the longest. On a quiet page, and even during an ordinary page load, they can outnumber the page's own.

Both ids are per process. layerId and layerTreeId come from counters that start at 1 in every process, so ids from different processes collide: in one capture the browser's UI tree and a cross-site iframe's renderer both had layerTreeId 1. Key on pid as well, and use SetLayerTreeId to find which tree belongs to which frame.

Some of them are instants. A few UpdateLayer records have ph I and "s": "t" instead of X and a dur. They come from the same call. Chrome records trace timestamps in whole microseconds, cutting each begin and end down to the microsecond, and when both land in the same one the slice has zero duration, which Perfetto's JSON exporter writes as an instant. They are the fastest calls, from the browser and from renderers alike. Treat them as zero-length slices, and read a dur of 1 as "under two microseconds", not as a measurement. A lone B with no end is a slice that was still open when the recording stopped.

Only a disabled-by-default category has it. UpdateLayer is on disabled-by-default-devtools.timeline. DevTools turns that on for every Performance recording; a custom trace that asks for devtools.timeline alone gets none.

The Performance panel hides it. DevTools records it, then gives it a hidden style, so no bar is drawn and its time shows up as self time of the Commit above it.

Advanced paint instrumentation makes it far longer. That checkbox adds disabled-by-default-devtools.timeline.picture, and with it on, every layer that took a new display list serializes that list into a cc::DisplayItemList:snapshot event inside this slice: cc plays the whole list back into a Skia picture and base64-encodes it. In two small fixtures the median for such layers went from about ten microseconds to about a millisecond, and the browser's grew as well. Layers that did not change were unaffected. Read durations from a recording without that setting.

DevTools does not use it for layer attribution any more. Its one consumer, trace_engine's LayerTreeHandler, keeps each UpdateLayer so that it can tell which layer a DisplayItemList snapshot on the same thread belongs to, and then looks for a Paint with that layerId to attach the snapshot to. Blink now writes layerId: 0 on every Paint, which the handler discards, so the pairing never succeeds: run over a Chrome 153 trace recorded with the paint-instrumentation categories, it matched nothing. It never read RasterTask, and it builds no layer tree; the tree DevTools shows for a trace comes from LayerTreeHostImpl snapshots.

Example event a local fixture page
{
  "name": "UpdateLayer",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102926233,
  "dur": 460,
  "tdur": 372,
  "tts": 186859,
  "pid": 8178,
  "tid": 8178,
  "args": {
    "layerId": 29,
    "layerTreeId": 1
  }
}

See also Commit · Layerize · Paint · RasterTask · BeginMainThreadFrame · UpdateLayerTree

categories
disabled-by-default-devtools.timeline
usage
high
duration
very short
references
devtools_instrumentation.h, picture_layer.cc, frame_viewer_instrumentation.cc, tile_manager.cc
UpdateLayerTreeUpdate layer treeno longer emitted

The old name for Chrome bringing its tree of compositing layers up to date after layout, in the step now called PrePaint.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Spec

Blink's FrameView, later LocalFrameView, traced the part of a rendering update between layout and paint for one local frame root under this name, on the renderer's main thread, from Chrome 38 until Chrome 102 renamed it PrePaint.

Behaviour

Current Chrome does not write this event. No Chromium source uses the name for a trace event any more, and a recording on Chrome 153 with every default category and all the DevTools and cc debug categories switched on contained none. You meet it in a trace recorded by Chrome 101 or earlier, or in a tool built to read one.

Its last form, from Chrome 45 to 101, was a slice on devtools.timeline that Blink wrote on the renderer's main thread each time it ran the rendering lifecycle past layout for a local frame root (the main frame, or an iframe whose parent is rendered by another process), with a single argument, data.frame. That is normally once per frame, with two exceptions. The lifecycle runs in a loop, and a ResizeObserver callback that changes layout sends it round again, writing a second slice inside the same update. And by Chrome 101 every hit test, such as document.elementFromPoint(), ran the lifecycle up to this step and wrote one outside any frame, even when nothing on the page had changed.

The name describes the job it was built around. Before Chromium's CompositeAfterPaint project, Blink decided which elements got their own compositor layer before painting, then updated its tree of those layers to match, and that update is the layer tree in the name. What else sat inside the slice changed release by release:

Chrome 38-44   an instant, no duration, at the start of the compositing update
Chrome 45-56   compositing update, then paint invalidation
Chrome 57-58   the same, with the pre-paint step inside, though its tree
               walk only ran behind an experimental flag
Chrome 59-85   compositing update, then the pre-paint tree walk
Chrome 86-96   compositing inputs, pre-paint, compositing assignments
Chrome 97-98   the same calls, but compositing assignments no longer
               assigned layers
Chrome 99-101  compositing inputs and pre-paint only
Chrome 102     renamed PrePaint, same place in the code

The instant was a mistake. Blink revision r196226 (commit 6b5929fa9f in the old Blink repository, 07d4e5607d in Chromium's), in June 2015, turned it into a slice with the message "This was never meant to be an instant event, appears to be a typo", and r196147 a few days earlier moved it from disabled-by-default-devtools.timeline to plain devtools.timeline; both shipped in Chrome 45. CompositeAfterPaint moved the layer decision after paint and was switched on by default in Chrome 97, after which the compositing-assignments phase inside this slice stopped assigning layers, and Chrome 99 removed the call. From Chrome 99 the slice held exactly what the renamed event held in Chrome 102.

The rename is crrev.com/c/3519012, commit 3a09b7b7dd, "Add a Pre-Paint devtools timeline event (blink side)", by Philip Rogers, landed on 22 March 2022 and first shipped in Chrome 102. It changed the name on the same line, kept the category, and swapped the argument builder: UpdateLayerTree wrote only frame, PrePaint added isMainFrame and page, and isOutermostMainFrame followed in Chrome 105. The DevTools half landed six days earlier and taught the Performance panel the new name while keeping the old one.

There was an earlier UpdateLayerTree that was never a trace event. Before the DevTools Timeline was built on Chrome's tracing, Blink's InspectorTimelineAgent sent DevTools its own records over the DevTools protocol, and one record type had this name: from Chrome 36 a duration record around the same compositing update. For Chrome 38 to 41 both existed, on neighbouring lines of the same function. Blink revision r189656 (commit 59a9467588 in Chromium's repository) deleted the record type with the rest of the old Timeline in February 2015 ("Timeline is replaced with tracing"), so Chrome 42 onward has only the trace event. If you find the name in a DevTools timeline export from 2014, it is that record, in that tool's own format, and it will not be in any trace file.

Traps

One name, five different measurements. A trace from Chrome 38 to 44 has the event with no duration at all, so summing it gives zero, not a cheap step. From 45 to 58 it covers the compositing update and paint invalidation, and the pre-paint tree walk either ran after the slice closed or did not run by default. From 45 all the way to 96 it also contains the layer decisions that current Chrome makes after paint, in Layerize. Chrome 97 and 98 are layer-free, and only Chrome 99 to 101 match a Chrome 102 PrePaint exactly. Comparing durations across those boundaries compares the definition, not the page.

Renaming it to PrePaint is safe only for Chrome 97 to 101. Tools that want the modern name will accept the renamed event, but on older traces they are being handed a slice that includes, or lacks, other phases. It also carries only frame, never isMainFrame or page, and a parser that expects isOutermostMainFrame will miss it on PrePaint from Chrome 102 to 104 as well.

The category moved. From Chrome 45 it is on plain devtools.timeline; recording only disabled-by-default-devtools.timeline does not capture it. Only the Chrome 38 to 44 instant lives on the disabled-by-default category.

DevTools draws it and then ignores it. The Performance panel still labels it "Update layer tree" under Rendering. But the layout-shift handler collects only events named PrePaint, and the CLS culprits insight ties every root cause to one of those. On a trace older than Chrome 102 the layout shifts still appear, sitting inside this slice, but the culprits list stays empty. trace_engine declares the name and no type or arguments for it.

Lighthouse keeps both names in one bucket, but not the work. Its Rendering task group lists UpdateLayerTree and PrePaint side by side, with a comment pointing at the rename. It does not list Layerize, which falls through to its parent's group and ends up as Other. The layer-assignment time that sat inside UpdateLayerTree, and so counted as Rendering, up to Chrome 96 is Other in a current trace.

Look-alikes. UpdateLayer is a different event. UpdateLayerTreeHost is the name of methods in cc and in the browser's compositor, not an event, and Android.Compositor.UpdateLayerTree.Duration.Subsampled is a UMA histogram, not a trace event.

Example event

Renamed to PrePaint in Chrome 102. The collection recorded PrePaint on every site and this name on none.

See also PrePaint · Layerize · CompositeLayers · Layout · Paint · UpdateLayer · LayoutShift

references
local_frame_view.cc
UpdateLayoutTreeRecalculate style

Chrome brings a document's styles, and the layout tree built from them, up to date.

Spec

Blink's Document::UpdateStyleAndLayoutTreeForThisDocument brings one document's computed styles up to date and rebuilds the parts of its layout tree that depend on them, together with the stylesheet, media query and invalidation work that has to happen first, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.beginData.frameyes100.0%
args.beginData.sampleTraceIdopt100.0%
args.beginData.stackTrace[].columnNumber-40.4%
args.beginData.stackTrace[].functionName-40.4%
args.beginData.stackTrace[].lineNumber-40.4%
args.beginData.stackTrace[].scriptId-40.4%
args.beginData.stackTrace[].url-40.4%
args.elementCountyes100.0%

Observed 42,674 times on 182 of 300 sites.

Behaviour

One event is one pass of Blink's style update over one document. Before it opens the slice, Blink checks whether anything is out of date, and if nothing is it returns without writing anything. So every event had work waiting, and a read against a clean document leaves no trace: ten getComputedStyle(body).color reads in a row wrote one event, for the first read, which found a running transition to advance, and nothing for the nine after it. Inactive documents, render-throttled frames (an off-screen cross-origin iframe, for example) and documents whose lifecycle is postponed are skipped the same way.

The slice covers more than matching selectors. In order, Blink advances animation timing, re-evaluates matchMedia lists, updates SVG <use> trees, turns any stylesheet that was added or changed into a rule set (Blink's indexed form of a stylesheet, built before any selector can be matched against it), handles counter styles, viewport units, env() and font changes, then walks the pending invalidation sets (the per-selector lists Blink uses to decide which elements a class, id or attribute change can affect) to mark those elements, then recalculates their styles, and finally rebuilds the layout tree, the tree of boxes that layout works on, for elements that gained or lost a box. Recording blink or blink_style shows the main stages as child slices: Document::updateActiveStyle, Document::updateStyle, and inside that Document::recalcStyle and Document::rebuildLayoutTree.

A recalculation is either scheduled or forced. A change writes ScheduleStyleRecalculation, and the next rendering update runs this pass after the requestAnimationFrame callbacks and before Layout. A forced pass happens when script reads something that needs current style, such as a property of getComputedStyle() or a geometry property like offsetHeight: Blink runs the pass there and then, inside the script's task. A style read only forces the pass if the element being read could be affected. Reading body's color after changing the width of one of its children forced nothing, and that change waited for the next frame. Blink also forces passes of its own with no script running, for example while handling input or dispatching an event. In a same-process iframe the parent document is brought up to date first, so a read in the iframe can write the parent's event, if the parent had work waiting, before the iframe's own. A single update can also write two in a row for the same document, when a ::backdrop or an element leaving the top layer needs another pass.

beginData is written as the slice opens. It always has frame, the id of the document's frame. With disabled-by-default-devtools.timeline.stack recorded, which DevTools does by default, every event also gets a sampleTraceId, and it gets a stackTrace only if V8 was running JavaScript at that moment. JavaScript is only on the stack when script caused the pass synchronously, so the stack is the mark of a recalculation forced by script. In four traces, three real sites and one older capture, every event with a stack sat inside a script-execution slice such as FunctionCall, EvaluateScript or v8.run, and every event without one did not. The script is not always the page's own: a browser extension's content script runs in the page's process, and a pass nested inside a forced one (the parent document of an iframe, or an SVG image's own document) carries the stack of the script that forced the outer pass. How many events in a recording carry a stack is therefore a property of the page's script and of what the recording did to the page, which is the split you see in the arguments table.

elementCount is written as the slice closes. It is how many times StyleResolver::ResolveStyle, the function that computes one style from scratch, completed for this document during the pass, read as the difference in a per-document counter. What that number does and does not include is in the traps below.

Derivations

Which line forced a recalculation, and how much forced style work each script caused. With the stack category recorded, the top frame of beginData.stackTrace is the JavaScript that asked, and its column points at the property read that did it: in getComputedStyle(el).color it is the .color, not the getComputedStyle() call, which on its own does nothing. DevTools' Function stack puts these frames over the top of the stack it rebuilds from the CPU profile, because profile frames carry where a function is declared and these carry where it was called from. Adding up the duration of stack-bearing events by the top frame's URL gives forced style time per script, including the tasks that stay under DevTools' warning threshold. That sum means something only in a recording without invalidation tracking or selector statistics (see the traps), and only if nested events are left out so no pass is counted twice; forced Layout is a separate event and a separate sum.

How far an invalidation reached. elementCount is how many styles Blink computed from scratch, and that is decided by invalidation, not by how many styles actually change. Adding a class to body with the rule .on #g div restyled every div on a test page, 1505 of them, because Blink keys the invalidation for that rule on the div in its last compound, and only the 500 inside #g changed. A rule keyed on a class, .on3 .h3, restyled exactly the 500 elements that carried it. Comparing elementCount with the number of elements you expected to change is a direct test of whether a selector or the element you toggle a class on is too broad. It works for class, id and attribute changes; add any ::before and ::after the elements have, and do not use it for a change to an inherited property, which the count mostly leaves out. With blink or blink_style recorded you do not have to know the expected number: counters on the nested Document::updateStyle split the same resolutions into stylesChanged and stylesUnchanged, 500 and 1005 in the body class example.

Which styles it counted. Record disabled-by-default-devtools.timeline.invalidationTracking and every counted resolution also writes a StyleResolver::ResolveStyle instant, with the node id, inside the slice. In every recalculation of two separate captures the number of those instants equalled elementCount, so they are the list the number stands for. A ::before or ::after shows up as a node of its own, with the originating element as parentNodeId and pseudoId 0, so pseudoId does not separate them from elements. The recording makes the slice many times longer (see the traps), so use it to find out what was restyled, not how long it took.

Traps

elementCount is not the number of elements whose style changed. It counts completed calls to StyleResolver::ResolveStyle, and those differ from changed elements in both directions. Every restyled element is counted whether or not its style came out different: of the 1505 in the body class example, 1005 were unchanged. Pseudo-elements are counted: turning on a ::before for 500 elements added 500. An element matching an @starting-style rule has its style resolved twice and counts twice. In the other direction, when only independent inherited properties change on a parent (color, visibility, pointer-events, cursor, text-transform and a few more), Blink copies them into children's existing styles without resolving them, and those children are not counted: setting color on a container of 500 plain divs counted 1, and setting it on a body whose divs had ::before pseudo-elements changed 2005 styles and counted 503, because pseudo-elements cannot take that shortcut. Elements inside a display: none subtree are not restyled at all, so a rule that only reaches into one counted 0, and when script asks for the computed style of such an element Blink resolves it on demand outside any UpdateLayoutTree, where no event counts it. DevTools shows the field as "Elements affected", which is wrong on both counts.

Container-query restyles are not in this event. When a size container changes size, Blink restyles the elements that depend on it from inside layout. Widening a container so that a @container rule flipped for 500 children wrote an UpdateLayoutTree with elementCount 1, the container, and all 500 resolutions happened inside the following Layout, which counts them nowhere.

Few elements and a long duration does not mean slow selectors. Adding a <style> of 5000 rules that matched nothing wrote a pass of about 3ms with elementCount 0; nearly all of it was RuleSet::addRulesFromSheet, building the rule set. Setting color on a container of 500 plain divs took about half a millisecond with elementCount 1, spent copying the new value into the 500 children. Invalidation walking, layout tree rebuilding and font updates are in the slice too, and real sites write zero-count passes of several hundred microseconds. Dividing the duration by elementCount gives a number with no meaning. Record blink or blink_style to see which stage the time went to, and read counters on the nested Document::updateStyle, which splits the work into styles changed, unchanged and propagated.

The categories you record change the duration, and slow the page too. On a test page, a class change that restyled about 2,000 elements took about 2ms timed from inside the page with nothing recording, and the UpdateLayoutTree slice read the same with DevTools' timeline and stack categories. Adding disabled-by-default-devtools.timeline.invalidationTracking made the slice read 20 to 26ms, about 8.5ms came with disabled-by-default-blink.debug, and 26 to 28ms with both, which is what DevTools' "Enable CSS selector stats (slow)" setting records. The page's own timer grew by the same amount, so this is work the recording adds to the main thread, not a fault in the timestamps. Invalidation tracking writes several records, with the selectors involved, for every element an invalidation set reaches, and its cost follows that reach rather than elementCount: a pass that resolved 503 styles with no invalidation set involved went from about 2.4 to 3.3ms. blink.debug switches on a clock read around every selector match attempt. The real-site traces behind this manual's figures were recorded with invalidation tracking on, so the durations they report for this event carry the same inflation. A recording made with either category is fine for finding out what was restyled and misleading about what it costs.

Selector stats are paired by order. The SelectorStats event is written just after the slice closes, and DevTools attaches each one to the last recalculation it saw, with no check of frame or time. Blink collects the statistics across all matching since the last dump, so matching done during layout for container queries is reported with the next recalculation. In two captures a recalculation that restyled nothing carried 1000 match attempts for #cq div, all of them from the layout before it.

elementCount can be missing. It is written when the slice ends, so a recalculation still running when the recording stopped is left as a B event with beginData and no count.

The stack depth depends on the recorder. Blink asks V8 for a short stack, which is one frame unless a client with the Runtime domain enabled is attached. The same three-deep call gave [inner] under Chrome's own --trace-startup and the full chain through puppeteer. A DevTools recording gets the deep version. lineNumber and columnNumber count from one; DevTools subtracts one before it shows them.

Some recalculations belong to other documents. An SVG image has a document of its own, and on one real page two of them loaded and ran their own style passes inside the page's recalculation, with an empty document URL and a full set of loading events. Because the page's script was still on the stack, those nested events carry the page's stackTrace. Chrome's own pages write these events too, in their own processes. An extension's content script runs in your page's process, and when it forces a recalculation the event is your page's, with the extension's script as the stack: in one trace a chrome-extension:// content script forced a pass over 327 of the page's elements. Filter on pid and beginData.frame before adding anything up, check the stack's URL before blaming your own code, and do not count nested slices twice.

DevTools finds forced recalculations without the stack, and counts more of them. Its forced reflow warning, which the Forced reflow insight is built from, counts Layout and UpdateLayoutTree slices nested inside any event it treats as JavaScript, whether or not they carry a stack, and reports a task only when those add up to thirty milliseconds. EventDispatch is on its list of JavaScript events, so a pass Blink forced while dispatching an event, with no script on the stack, is flagged as well.

The raw name is not the one DevTools uses. DevTools calls the event RecalcStyle in its code and "Recalculate style" on screen; a trace file has only UpdateLayoutTree. With blink or blink_style recorded there is also a Document::recalcStyle slice, which is only the recalculation stage inside this one.

Example event a local fixture page
{
  "name": "UpdateLayoutTree",
  "cat": "blink,devtools.timeline",
  "ph": "X",
  "ts": 1103040787,
  "dur": 83,
  "tdur": 76,
  "tts": 131537,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "beginData": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "sampleTraceId": 6909218608593002,
      "stackTrace": [
        {
          "columnNumber": 39,
          "functionName": "",
          "lineNumber": 85,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ]
    },
    "elementCount": 1
  }
}

See also ScheduleStyleRecalculation · StyleResolver::ResolveStyle · StyleRecalcInvalidationTracking · StyleInvalidatorInvalidationTracking · SelectorStats · Document::updateStyle · Layout · Document::UpdateStyleAndLayout · Blink.ForcedStyleAndLayout.UpdateTime · ParseAuthorStyleSheet

categories
blink, devtools.timeline
usage
moderate
duration
long
undeclared args
args.beginData.stackTrace[].columnNumber, args.beginData.stackTrace[].functionName, args.beginData.stackTrace[].lineNumber, args.beginData.stackTrace[].scriptId, args.beginData.stackTrace[].url
references
document.cc, source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc, style_resolver.cc

Invalidation tracking

Why Blink decided something was dirty. Needs its own trace category.

LayoutImageUnsized

Chrome styled an image whose CSS does not fix both its width and its height.

Spec

Blink's LayoutImage::StyleDidChange writes this instant on the renderer's main thread, during style recalculation, whenever an image box receives a computed style that LayoutImage::IsUnsizedImage judges to leave its dimensions open.

Arguments

pathdeclaredobserved
args.data.frameIdyes100.0%
args.data.nodeIdyes100.0%

Observed 8,564 times on 108 of 300 sites.

Behaviour

The check runs when an image box is given a style, not when it is laid out or when its file arrives. That happens when the box is created, when it is created again after the element comes back from display: none, and on every later restyle that hands the image a new computed style, including an inherited one: setting color on a wrapper wrote a second event for the unsized image inside it, while setting background on the same wrapper, which does not inherit, wrote nothing. A layout-only change, such as narrowing the image's container, writes nothing, and neither does the image finishing loading or failing to. So the count is not the number of unsized images; it is unsized images times the number of times each was restyled, and a real page can repeat the same node many times. In every capture the event sat inside UpdateLayoutTree, usually long before the image had loaded: a test image delayed by 1500ms had its event about 10ms after navigation.

The rule, from IsUnsizedImage, reads the computed style and nothing else. An image counts as sized when its width and its height are both a length, a percentage, or a calc() made only of those, or when it has a bare aspect-ratio: <ratio> and one of the two. auto, fit-content and the other sizing keywords do not count, aspect-ratio on its own does not count, and min- and max- sizes are not consulted. HTML width and height attributes count, because Blink turns them into CSS width and height as presentational hints, styles that any author rule overrides. They also produce aspect-ratio: auto w / h, and that form does not count: only a bare ratio does, so aspect-ratio: auto 4/3 in your own CSS does not count either. In a <picture>, a selected <source> that carries width or height replaces the <img>'s own. srcset, sizes and loading=lazy play no part; a lazy image far below the fold wrote its event without ever being fetched. An image under display: none, or inside something that is, has no box and writes nothing; visibility: hidden keeps the box and does write it.

The box can belong to more than <img>. <video>, <input type=image> and an <object> or <embed> holding an image use the same layout class, and so do list-style-image markers and anything given an image with content: url(), pseudo-elements included. SVG <image> and CSS background images do not.

The event is written on devtools.timeline, but only after Blink has checked that disabled-by-default-devtools.timeline is recording too. Both are in the Performance panel's default set. The invalidation tracking category has nothing to do with it: adding it changed neither the count nor which images were reported.

DevTools reads it in one place, the CLS culprits insight, and shows the result in two. For each rendering update that contains a layout shift, the insight takes the first PaintImage written after that update's PrePaint and asks whether any LayoutImageUnsized has the same nodeId. If one does, that image is recorded as a cause of every shift in the update. Select a shift in the Layout shifts track and the details pane lists it as "Unsized image" with a link to the node; the insight's Top culprits list, which covers only the worst cluster and stops at three, shows it as "Unsized image element" after web fonts, injected iframes and non-composited animations. Lighthouse's layout shift audit shows the same match as "Media element lacking an explicit size".

Traps

It flags images that cannot shift. The responsive pattern everyone recommends, width and height attributes with height: auto in CSS, whether beside width: 100% or the common max-width: 100% reset, is unsized by this rule, because height: auto overrides the height attribute and the ratio the attributes leave behind is the auto w / h form. Chrome still reserves the box from that ratio: in a test such an image loaded late without moving anything, and was reported anyway. The same goes for aspect-ratio: auto 4/3. Adding a bare aspect-ratio with the same ratio silences it. The event is a statement about the style, not a prediction of a shift.

It misses images that do shift. A percentage counts as sized even when it cannot resolve. height: 50% inside a parent whose height is auto behaves as auto in layout, so the image took its height from the file when it arrived and pushed the paragraph below it down, with no LayoutImageUnsized and no culprit in DevTools.

Lighthouse's unsized-images audit uses a different rule. It inspects the DOM and CSS itself and never reads this event. It accepts a width or height attribute even when CSS sets that axis to auto, so an image can pass that audit and still be reported here.

A recording that starts late has none. The event is written when the image is styled, so if you start recording after the page has loaded and then trigger the shift, the trace holds the shift and the image's paint but no LayoutImageUnsized, and DevTools cannot name the image as the cause. Record from a reload.

The culprit link is a guess by paint order. DevTools only looks at the first image painted after the shift, however much later that is, in any frame, and ignores when the LayoutImageUnsized was written. In a test, a banner inserted by script caused the only shift, no image painted for the next 900ms, and then a correctly reserved image that this rule flags arrived and was named as the cause. The other way round, two unsized images that shifted content in the same update produced one culprit, the one painted first.

nodeId can be 0, and then DevTools never names it. Images with no DOM node of their own, a ::before or ::after with content: url() or a list-style-image marker, report nodeId: 0, which resolves to nothing. Their PaintImage carries the host element's nodeId instead, so the culprit match can never succeed for them, even when they cause the shift. For the rest nodeId is a backend node id that DOM.describeNode resolves while the node is alive, and it may be a <video>, <input> or <object> rather than an <img>. The event has no URL; the PaintImage for the same nodeId has it.

Count elements, not events. One image restyled five times is five events. Deduplicate on nodeId within a renderer process before you count unsized images.

Both timeline categories are needed. A trace recorded with devtools.timeline alone, or with disabled-by-default-devtools.timeline alone, contains none of these, however many unsized images the page has.

Example event from https://www.bbc.com/news
{
  "name": "LayoutImageUnsized",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1109175726,
  "tts": 95991,
  "pid": 8475,
  "tid": 8475,
  "s": "t",
  "args": {
    "data": {
      "frameId": "9AE6572E1DCD19954F9F2627AE88998A",
      "nodeId": 16
    }
  }
}

See also LayoutShift · PaintImage · PrePaint · UpdateLayoutTree · StyleRecalcInvalidationTracking

categories
devtools.timeline
usage
moderate
references
layout_image.cc, identifiers_factory.cc
LayoutInvalidationTrackingneeds Enable CSS selector stats (slow) or Invalidation tracking

Chrome marked one piece of the page's layout tree as needing to be laid out again.

A DevTools Performance recording contains this event only with Enable CSS selector stats (slow) or Invalidation tracking turned on, which records disabled-by-default-devtools.timeline.invalidationTracking.

Spec

Blink's LayoutObject::SetNeedsLayout records one layout object, a box or text run in the layout tree, going from not needing to needing a full layout of its own, with the reason string its caller passed and the DOM node the object belongs to, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.nodeIdyes100.0%
args.data.nodeNameopt100.0%
args.data.reasonyes100.0%
args.data.stackTrace[].columnNumber-26.7%
args.data.stackTrace[].functionName-26.7%
args.data.stackTrace[].lineNumber-26.7%
args.data.stackTrace[].scriptId-26.7%
args.data.stackTrace[].url-26.7%

Observed 1,256,445 times on 182 of 300 sites.

Behaviour

Every object in Blink's layout tree, the tree of boxes and text runs that mirrors the rendered part of the DOM, carries a bit that says it needs a full layout of its own. SetNeedsLayout sets that bit and writes this event only when the bit was clear, and laying the object out clears it again. So an object writes at most one of these between two layouts, whatever happens to it in between. In one capture, writing an element's width, forcing a style update with getComputedStyle, writing its height and forcing style again wrote one event: the second style update found the box already dirty. The same sequence with an offsetHeight read in the middle, which lays the box out, wrote two. (Twenty writes to width in one task also produce one event, for a simpler reason: style is recalculated once, after the last write.)

Only that call writes it. When Blink then marks the element's ancestors, it uses a different bit and writes nothing. Changes that only need a positioned box moved write nothing either: setting top or left on an absolutely positioned element, or top on a relatively positioned one, produced a Layout and no event. Nor do changes that only need a repaint or a compositor update, such as changing an existing transform, although adding or removing a transform writes Style changed. The event is written first, and the walk up the ancestors that may end in an InvalidateLayout comes after it in the same call.

The caller supplies reason, one of thirty fixed strings that Blink keeps for tracing only. It names the code path that did the marking, not the thing you changed. Added to layout is written for every layout object Blink creates and inserts, so every element and text node the parser builds writes one, as do anonymous boxes and pseudo-element boxes. Removed from layout is written for an object being removed that has been laid out before and is not already waiting for layout. Style changed means a style recalculation produced a new style whose difference needs a full layout; it does not say which property or rule. Text changed comes from changing a text node's data, and Size changed from replaced or embedded content, such as an image, canvas, video, iframe or SVG root, whose natural size changed. Attribute changed is narrower than it sounds: a handful of form control, frameset, table column, canvas and MathML attributes, such as size on an input or rows on a textarea. An ordinary attribute or class change, including an image's width attribute, reaches layout as Style changed. DOM changed comes only from a canvas transform change; adding and removing nodes is Added to layout and Removed from layout. Unknown includes whole-view relayouts, which is what a viewport resize produced, and Inspected by devtools is DevTools' element inspector dirtying a flex or out-of-flow box to draw its overlay.

Fonts changed comes from a walk over the whole layout tree that marks every object whose font fallback list Blink has thrown away, and which lists go depends on what changed. When a web font finishes loading, only the lists that were waiting on it go, so only the objects using that font are marked. Any other change to the page's set of fonts throws them all away: a stylesheet adding an @font-face rule, document.fonts.add(), or a loading font entering or leaving its block period. Then every object in the tree is marked. In a fixture, adding a FontFace for a family nothing used marked every layout object on the page, and the Layout that followed reported all of them dirty; the web font that one element did use marked three objects when it arrived.

nodeId and nodeName name the nearest DOM node that generated the object. Many layout objects have no node of their own, so Blink walks up the layout tree until it finds one. A text run names its own text node, as #text. The root of the layout tree names the document. An anonymous block Blink creates to wrap inline content names the element it sits in, and a ::before or ::after box, and the generated text inside it, name the element they belong to. Adding a block ::before to a div with text in it wrote three Added to layout events that all named the div: the pseudo-element box, its generated text, and the anonymous block that now wraps the div's own text. Some callers mark a parent or containing block on a child's behalf, and the event then names that ancestor. nodeName is the tag plus the element's id and classes as they were when the event was written. When no node exists anywhere up the chain, both fields are left out; which situations produce that has not been established.

stackTrace is captured on every event, whatever categories you record, and it is present exactly when JavaScript was running at the moment the object was marked. Where Blink marks the object decides that. Removing a node, changing a text node's data, setting canvas.width or an input's size mark objects synchronously, so the stack names the line that made the change. Writing a style, toggling a class, appending an element or writing inside a requestAnimationFrame callback leaves the marking to the style recalculation of the next rendering update, which runs with no script on the stack. A forced read such as offsetHeight or getComputedStyle pulls that recalculation into your task, and the stack then names the read. Elements the parser builds get no stack, unless a script forces layout while the page is still parsing: then every element parsed before that script carries its stack. Fonts, images arriving and CSS transitions had none in any capture. There is no sampleTraceId.

Derivations

Which objects a Layout had to lay out afresh, and why. These events, on the same frame since the previous Layout, name each object whose own layout was invalidated and the code path that did it. A wave of Fonts changed across the whole tree is a change to the page's set of fonts rather than to those elements, and a run of Added to layout and Removed from layout is DOM churn. The list is not the whole of Layout's dirtyObjects: that count also includes the ancestors marked on the way up and boxes waiting for a positioned move, which write none of these. No other event in the trace breaks a layout down by object.

Traps

The reason names the code path, and only the first one counts. Once an object is dirty, later invalidations of it write nothing until it has been laid out. In one capture, a canvas whose width changed after a style change wrote only Style changed, and an element whose style changed just before it was removed wrote no Removed from layout. And Style changed on a box can mean any property that affects layout.

Size changed on an image does not mean it was unsized. When an image's natural size arrives, Blink marks it for layout unless both its width and height are fixed, and always when it is a flex item. An image with width and height attributes and the usual height: auto wrote Size changed on arrival, as did one inside a flex container, though neither moved anything. LayoutImageUnsized is the event that flags images without dimensions.

nodeName is often not the element you changed. Anonymous boxes, pseudo-element boxes and their generated text all report the element they belong to, and callers that mark a parent or containing block report that ancestor. Changing only a ::before box's width wrote two Style changed events naming the host div, whose own box did not change. User-agent shadow content, such as a broken image's alt text, appears under internal ids you never wrote.

A stack is not proof that script did it, and no stack is not proof it did not. The stack is whatever was running when Blink marked the object. A style write or appendChild with nothing forcing it gets no stack, although your script caused it. The other way round, a parse-time script that reads offsetHeight forces layout of everything parsed so far, and in one capture every element above it carried that script's stack. A stack does not mean layout was forced either: removing a node or setting canvas.width carries one with no layout in the task. For forced reads the top frame is the read, not the write.

Recording this category changes the timings you are reading, in proportion to how many invalidations the page writes. Every one of these events asks V8 for a stack, and the category writes other per-element events alongside them. In one set of measurements, a forced update that created ten thousand layout objects took three to four times as long as under a normal DevTools recording. Nearly all of the extra time was inside UpdateLayoutTree, which grew roughly tenfold, and about half of it went on stacks because script was running. A class change restyling a few thousand elements grew the same way. Resizing a container of ten thousand objects, which marks one, cost nothing measurable, and Layout durations barely moved in any case. Do not read UpdateLayoutTree durations, or the time of script that forces them, from a trace that has these events.

Recording it. A DevTools recording has none unless the Invalidation tracking checkbox in the Performance section of DevTools' settings is on, which is not in the panel's own recording settings, or Enable CSS selector stats (slow) is on, which records the same category.

What DevTools shows. The event is not drawn in the flame chart unless Show all events is on. It appears when you select a Layout, under "Invalidations (N total)", grouped by reason, each row a link to the node and, when there is a stack, the script location of its top frame. DevTools keeps at most twenty per layout and drops duplicate rows, and it clears its pending list only after a Paint, so every forced layout in one task lists the invalidations of the ones before it as well: the fifth forced layout in a loop shows all five. It never attaches them to UpdateLayoutTree. The "First layout invalidation" stack and "Initiated by" link on a Layout come from InvalidateLayout, not from this event. LayoutShiftsHandler collects these events and their node ids, but nothing in DevTools reads what it collects: no layout shift culprit comes from this event.

nodeId belongs to one renderer process. It is a backend node id, resolvable through CDP only against the process that wrote it, and Chrome's own pages in other processes write these too. Filter on args.data.frame first.

Stack depth measures your recorder. V8 captures one frame unless a client has the Runtime domain enabled, and then up to 200. The frames run top first, lineNumber and columnNumber count from one, and scriptId is a string.

Example event a local fixture page
{
  "name": "LayoutInvalidationTracking",
  "cat": "disabled-by-default-devtools.timeline.invalidationTracking",
  "ph": "I",
  "ts": 1102950826,
  "tts": 45939,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "nodeId": 14,
      "nodeName": "#text",
      "reason": "Removed from layout",
      "stackTrace": [
        {
          "columnNumber": 81,
          "functionName": "note",
          "lineNumber": 24,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        },
        {
          "columnNumber": 5,
          "functionName": "",
          "lineNumber": 47,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ]
    }
  }
}

See also InvalidateLayout · Layout · UpdateLayoutTree · StyleRecalcInvalidationTracking · ScheduleStyleInvalidationTracking · LayoutImageUnsized · LayoutShift

categories
disabled-by-default-devtools.timeline.invalidationTracking
usage
very high
undeclared args
args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
v8-debugger.cc, layout_invalidation_reason.h, capture_source_location.cc, node.cc, source_location.cc, v8-stack-trace-impl.cc and 1 more
ScheduleStyleInvalidationTrackingneeds Enable CSS selector stats (slow) or Invalidation tracking

Chrome notes that a change to an element may have put some styles out of date, and queues them to be checked.

A DevTools Performance recording contains this event only with Enable CSS selector stats (slow) or Invalidation tracking turned on, which records disabled-by-default-devtools.timeline.invalidationTracking.

Spec

Blink's RuleInvalidationData writes one instant for each invalidation set it finds after an element's class, id, attribute or pseudo-class state changes, just before PendingInvalidations decides what to mark or queue for the next style recalculation, on the renderer's main thread and only when disabled-by-default-devtools.timeline.invalidationTracking is recorded.

Arguments

pathdeclaredobserved
args.data.changedAttributeopt42.9%
args.data.changedClassopt12.2%
args.data.changedIdopt0.0%
args.data.changedPseudo-44.9%
args.data.frameyes100.0%
args.data.invalidatedSelectorIdopt100.0%
args.data.invalidationSetopt100.0%
args.data.nodeIdyes100.0%
args.data.nodeNameopt100.0%
args.data.reasonopt0.0%declared, never observed
args.data.stackTrace[].columnNumber-72.9%
args.data.stackTrace[].functionName-72.9%
args.data.stackTrace[].lineNumber-72.9%
args.data.stackTrace[].scriptId-72.9%
args.data.stackTrace[].url-72.9%

Observed 70,330 times on 162 of 300 sites.

Behaviour

When Blink turns a stylesheet into a rule set it also compiles invalidation sets: for each class, id, attribute name and pseudo-class that appears in a selector, a note of what a change to it can affect, such as the element itself, its descendants that match .kid, or the siblings that follow it. When one of those features changes on an element, the style engine looks up the sets keyed on it and hands them to PendingInvalidations, which marks elements or queues work for the next UpdateLayoutTree. This event is written at the lookup, once for every set found. A class used in both .x .kid and .x + div has a descendant set and a sibling set, and toggling it wrote two events.

There are four versions of the payload, one per kind of change, and each adds exactly one key: changedClass, changedId, changedAttribute or changedPseudo. invalidatedSelectorId says which kind it is, as the string class, id, attribute or pseudo. Everything else is shared: frame, nodeId and nodeName for the element whose class, id, attribute or state changed, invalidationSet for the set that was found, and stackTrace when there is one.

Class, id and attribute events come from writes to the DOM. An id change looks up both the old id and the new one. Writing class is also an attribute change, so on a page with a selector on the class attribute, such as [class*=icon], one classList.add() writes changedClass events and a changedAttribute event for class as well. Setting a property through element.style counts as a change to the style attribute: it marks the element for recalculation directly, and it writes a changedAttribute: "style" event only when some selector tests [style], exactly as setAttribute('style', ...) does. When script inserts or removes an element beside siblings that + or ~ rules care about, Blink looks up the sibling sets for the classes, id and attributes those siblings already have, so an event can name a class nobody changed. Pseudo events come from state: :hover, :focus and :active, but only on elements whose style was flagged as depending on them, :disabled and :enabled on form controls, :defined as custom elements are defined, :first-child, :last-child and :only-child when script adds or removes children, and, going by the source, :link, :visited and :any-link on every link when the browser updates a document's visited links. For the structural ones the element is the sibling whose position changed: appending an <li> wrote last-child for the item that used to be last, not for the new one. The HTML parser does not write these for your document, because its insertions skip the sibling checks and it sets attributes before an element is in the document. Blink's own C++ does write them, for elements it owns: on real sites the built-in controls of a <video> and the placeholder inside an <input> wrote class, attribute and pseudo events while no script was running.

Many invalidations write nothing. A class that no selector mentions has no set, and toggling one wrote no event. Classes and ids that appear as the subject of a rule, as in .x { } or #x { }, are a special case. Each stylesheet counts them as it is parsed, classes and ids together and repeats included, and after the fiftieth Blink stops storing sets for them and adds them to a Bloom filter instead, a compact yes-or-probably lookup. A name found there invalidates the element without an event. Behind 49 such rules, a fiftieth class wrote an event and a fifty-first restyled its element and wrote nothing; a class in a second stylesheet started the count again and wrote one. :nth-child()-type positional invalidation, universal sibling rules such as div + .u, :has() invalidation of ancestors, adding or removing a stylesheet and, going by the source, ::part() all restyle elements without this event. The lookup is skipped entirely in an inactive document, during a style recalculation, and when the element's parent is already marked for a full subtree recalculation, which covers the whole document before its first style pass.

Because the event is written before scheduling decides anything, it can also be followed by nothing. Toggling a class on a display: none element wrote the event and no recalculation, because Blink drops descendant invalidation for an element that has no style. Going by the source, it also drops a sibling set when the element has no next sibling, and descendant sets when the element is already marked for a full subtree recalculation.

What happens next depends on the set. A set that covers the element itself marks it for recalculation on the spot, and a StyleInvalidatorInvalidationTracking with the reason "Invalidation set invalidates self" follows immediately. Descendant and sibling sets are queued on the element and walked at the start of the next UpdateLayoutTree, which writes a StyleInvalidatorInvalidationTracking for the element they were queued on and for each element they reach. Twenty class toggles in one task wrote twenty of these events and one recalculation.

stackTrace is there when JavaScript was running at the moment of the change, and only then. Script-made changes carry one; a hover from the mouse, focus from a click or the Tab key, a <details> opened by a click and Blink's own updates to its built-in controls do not. That is the split in the arguments table. Unlike the stack on ScheduleStyleRecalculation, this one does not need the disabled-by-default-devtools.timeline.stack category. How deep it goes depends on who is recording: Blink asks V8 for a one-frame stack unless a client with the Runtime domain enabled is attached to the page. The same three-deep call wrote one frame when the page was traced from the browser with nothing attached to it, and all three once a client attached and enabled Runtime. A recording made in DevTools gets the full chain.

Derivations

Which change queued the work a recalculation did, and how far it reached. The events before an UpdateLayoutTree name each element and class, id, attribute or pseudo-class that queued work in it and, when script did it, the line. Follow invalidationSet together with nodeId. A descendant or sibling set shows up in the recalculation as a StyleInvalidatorInvalidationTracking with the reason "Element has pending invalidation list" for the same nodeId, carrying the same value in invalidationList[].id, followed by one "Invalidation set matched ..." event with that id for each descendant or sibling the set reached. A set that covers the element itself writes "Invalidation set invalidates self" for the same nodeId straight away. Counting the matched events for one change says how many elements a single class toggle hit, and the selectors on them name the rules responsible. Neither half is enough alone: the invalidator events do not say what changed or who changed it, and this event does not say what it reached.

Traps

invalidatedSelectorId is not an id. It holds the kind of change, class, id, attribute or pseudo, and the value to look at is in the matching changed... key. reason appears in DevTools' type for this event and is never written. DevTools shows these events only in the Invalidations section of a Recalculate style event, grouped by that kind; it knows class, id and attribute, so every pseudo-class event, the most common kind on real sites, is filed under "unknown".

Older traces spell it differently. Up to Chrome 137, pseudo-class events carried invalidatedSelectorId: "attribute" and Blink's internal enum name in changedPseudo, such as PseudoHover, so DevTools listed them as an attribute change with no name; Chrome 138 switched to pseudo and hover. Up to Chrome 119, adding a stylesheet with type-selector rules could also write an event with invalidatedSelectorId: "ruleset" and no changed... key. Chrome 120 removed that call; the ruleset string is still defined in the source, and nothing writes it.

No event does not mean no invalidation. A plain .x { } class past the fiftieth subject class or id of its stylesheet, a new stylesheet, positional and universal sibling rules, :has() and the other paths above restyle elements without writing one. To see whether a change cost anything, look at the StyleRecalcInvalidationTracking and UpdateLayoutTree events, not at the absence of this one.

An event does not mean work. It is written before Blink decides whether to schedule anything. A change on a display: none element, or a sibling set on an element with no next sibling, writes the event and nothing after it.

The value named may not have changed, and the event does not say which way it went. Inserting a node beside .sib wrote changedClass: "sib" for an element whose class was untouched. Nothing records whether a class was added or removed. nodeName usually shows the element after the change, but not when the class attribute is emptied: className = '' on class='open x' wrote changedClass: "open" with nodeName still showing class='open x'. One change can also write several events: descendant and sibling sets, old and new id, and a changedAttribute: "class" event on top of the class events.

invalidationSet names a set, not a change or a rule. It is the memory address of Blink's InvalidationSet object, so it only compares within one renderer process. Every change that looks up the same set, on any element, carries the same value: toggling open on two different elements wrote the same address twice. Every set that only covers the element itself is one shared object, so a .selfonly { } class, a #selfid { } id and an li:last-child rule all wrote the same address. The address changes when a second stylesheet adds rules keyed on the same class, id, attribute or pseudo-class, because Blink then merges the two sets into a new object. Adding an unrelated stylesheet left the address as it was, and removing the second sheet brought the old address back.

Join on the address and the node, never the address alone. The shared self-invalidation address also appears on StyleInvalidatorInvalidationTracking events for changes that wrote nothing here: a class past the fiftieth in its stylesheet wrote "Invalidation set invalidates self" with that address and no event of its own, so an address-only join pins it on whichever earlier change used the same set. Positional and universal sibling invalidation write invalidator events with ids that no event here carries. And when two elements queued the same descendant set before one recalculation, the matched events that follow carry the same id, which does not say which element's change reached them.

The element may be one you never wrote. Blink's built-in controls live in shadow trees it manages, and their updates write these events with no stack: INPUT class='pause' and LABEL class='animated-1' were a <video>'s controls, DIV id='placeholder' was an <input>'s placeholder.

Stack positions count from one. lineNumber 29 and columnNumber 38 pointed at line 29, column 38 of the source counting from one. DevTools converts some events' stacks to count from zero before linking them, and this event is not on its list, so a link from the Invalidations section probably lands one line late; that is from reading DevTools' source and was not checked in the UI.

A run of these, each followed by a recalculation, is not in itself thrashing. One change per animation frame, each picked up by that frame's recalculation, draws the same picture and is fine. Thrashing is a recalculation forced inside a script's task by a read of style or layout, and it shows as an UpdateLayoutTree nested inside the script's slice, which needs no invalidation tracking to see. DevTools only warns about a forced reflow when the forced UpdateLayoutTree and Layout slices in one task add up to 30ms or more, so a loop of small forced recalculations gets no warning. In one capture, twenty class toggles each followed by an offsetWidth read wrote twenty of these and twenty recalculations inside the timer's task, together well under a millisecond. Find the pairs by the nesting, and use these events to name the write in each.

DevTools' Invalidations list attributes too much. It attaches every invalidation event to each recalculation in the same frame until the next Paint, not only to the first recalculation after it, and keeps the latest twenty per recalculation while counting all of them. In a loop of forced recalculations the last one lists writes the earlier ones had already handled. This is from DevTools' source, not from the UI.

The category changes what it measures. Recording disabled-by-default-devtools.timeline.invalidationTracking makes style passes longer, by less than two times to more than ten times across the captures behind this manual, and more the further the invalidation sets reach (see UpdateLayoutTree). Use a recording with these events to find out what changed and what it reached, and a recording without them to time it.

Example event from https://www.bbc.com/news
{
  "name": "ScheduleStyleInvalidationTracking",
  "cat": "disabled-by-default-devtools.timeline.invalidationTracking",
  "ph": "I",
  "ts": 1108824349,
  "tts": 460859,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "changedClass": "jUHzNV",
      "frame": "2EA8810E92C12C680C55B2941251AA98",
      "invalidatedSelectorId": "class",
      "invalidationSet": "0x2764030ccf40",
      "nodeId": 130,
      "nodeName": "DIV class='Backdrop-styles__BackdropStyled-sc-9f7d4825-0 dya-dBg'",
      "stackTrace": [
        {
          "columnNumber": 8015,
          "functionName": "E",
          "lineNumber": 1,
          "scriptId": "46",
          "url": "https://static.files.bbci.co.uk/bbcdotcom/web/20260907-085256-bea8b058c0-web-3.21.0/_next/static/chunks/0y-nyl-getpga.js"
        },
        {
          "columnNumber": 91540,
          "functionName": "oq",
          "lineNumber": 1,
          "scriptId": "46",
          "url": "https://static.files.bbci.co.uk/bbcdotcom/web/20260907-085256-bea8b058c0-web-3.21.0/_next/static/chunks/0y-nyl-getpga.js"
        },
        {
          "columnNumber": 90771,
          "functionName": "oQ",
          "lineNumber": 1,
          "scriptId": "46",
          "url": "https://static.files.bbci.co.uk/bbcdotcom/web/20260907-085256-bea8b058c0-web-3.21.0/_next/static/chunks/0y-nyl-getpga.js"
        },
        "... [112 more items]"
      ]
    }
  }
}

See also StyleInvalidatorInvalidationTracking · StyleRecalcInvalidationTracking · UpdateLayoutTree · ScheduleStyleRecalculation · StyleResolver::ResolveStyle · SelectorStats · LayoutInvalidationTracking

categories
disabled-by-default-devtools.timeline.invalidationTracking
usage
moderate
undeclared args
args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url, args.data.changedPseudo
references
v8-debugger.cc, rule_invalidation_data.cc, invalidation_set.cc, capture_source_location.cc, source_location.cc, v8-stack-trace-impl.cc and 1 more
SelectorStatsneeds Enable CSS selector stats (slow)

Chrome's count of how it matched each CSS selector against elements during one style recalculation.

A DevTools Performance recording contains this event only with Enable CSS selector stats (slow) turned on, which records disabled-by-default-blink.debug.

Spec

A complete event that Blink's ElementRuleCollector writes on the renderer main thread straight after each UpdateLayoutTree, listing every selector that was tried against an element in that renderer process since the previous such event, with its stylesheet, the number of elements it was tried on, how many of those a Bloom filter ruled out, how many it matched, and the time spent, recorded only under disabled-by-default-blink.debug.

Arguments

pathdeclaredobserved
args.selector_statsoptdeclared, never observed

Behaviour

With disabled-by-default-blink.debug recorded, Blink swaps its selector-matching loop for an instrumented copy. For each element, it reads the clock around every candidate rule and adds the result to one table per renderer process, keyed by the selector's text and the stylesheet it came from. When a style pass ends, the code that closes UpdateLayoutTree empties that table into this event on the very next line. So you get one SelectorStats a few microseconds after every UpdateLayoutTree, next to it rather than inside it. A same-origin iframe runs its own pass and gets its own event. A style update with nothing to do writes neither event.

The table holds everything matched in that process since the last time it was emptied. That is usually just the pass that ended, but not always, because Blink also matches selectors outside a style pass and that work goes into the next event, whenever it comes. When a @container rule flipped for 200 children, the UpdateLayoutTree for that change showed 4 match attempts, because the children were restyled inside the following Layout. The 200 .cqk attempts turned up in the event after an unrelated style change 400ms later. A getComputedStyle() read of an element inside a display: none subtree wrote no events, and its match turned up in the next pass's event in the same way. The table is shared by every document in the process: the same read made inside a same-origin iframe turned up in the parent page's next event. If nothing was matched since the last event, it is written with empty args, which a pass with an elementCount of 0 does not guarantee.

A selector only gets a row if it was actually tried. Blink files every selector under its rightmost part (an id, a class, an attribute name, a tag, one of a few pseudo-classes such as :link and :focus, or "universal" when the rightmost part has none of these) and tries an element against only the selectors filed under that element's own id, classes, attributes and tag, the pseudo-classes it is in, and the universal ones. match_attempts is the number of elements a selector was tried on after that filtering. On a test page, .card:has(.hot) was tried twice, once for each .card, a selector for a class nobody used never appeared, and .list * was tried on all 814 elements the pass restyled. Each selector in a comma-separated list gets its own row. Two rules with the same selector text in the same stylesheet share a row.

fast_reject_count counts attempts rejected before the full selector check ran. A Bloom filter is a small bit set that can say for certain that something is absent. Blink keeps one for the tags, ids, classes and attribute names of the ancestors of the element being matched, and one for the element's own classes and attributes. .nope .item was rejected this way on all 400 .item elements because no ancestor had the class nope. match_count is the number of attempts that matched, including any @container condition the rule sits in. elapsed (us) is the total time of all attempts in microseconds, measured around the loop body for that selector: the filter check, the selector check, any @container condition, and recording the match. Building the style from the matched rules, invalidation and the layout tree are outside it.

style_sheet_id has the form style-sheet-<process id>-<n>, the id the DevTools CSS panel uses for that stylesheet. Rules from Chrome's built-in stylesheet appear as ua-style-sheet.

The event's own dur is the time spent writing the table into the trace and clearing it, from 1us up to most of a millisecond on a large page. It is not the matching time.

Derivations

Which selectors the Bloom filters cannot help with. match_attempts - match_count is the number of non-matches, and fast_reject_count is how many of those were rejected cheaply. The rest went through the full selector check before failing. DevTools shows 1 - fast_reject_count / (match_attempts - match_count) as "% of slow-path non-matches". A selector that is tried on many elements, matches few and scores near 100% is doing full checks for nothing. On a test page .item:nth-child(2n+1) scored 100%, because it names no ancestor and its pseudo-class gives the filters nothing to test, while the longer .nope .item scored 0%. The number of attempts shows where that work comes from: it depends on the selector's rightmost part, not on how long the selector is. On Wikipedia's CSS article the selectors with the most attempts all ended in *, :first-child, :last-child or :not(...), which files them as universal, and each was tried on nearly every element the load restyled, however long its left-hand side.

Traps

The recording makes style work several times slower, and the time column includes the cost of measuring. A forced full re-match (a class toggle on <html> with a html.zz * rule, forced with getComputedStyle) took 11.4ms on a 9,000-element test page with nothing recording and the same with DevTools' timeline categories, 77ms with disabled-by-default-blink.debug, and 90ms with both categories DevTools' setting records. On Wikipedia's CSS article it went from 11ms to 293ms and 305ms, and on a small 534-element page from 0.6ms to 3.7ms and 4.1ms. These are one machine, headless, one Chrome build. The page's own performance.now() grew by the same amount as the trace durations, so this is real main-thread work that the recording adds. Do not trust the duration of any style event in a trace recorded with this category.

elapsed (us) ranks selectors within one recording and is not a cost. Every attempt is timed between two clock readings, so each carries a fixed measuring cost of a clock read and the loop around it, about 20ns on the test machine. A selector that the filter rejected on every one of 11,488 attempts averaged 38ns per attempt. Rows with many cheap attempts are inflated most. On Wikipedia the column for one forced pass added up to more than the whole pass took with nothing recording; on the test page it came to about half. Nobody has checked that the order it gives matches the order without instrumentation.

Single values of elapsed (us) are noise. The clock is read in whole microseconds, so an attempt shorter than a microsecond reads 0, or 1 when a microsecond tick falls inside it. Over thousands of attempts those zeros and ones average out to a usable total. A row with a handful of attempts means little: one attempt of .item.one read 6us.

An event can carry work from before its pass, and from another document. The table is emptied only when a style pass ends, and there is one per renderer process. Selectors matched during Layout for container queries, on demand for getComputedStyle() inside display: none, or in a same-origin iframe outside its own pass, are reported with whichever pass the process runs next, which may have nothing to do with them. A pass with an elementCount of 1 that carries hundreds of attempts is the usual sign.

DevTools can attach the statistics to the wrong recalculation. Its handler attaches each SelectorStats to the last UpdateLayoutTree it read, from any thread or process. When an out-of-process iframe restyles at the same time as the page, the frame's statistics can appear under the page's recalculation and the other way round. When you work from the raw trace, pair each event with the UpdateLayoutTree just before it on the same pid and tid.

ua-style-sheet on one of your own selectors is Blink checking a stylesheet change, not a built-in rule. When a stylesheet is added or its rules change, Blink walks the elements and asks, for each, whether any of the changed rules could apply to it. That check runs without the context Blink uses to find the rule's stylesheet, so the lookup fails and the row is filed under ua-style-sheet. It stops at the first rule that matches and records nothing for that element, so these rows always show 0 matches and count only the elements where nothing matched. They appear on the pass after a stylesheet loads, is inserted, or is edited through the CSSOM.

Two stylesheets with the same contents are counted as one. Blink shares the parsed contents of a stylesheet loaded twice from the same URL, and the lookup returns one of the two. When a test page linked its stylesheet a second time, the whole pass was reported under the new sheet's id with both sheets' attempts added together, and nothing under the original. After one of them was edited through the CSSOM they got separate rows again. The ids are only linkable while DevTools is still attached to the page that created them, before it reloads. In a trace loaded from a file the Style sheet column cannot link them.

DevTools' setting records invalidation tracking as well. "Enable CSS selector stats (slow)" adds disabled-by-default-blink.debug and disabled-by-default-devtools.timeline.invalidationTracking. The tab's Invalidation count column is not in this event. DevTools works it out from the invalidation-tracking events and, for subtree invalidations, by counting descendants in the page's DOM as it is now, not as it was when the trace was recorded. Selecting a single recalculation always shows the Selector stats tab. Selecting a range totals it only when the viewer's own setting is on. The event itself is hidden in the flame chart.

The tab's explanation of slow-path non-matches is not quite right. Its tooltip blames selector complexity. The filters only know the tags, ids, classes and attribute names of the element and its ancestors, so a short selector that names no ancestor, such as .item:nth-child(2n+1), can never be ruled out early, while a long chain of ancestor classes can be rejected at once. The column also counts every attempt that neither matched nor was filtered out for any other reason: a one-class selector inside a @container block whose condition was false scored 100%, because the selector matched and the container condition did not.

The events are large. One Wikipedia load wrote 64 of them, about 1.7MB of JSON, with over 2,000 distinct selector and stylesheet pairs.

Example event

Not seen, because the collection never enabled the disabled-by-default-blink.debug category that gates it. The CSS selector stats setting in DevTools works by switching that category on, and any trace that enables it produces the event.

See also UpdateLayoutTree · StyleResolver::ResolveStyle · StyleInvalidatorInvalidationTracking · StyleRecalcInvalidationTracking · Layout · ParseAuthorStyleSheet

categories
disabled-by-default-blink.debug
references
element_rule_collector.cc
StyleInvalidatorInvalidationTrackingneeds Enable CSS selector stats (slow) or Invalidation tracking

Chrome's style invalidator marked an element to have its style recalculated.

A DevTools Performance recording contains this event only with Enable CSS selector stats (slow) or Invalidation tracking turned on, which records disabled-by-default-devtools.timeline.invalidationTracking.

Spec

Blink records one step of applying an invalidation set: an element a set caught for style recalculation, or a node it reached that carries pending sets, written by PendingInvalidations when the DOM change is made and by StyleInvalidator at the start of the next style update, on the renderer's main thread.

Arguments

pathdeclaredobserved
args.data.extraDataopt0.0%declared, never observed
args.data.frameyes100.0%
args.data.invalidationList.classesopt0.0%declared, never observed
args.data.invalidationList.idyes0.0%declared, never observed
args.data.invalidationList[].allDescendantsMightBeInvalid-8.3%
args.data.invalidationList[].attributes[]-29.4%
args.data.invalidationList[].classes[]-52.8%
args.data.invalidationList[].customPseudoNames[]-4.6%
args.data.invalidationList[].id-100.0%undeclared, always present
args.data.invalidationList[].ids[]-5.6%
args.data.invalidationList[].insertionPointCrossing-0.0%
args.data.invalidationList[].invalidatesParts-0.1%
args.data.invalidationList[].invalidatesSlotted-0.0%
args.data.invalidationList[].tagNames[]-60.9%
args.data.invalidationList[].treeBoundaryCrossing-4.7%
args.data.nodeIdyes100.0%
args.data.nodeNameopt100.0%
args.data.reasonyes100.0%
args.data.selectorCount-63.1%
args.data.selectorPart-94.9%
args.data.selectors.selectoryes0.0%declared, never observed
args.data.selectors.style_sheet_idyes0.0%declared, never observed
args.data.selectors[].selector-63.1%
args.data.selectors[].style_sheet_id-63.1%
args.data.subtreeyes0.0%declared, never observed

Observed 170,721 times on 166 of 300 sites.

Behaviour

When you change a class, id or attribute, Blink does not restyle the page. It looks up the invalidation set for what changed: a precomputed list of the classes, ids, tag names and attributes that rules keyed on that change can reach. For .on .x .y the set for class on holds y, the last compound. Blink schedules the set on the element that changed, which writes ScheduleStyleInvalidationTracking, and applies it later. This event records the applying.

It is written at two moments. The first is at the DOM change itself, in the same task. If a set says the element itself needs a new style, Blink marks it there and then and writes the reason Invalidation set invalidates self; if the set covers everything below the element, as a rule like .on * does, it writes Invalidation set invalidates subtree and marks the whole subtree. Each sits between the ScheduleStyleInvalidationTracking and the StyleRecalcInvalidationTracking of the same change, outside any UpdateLayoutTree.

The second is during the next style update. Inside UpdateLayoutTree, after stylesheet, media query and font work and before any style is recalculated, Blink walks the DOM and applies the pending sets. With the blink category recorded this walk is the Document::updateStyleInvalidationIfNeeded slice, and it closes before Document::updateStyle opens. The walk writes Element has pending invalidation list on each node where descendant sets were scheduled, then one event for every element a set catches, normally followed by a StyleRecalcInvalidationTracking for the same node. Sets for sibling rules such as .a + .b are applied without a pending-list event. If script forced the update, all of this happens inside the script's task.

A caught element is reported with the first feature that matched, tested in a fixed order: whole subtree, tag name, id, class, custom pseudo-element name, attribute, part, and a tree-counting dependency (sibling-index() or sibling-count()). That gives the reasons Invalidation set matched tagName, matched id, matched class, matched custom pseudo element name, matched attribute, matched part and Invalidation set invalidates tree-counting. selectorPart is the tag, id, class, attribute or custom pseudo-element name that matched; it is empty for self, subtree, part and tree-counting, and missing on the pending-list event. A reason string Invalidate custom pseudo-element is defined in Chromium but nothing writes it. Blink stops at the first descendant set that catches an element, but tests every sibling set, so one element can get more than one event.

invalidationList is the set that matched, or on the pending-list event every set scheduled on that node. Each entry is the whole set: its id, the flags that are true, and every class, id, tag name, attribute and custom pseudo-element name it holds, not only the one that caught this element. id is the set's address in memory, written by the same function as invalidationSet on ScheduleStyleInvalidationTracking, so the two are equal for the same set.

selectors and selectorCount come from a map Blink builds, only while tracing, from each feature in each invalidation set back to the selectors that put it there. Blink builds it when disabled-by-default-devtools.timeline.invalidationTracking or disabled-by-default-blink.debug is recorded, on the first invalidation after recording starts, so a recording started after the page loaded gets it too. Only the matched reasons and invalidates subtree look the map up; invalidates self, invalidates tree-counting and the pending-list event never carry the two fields. A lookup can also come back empty: rules written inside another rule with CSS nesting are never entered in the map, so an element caught by one gets its event without selectors.

So the number you see follows the reach of invalidation sets: one event per element caught, plus one per node that carried descendant sets. Elements the walk skips get nothing: everything below an element already marked for a whole-subtree restyle, and the children of an element with no computed style, such as one inside display: none.

Derivations

Which change restyled which elements. ScheduleStyleInvalidationTracking carries the script stack and the class, id or attribute that changed, and its invalidationSet equals the invalidationList id on the events the walk writes for that set. The id names a set, not a change: every element that got class on before the same style update scheduled the same set, so two functions each adding on to a different element produce two schedule events with two stacks and one id on every walk event. To split them, use the tree. The walk writes the pending-list event on the element that changed, with the schedule event's nodeId, and the matched events for that element's descendants follow it. Grouping those gives, for one classList.add, the elements it pulled into the restyle and the feature that pulled each one in, which the recalculation's elementCount only gives as a total. It stops working when one changed element is inside another that scheduled the same set, since either change would have caught the inner elements, and for the sets Blink applies without a schedule event (see the traps).

Which selectors cause broad restyles. Invalidation set invalidates subtree with its selectors names the rules that turn one class change into a restyle of everything below the element, such as .on * or a descendant rule ending in a pseudo-element. Counting matched events by selectorPart and selectors shows which feature catches the most elements; a selector whose last compound is loose, like .on .x .y, catches every .y under the element whether or not it is inside a .x, and tightening that compound is the fix. Use it to find what was caught, not what it cost (see the traps).

Traps

The selectors are not the rule that matched. selectors lists every selector that contributed the matched feature to that invalidation set, and nothing checks them against the element. With .on .x, .on span.x and .on .x .y on a page, adding on reported .on span.x for a div.x, and .on .x .y for a .y that was inside no .x at all. They tell you why Blink caught the element, not which rule now styles it. Each entry is Blink's own serialisation of one selector from the rule's selector list, not your source text: .q1>.q2 comes back as .q1 > .q2, and from .a, .b .c you get only .b .c. The order of the entries means nothing.

The feature reported is the first that matched, not the most specific. Tag names are tested before ids, ids before classes, classes before attributes. On one real page a div class='frb-button' was reported as matched tagName div, with twelve .mw-spinner-container > div:nth-child(n) selectors, though the same set also held its class frb-button.

There is no stack on this event. Neither of its builders asks for one, even when the event is written inside your script. For the script, read the ScheduleStyleInvalidationTracking before it, which has the stack of the change, or the StyleRecalcInvalidationTracking after it, which has a stack only when script was running at that moment: at the change itself, or in a recalculation that script forced.

Selector detail is missing on three reasons by design, and on nested CSS. invalidates self, invalidates tree-counting and Element has pending invalidation list never carry selectors. The other reasons carry them with invalidation tracking alone; the slow "Enable CSS selector stats" setting adds nothing to this event. But a rule written inside another rule with CSS nesting, such as .nest { & .nx {} }, is never entered in Blink's selector map, so the element it catches gets an event with no selectors.

style_sheet_id is not a file. It is style-sheet-<process>-<n>, the same string the DevTools protocol uses for a stylesheet, with n given out in the order sheets are first asked about, so the same sheet can have a different id in another recording. Nothing else in a trace file maps it to a URL; SelectorStats carries the same ids. A rule in a file pulled in with @import is reported under the sheet that imports it. ua-style-sheet means the browser's own stylesheet, and also any sheet that is not in the caught element's own tree scope: a page rule .pon ::part(p) that caught a span inside a shadow root, and a shadow root's ::slotted(.sl) rule that caught a span in the page, were both reported as ua-style-sheet.

id is an address, and it names a set, not an invalidation. Every element that schedules the same set gets the same id, and the set gets a new address when stylesheets change. Some sets have no ScheduleStyleInvalidationTracking to match: the shared set Blink uses for classes and ids that only ever style the element that has them, once a stylesheet has about fifty of those (Blink then keeps their names in a Bloom filter, which can also produce invalidates self for a class that no rule uses), the sets for :nth-child() and similar selectors, the tree-counting set, which Blink schedules on the parent when children are added or removed, and the descendant part of a sibling rule: for .s2 + .t2 .u2 the event on .u2 carries an id that no schedule event has. Blink always schedules the :nth-* sets and the tree-counting set together, so a child added or removed can bring events from either kind of rule.

An event is not a restyle. The pending-list event marks nothing. For a sibling rule with a descendant, like .s2 + .t2 .u2, the .t2 gets a matched class event only so Blink can look below it, and is not marked. One element can be caught by two sets and get two events and one recalculation mark, and an element in a slot can be reached twice and get two of each. The mark can be refused after the event is written: when the element's parent has no computed style Blink writes this event and then skips the element, and no StyleRecalcInvalidationTracking follows. The reverse happens too: an element whose container query depends on sibling-index() or sibling-count() is marked with no event of this name. Count restyles with elementCount or StyleResolver::ResolveStyle, not with this.

Two fields DevTools declares are never here. trace_engine types this event with subtree and extraData. Those belong to StyleRecalcInvalidationTracking, which is where the subtree flag lives: DevTools' SelectorStatsHandler says so in a comment, and pairs an event that has selectors with the next StyleRecalcInvalidationTracking for the same node that has subtree set. When the mark is refused that partner never comes, and the subtree invalidation is counted as a single node.

What DevTools does with it. Recalculate style shows an "Invalidations" section, grouped by reason, keeping only the last 20 per recalculation, with node links and no selectors. The selectors are used only for the "Invalidation count" column of selector stats. Two effects from reading its source, not checked in the panel: invalidations waiting for a recalculation are also given to a second recalculation in the same frame, and a subtree invalidation written at the DOM change is counted against the recalculation before it.

Recording it changes what it measures. The category makes restyle-heavy style passes run many times longer (see UpdateLayoutTree), and the first invalidation after recording starts also has Blink walk the page's active stylesheets again to build the selector map. Use a recording with it to find out what was caught and why, and a recording without it for how long style took.

Example event from https://web.dev
{
  "name": "StyleInvalidatorInvalidationTracking",
  "cat": "disabled-by-default-devtools.timeline.invalidationTracking",
  "ph": "I",
  "ts": 1235492157,
  "tts": 207175,
  "pid": 10309,
  "tid": 10309,
  "s": "t",
  "args": {
    "data": {
      "frame": "614FC5655E48B642B0235ABCA128B0C6",
      "invalidationList": [
        {
          "attributes": ["collapsed", "active", "fixed", "position", "animatable"],
          "classes": ["hide-collapsed-panel", "devsite-concierge-panel", "devsite-concierge-panel-chat-input", "devsite-sidebar", "devsite-book-nav-toggle", "devsite-floating-action-buttons", "mobile-view-not-enabled", "devsite-concierge-menu-icon", "... [13 more items]"],
          "id": "0x109400913c00",
          "ids": ["gc-wrapper"],
          "tagNames": ["devsite-header", "devsite-footer-promos", "devsite-concierge", "devsite-panel", "google-codelab", "devsite-footer-linkboxes", "devsite-book-nav", "devsite-concierge-ai-panel", "... [3 more items]"]
        }
      ],
      "nodeId": 22,
      "nodeName": "SECTION class='devsite-wrapper'",
      "reason": "Invalidation set matched class",
      "selectorCount": 1,
      "selectorPart": "devsite-wrapper",
      "selectors": [
        {
          "selector": "body[ready] .devsite-wrapper",
          "style_sheet_id": "style-sheet-10309-1"
        }
      ]
    }
  }
}

See also ScheduleStyleInvalidationTracking · StyleRecalcInvalidationTracking · UpdateLayoutTree · SelectorStats · StyleResolver::ResolveStyle · LayoutInvalidationTracking

categories
disabled-by-default-devtools.timeline.invalidationTracking
usage
high
undeclared args
args.data.invalidationList[].allDescendantsMightBeInvalid, args.data.selectorCount, args.data.selectorPart, args.data.invalidationList[].tagNames[], args.data.invalidationList[].customPseudoNames[], args.data.invalidationList[].treeBoundaryCrossing, args.data.invalidationList[].attributes[], args.data.invalidationList[].ids[] and 3 more
references
invalidation_set.cc, style_invalidator.cc, invalidation_set_to_selector_map.h, identifiers_factory.cc, inspector_trace_events.h
StyleRecalcInvalidationTrackingneeds Enable CSS selector stats (slow) or Invalidation tracking

Chrome records a request to recalculate one element's style, with the element and the reason.

A DevTools Performance recording contains this event only with Enable CSS selector stats (slow) or Invalidation tracking turned on, which records disabled-by-default-devtools.timeline.invalidationTracking.

Spec

Blink writes one instant event on the renderer's main thread for each call to Node::SetNeedsStyleRecalc, and for each node inserted under an element that already has a style, recording the node, whether the request covers the node alone or the node and every descendant, and a reason string.

Arguments

pathdeclaredobserved
args.data.extraDataopt100.0%
args.data.frameyes100.0%
args.data.nodeIdyes100.0%
args.data.nodeNameopt100.0%
args.data.reasonyes100.0%
args.data.stackTrace[].columnNumber-41.5%
args.data.stackTrace[].functionName-41.5%
args.data.stackTrace[].lineNumber-41.5%
args.data.stackTrace[].scriptId-41.5%
args.data.stackTrace[].url-41.5%
args.data.subtreeyes100.0%

Observed 636,276 times on 182 of 300 sites.

Behaviour

Blink writes this event each time something asks for a node's style to be recalculated, and it writes it before it looks at what the node is already marked with. So one event is one request, not one change of state: five writes to the same element's style in one task wrote five events and cost one recalculated element, and a request on an element that was already marked for a bigger recalculation still wrote one. The only checks that come first are that the node is in an active document and that its parent already has a computed style. That second check is why nothing is written for nodes inside display: none, for nodes inserted during the initial parse, before the first style recalculation, and for the nodes inside a fragment that is inserted whole, which writes one event for its root. A second call site, for inserted nodes, behaves the same way and always writes the reason Node was inserted into tree. The node can be a text node, which shows up as #text.

Some callers skip the call when the node is already dirty, so not every conceivable request is recorded. A CSS animation marks an element only if it is clean, which comes out as one Animation event per animating element per frame, and none for an element that script had already marked before the tick. And once an invalidation set has marked an element for a subtree recalculation, Blink stops matching invalidation sets below it, so such a subtree invalidation is one event on its root however large the subtree is. Other callers do not stop: changing lang writes a subtree: true event for every element under it.

reason is one of 52 fixed strings in Blink's style_change_reason.cc. The ones you meet most: Related style rule means an invalidation set matched (Blink's per-selector lists of which class, id or attribute changes can affect which elements), either at once, for the element whose class or attribute changed, or later, for its descendants, when the style update walks the pending sets. Inline CSS style declaration was mutated is a write through el.style. Style rule change means a stylesheet was added or changed and Blink marked each element one of its rules could affect, one event per element, which is why CSS that arrives after the first style recalculation writes these in bulk. Attribute is a presentation attribute such as hidden or lang, or an attribute the element's style reads with attr(); an attribute used in a selector, such as [data-state], arrives as Related style rule. PseudoClass is a user-action pseudo-class on an element whose own style depends on it, a dir=auto change, or a lang change. Some changes are handled as a single subtree request on <html>, which is how Blink asks for every element to be recalculated: registering a custom property with CSS.registerProperty wrote one PropertyRegistration event, and adding a color-scheme meta tag wrote one PlatformColorChange event. Language, Zoom, Printing and Inspector work the same way.

subtree is true when the request was for the node and all of its descendants in the flat tree (the DOM with shadow trees and slotted children in place), and false for everything else. Under a subtree request Blink recalculates every descendant that has a style, and it does not stop early when a style comes out unchanged, as it does for a request on a single element. On a test page with 1500 elements, adding a class to body for the rule .on * { visibility: visible }, which changed nothing because everything was already visible, wrote one event and recomputed 1505 styles; the same toggle for a rule that matched only body recomputed one. Subtrees Blink never styles are left out: the children of a display: none element and of a content-visibility: hidden element were not recalculated.

extraData is written on every event and is an empty string unless there is something to add. For Attribute it is the attribute's name. For PseudoClass from a user action it is the pseudo-class, such as :hover, :active, :focus, :focus-visible or :focus-within. PseudoClass events from lang and dir=auto changes have it empty, and most pseudo-class changes never use PseudoClass at all: :checked, :disabled on a checkbox, or :hover on an ancestor in .menu:hover .item, reaches the affected element through an invalidation set and arrives as Related style rule.

stackTrace is present only if V8 was running JavaScript at the moment of the request, so it marks the requests that script made synchronously: style and attribute writes, class toggles that invalidate the element itself or its whole subtree, appendChild, setting lang. Parser insertions, real mouse and keyboard input and animation ticks carry none. Requests Blink defers to the style update are written inside that UpdateLayoutTree, not before it, and carry a stack only when script forced the update, even when script caused them: the same .on .x class toggle wrote stackless Related style rule events for its descendants when they were resolved in the next frame, and events whose top frame was the offsetHeight read when that read forced the update in the same task. A <style> element appended by script behaves the same way, because Blink applies new rules during the style update: its Style rule change events had no stack, unless a read forced the update.

Derivations

Which selector turned a small change into a large recalculation. A subtree: true event with reason Related style rule is immediately preceded by a StyleInvalidatorInvalidationTracking on the same node whose selectors names the rule, such as .on *. The elementCount of the UpdateLayoutTree that follows on that frame is the most that rule can have cost; it is exactly its cost only when nothing else was marked in between. No other event ties a rule to a subtree recalculation.

Which elements were dirtied for a recalculation, and why. UpdateLayoutTree says how many styles were computed; these events name the elements that were marked, with nodeName carrying tag, id and classes, and reason and extraData saying what marked them. Collect the ones since the previous recalculation and the ones inside the slice's own time range, where the deferred marks are. Grouping them by reason separates a stylesheet arriving, script writes, input and animations inside one recalculation.

Traps

The number of events says nothing about the cost. One subtree: true event recomputed 1505 styles. One subtree: false event for font-size on body recomputed 1503, because the change was inherited by every element under it. Setting lang on <html> wrote an event for every element in the document, 2008 of them, for a recalculation of 1507, and it wrote events for the children of a content-visibility: hidden element that were then not recalculated at all. Five writes to one element's style wrote five events for one recalculated style. For cost, read elementCount and the duration on UpdateLayoutTree.

subtree: false is not one kind of request. It covers a full recalculation of the node and the cheaper inline-style-only path Blink takes when a style write touched only independent properties, and the event cannot tell them apart. Neither does it tell you what the node was already marked with.

Deferred marks are inside the recalculation, not before it. Descendants reached through an invalidation set and elements affected by a new stylesheet are marked by the style update itself, so their events fall within the UpdateLayoutTree slice. A tool that only looks at events before the slice misses them.

stackTrace does not need the stack category. Unlike ScheduleStyleRecalculation and InvalidateLayout, this event captures its stack without disabled-by-default-devtools.timeline.stack: a recording without it kept the stacks. There is no sampleTraceId. A missing stack means no JavaScript was running at the moment of the request, which for deferred requests is not the same as script not being responsible: a class toggle's descendants are marked in the next frame, with no script on the stack, unless something forced the update.

Stack depth measures your recorder, not the page. V8 captures one frame unless a client has the Runtime domain enabled, and then up to 200. The same three-deep call chain came out as four frames under Puppeteer and as one frame in a --trace-startup recording. The frames run top first, lineNumber and columnNumber count from one, and scriptId is a string.

Only Related style rule events come in a pair. DevTools' SelectorStatsHandler says a subtree invalidation arrives as a StyleInvalidatorInvalidationTracking carrying the selectors followed by this event carrying the subtree flag, and that is exactly true when the reason is Related style rule. A self-invalidation, such as a class toggle matched by .me, has a partner too, but that partner names no selector. lang changes, custom property registration, color-scheme changes, and :hover or :focus on an element with ::first-letter all write subtree: true with no partner and no selector to blame. The handler itself only merges the Related style rule case.

A whole-document Inspector request is DevTools. Forcing a pseudo-class state in the Elements panel or starting CSS coverage writes one subtree: true event with reason Inspector on <html> and recalculates every element. It is the tool, not the page.

reason is not the short list DevTools declares. trace_engine types the field as Animation or Related style rule; Chrome writes any of 52 strings.

Absence proves nothing about unstyled nodes. A request on a node whose parent has no computed style is dropped before the event is written: inside display: none, during the initial parse, or for the descendants of a node that was just inserted.

Recording this category slows style down. With it on, Blink also writes a StyleResolver::ResolveStyle event for every style it computes, so the extra cost grows with the size of the recalculation. On one test page with 1500 elements, over seven recordings each, the subtree recalculation above took a median 3.3 ms with invalidation tracking and 1.3 ms without, and the font-size recalculation of the same 1505 styles 13 ms against 1.9 ms. DevTools records the category when Invalidation tracking is on in Settings or Enable CSS selector stats is ticked in the capture settings, so compare durations against a recording without either.

DevTools attaches these to the recalculation, and keeps 20. InvalidationsHandler lists invalidation events under each UpdateLayoutTree on the same frame as "Invalidations (N total)", keeping the last 20 while N keeps counting. From its source, the pending list is cleared only after a paint, so two recalculations in the same frame both list the invalidations that came before the first.

nodeId is a backend node id, and every renderer writes these. Chrome's own pages do too: a recording of a local test page contained events from the omnibox popup's renderer, with stacks from Chrome's own scripts. Filter on args.data.frame before you resolve anything.

Example event a local fixture page
{
  "name": "StyleRecalcInvalidationTracking",
  "cat": "disabled-by-default-devtools.timeline.invalidationTracking",
  "ph": "I",
  "ts": 1102950866,
  "tts": 45978,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "extraData": "",
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "nodeId": 15,
      "nodeName": "#text",
      "reason": "Node was inserted into tree",
      "stackTrace": [
        {
          "columnNumber": 81,
          "functionName": "note",
          "lineNumber": 24,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        },
        {
          "columnNumber": 5,
          "functionName": "",
          "lineNumber": 47,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "subtree": false
    }
  }
}

See also StyleInvalidatorInvalidationTracking · ScheduleStyleInvalidationTracking · UpdateLayoutTree · ScheduleStyleRecalculation · SelectorStats · StyleResolver::ResolveStyle · LayoutInvalidationTracking

categories
disabled-by-default-devtools.timeline.invalidationTracking
usage
very high
undeclared args
args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
v8-debugger.cc, style_change_reason.h, style_change_reason.cc, node.cc, element.cc, node.h and 3 more

JavaScript and V8

Execution, compilation, timers, scheduling and garbage collection.

AbortPostTaskCallbackCancel postTask

Chrome cancels a callback the page queued with scheduler.postTask(), because the abort signal passed with it fired.

Spec

Blink's DOMTask::OnAbort cancels a scheduler.postTask() task whose AbortSignal has aborted and rejects the task's promise with the signal's reason, writing a complete slice around the rejection on the thread whose code aborted the signal.

Arguments

pathdeclaredobserved
args.datayesdeclared, never observed
args.data.frameoptdeclared, never observed
args.data.stackTraceoptdeclared, never observed
args.data.taskIdyesdeclared, never observed

Behaviour

scheduler.postTask() accepts a signal option. When that signal aborts while the task is still waiting, Blink cancels the queued task, then opens this slice and rejects the promise postTask() returned with the signal's reason. It is written synchronously at the moment of the abort, inside whatever code caused it: the FunctionCall or EventDispatch of the script that called abort(), a RunMicrotasks slice when abort() ran after an await, or a bare timer task when an AbortSignal.timeout() runs out. It is not written at the time the task would have run. Any abort counts: AbortController.abort(), TaskController.abort(), an AbortSignal.timeout() running out, or an abort reaching an AbortSignal.any() signal from one of its sources. Several pending tasks on one signal write one event each, in the order they were posted, before that signal's own abort listeners run. Tasks on an AbortSignal.any() signal come after the source signal's tasks and after the source's abort listeners, whatever order they were posted in.

The slice is short, microseconds rather than milliseconds, because it covers only writing the payload and rejecting the promise. The page's .catch() handler for that rejection is not inside it; it runs later as a microtask.

A lot of cancellation writes nothing. A task that has already run to completion is out of the signal's reach, so a late abort() is silent. A callback that returned a promise which has not settled yet is out of reach too, and the promise postTask() returned follows the callback's promise instead. A signal that was already aborted when postTask() was called makes postTask() return a rejected promise straight away, with no SchedulePostTaskCallback and no task at all. A second abort() on the same signal does nothing. TaskController.setPriority() only moves the task to a different priority, it never aborts. A task posted without a signal has no way to be aborted. Removing an iframe does not abort anything either: its queued tasks are dropped with no event and never run, and an abort() called after the removal writes nothing.

Navigating away never writes this event by itself, and what happens to the queued tasks depends on the back/forward cache. If the old document is destroyed, its tasks are dropped like a removed iframe's. If the page is kept in the back/forward cache, its tasks are frozen with it and run when the user comes back. The only abort events during a navigation are the ones the page asks for, such as a pagehide handler calling abort(); that handler is still running in a live document, so the event is written with the handler on its stack.

The one time the task has started is a self-abort. A callback that aborts its own signal while it is running writes this event nested inside its own RunPostTaskCallback, and the callback carries on to the end. Its promise is still rejected with the abort reason.

taskId is the number that the task's SchedulePostTaskCallback carries too. The counter behind it belongs to the renderer process, starts at 0, keeps counting across navigations in that process, and also numbers scheduler.yield() continuations, so ids restart in every process in a trace and a process's postTask ids have gaps.

frame is the frame whose scheduler held the task, and is missing for tasks in a worker. With disabled-by-default-devtools.timeline.stack recorded, which DevTools does by default, every event gets a sampleTraceId, and it gets a stackTrace when JavaScript called abort(). That stack is the code that did the cancelling. The code that posted the task is on the matching SchedulePostTaskCallback. There is no priority or delay on this event; read those from the schedule event as well.

Traps

The stack is the abort caller, not the postTask() caller. To find which code queued the work, join to SchedulePostTaskCallback and read its stack.

Join on pid and taskId together. Each renderer process counts from 0, so a trace with a cross-site iframe or a second tab can hold two unrelated tasks with the same taskId. DevTools' own initiator matching keys on taskId alone, and when ids collide it links an abort in one process to the schedule event of the other.

It is a complete slice, not an instant. Its duration is the bookkeeping of cancelling, not time the page saved or spent on the task.

The absence of this event proves nothing about cleanup. A task the page forgot to cancel runs and writes RunPostTaskCallback. A task belonging to a removed iframe or a destroyed document is dropped without writing this or anything else, and a page held in the back/forward cache keeps its tasks and runs them on return. A SchedulePostTaskCallback with neither a Run nor an Abort by the end of the recording was still queued, frozen with a cached page, or belonged to a document that went away. What the event does prove is that the page's own code cancelled the task, and its stack names that code.

A self-abort puts this event inside the task's own RunPostTaskCallback, so "cancelled" does not always mean "never ran".

AbortSignal.timeout() aborts from a timer with no JavaScript on the stack, so those events have a sampleTraceId but no stackTrace. Without the stack category, no event has either.

Code built on trace_engine: isAbortPostTaskCallback narrows to the RunPostTaskCallback type, so TypeScript will offer priority and delay, which this event never has. The declared type also gives stackTrace as a single call frame where the trace holds an array of them, and leaves out sampleTraceId.

Example event a local fixture page
{
  "name": "AbortPostTaskCallback",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103043890,
  "dur": 22,
  "tdur": 21,
  "tts": 133996,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "sampleTraceId": 6909218608593085,
      "stackTrace": [
        {
          "columnNumber": 27,
          "functionName": "",
          "lineNumber": 55,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "taskId": 1
    }
  }
}

See also SchedulePostTaskCallback · RunPostTaskCallback · ScheduleYieldContinuation · FunctionCall

categories
devtools.timeline
references
source_location.cc, v8-debugger.cc, dom_scheduler.h, dom_task.cc, script_loader.cc, heap.cc
BlinkGC.AtomicPhaseno longer emitted

Blink's own garbage collector held one thread while it finished collecting the C++ objects behind the DOM.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Spec

Oilpan, Blink's original garbage collector for its C++ objects, timed the atomic pause of a collection it ran on its own, from ThreadState::RunAtomicPause in Blink's thread_state.cc on blink_gc,devtools.timeline, covering the end of marking, weak processing, pre-finalizers, compaction and any eager sweep on that thread; Chrome 93 is the last release that writes it.

Behaviour

Blink keeps the C++ objects behind the DOM, nodes included, on a garbage-collected heap of its own. Until Chrome 94 that heap was managed by Blink's own collector, Oilpan, with one heap per thread. Every collection ends in the atomic pause: the thread is held, no script runs, and the collector completes marking, clears weak references, runs pre-finalizers (cleanup hooks that must run while dead objects are still readable) and, when it decides to, compacts. How much marking is left for the pause depends on the collection: an incremental one has done most of it while the page ran, an atomic one does all of it here. This event wrapped that pause, in ThreadState::RunAtomicPause, as one complete slice. From Chrome 77 each step is its own BlinkGC.AtomicPause* child; a Chrome 76 build nests BlinkGC.AtomicPhaseMarking, BlinkGC.AtomicPhaseCompaction, BlinkGC.EagerSweep and similar instead. When the collection asked for an eager sweep, freeing every dead object happened inside the slice too; otherwise most of the sweeping ran afterwards, concurrently and lazily.

Which collections wrote it changed in Chrome 77. From Chrome 74 V8 collects Blink's heap together with the JavaScript heap (unified heap), but Oilpan also still scheduled collections of its own, and a Chrome 76 build wrote the event on the main thread for those, inside ordinary tasks, and for the collections that follow a gc() call. In Chrome 77 two changes landed together: Oilpan stopped scheduling collections of its own "unless absolutely needed (e.g. worker teardown)", and the event was reduced to the path such stand-alone collections take, with a source comment calling it a legacy scope for adding them to the DevTools timeline. Chrome 77 and 78 kept one more main-thread path: a detailed memory-infra dump ran a snapshot collection, and a Chrome 78 build wrote the event on each renderer's main thread once per detailed dump, inside MemoryDumpManager::InvokeOnMemoryDump. Chrome 79 removed that path and the last of Oilpan's own scheduling, which leaves the termination GC that runs when a worker thread shuts down. It removes the heap's roots, marks and sweeps everything in one pause, and repeats while finalizers leave new roots behind, so a teardown normally writes one. A capture from a Chromium build of Chrome 93's heap code wrote one on each dedicated worker's thread as it ended, whether by terminate() or self.close(), with the sweep taking most of it, and none on the main thread, not for DOM churn, not for a gc() call and not for detailed memory dumps. A normal page's collections were unified ones, and Blink's pause steps appeared inside V8's MajorGC as BlinkGC.AtomicPauseMarkPrologue, BlinkGC.AtomicPauseSweepAndCompact and their siblings.

The name dates from May 2018 (commit 50ff9ebe68, Chrome 69), on blink_gc alone. Commit 8bf89c2fc1 added devtools.timeline in Chrome 70, and the same change pointed DevTools' "DOM GC" row at it instead of the older BlinkGCMarking. Chrome 71 added the forced argument (fc3eaf7c86). From Chrome 72, and in practice from Chrome 74 when unified collections were switched on, unified collections also wrote the name, in several short pieces per MajorGC, on disabled-by-default-blink_gc and without arguments; commit 99c6228f7e removed those in Chrome 77, leaving the one call in RunAtomicPause that survived to the end.

Chrome no longer writes it. Commit 5e0ecc1921 set the build flag enable_blink_heap_use_v8_oilpan to true before Chrome 94 branched, after three reverted attempts, so from Chrome 94 Blink's heap is V8's cppgc, a C++ collector that lives in V8, and Blink's own heap code is no longer compiled. The flag is false in 93.0.4577.82 and true in 94.0.4606.61. Commit 0e8ab8bb7a deleted that code, and Chrome 96 is the first release whose source does not contain the string.

Nothing took over the name. On the main thread the same pause now sits inside MajorGC as several CppGC.AtomicMark, CppGC.AtomicWeak, CppGC.AtomicCompact and CppGC.AtomicSweep slices on the cppgc category, next to V8's V8.GC_MC_MARK_EMBEDDER_* slices. They do not overlap, and each carries the collection's epoch, a counter shared by every heap in the process, so the pieces of one pause are the ones on one thread with one epoch. At worker teardown cppgc clears the roots and sweeps without marking, which writes CppGC.AtomicSweep on the worker thread. Current DevTools still maps the old name: load a Chrome 70 to 93 trace and each event shows as a "DOM GC" slice in the scripting colour on the track of the thread that ran it. You will only meet the event in a trace recorded by Chrome 93 or earlier.

Traps

A main thread without it did not skip Blink's collector. From Chrome 77 the normal collection was a unified one, and its Blink pause is inside MajorGC: as BlinkGC.AtomicPause* children up to Chrome 93, as CppGC.Atomic* pieces from Chrome 94. Counting BlinkGC.AtomicPhase in a trace from Chrome 79 to 93 measures worker shutdowns, not DOM garbage collection. A DevTools recording from Chrome 77 to 83 has no Blink breakdown at all: the BlinkGC.AtomicPause* children were written on blink_gc alone, which DevTools does not record, until Chrome 84 added devtools.timeline to them.

From Chrome 79, look for it on worker threads. It ran on the thread being torn down, not on the page's main thread, so a long one did not block input or rendering. Current DevTools draws it as "DOM GC" on that worker's track. In Chrome 77 and 78 a trace that recorded detailed memory-infra dumps also has one on each renderer's main thread per dump: that is the dump's own snapshot collection, so the recording caused it.

forced changed meaning in Chrome 77. From Chrome 71 it marked collections run for testing, including the ones that followed a gc() call, so that benchmarks could leave them out. Chrome 77 added thread-termination GCs for the same reason, so from then on every one written with default settings carries true, and a gc() call no longer produces the event at all. Before Chrome 77 a worker teardown carried false. trace_engine declares no arguments for the event, so forced only shows in the raw JSON.

The duration can include the sweep. A termination GC marks and sweeps inside the slice, and with its roots already removed the sweep is most of it. A collection with lazy sweeping closed the slice with most of the sweeping still to do. Compare BlinkGC.AtomicPauseSweepAndCompact inside it before comparing two of them.

Before Chrome 77 the same name meant more than one thing. In Chrome 69 it was on blink_gc only, and DevTools' "DOM GC" row still pointed at an older name. From Chrome 72 to 76 unified collections also wrote it inside MajorGC, in several short pieces per collection, on disabled-by-default-blink_gc and with no forced argument. A trace that recorded that category holds two kinds of BlinkGC.AtomicPhase under one name, and DevTools labels both "DOM GC" because it matches on the name alone. Filter on the category.

There is no drop-in successor. CppGC.IncrementalSweep, the only cppgc event DevTools labels (as "C++ GC"), is the lazy sweeping after a pause, not the pause. The atomic pieces are recorded in a DevTools trace, because cppgc is one of its categories, but DevTools does not list them as visible events. Lighthouse's Garbage Collection group names BlinkGC.AtomicPhase and no CppGC. event; the CppGC. pieces still land in that group because they sit inside MajorGC. Lighthouse groups main-thread tasks only, so it never saw the worker-thread events of Chrome 77 to 93. To compare an old trace with a new one, add up the CppGC.Atomic* slices that share a thread and an epoch inside each MajorGC.

DevTools has two names for it. DOMGC and the misspelled GC_COLLECT_GARBARGE in TraceEvents.ts both hold the string BlinkGC.AtomicPhase. The "DOM GC" style is attached through the second, but the style map is keyed by the string, so it applies to every event with that name.

Example event

Chrome stopped writing this in Chrome 85, when it left Blink's own heap code. Blink's garbage collector now runs on V8's cppgc, which writes CppGC.* events instead.

See also MajorGC · CppGC.IncrementalSweep · MinorGC

references
thread_state.cc
CancelAnimationFrameCancel animation frame

The page cancelled a callback it had queued with requestAnimationFrame().

Spec

Blink's FrameRequestCallbackCollection, the per-document (or per-worker) list of animation frame callbacks, found the id being cancelled among the callbacks it still holds and cancelled it, recorded as an instant event on that document's main thread or that worker's thread.

Arguments

pathdeclaredobserved
args.data.frame-100.0%undeclared, always present
args.data.id-100.0%undeclared, always present
args.data.sampleTraceId-100.0%undeclared, always present
args.data.stackTrace[].columnNumber-100.0%undeclared, always present
args.data.stackTrace[].functionName-100.0%undeclared, always present
args.data.stackTrace[].lineNumber-100.0%undeclared, always present
args.data.stackTrace[].scriptId-100.0%undeclared, always present
args.data.stackTrace[].url-100.0%undeclared, always present

Observed 908 times on 42 of 300 sites.

Behaviour

Every document keeps its own list of requestAnimationFrame() callbacks, and so does every dedicated worker. When you call cancelAnimationFrame(id), Chrome looks for the id in two places: the callbacks queued for the next frame, and, while a frame's callbacks are running, the snapshot of callbacks that frame is working through. It writes this event only when it finds a match.

So most calls that do nothing leave nothing behind. An id that was never issued, 0, an id whose callback already ran in an earlier frame, and a second cancel of the same id all write no event. This is a record of callbacks Chrome found and cancelled, not of calls to the function.

The snapshot is kept until every callback in the frame has been visited, which makes it hold callbacks that have already run. A callback that cancels its own id while running writes the event, and so does a callback that cancels one that ran a moment earlier in the same frame. Nothing is prevented in either case. Cancelling a callback due later in the same frame does stop it: that one gets no FireAnimationFrame.

The event belongs to the document whose cancelAnimationFrame was called, not to the script that called it. A parent page calling iframe.contentWindow.cancelAnimationFrame(id) writes an event carrying the iframe's frame, with the parent's script in stackTrace. In a dedicated worker the event is written on the worker's thread, and frame is replaced by worker.

Chrome also cancels some of its own animation frame callbacks, and those write the same event. A <marquee> asks for one frame callback when it is inserted or started, to set up its scrolling animation. Calling stop() on it, or removing it from the page, before that frame arrives cancels the callback, and stackTrace then shows whichever script did that, though it never called cancelAnimationFrame(). Once that frame has run, stop() pauses the animation instead and writes nothing.

stackTrace and sampleTraceId are written only when disabled-by-default-devtools.timeline.stack is recorded, which a DevTools Performance recording always does. With that category on, every cancel made from JavaScript carries a stack.

Derivations

stackTrace identifies the code that cancelled, and the RequestAnimationFrame with the same key identifies the code that scheduled. When they are different scripts, one piece of code is cancelling another's callback.

To account for every RequestAnimationFrame in a trace, join on frame (or worker) plus id, one document at a time. A registration with a FireAnimationFrame ran, whether or not a cancel also exists. One with a cancel and no fire was dropped. One with neither was still pending when the trace ended or when its document went away: removing an iframe with a callback pending writes neither event. From Chrome 152 a key can belong to one of your callbacks or to one of Chrome's (see traps); both counters only go up, so within one document an id that breaks your page's running sequence is Chrome's.

Traps

id is only unique within one document. Every document and every dedicated worker counts from 1, so an iframe and its parent both have an id 1, and frame (or worker) must be part of any join. frame also stays the same across navigations of that frame, even a cross-site one that moves the page to a new renderer process, so frame plus id repeats within a single trace. Split the trace at each navigation before you join.

From Chrome 152, Chrome's own animation frame callbacks (<marquee>, a paint-timing measurement after a back/forward cache restore, and a few others that only ever request) count from a second counter kept alongside yours. They write the same RequestAnimationFrame, CancelAnimationFrame and FireAnimationFrame events, and no field says which counter an id came from. A marquee's id 2 and your id 2 on the same frame look identical.

Do not reconcile by counting. Requests minus fires minus cancels is wrong, because a cancel can come after its callback already fired in the same frame, or from inside it. Join event to event, as described above.

The cancel event is not proof that nothing ran. Look for a FireAnimationFrame with the same key before it, or around it.

A trace recorded without disabled-by-default-devtools.timeline.stack, which is easy to do with a custom trace config, has no stackTrace and no sampleTraceId. A cancel that Chrome makes with no script running has sampleTraceId but no stackTrace even with the category on. In source the only such cancel is the back/forward cache one, which writes only when a page comes back from the cache a second time before it has drawn a frame since the first return (not captured).

How much of the stack you get depends on what recorded the trace, not on the page. When a client with the Runtime domain enabled is attached, as DevTools and Puppeteer are, stackTrace holds the whole JavaScript stack. Without one, for example a trace started with --trace-startup, it holds only the top frame: the function that called cancelAnimationFrame(), which is often a library's wrapper rather than the code that decided to cancel.

In DevTools the event is labelled "Cancel animation frame" and drawn as a zero-duration entry in the Scripting colour. The details pane shows its stack under "Function stack", but not the callback id, and no initiator arrow links it to the RequestAnimationFrame it cancelled. trace_engine has only a name constant for it, no typed interface, so args.data.id is read from the raw event.

Example event from https://www.bbc.com/news
{
  "name": "CancelAnimationFrame",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1109193538,
  "tts": 107873,
  "pid": 8475,
  "tid": 8475,
  "s": "t",
  "args": {
    "data": {
      "frame": "9AE6572E1DCD19954F9F2627AE88998A",
      "id": 1,
      "sampleTraceId": 6899808067347206,
      "stackTrace": [
        {
          "columnNumber": 136403,
          "functionName": "n",
          "lineNumber": 1,
          "scriptId": "7",
          "url": "https://cdn.privacy-mgmt.com/Notice.97af9.js"
        }
      ]
    }
  }
}

See also RequestAnimationFrame · FireAnimationFrame · CancelIdleCallback · v8::Debugger::AsyncTaskScheduled

categories
devtools.timeline
usage
moderate
undeclared args
args.data.frame, args.data.id, args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-debugger.cc, frame_request_callback_collection.cc, inspector_trace_events.cc, script_loader.cc, heap.cc
ConsoleTimeConsole timenever written by Chrome

DevTools' name for a console.time() timer, which Chrome itself records under the label you gave it.

No Chrome build writes this name into a trace file. It exists in the model, not on the wire.

Spec

ConsoleTime is a type in DevTools' trace model, not an event on the wire: TraceFilter assigns it to every event on the blink.console category, where Blink's ThreadDebuggerCommonImpl records each console.time() and console.timeEnd() as a nestable async begin and end pair named after the timer's label, on the thread that made the call.

Behaviour

Look for your label, not for ConsoleTime. When a page calls console.time('checkout'), V8 handles the call first and then asks Blink to write a b event named checkout on the blink.console category; console.timeEnd('checkout') writes the matching e event with the same name and id. Neither half carries arguments. The call can come from the main thread or a worker, and the pair is written on whichever thread made it. A console.time() with no argument is labelled default. None of this depends on DevTools being open: a trace recorded from startup, with no DevTools client attached, contains the same pairs.

V8 decides whether Blink writes anything at all, and it keeps timers per JavaScript context, so a page, each of its iframes and each worker have their own. A second console.time() for a label that is still running in that context is rejected with a console warning, as is a console.timeEnd() for a label that is not running, and neither reaches blink.console. console.timeLog() never does. Separately, V8 wraps every one of these calls, accepted or rejected, in a complete event on disabled-by-default-v8.inspector: V8Console::Time, V8Console::TimeEnd, V8Console::TimeLog. Those measure the cost of the call itself, not your timer.

console.timeStamp() is not part of this. It writes a TimeStamp event on devtools.timeline.

A DevTools Performance recording asks for blink.console and disabled-by-default-v8.inspector by default, so both the pairs and the call events are in any trace saved from the panel. The panel pairs each begin with its end and draws the timer on the Timings track, under its label, alongside performance.measure() and console.timeStamp() entries.

The name ConsoleTime is older than the trace event. Before DevTools read Chrome's trace buffer, Blink's InspectorTimelineAgent sent DevTools a record of type ConsoleTime over its own Timeline protocol; it was never a trace event. Blink deleted that code in February 2015 (r189656, "DevTools: remove deprecated Timeline implementation"), so Chrome 41 was the last version to send it. By then the trace event beside it was already named after the label. DevTools kept its matching constant, and its trace model still files blink.console trace events under it.

Derivations

The pair puts your own timer on the same clock as everything the browser did. Where the console prints one number, the trace shows which tasks, scripts, layouts and garbage collections ran between the b and the e, and on which thread.

Traps

Nothing in the trace is called ConsoleTime. Filter on the blink.console category and read the label from name. DevTools uses the type internally, to keep label-named events from being hidden by the allow-list its tree views apply; its "Console time" label is never shown for a real event.

blink.console is not only your timers. Chrome also writes ConsoleMessage::Error there, an instant event for error messages Blink logs itself, such as a failed resource load. Calling console.error() does not produce one, and neither does an uncaught exception. TraceFilter files these under ConsoleTime too.

The id is not a timer id, and what it means depends on the Chrome version. From Chrome 145, id2.local is a lane number chosen when the trace is converted to JSON: unrelated labels share it once a lane is free, and worker timers share lanes with the main thread's. From Chrome 121 to 144 it was a per-label hash, with a scope of blink.console:console.time. In stable releases from Chrome 52 to 120, every timer on a thread shared one id. Match a begin to its end by id and name, as DevTools does, and never by id alone.

Even id and name together cannot tell apart two timers with the same label running at once in two contexts on the same thread, such as a page and a same-origin iframe. V8 accepts both, because each context has its own timers, but the trace gives all four events the same name and id, so the first console.timeEnd() closes whichever timer started last. The durations in the trace then disagree with the ones printed in the console.

A timer that is started and never ended leaves a lone b event. DevTools drops it, so it does not appear on the Timings track at all.

A rejected call leaves a trace of itself only on disabled-by-default-v8.inspector. A V8Console::TimeEnd with no e event beside it is a console.timeEnd() for a timer that did not exist, for example a mistyped label, or a timer started in another frame or worker.

Chrome 83 and earlier wrote timers with the older S and F phases. Current DevTools pairs only b and e, so it draws no timers from a trace that old.

The blink category does not include blink.console, and neither does this manual's corpus crawl, which is why the arguments table has nothing to show. DevTools and Puppeteer record it by default, but a trace configuration that lists its categories has to name blink.console itself.

Example event

Chrome never writes this name, which DevTools gives to any event on the blink.console category. A fixture page calling console.time and console.timeEnd produced V8Console::Time, V8Console::TimeEnd and one event named after the label.

See also TimeStamp · UserTiming · UserTiming::Measure

references
TraceFilter.ts
CppGC.IncrementalSweepC++ GC

One step in which Chrome's C++ garbage collector frees the objects its last collection found dead.

Spec

CppGC.IncrementalSweep is one step of the sweeping phase of cppgc, the garbage collector in V8 that manages Blink's C++ objects, run on the thread that owns the heap (the renderer main thread, or a worker's own thread) after a major collection, and written by cppgc's StatsCollector on the cppgc category from four call sites in V8's sweeper.cc that make three kinds of step: a posted sweep task with a 5 ms budget, a sweep forced by an allocation, and the call that finishes all remaining sweeping.

Behaviour

Blink keeps the C++ objects behind the DOM, the layout tree and most Web APIs, Event, Headers, URLSearchParams and DOMMatrix among them, on a garbage-collected heap managed by cppgc, the C++ garbage collector that lives in V8 and that Blink uses in place of its own former collector (see BlinkGC.AtomicPhase). A major garbage collection marks the JavaScript heap and this C++ heap together. At the end of its pause, inside MajorGC, CppGC.AtomicSweep starts the sweep: the phase that walks the heap's pages, runs the destructors of every object marking did not reach, and hands the memory back. Most of that work happens after the pause. A background job, CppGC.ConcurrentSweep on a thread-pool thread, sweeps pages. Destructors and the return of pages have to run on the thread that owns the heap, so that thread does its share in steps, and each step is one CppGC.IncrementalSweep, carrying the epoch of the collection it is cleaning up after.

A step starts in one of three ways. Usually it is a task of its own: when sweeping starts, cppgc posts a sweep task to a low-priority queue (on the main thread, Blink's V8 user-visible queue; low priority, not idle time) and gives each run 5 ms. Its child is CppGC.SweepInLowPriorityTask, with the budget in max_duration_ms and the kind of work in sweeping_mode. A second, normal-priority task takes over, as CppGC.SweepInTask, only if the low-priority one has not run for 100 ms. While the background sweeper is still busy, a task step runs in only-finalizers mode: it runs destructors and returns pages that were already swept, often in a few microseconds, and then posts the next task straight away. Once the background job is done the mode is all and the step also sweeps what is left.

The second way is lazy sweeping: when Blink allocates an object while sweeping is still under way and finds no free block of the right size, the allocator sweeps for up to 500 microseconds, then, if it cannot grow the heap, for as long as it takes. That step, child CppGC.SweepOnAllocation, runs inside whatever was allocating: your script, Layout creating layout objects, UpdateLayoutTree, the HTML parser.

The third is a finishing step, child CppGC.SweepFinish, which does all remaining work with no time limit when sweeping has to be complete. The usual trigger is the next collection: before it starts marking, V8 finishes the old sweep inside V8.GC_HEAP_ENSURE_SWEEPING_COMPLETED, under V8.GCIncrementalMarkingStart, and that happens inside whatever allocation pushed the heap over its limit, often Layout or script. The allocator also finishes the sweep as a last resort when it cannot find memory any other way, and so does tearing a heap down.

The step runs on the thread that owns the heap. Every Blink thread has its own, so a dedicated worker that creates and drops Blink objects writes its steps on the worker's thread, and the page's main thread writes none for them.

Some collections write none. A forced collection, from gc() (with --js-flags=--expose-gc), from the Performance panel's Collect garbage button, or from a low-memory notification, sweeps atomically inside MajorGC, as CppGC.SweepFinish with forced set, and writes no CppGC.IncrementalSweep. With default flags, young-generation collections (MinorGC) leave the C++ heap alone, so a page that never triggers a major collection has no sweep steps however much it allocates. The name gets a .Minor suffix for a young-generation cppgc collection, which V8 only runs behind the experimental --cppgc-young-generation flag.

The number of events follows how often the low-priority queue gets a turn while the background sweeper is busy, not how much garbage there was. Three recordings of the same page on one machine wrote between roughly three hundred and a thousand steps, and the time spent in them stayed within a few percent. What the time tracks is dead C++ objects, of any kind: in captures on the same machine, a page that only created and dropped Event, Headers, URLSearchParams and DOMMatrix objects spent about as long sweeping as one that built, laid out and removed thousands of DOM nodes over the same eight seconds, and a page whose only garbage was plain JavaScript still wrote a short step after each major collection.

cppgc is enabled in every DevTools Performance recording, with no setting involved, and the panel draws each step as "C++ GC" in the scripting colour on the track of the thread that ran it. It is the only CppGC.* event the panel draws; the others are recorded but hidden unless the panel's "Show all events" setting is on.

Derivations

The main-thread cost of sweeping up after one collection. Add up the CppGC.IncrementalSweep durations on one thread that share an epoch; the CppGC.AtomicMark, CppGC.AtomicWeak and CppGC.AtomicSweep pieces inside the MajorGC that started the sweep carry the same epoch, so the total can be charged to that collection. It is the same number cppgc reports to Chrome's own metrics as the collection's incremental sweep time, and it is garbage-collection time the MajorGC slice does not include.

Sweeping charged to your own work. A step whose ancestor is Layout, UpdateLayoutTree, ParseHTML or a FunctionCall, rather than a task of its own, ran synchronously inside that work: a lazy step, or a finishing step under V8.GC_HEAP_ENSURE_SWEEPING_COMPLETED. Their summed duration is how much of that work's time was the C++ collector cleaning up after an earlier collection, which the parent's name does not tell you.

Traps

Do not count the events. While the background sweeper runs, each low-priority step posts the next as soon as it finishes, so how many there are depends on how often that queue gets a turn. Recordings of the same page on the same machine differed about threefold in count while the summed duration moved a few percent. Sum the durations per epoch instead.

It is not a DOM signal. Any Blink object with a C++ side ends up here when it dies: DOM nodes and their layout objects, but also events, headers, matrices, observers. In captures, a page that dropped only non-DOM Blink objects spent about as long sweeping as one that built, laid out and removed thousands of DOM nodes, and a page with no Blink allocation at all still wrote a step after each major collection. Seeing the event tells you a major collection ran; its summed length tells you how much the C++ side had to clean up, not what kind of object it was.

Garbage collection is not only MajorGC. The sweep steps are normally outside it, so a trace's GC cost is the MajorGC slices plus these. Most are low-priority tasks with a 5 ms budget. Low priority is not idle time: while one runs, input waits for it like it waits for any other task.

Some steps run inside your layout and script. A lazy step runs inside the allocation that needed memory, and a finishing step runs inside V8.GCIncrementalMarkingStart wherever the next collection was kicked off, so both show up as children of Layout, UpdateLayoutTree, ParseHTML or a FunctionCall and count toward that work's time. Neither is bounded like a task step: the lazy step's 500-microsecond budget is only its first try, and a finishing step has no limit. In one capture a finishing step ran for 14.9 ms inside Layout.

Length does not tell you the kind. The deadline is checked every fourth page, so task steps regularly run a little past 5 ms, and a lazy step can run for milliseconds. A slice over 5 ms is not proof of a finishing step: check for a CppGC.SweepFinish child or a V8.GC_HEAP_ENSURE_SWEEPING_COMPLETED parent.

The finishing step belongs to the previous collection. It sits at the start of the next collection's marking but carries the epoch of the sweep it finishes. Group by epoch, not by the nearest MajorGC.

In a DevTools recording the kind is half hidden. The panel draws no child of this event unless its "Show all events" setting is on, and even then only the children on cppgc, because that setting adds no disabled-by-default category. In the raw JSON, CppGC.SweepInLowPriorityTask, CppGC.SweepInTask and CppGC.SweepFinish are on cppgc and recorded; CppGC.SweepOnAllocation and the children that do the page work (CppGC.SweepFinalizeEmptyPages, CppGC.SweepFinalizeSweptPages, CppGC.SweepPages) are on disabled-by-default-cppgc, which DevTools never asks for. So a step with none of the three named children is a lazy one. A child that took less than a microsecond is written as an instant event (ph: "I") instead of a slice, and a parser that reads only slices will mistake those task steps for lazy ones.

Other tools do not record it. The Lighthouse bundled with current DevTools and puppeteer's default categories leave out cppgc, and so did this manual's crawler, which is why the corpus has no CppGC.* events even though its pages ran major collections. Add cppgc to the category list, and disabled-by-default-cppgc for the page-level children.

The arguments are only in the raw JSON. trace_engine models none. epoch is cppgc's own counter, shared by every C++ heap in the process, so filter by thread as well; it is not the epoch on V8's V8.GC_* events, and it is not consecutive per heap. forced is false on every one, even after the Collect garbage button, because forced collections sweep atomically and never write this event.

Check the worker tracks. A worker's sweep steps are on the worker's thread and cost the main thread nothing.

The source moved. In June 2026 V8 renamed src/heap/cppgc/ to src/heap/cppgc-internal/. Links and older write-ups that point at v8/src/heap/cppgc/sweeper.cc describe the same code.

Example event

Not seen. The fixture page was traced with the cppgc category on, and no C++ garbage collection ran while it was being recorded. The site traces did not enable cppgc at all.

See also MajorGC · BlinkGC.AtomicPhase · V8.GCIncrementalMarkingStart · V8.GC_HEAP_ENSURE_SWEEPING_COMPLETED · Layout · FunctionCall · MinorGC

categories
cppgc
references
stats-collector.h
DoDecryptDecrypt

Chrome decrypts data for a call to Web Crypto's decrypt().

Spec

DoDecrypt is a scoped event on disabled-by-default-devtools.timeline that Chromium's Web Crypto layer (WebCryptoImpl::Decrypt in components/webcrypto) writes around the decryption for one crypto.subtle.decrypt() call, and since Chrome 141 it runs on the thread that made the call: the renderer main thread for a page, the worker's own thread for a worker.

Behaviour

When a page calls crypto.subtle.decrypt(), Blink first normalizes the algorithm, copies the input bytes and checks that the key may be used for decryption. If that passes, it hands the job to Chromium's Web Crypto layer, which writes DoDecrypt around the decryption itself, done by BoringSSL, Chrome's cryptography library. Straight after it, on the same thread and in the same task, it writes DoDecryptReply around the step that copies the plaintext into a new ArrayBuffer and resolves the promise. The two events are siblings, not parent and child. The promise is already resolved by the time decrypt() returns, and the code after your await or in your .then() runs at the next microtask checkpoint, the point where the current script has finished and queued promise reactions run.

That is the behaviour from Chrome 141 on. Chrome's Web Crypto layer used to post almost every operation to a thread of its own, one per renderer process, named WebCrypto: DoDecrypt ran there, and DoDecryptReply came back later as a separate task on the thread that made the call. A 2025 change (crrev.com/c/6767493) moved most operations onto the calling thread, on the grounds that the thread hop cost about as much as the cryptography. It shipped in Chrome 141 behind a feature switch, WebCryptoOnMainThread, that was on by default, so Chrome 141 to 148 still take the old path when started with --disable-features=WebCryptoOnMainThread. Chrome 149 removed the switch (crrev.com/c/7735863). The WebCrypto thread still exists, but only RSA key generation and PBKDF2 key derivation use it, so a trace can show that thread and still have every DoDecrypt on the main thread.

So a decrypt() in a page now runs inside whatever task called it. Called from a click handler, DoDecrypt sits under EventDispatch and FunctionCall, and its time is part of that interaction's processing time. Called from a dedicated worker, it runs on the worker's thread and the main thread never sees it.

You get one DoDecrypt for each call that reaches the crypto layer. A call Blink rejects first, because the key lacks the decrypt usage or belongs to a different algorithm, writes nothing, and its promise rejects with InvalidAccessError. A call that fails during decryption, such as AES-GCM ciphertext that fails its authentication tag, still writes both events and rejects with OperationError. Failing does not make it cheaper: in Chrome 154 a 16 MiB AES-GCM decrypt with a corrupted tag spent about 5 ms in DoDecrypt, no less than a good one, while its DoDecryptReply took well under a tenth of a millisecond because there was no plaintext to copy. unwrapKey() decrypts too, but under DoUnwrapKey, not this event.

How long it takes depends on the algorithm, the key size and the input size, and the event records none of them. On a desktop Ryzen 9 5900X in Chrome 154, AES-GCM with a 256-bit key took about 3 microseconds for 1 KiB, 12 microseconds for 64 KiB and just over half a millisecond for 1 MiB. RSA-OAEP, which decrypts with the private key, took about 0.55 to 0.6 ms per call with a 2048-bit key and about 3.8 ms with a 4096-bit key, the same for 32 bytes as for the 190-byte maximum. One RSA-2048 decrypt cost as much as AES-GCM on a megabyte. These are repeated captures on one machine; other CPUs give other numbers.

A DevTools Performance recording always includes disabled-by-default-devtools.timeline, so these events are in any trace saved from the panel. The panel draws them as Decrypt in the Scripting colour, on the track of the thread that made the call.

Traps

Where you find it depends on the Chrome version. In a trace from Chrome 140 or earlier, or from Chrome 141 to 148 started with WebCryptoOnMainThread disabled, DoDecrypt is on a thread named WebCrypto and DoDecryptReply is a separate task on the calling thread, a little later. Otherwise, from Chrome 141 on, both sit on the calling thread, back to back. Advice written for the old model, that Web Crypto runs off the main thread and so does not block rendering, is wrong for current Chrome. So is the comment in webcrypto_impl.cc that says the Do*() functions run on the crypto thread: the DoDecrypt scope is no longer one of them.

A long DoDecrypt does not tell you whether the page decrypted a lot of data or used an expensive algorithm. Symmetric decryption scales with the bytes; RSA-OAEP costs the same on every call whatever the input, so sending it less data saves nothing, and only fewer calls or a smaller key will. Find the algorithm in the code that made the call, through the enclosing FunctionCall or the JavaScript samples.

DoDecrypt is not the whole cost of decrypt(). Blink copies the input before it, and no trace event marks that copy; the copy of the result into a new ArrayBuffer is in DoDecryptReply. For large AES-GCM payloads both copies are of the same order as the decryption. In Chrome 154 on the machine above, a 16 MiB decrypt with a 256-bit AES-GCM key spent about 4 ms inside DoDecrypt, 3 ms inside DoDecryptReply and 3 to 4 ms in the unmarked stretch just before DoDecrypt; the call as a whole took 10 to 12 ms. In Chrome 141 that unmarked stretch alone was about 40 ms. Add DoDecryptReply, and do not read the unlabelled time just before DoDecrypt as idle: it is inside the same task and blocks the thread like the rest.

A decrypt() call with no DoDecrypt after it was rejected by Blink before it reached the crypto layer. It did not run somewhere else.

Example event a local fixture page
{
  "name": "DoDecrypt",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938330,
  "dur": 6,
  "tdur": 5,
  "tts": 38495,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoDecryptReply · DoEncrypt · FunctionCall · EventDispatch · RunMicrotasks

categories
disabled-by-default-devtools.timeline
references
webcrypto_impl.cc
DoDecryptReplyDecrypt reply

Chrome hands the result of a Web Crypto decrypt() call back to the page and settles its promise.

Spec

Chrome's Web Crypto component (WebCryptoImpl::Decrypt in components/webcrypto/webcrypto_impl.cc) copies the plaintext into a new ArrayBuffer and resolves the promise from crypto.subtle.decrypt() with it, or rejects it with a DOMException, as a complete slice on disabled-by-default-devtools.timeline written straight after DoDecrypt on the thread that made the call.

Behaviour

crypto.subtle.decrypt() does all of its work before it returns. Blink, Chrome's rendering engine, first checks the arguments and copies your ciphertext, then calls into Chrome's Web Crypto component, which writes two slices back to back on the thread that called: DoDecrypt around the decryption itself, done by BoringSSL, Chrome's cryptography library, and DoDecryptReply around handing the result back. Inside the reply, Blink allocates an ArrayBuffer the size of the plaintext, copies the plaintext into it, wraps it as a JavaScript object and resolves the promise. If the decryption failed, for example because an AES-GCM authentication check did not pass after a wrong key, a wrong IV or tampered data, or AES-CBC padding did not check out, it creates an OperationError DOMException and rejects the promise instead. Either way, the promise is normally settled before decrypt() returns to your code.

Settled is not the same as run. Blink resolves the promise with microtasks held back, so neither your .then() callback nor the code after an await runs inside this slice. They run at the next microtask checkpoint, after the script that called decrypt() has returned: in a trace, in the RunMicrotasks slice that follows the calling FunctionCall in the same task. On one thread the order is DoDecrypt, DoDecryptReply, the rest of your synchronous code, then your continuation. Captures in Chrome 154 showed exactly that order on the main thread and on a dedicated worker, where both slices and the continuation ran on the worker's thread.

The exception is a paused page. Blink puts the resolution off to a separate task when the page is paused, which in practice means stopped in the DevTools debugger, with decrypt() called from the console at a breakpoint. The reply slice still runs and still makes the copy, but the promise stays pending for as long as the page is paused and resolves in a task of its own after you resume, with the continuation in that task's microtask checkpoint. The source takes the same detour when script is forbidden at that moment; no capture here reached that case.

You get one per decrypt() call that reached the decryption. A call rejected while its arguments are checked, such as an unknown algorithm name, or a key whose algorithm does not match or whose usages lack "decrypt", is rejected without either slice. So is a call from a context that is already being torn down. unwrapKey() also decrypts, but writes DoUnwrapKey and DoUnwrapKeyReply.

The duration is mostly that copy, so it grows with the size of the plaintext and not with the cost of the cipher. On an AMD Ryzen 9 5900X under Linux, in Chrome 154, AES-GCM with a 256-bit key, ten decrypts per size on each thread, DoDecryptReply took around 10 microseconds for 1 KB of plaintext, about half a millisecond for 1 MB and 11 to 20 ms for 64 MB, on the main thread and in a worker alike; copying an ArrayBuffer of the same size with slice() in the same page took as long. At 64 MB the reply was about 70% of the time DoDecrypt spent on the decryption itself. RSA-OAEP with a 2048-bit key and SHA-256, decrypting 32 bytes, spent about half a millisecond in DoDecrypt and under 20 microseconds in the reply.

The threading changed in Chrome 141. The events date from Chrome 64, when every Web Crypto operation ran on a separate thread named WebCrypto, one per renderer process, shared by the main thread and every worker. There, DoDecrypt ran on the WebCrypto thread, and DoDecryptReply was posted back to the calling thread as a task of its own, running once that thread was free, with the continuation in that task's microtask checkpoint. Commit 08e592f270 (Chrome 141) moved most operations onto the calling thread, because the thread hop cost as much as the cryptography or more, and kept the trace events. A WebCryptoOnMainThread feature switch kept the old path available until commit 3474ce0c06 deleted it in Chrome 149. Only RSA key generation and PBKDF2 key derivation still use the WebCrypto thread.

DevTools records the category in every Performance recording, labels the event "Decrypt reply" and gives it the Scripting colour. This manual's corpus crawl records the category too and contains other Web Crypto events, but no crawled page called decrypt() while it was recording, which is why the entry has no real-world sample.

Derivations

The event carries no size, so its duration is the trace's only measure of how much plaintext came back: the reply is a copy of it, and a copy costs roughly the same per megabyte on a given machine. Set it against DoDecrypt on the same thread to tell the two kinds of expensive decrypt apart. Both long means a large payload, where splitting the data or moving the work to a worker helps; a long DoDecrypt with a reply of a few microseconds means an expensive algorithm on a small input, such as RSA, where the size of the data is not the problem.

Traps

Your .then() is not inside it. The promise is settled in the slice, but the continuation runs later, in RunMicrotasks after the calling script returns. Time spent in your callback is not part of DoDecryptReply, and code that runs between the decrypt() call and the end of the script delays the callback without showing up in either crypto slice.

A short reply at 1 KB says nothing about a large one. The reply is a copy of the plaintext, so a few microseconds for a small message becomes milliseconds for a large one. Read its duration as a cost of the data size, not as fixed overhead.

"Reply" does not mean work came back from another thread. From Chrome 141 on, DoDecrypt and DoDecryptReply are a few microseconds apart with nothing queued between them: both run inside the decrypt() call, nested in the calling script's slice. In a trace from Chrome 140 or earlier, the gap between them is real waiting, first for the shared WebCrypto thread and then for the calling thread to finish what it was doing, and DoDecrypt sits on another thread.

Stopped in the debugger, the promise does not settle here. A decrypt() typed into the console at a breakpoint writes both slices at once, but the promise stays pending until you resume and is resolved in a later task. Timing a decrypt that way measures the pause.

The two slices are not the whole cost of a decrypt(). Blink copies the ciphertext before DoDecrypt starts, as the spec requires, and that copy is in no crypto slice: it shows as time inside the calling script. For large data it costs about as much as the reply. In the capture above, a 64 MB decrypt on the main thread held its thread for 10 to 20 ms before DoDecrypt, 15 to 31 ms in DoDecrypt (18 ms typical) and 11 to 20 ms in DoDecryptReply.

Decrypting on the main thread now blocks the main thread. Before Chrome 141 only the two copies, of the ciphertext in and the plaintext out, did. If a large or frequent decrypt() shows up in your main-thread tasks, a dedicated worker is where to move it; Chrome no longer moves it for you.

A large reply can contain garbage collection. In captures with plaintexts of 8 MB and more, V8 started incremental marking inside some DoDecryptReply slices, a few hundred microseconds each, shown as V8.GCIncrementalMarkingStart in a DevTools recording. That time belongs to the collector, not to the copy.

Nothing says what was decrypted or whether it worked. There are no arguments: no algorithm, key size, data size or result. A rejected decrypt() writes the same slice as a successful one.

The category must be recorded. The event is on disabled-by-default-devtools.timeline. DevTools always records it, but a custom trace configuration that asks for devtools.timeline alone gets no Web Crypto events at all.

Example event a local fixture page
{
  "name": "DoDecryptReply",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938337,
  "dur": 1,
  "tdur": 1,
  "tts": 38500,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoDecrypt · DoEncryptReply · DoDigestReply · RunMicrotasks · FunctionCall

categories
disabled-by-default-devtools.timeline
references
webcrypto_impl.cc
DoDigestDigest

Chrome hashes the data a script passed to crypto.subtle.digest().

Spec

Chromium's WebCryptoImpl::Digest runs the requested SHA hash over one crypto.subtle.digest() call's copy of the input, in a single BoringSSL pass, synchronously on the thread that made the call: the renderer main thread for page script, the worker's own thread inside a worker.

Behaviour

Every crypto.subtle.digest() call that gets past argument checking writes one DoDigest, followed immediately by one DoDigestReply on the same thread, back to back under the same parent, and both are written before digest() returns. Blink first normalises the algorithm name and copies the bytes you passed, because the Web Crypto spec requires a copy. It then calls WebCryptoImpl::Digest, which opens DoDigest, hashes the copy, closes it, and opens DoDigestReply to wrap the result in an ArrayBuffer and resolve the promise. The pair therefore sits inside whatever script made the call: a FunctionCall, a TimerFire, or a RunMicrotasks when the call follows an await. An algorithm name Chrome does not support is rejected before this point and writes neither event, and so does a call whose document or worker is already gone.

The hash runs on the calling thread and cannot be interrupted, so a digest on the main thread blocks the main thread for as long as it takes. Calling it from a dedicated worker moves both the event and the cost onto that worker's track. The cost grows with the length of the input and depends on the algorithm and the CPU. On an AMD Ryzen 9 5900X under Linux, DoDigest for SHA-256 took under 15 microseconds for 1 KB, about 0.5 ms for 1 MB and 22 to 26 ms for 50 MB, the same on the main thread and in a worker and the same in every Chrome version from 141 to 154. Over the same 50 MB, SHA-1 took 21 to 23 ms, while SHA-384 and SHA-512 took 66 to 81 ms, about three times as much. That gap comes from the CPU: BoringSSL, Chrome's cryptography library, has a path for the x86 SHA extensions only for SHA-1 and SHA-256, and this CPU has them. On a CPU without them the ratio will be different.

DoDigest is written only by Chrome's Web Crypto component, on the path crypto.subtle.digest() takes. Subresource integrity, the integrity attribute on scripts and stylesheets and the integrity option of fetch(), is checked by Blink's own ComputeDigest, which calls BoringSSL directly and has never written this event. A hashing library written in JavaScript or WebAssembly shows up as ordinary script, not here.

Traps

DoDigest is not the whole cost of the call. The copy of your input happens before it, on the same thread, inside the enclosing script slice and outside any Web Crypto event, and its cost has changed a lot between versions. With 50 MB of SHA-256 input on the machine above, the copy took about 120 to 135 ms on Chrome 141, where digest() held the main thread for 145 to 160 ms while DoDigest showed about 23, and over half a second on a Chrome 145 build. Chrome 146 made the copy about ten times faster (commit a2039f64c0, which replaced an element-by-element copy in Chromium's base::ToVector with a single range construction), and on Chrome 146 and 154 it took 9 to 25 ms. Read how long the call blocked from the slice around it, not from DoDigest.

The threading depends on the Chrome version. The events arrived in Chrome 64 (commit b4e75fa1c1), and until Chrome 140 the hash ran on a separate renderer thread named WebCrypto, one per renderer process and shared by its workers, while DoDigestReply came later as its own task on the calling thread, where the promise resolved. Commit 08e592f270 moved the hash onto the calling thread in Chrome 141, behind a WebCryptoOnMainThread feature that was on by default; launching with --disable-features=WebCryptoOnMainThread brought the old threading back until commit 3474ce0c06 removed the switch in Chrome 149. A trace with DoDigest on a WebCrypto thread comes from an older Chrome or from that switch, and in it the calling thread's share of the cost is the copy, not DoDigest.

There are no arguments, so the trace does not say which algorithm was used or how many bytes were hashed. The duration is the only clue, and it depends on both and on the CPU.

The promise is already resolved when digest() returns: DoDigestReply resolves it, and code after await resumes at the next microtask checkpoint of the same task. There is no wait for a reply task in current Chrome.

The event is on disabled-by-default-devtools.timeline. The Performance panel records that category on every recording and draws the event as "Digest" in the Scripting colour, but a trace recorded with only devtools.timeline will not contain it.

Example event a local fixture page
{
  "name": "DoDigest",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102937607,
  "dur": 150,
  "tdur": 56,
  "tts": 38231,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoDigestReply · DoEncrypt · DoSign · RunMicrotasks · FunctionCall

categories
disabled-by-default-devtools.timeline
usage
moderate
duration
short
references
webcrypto_impl.cc
DoDigestReplyDigest reply

Chrome hands the finished hash from a `crypto.subtle.digest()` call back to the page by resolving the call's promise.

Spec

In Chrome 141 and later, WebCryptoImpl::Digest wraps the finished hash in an ArrayBuffer and resolves the promise that crypto.subtle.digest() returned, through Blink's CryptoResultImpl, synchronously inside the digest() call and on the thread that made it, the renderer's main thread or a worker's.

Behaviour

Since Chrome 141, digest() does all of its work before it returns. Blink copies your input, then Chrome's Web Crypto code opens DoDigest around the hash itself and, the moment that closes, opens this event around the reply: it builds an ArrayBuffer holding the 20 to 64 bytes of the hash and resolves the promise with it. The two events are written back to back, in the same function, on the thread that called digest(), and both sit inside whatever JavaScript made the call. In the captures behind this entry every call came after an await, so that was a RunMicrotasks slice. You get one pair per call. A call that is rejected before it reaches this code, such as one naming an algorithm Chrome does not support, writes neither.

The promise is fulfilled inside this event, so it is already settled by the time digest() hands it back to your code. Your code is not inside it. Blink resolves the promise with microtasks held off, so an await or a .then() on the result runs later, at the next microtask checkpoint, after the script that called digest() has finished. In a capture where the script busy-waited for 5 ms after calling digest(), the .then() callback ran after the busy loop, 5 ms after this event had ended.

The name is older than this arrangement. When the event was added, in Chrome 64, every Web Crypto operation was posted to a dedicated thread named WebCrypto: DoDigest ran there, and DoDigestReply was a separate task posted back to the calling thread. Chrome 141 moved digest, and most other operations, onto the calling thread, because the hop between threads could cost as much as the hashing itself (commit 08e592f2700b, behind a WebCryptoOnMainThread feature that was on by default), and kept the old event names on purpose. Chrome 149 removed the switch.

Because each call finishes before it returns, two digest() calls issued one after the other without an await between them do not overlap in the trace. A capture of a 1 MiB hash followed immediately by a 16-byte one gave DoDigest, DoDigestReply, DoDigest, DoDigestReply, in call order, all inside the calling script, with both callbacks running afterwards in the same order. Promise.all() makes them look concurrent in your code, not in Chrome.

DevTools records the category in every Performance recording and draws the event as Digest reply, in the Scripting colour, on the track of the thread that called digest().

Traps

There is no span to time. The gap between DoDigest ending and this event starting is the cost of closing one trace scope and opening the next, a few microseconds. The hash is DoDigest's own duration. This event's duration is building the ArrayBuffer and resolving the promise, and it does not grow with the input: across SHA-1, SHA-256, SHA-384 and SHA-512 over inputs from 16 bytes to 16 MiB, in Chrome 141 and 154 on a Ryzen 9 5900X under Linux, it stayed between 1 and 88 microseconds while DoDigest grew with both algorithm and length, and the longest were the first call on a thread. The copy Blink makes of your input happens before DoDigest starts, so it is in neither event.

Traces from Chrome 140 and earlier look different, and so do Chrome 141 to 148 with the WebCryptoOnMainThread feature switched off. DoDigest is on a thread named WebCrypto, and it posts the reply back before it closes, so the hash is inside DoDigest, not in the gap. This event is then a task of its own on the calling thread, and the gap is how long the calling thread took to get round to it: the rest of the task that called digest(), and anything queued ahead of the reply. With the old path switched back on (Chrome 141, same machine, SHA-256 over 1 MiB), the gap was 4 to 29 microseconds with an idle caller and 4.3 to 4.6 ms when the calling script stayed busy for 5 ms after the call, against about 0.6 ms for the hash. In that model the .then() callback runs inside the reply's own task, straight after this event.

Pair it on the same thread. In current traces the reply belongs to the DoDigest immediately before it on its own thread. Pairing by time across threads fails: with a page and a worker hashing at once, the nearest DoDigest in time was the other thread's in 19 of 40 replies. In the old model the nearest DoDigest can be the wrong one even for a single caller: two calls from one script queue both hashes on the WebCrypto thread before either reply runs, so pair them in order, first with first. Order does not help when a page and a worker hash at the same time, because a DoDigest on the WebCrypto thread does not say which thread asked. Only a recording that includes the toplevel.flow category settles it: the reply's task then carries a SequenceManager PostTask flow whose id matches the one started inside its DoDigest. A DevTools Performance recording does not include that category.

The promise settling is not your code running. The code that uses the hash runs as a microtask once the calling script has finished: in the RunMicrotasks slice that follows it on the same thread, or further along the same slice when the call to digest() was itself made after an await.

No arguments means no algorithm, no input length and no content. Two hashes of the same bytes look exactly like two hashes of different bytes of the same length: in a capture, the same 1 MiB hashed twice and two different 1 MiB buffers produced indistinguishable pairs. A trace cannot tell you that a page hashed the same data twice; only the calling code can.

Example event a local fixture page
{
  "name": "DoDigestReply",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102937758,
  "dur": 10,
  "tdur": 9,
  "tts": 38287,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoDigest · RunMicrotasks · DoEncryptReply · DoSignReply

categories
disabled-by-default-devtools.timeline
usage
moderate
duration
short
references
webcrypto_impl.cc
DoEncryptEncrypt

Chrome encrypts the data passed to one call to Web Crypto's encrypt().

Spec

Chrome's Web Crypto implementation, WebCryptoImpl::Encrypt in components/webcrypto, runs the cipher for one crypto.subtle.encrypt() call, synchronously on the thread that made the call: the renderer main thread for a page, the worker's own thread for a worker.

Behaviour

When a page calls crypto.subtle.encrypt(), Blink first normalizes the algorithm, copies your input bytes and checks that the key is allowed to encrypt with that algorithm. It then hands the job to Chrome's Web Crypto code, which opens this slice, sets up the cipher for your key, allocates the output buffer, encrypts, and closes the slice. DoEncryptReply follows immediately on the same thread: it copies the ciphertext into a new ArrayBuffer and resolves the promise. Both slices sit inside whatever ran the calling script: FunctionCall for a timer or event handler, EvaluateScript for top-level script, RunMicrotasks when the call came after an await. The promise is already resolved by the time encrypt() returns; your code after await runs a little later, at the next microtask checkpoint.

This is how Chrome has worked since version 141. From Chrome 64, when these events were added, to Chrome 140, every Web Crypto operation was posted to a single renderer thread named WebCrypto: DoEncrypt ran there, and DoEncryptReply ran later as a separate task back on the calling thread. Chromium commit 08e592f2700b ("Run most WebCrypto tasks on the calling thread", landed August 2025, first shipped in Chrome 141) moved the fast operations inline. Its reasoning: cryptography is CPU-bound work that has to run somewhere, and the thread hop cost about as much as the operation itself, if not more. Chrome 141 to 148 kept --disable-features=WebCryptoOnMainThread as an escape hatch; Chrome 149 removed it. Despite the flag's name, inline means on the calling thread, so a worker's encrypt() runs on the worker.

Three operations still hop to the WebCrypto thread today, because they can take long enough to matter: generateKey() for the RSA algorithms, and deriveBits() and deriveKey() with PBKDF2. encrypt() is never one of them, whatever the algorithm.

One event is written per call that reaches Chrome's Web Crypto code, including calls it rejects, such as an AES-CBC IV of the wrong length or an RSA-OAEP plaintext that is too long; the promise is then rejected inside DoEncryptReply. Calls that fail Blink's own checks first, such as an unknown algorithm or a key without the encrypt usage, reject without writing either event.

Derivations

Count DoEncrypt slices inside one task. Hundreds in a single task is a page encrypting many small payloads one call at a time, and the task's own duration, not the sum of the DoEncrypt slices, is what that cost the thread.

Traps

It is on the thread that called it, not off it. Web Crypto used to run encryption on a background thread, and advice written in that era still says it does not block rendering. Since Chrome 141 it runs inside your JavaScript, on the main thread for a page, and blocks it for as long as it takes. Only RSA key generation and PBKDF2 still run elsewhere.

Check the Chrome version before reading thread placement. A trace from Chrome 140 or earlier, or from Chrome 141 to 148 launched with --disable-features=WebCryptoOnMainThread, shows DoEncrypt on the WebCrypto thread and DoEncryptReply in a later task on the calling thread. The gap between them in those traces is the thread hop, any queue on the WebCrypto thread, and however long the calling thread was busy with other work, because the reply waits its turn there like any other task. In one capture (Chrome 141 with the flag off), 200 calls finished encrypting within about 2 ms, but every reply waited for the 20 ms of script that followed them, so each gap was about 22 ms. In current Chrome the two are back to back and the gap means nothing.

The slice understates what encrypt() costs. It covers the crypto step: setting up the cipher for the key, allocating the output and encrypting. Normalizing the algorithm, copying your input, creating the promise and the binding into V8 happen before it, outside both events, and copying the output into an ArrayBuffer happens in DoEncryptReply. In traced tests (Chrome 154, AES-GCM with a 256-bit key, an AMD Ryzen 9 5900X desktop) DoEncrypt accounted for under a fifth of the time the calls held the main thread for payloads from 64 bytes to 64 KiB, and about a third or more for a single 1 MiB call. Measure the enclosing task.

Many small calls cost far more than one large one, and most of the extra is not in this event. On the same machine, with no tracing running, 1 MiB encrypted with AES-GCM and a 256-bit key took about 1.5 ms as one call and 60 to 75 ms as 16,384 calls of 64 bytes, all of it on the calling thread. Issuing the calls without awaiting each one did not help, because each call does its work before it returns. One huge call was not the cheapest either: 16 calls of 64 KiB took about half as long as one call of 1 MiB, and a trace put the difference inside DoEncrypt. Web Crypto has no batch API, so fewer calls means combining plaintext, which changes what you have to store (one IV and one tag per combined message instead of one per payload).

No arguments. Chrome writes neither the algorithm, the key size nor the input length, and all three change the duration. Two DoEncrypt slices of different lengths may simply be different algorithms or different sizes.

DevTools hides the key setup. The Performance panel shows "Encrypt" and "Encrypt reply" in the Scripting colour. The calls that set up a key, such as importKey(), generateKey(), deriveKey() and deriveBits(), write their own Do* events on the same category, but DevTools has no style for them and leaves them out of the flame chart unless you turn on "Show all events" in the Performance section of DevTools Settings. They are always in the raw trace.

Example event a local fixture page
{
  "name": "DoEncrypt",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938184,
  "dur": 11,
  "tdur": 10,
  "tts": 38446,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoEncryptReply · DoDecrypt · DoDigest · DoSign · RunMicrotasks · FunctionCall

categories
disabled-by-default-devtools.timeline
usage
low
duration
typical
references
webcrypto_impl.cc
DoEncryptReplyEncrypt reply

Chrome settles the promise returned by a Web Crypto encrypt() call, with the ciphertext or with an error.

Spec

WebCryptoImpl::Encrypt in components/webcrypto opens this slice immediately after DoEncrypt, on the thread that called crypto.subtle.encrypt(), and inside it passes the result to Blink's CryptoResultImpl, which copies the ciphertext into a new ArrayBuffer and resolves the call's promise with it, or rejects the promise if encryption failed.

Behaviour

When a page calls crypto.subtle.encrypt(), Blink checks the algorithm, copies the input bytes, checks that the key is allowed to encrypt, and then calls Chrome's Web Crypto layer synchronously. That layer does two things back to back on the calling thread: it encrypts, in a DoEncrypt slice, and then it hands the result back, in this one. Both slices are nested inside the script that called encrypt(): under its FunctionCall, or under RunMicrotasks when the call comes after an await. A call made in a dedicated worker is written the same way on the worker's thread.

Handing back is two steps. Blink allocates a new ArrayBuffer and copies the ciphertext into it, then resolves the promise with that buffer. Resolving does not run your code. The then() callbacks and await continuations waiting on the promise are queued as microtasks and run once the script that called encrypt() has finished. So the promise is already fulfilled by the time encrypt() returns, and several calls issued from one function without awaiting do not overlap at all: each writes its DoEncrypt and DoEncryptReply before the next call starts, and all of their callbacks run together afterwards.

There is one DoEncryptReply for every DoEncrypt. A call that fails during encryption, such as RSA-OAEP input too long for the key, still writes both, and this slice rejects the promise with an OperationError. A call that fails before it reaches Chrome's crypto layer writes neither: an unknown algorithm name, or a key that does not match the algorithm or lacks the encrypt usage, is rejected by Blink first.

The name is a leftover. From Chrome 64, when these events were added, until Chrome 140, DoEncrypt ran on a separate thread called WebCrypto, one per renderer process and shared by the page and its workers, and DoEncryptReply was a separate task posted back to the thread that called encrypt(). Chrome 141 moved the work onto the calling thread, because the thread hop cost as much as the encryption itself, or more. The same change kept the trace events, with a note that DevTools tests seemed to depend on them, so the names now describe a hop that no longer happens. It shipped behind a feature switch, on by default, which Chrome 149 removed. Only RSA key generation and PBKDF2 still go to the WebCrypto thread.

DevTools labels the event "Encrypt reply" and draws it in the Scripting colour. Its category, disabled-by-default-devtools.timeline, is part of every Performance panel recording.

Derivations

In a trace from Chrome 141 or later, DoEncrypt plus DoEncryptReply on a thread is the time Web Crypto encryption held that thread. None of the work happens anywhere else now, so on the main thread all of it is blocking time, and moving the calls into a worker is how you take it off the main thread.

Traps

In Chrome 141 and later there is no queue between the two halves. DoEncryptReply starts within a few microseconds of the end of DoEncrypt, on the same thread, so the gap between them measures nothing.

The pair is not the latency your code sees. It leaves out Blink's work before DoEncrypt (checking the algorithm and copying the input, which for a large input takes visible time and has no event of its own) and the wait after DoEncryptReply until your callback runs. In a test where one function issued four calls and then kept the main thread busy for 40 ms, every promise was fulfilled before the busy loop started, every callback ran after it ended, and no crypto event covered those 40 ms. To measure what the page waited for, put a performance.mark() at the call and in the callback.

It is not always short, because the copy grows with the ciphertext. On a Ryzen 9 5900X in Chrome 141 and 154, with each call awaited on its own, DoEncryptReply took a few microseconds for 1 KiB of output, about half a millisecond for 1 MiB, about a millisecond for 4 MiB and 2.5 to 3.5 ms for 16 MiB, with AES-GCM and AES-CBC alike. Between 16 and 256 KiB the same copy cost three to five times more for whichever algorithm the page ran first, so a difference at those sizes can come from what the page allocated before rather than from the call. Do not compare it with the DoEncrypt next to it: AES-CBC encrypts several times slower than AES-GCM, so the same reply is a much smaller share of the pair. Called from the main thread, all of it is main-thread time.

Traces from Chrome 140 and earlier look different, and so do Chrome 141 to 148 run with --disable-features=WebCryptoOnMainThread. DoEncrypt is on the WebCrypto thread and DoEncryptReply is a task of its own on the calling thread, so there is a gap. It measures how long the reply waited for the calling thread, not crypto work queued behind other crypto work. In captures on Chrome 141 with the switch off, a 1 MiB call had a gap of 10 to 16 microseconds when the main thread was idle and about 40 ms when the page ran 40 ms of its own script after the call. Four small calls queued behind an RSA 4096 key generation started their DoEncrypt a quarter to two thirds of a second late, yet the gap after each stayed under a third of a millisecond. Crypto work queueing on the WebCrypto thread happens before DoEncrypt starts, where nothing marks it.

Nothing links a DoEncryptReply to its DoEncrypt: there is no id, no algorithm and no size. In current traces, take the DoEncrypt that ends just before it on the same thread. In old traces the order is first in, first out per calling thread, but one WebCrypto thread served the page and all its workers, and a request whose document or worker went away while it waited wrote a DoEncrypt with no reply. With the toplevel category recorded, the task around an old-style reply names DoEncrypt as the function that posted it (src_func), which marks it as a crypto reply but not which one.

A trace configuration that lists its categories must name disabled-by-default-devtools.timeline; devtools.timeline does not include it.

Example event a local fixture page
{
  "name": "DoEncryptReply",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938196,
  "dur": 5,
  "tdur": 4,
  "tts": 38456,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoEncrypt · DoDecryptReply · DoDigestReply · DoSignReply · DoVerifyReply · RunMicrotasks

categories
disabled-by-default-devtools.timeline
usage
low
duration
short
references
webcrypto_impl.cc
DoSignSign

Chrome computes the signature for a call to crypto.subtle.sign().

Spec

WebCryptoImpl::Sign in Chromium's components/webcrypto wraps the signing computation for one crypto.subtle.sign() call, BoringSSL's HMAC, ECDSA, Ed25519, RSASSA-PKCS1-v1_5 or RSA-PSS operation including the hash of the whole input, in a complete slice on disabled-by-default-devtools.timeline, written since Chrome 141 synchronously on the thread that called sign(), main thread or worker.

Behaviour

When a script calls crypto.subtle.sign(), Blink first normalizes the algorithm, copies the input bytes, creates the promise and checks that the key allows signing with that algorithm. A call that fails any of those checks is rejected without writing this event. Otherwise Chrome opens DoSign around the cryptography itself, then opens DoSignReply straight after it on the same thread, which wraps the signature in an ArrayBuffer and resolves the promise. A call that fails inside the cryptography still writes both, and DoSignReply rejects the promise instead. So each sign() that gets past Blink's checks writes the pair back to back, nested inside the JavaScript that called it: under whatever task ran the script for the first call, under RunMicrotasks for a call made after an await. The promise is already resolved by the time sign() returns, but code waiting on it still runs later, at the next microtask checkpoint.

Everything the algorithm does happens inside the slice, including hashing the input. HMAC is one pass of the hash. ECDSA and RSA hash the input once and then do a private-key operation whose cost is set by the curve or the key size. Ed25519 runs the whole message through SHA-512 twice, once for the nonce and once for the challenge. On one desktop (Ryzen 9 5900X, Chrome 154, Linux, headless, median of 20 calls), a 1 KiB input took about 2 us with HMAC SHA-256, 24 us with Ed25519, 35 us with ECDSA P-256, 0.44 ms with a 2048-bit RSASSA-PKCS1-v1_5 key and 3.1 to 3.6 ms with a 4096-bit one. At 1 MiB the same calls took about 0.47, 2.7, 0.48, 0.88 and 3.5 ms: the hash is most of the cost for HMAC, ECDSA and Ed25519, about half for RSA-2048 and a small part for RSA-4096. On the same machine one SHA-512 pass over 1 MiB took 1.3 ms against 0.44 ms for SHA-256, which is why Ed25519's two passes cost more than an RSA-2048 signature over the same bytes. Thread time matched wall time throughout, because this is pure CPU work. A phone will be slower; these numbers are for comparing algorithms, not a budget.

It did not always run on the calling thread. From Chrome 64, when the event was added (r518851), to Chrome 140, sign() posted the work to a single per-process thread named WebCrypto, shared by the main thread and every worker. DoSign was written there, and DoSignReply arrived later on the calling thread as a task of its own. Commit 08e592f2700b (r1501639, Chrome 141) moved signing, along with most other Web Crypto operations, onto the calling thread, because the thread hop cost as much as the cryptography. It kept the trace events, and it kept the old path behind a WebCryptoOnMainThread feature that was on by default. Commit 3474ce0c0666 (r1611031, Chrome 149) removed that switch. RSA key generation and PBKDF2 still go to the WebCrypto thread; signing does not.

A DevTools Performance recording always includes the category, and the panel draws the slice as "Sign" in the Scripting colour on the track of the thread that ran it.

Derivations

Because the slice sits inside the task that called sign(), adding up the DoSign slices within a long task, or within an interaction's processing time, gives how much of that time was spent signing. That is the number that tells you whether moving the signing into a worker would help, and a worker's DoSign shows up on the worker's own track.

In traces from Chrome 140 and earlier, the gap between the end of DoSign on the WebCrypto thread and the start of the matching DoSignReply is how long the result waited for the calling thread to be free. In current traces the two are back to back and the gap means nothing.

Traps

A loop of await crypto.subtle.sign() does not yield. Because the promise is already resolved when sign() returns, each await resumes at the same microtask checkpoint, and the whole loop runs as one task. In two captures on Chrome 154, one script making 504 awaited sign() calls ran as a single main-thread task of 520 to 550 ms, 450 to 485 ms of it inside DoSign. Signing a batch of tokens with an RSA key in a click handler blocks rendering and input for the entire batch.

The thread depends on the Chrome version. In a trace from Chrome 140 or earlier, look for DoSign on the WebCrypto thread, where it did not block the page; the calling thread shows only DoSignReply, as a separate short task. From Chrome 141 both are on the calling thread. Chrome 141 to 148 go back to the old behaviour when launched with --disable-features=WebCryptoOnMainThread, so a trace from those versions can show either layout. From Chrome 149 the switch does nothing.

DoSign is not the whole cost of sign(). Normalizing the algorithm, copying the input and creating the promise happen inside the sign() call before the slice opens, and no trace event covers them. On the desktop above that untraced time grew with the input, about 0.4 ms per MiB whatever the algorithm, so with a 1 MiB input the page measured roughly 0.4 ms more around await sign() than DoSign shows. Most of it is the input copy: a call rejected after the copy but before any cryptography (a key without the sign usage) costs the same time, and one rejected before the copy costs almost nothing.

The duration does not tell you the algorithm. There are no arguments, so nothing names the algorithm, the key or the input length, and a slow DoSign can be a large input as easily as a large key: on the desktop above, Ed25519 over 1 MiB took three times as long as RSA-2048 over the same bytes. HMAC, a keyed hash rather than a public-key signature, is a DoSign too.

A hand-picked category list needs disabled-by-default-devtools.timeline. The Performance panel records it by default, and so does Puppeteer's default category list; a trace started with any list that leaves it out has no DoSign at all.

Example event a local fixture page
{
  "name": "DoSign",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938397,
  "dur": 201,
  "tdur": 86,
  "tts": 38554,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoSignReply · DoVerify · DoDigest · RunMicrotasks · FunctionCall

categories
disabled-by-default-devtools.timeline
usage
low
duration
long
references
webcrypto_impl.cc
DoSignReplySign reply

Chrome hands the outcome of a Web Crypto sign() call back to the promise that sign() returned.

Spec

The second of two back-to-back slices that WebCryptoImpl::Sign in components/webcrypto/webcrypto_impl.cc writes on disabled-by-default-devtools.timeline for every crypto.subtle.sign() call that reaches Chrome's crypto code, covering Blink's CryptoResultImpl copying the finished signature into an ArrayBuffer and resolving the page's promise, or rejecting it with the operation's error, on the thread that called sign().

Behaviour

When a page calls crypto.subtle.sign(), Blink first does its own checks: it reads the algorithm parameters, copies the data you passed in, and checks the key. Only then does it call into Chromium's Web Crypto implementation, which writes two slices in a row: DoSign around the signing itself, done by BoringSSL (Chrome's cryptography library), and DoSignReply around handing the result back. Inside the reply, Blink copies the signature, a few dozen to a few hundred bytes, into a new ArrayBuffer and resolves the promise with it. If the crypto library failed, it builds the DOMException instead and rejects. V8 marks the promise settled with microtasks held back, so your .then() callback, or the code after your await, does not run inside this slice. It runs later, in RunMicrotasks, at the next microtask checkpoint.

From Chrome 141 all of this happens synchronously, inside the call to sign(). Both slices sit on the thread that called it, nested in the script that made the call, a few microseconds apart, and DoSignReply has finished before sign() returns. The promise your code receives is already settled. A dedicated worker that calls sign() gets both slices on its own thread. In two sets of captures on Chrome 154 (Ryzen 9 5900X, Linux, headless), a successful reply took 3 to 36 microseconds for ECDSA P-256, Ed25519, RSA-2048 and HMAC SHA-256 with inputs from 1 KiB to 64 MiB, and nothing was nested inside it. Its length does not follow the input size or how expensive the signature was, because the only thing it copies is the signature. A rejecting reply ran longer, 17 to 239 microseconds, the longest being the first rejection in a page or worker, which had a v8.newInstance slice inside it.

Every sign() call that gets past Blink's checks writes exactly one DoSign followed by one DoSignReply, and that includes calls that then fail inside BoringSSL. RSA-PSS with a salt too long for the key is one: with a 2048-bit key and SHA-256, a saltLength of 222 signs and 223 fails. BoringSSL hashes the whole input before it refuses, so the failing call's DoSign is as long as a successful one, and the promise is rejected with an OperationError inside the reply. Calls that Blink rejects itself write neither slice: an unknown algorithm name, a missing parameter such as ECDSA's hash, a key made for a different algorithm, or a key without the "sign" usage, which includes any public key. The promise is rejected all the same, with no Web Crypto event in the trace. A call made while its page or worker is being torn down also writes neither.

It used to be a different shape. The event was added in Chrome 64 (commit b4e75fa1c1), when every signature ran on a separate thread named WebCrypto: DoSign was written there, and DoSignReply was a task posted back to the calling thread, where it resolved the promise and the microtask checkpoint at the end of that same task ran your callback. Commit 08e592f270 moved signing and most other operations onto the calling thread in Chrome 141, behind a feature switch, WebCryptoOnMainThread, that was on by default. Commit 3474ce0c06 removed the switch in Chrome 149. The WebCrypto thread still exists, but only RSA key generation and PBKDF2 key derivation use it.

DevTools records the category in every Performance recording and draws the slice as "Sign reply" in the Scripting colour, on the track of the thread that ran it.

Derivations

In a trace where DoSign is on the WebCrypto thread, from Chrome 140 or earlier or from Chrome 141 to 148 started with --disable-features=WebCryptoOnMainThread, the gap between the end of DoSign and the start of DoSignReply on the calling thread is how long the finished result waited for that thread to run the reply. In Chrome 141 and later, by default, the two are adjacent and there is no wait to measure.

Traps

It is not the cost of the signature. DoSign is the signing. The reply is only the handoff, a few microseconds whatever the algorithm, key size or input length. A slow sign() shows up as a long DoSign, not as a long reply. Adding the two together, as older advice suggests, adds almost nothing in current Chrome. With HMAC SHA-256 over 1 KiB on Chrome 154 (Ryzen 9 5900X), DoSign took 7 to 11 microseconds and the reply 4 to 18, so the two can be the same size; that says HMAC is cheap, not that the reply is slow.

The pair is not the whole cost to the thread either. Blink copies your input before DoSign opens, and no trace event covers the copy: it sits in the calling script's slice, between the call and DoSign. With 16 MiB of data that gap was 3 to 6 ms on Chrome 154 and 40 to 46 ms on Chrome for Testing 141, on the same machine; Chromium's copy routine became much faster in Chrome 146 (commit a2039f64c0). After DoSignReply a further 0.2 to 0.4 ms passed before sign() returned with 16 MiB, against a few microseconds with 1 KiB; the source frees the copy at that point, but that time was not profiled. To see what one call cost the thread, measure the script slice that contains both.

Settled is not the same as handled. The end of the slice is the moment the promise settled, but the code waiting on it runs later, in RunMicrotasks, after the rest of the calling function and any microtasks queued ahead of it.

A reply does not mean a signature was produced. A rejection inside BoringSSL writes the same pair, and the event has no arguments to tell the two apart. Nor does the absence of the pair mean sign() was never called, or that it cost nothing: a call that Blink rejects before reaching the crypto code writes nothing at all, and if the key check is what fails, the input has already been copied. A 16 MiB sign() with a key lacking the "sign" usage held the thread for 3 to 6 ms on Chrome 154 with no Web Crypto event in the trace.

Check which thread DoSign is on before comparing traces across versions. In Chrome 140 and earlier, DoSign ran on the WebCrypto thread and only the reply ran on the main thread, so the time between DoSign starting and DoSignReply ending includes the wait for the main thread. Chrome 141 to 148 can show either shape, because the old behaviour came back with --disable-features=WebCryptoOnMainThread. From Chrome 141 the signing itself runs on the thread that called sign(), so a page that signs on the main thread now pays for it there. Calling sign() from a worker moves both slices, and the input copy, off the main thread.

Example event a local fixture page
{
  "name": "DoSignReply",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938598,
  "dur": 11,
  "tdur": 10,
  "tts": 38640,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoSign · FunctionCall · RunMicrotasks · DoVerifyReply · DoDigestReply

categories
disabled-by-default-devtools.timeline
usage
low
duration
typical
references
webcrypto_impl.cc
DoVerifyVerify

Chrome checks a signature against the data it claims to sign, for a call to Web Crypto's verify().

Spec

Chromium's Web Crypto layer checks one signature for crypto.subtle.verify() in WebCryptoImpl::VerifySignature (components/webcrypto/webcrypto_impl.cc), writing a complete slice on disabled-by-default-devtools.timeline around the algorithm's verify routine, hashing of the data included, synchronously on the thread that called verify() since Chrome 141.

Behaviour

When a page calls crypto.subtle.verify(algorithm, key, signature, data), Blink does the bookkeeping first: it normalises the algorithm, copies the signature and the data, and checks that the key belongs to that algorithm and allows "verify". Only then does it hand the call to Chromium's Web Crypto code, which writes DoVerify around the check itself and DoVerifyReply straight after it, around resolving the promise. The check includes hashing the data. For RSA, ECDSA and Ed25519 that is BoringSSL's verify routine; for HMAC it means computing the MAC over the data again and comparing it with the signature, so an HMAC verify costs what an HMAC sign does.

Since Chrome 141 both slices are written synchronously, inside the verify() call, on the thread that made it: the main thread for page script, the worker's own thread in a dedicated worker. Their parent is whatever JavaScript slice called verify(), usually FunctionCall, or RunMicrotasks when the call came after an await. It was not always so. From Chrome 64, when these events were added, to Chrome 140, each verification was posted to a single background thread in the renderer named WebCrypto. DoVerify was written there, and DoVerifyReply was a separate task posted back to the calling thread. Chromium commit 08e592f (crrev.com/c/6767493) moved most Web Crypto operations onto the calling thread, reasoning that the thread hop cost as much as the cryptography, and Chrome 149 removed the switch that could turn that off. The WebCrypto thread still exists, but only RSA key generation and PBKDF2 derivation are sent to it.

A call that Blink rejects writes neither event: an unknown algorithm name, a missing parameter such as ECDSA's hash, a key made for a different algorithm, a key without the "verify" usage (a private key, for example), or an argument of the wrong type. A signature that simply does not match is not an error. The promise resolves false through the same two events, and checking a wrong signature costs the same as checking a right one. The exceptions are ECDSA and Ed25519 signatures of the wrong length, which are refused before any hashing and take a microsecond or two whatever the data size. RSA and HMAC have no such shortcut: a wrong-length RSA signature is rejected only after the data has been hashed, and HMAC computes the whole MAC before it compares, so both cost about a full verify on large data. DoVerifyReply also has a branch that rejects the promise, but none of the malformed inputs tried for this entry reached it.

The duration depends on the algorithm, the key and the amount of data, and the relationship between signing and verifying changes with all three. Measured on one desktop machine (AMD Ryzen 9 5900X, Linux, Chrome 154, headless, two independent test pages, medians of 40 to 60 calls per cell over several runs): with a 2048-bit RSASSA-PKCS1-v1_5 key and SHA-256, verifying a message of 1 KiB or less took about 13 to 16 microseconds against roughly 440 to 490 for signing, about a thirtieth; at 64 KiB verifying took about a tenth as long as signing, and at 1 MiB, where hashing dominates, about half. With ECDSA on P-256, verifying messages of 1 KiB or less took about 1.5 to 2 times as long as signing (roughly 65 to 110 against 35 to 60 microseconds), about 1.4 times at 64 KiB, and at 1 MiB verifying took only 3 to 7% longer. Ed25519 flips: verifying a small message took twice as long as signing it, while at 64 KiB verifying took about two-thirds as long and at 1 MiB half, because Ed25519 signing hashes the message twice and verifying hashes it once. HMAC-SHA256 took the same for both at every size. A dedicated worker gave the same figures as the main thread.

A DevTools Performance recording always includes the category. The panel labels the slice "Verify" and colours it as Scripting, nested under the script that called verify().

Derivations

The slice separates the cost of the signature check from the script around it. A JavaScript profile folds the check into the function that called verify(), together with the argument copies and the promise machinery; summing DoVerify inside a task tells you how much of that task was cryptography.

Traps

Awaiting verify() in a loop does not yield. The promise is already resolved when verify() returns, so the code after await runs in the same microtask checkpoint, and the next call follows straight away. In the test page for this entry, more than a thousand sign and verify calls in awaited loops ran as a single task of about three-quarters of a second. Until Chrome 140 each call went back to the event loop between operations, so code that was harmless then can now be a long task that holds up input.

The gap between DoVerify and DoVerifyReply is not a latency any more. From Chrome 141 they are back to back, a microsecond or so apart under the same parent. In a trace from Chrome 140 or earlier they are on different threads, DoVerify on the WebCrypto thread and DoVerifyReply in a later task on the calling thread, and the gap between them includes queueing. Check the Chrome version before comparing traces, and do not look for DoVerify on a WebCrypto thread in a current one. The comment in webcrypto_impl.cc that says the Do* functions run on the crypto thread is out of date for verification.

Nothing in the event names the algorithm, the key or the data size, and the durations cannot stand in for them: the same algorithm can be the cheap side at one data size and the expensive side at another.

The event does not tell you the answer. A DoVerify followed by DoVerifyReply looks identical whether the signature matched or not.

No DoVerify does not mean no call. A verify() that Blink rejected for a bad algorithm, key or argument writes nothing at all.

DoVerify is not the whole cost of the call. Blink's algorithm normalisation and its copies of the signature and the data happen before the slice opens, on the same thread, and are not inside it.

Some DoVerifyReply slices are too short to register. Chrome's trace clock counts whole microseconds, and the JSON exporter writes a slice that begins and ends in the same microsecond as an instant, ph: "I" with no dur. Filtering on ph: "X" drops them; count by name.

Identify the thread by its thread_name metadata, not by tid equal to pid. In the headless Linux captures for this entry the renderer main thread had tid 1 with Chrome's sandbox on, and tid equal to pid when Chrome was started with --no-sandbox.

Example event a local fixture page
{
  "name": "DoVerify",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938619,
  "dur": 67,
  "tdur": 66,
  "tts": 38659,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoVerifyReply · DoSign · DoSignReply · DoDigest · RunMicrotasks · FunctionCall

categories
disabled-by-default-devtools.timeline
references
webcrypto_impl.cc
DoVerifyReplyVerify reply

Chrome settles the promise returned by crypto.subtle.verify() with the result of the signature check.

Spec

Chromium's Web Crypto component (WebCryptoImpl::VerifySignature in components/webcrypto) hands the outcome of the DoVerify check to Blink's CryptoResultImpl, which fulfils the promise crypto.subtle.verify() returned with true or false, or rejects it when the check itself failed; since Chrome 141 this happens straight after DoVerify, synchronously inside the verify() call, on the main or worker thread that made it.

Behaviour

When a page calls crypto.subtle.verify(), Blink first checks the call: it resolves the algorithm name, copies the signature and data, and makes sure the key belongs to that algorithm and allows verify. It then calls Chrome's Web Crypto component, which writes two slices back to back on the same thread: DoVerify around the signature check itself, then this one around handing the result back. Neither carries any arguments.

Since Chrome 141 both run inside the verify() call, on the thread that made it: the main thread for a page, the worker's own thread for a dedicated worker. So the pair nests inside whatever called verify(): a FunctionCall under a timer or an event, or RunMicrotasks when the call came after an await. Twenty calls in a loop write twenty pairs inside one FunctionCall.

Inside this slice Blink resolves the promise. By the time verify() returns to your code, the promise is already fulfilled, but none of your code runs here: Blink settles it with microtasks held off, so your .then() callback or the code after await runs later, at the next microtask checkpoint. That is after the calling script returns, or further along the same RunMicrotasks if verify() was itself called from a microtask. You can see this from the page: a .then() attached to the verify promise runs before a Promise.resolve().then() queued after it. The one exception in source is a document whose context is paused, or a call made where script is forbidden; there Blink posts the resolution as a separate task instead (not captured).

A false result writes this event, and so does a failed check. A wrong signature, tampered data, or an ECDSA signature of the wrong length all fulfil the promise with false. An error inside the check rejects the promise with a DOMException, also inside this slice; an RSA-PSS saltLength of 2^31, too large for BoringSSL, is one way to reach it (OperationError). Errors Blink catches before calling Chrome's crypto code write neither DoVerify nor DoVerifyReply: an unknown algorithm name (NotSupportedError), a key for a different algorithm, a key without the verify usage, or a private key passed in its place (InvalidAccessError). A call made through the crypto.subtle of a document that has gone away, such as a removed iframe, writes nothing and its promise never settles.

The slice does no cryptography. Its duration is the cost of turning the result into a JavaScript value, a boolean or an exception object, and settling the promise; the check itself is DoVerify.

Chrome 64 to 140 did this differently, and so do Chrome 141 to 148 started with --disable-features=WebCryptoOnMainThread. DoVerify ran on a separate renderer thread named WebCrypto, which posted this event back to the calling thread as a task of its own. The promise settled in that task, and your continuation ran in the microtask checkpoint at the end of it. Chrome 141 moved most Web Crypto work onto the calling thread (commit 08e592f, crrev.com/c/6767493), keeping only RSA key generation and PBKDF2 on the WebCrypto thread; Chrome 149 removed the switch (3474ce0, crrev.com/c/7735863).

A DevTools Performance recording always includes disabled-by-default-devtools.timeline, and the panel draws this slice on the thread's track as "Verify reply" in the Scripting colour.

Traps

true and false look identical. Neither slice carries arguments, and in captures of 30 of each the durations overlapped and neither reply had anything inside it. jose's browser build goes further and catches a rejection from verify(), treats it as a failed check and throws its own JWSSignatureVerificationFailed, so even the page cannot tell a bad signature from a failed call.

A rejection leaves a mark, but not on this event. When the check itself fails, the reply builds a DOMException. In a trace recorded with DevTools or Puppeteer attached, that reply contains a toFramesVector child, V8's inspector turning a captured stack into frames while the exception is built, on disabled-by-default-v8.inspector. DevTools records that category but has no style for the event, so the panel draws it only with its "Show all events" setting on; it is in the saved file either way. A boolean reply had no child in any capture. Rejected replies also ran longer on the test machine, but not by a margin that survives a different recording setup, so do not sort them by duration.

A pair is not proof a signature was checked. jose also calls crypto.subtle.verify() to compare the authentication tag when it decrypts a JWE that uses AES-CBC with HMAC (A128CBC-HS256 and its siblings): it signs the received tag with a throwaway HMAC key and verifies its own tag against that. One such decrypt writes two DoImportKey, two DoSign, a DoGenerateKey, a DoVerify pair and a DoDecrypt, with no signature anywhere. Nor do most browser logins verify tokens in the page at all: auth0-spa-js, oidc-client-ts and jwt-decode decode JWTs without checking the signature, so their token handling writes no DoVerify.

No event does not mean no call. A verify() that Blink rejects up front (bad algorithm name, wrong key, missing usage) leaves nothing on this category, and neither does a call through a removed iframe's crypto.subtle. Look for the rejection, or the promise that never settles, in the page, not in the trace.

Which trace layout you have depends on the Chrome version. In a trace from Chrome 64 to 140, or from 141 to 148 with WebCryptoOnMainThread disabled, DoVerify sits on the WebCrypto thread and this event is a separate, short task on the main or worker thread, with the continuation's microtasks after it. Neither slice carries an id, so the only way to pair them there is by order. In a current trace they are adjacent on one thread, and the gap between them that older traces showed, the result waiting for the calling thread, no longer exists.

Filtering on ph of X can drop some. Chrome's trace clock counts whole microseconds, and a reply that begins and ends within the same one has a duration of zero. Perfetto's JSON exporter writes such a slice as an instant, "ph": "I" with "s": "t" and no dur. DevTools keeps it and draws it on the thread track one microsecond wide, with no time in its tooltip; only your own filter on ph or on dur loses it.

A custom trace configuration must name disabled-by-default-devtools.timeline; devtools.timeline alone does not include it. The manual's corpus crawl records that category and contains other Web Crypto events such as DoDigest and DoSign, but no crawled page called verify() while it was recording, so there is no sample from a real site. trace_engine has only a name constant for this event, and the event has no arguments to type.

Example event a local fixture page
{
  "name": "DoVerifyReply",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102938686,
  "dur": 2,
  "tdur": 1,
  "tts": 38725,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}

See also DoVerify · DoSignReply · RunMicrotasks · FunctionCall

categories
disabled-by-default-devtools.timeline
references
webcrypto_impl.cc
EvaluateScriptEvaluate script

Chrome compiles a script and runs its top-level code.

Spec

Blink's V8ScriptRunner::CompileAndRunScript compiles one classic (non-module) script with V8, runs its top-level code to completion and then decides whether to cache the compiled code, all synchronously on the thread that owns the script: the renderer main thread for a page, the worker's own thread for a worker.

Arguments

pathdeclaredobserved
args.data.columnNumber-100.0%undeclared, always present
args.data.frame-100.0%undeclared, always present
args.data.lineNumber-100.0%undeclared, always present
args.data.sampleTraceId-100.0%undeclared, always present
args.data.scriptId-100.0%undeclared, always present
args.data.stackTrace[].columnNumber-14.3%
args.data.stackTrace[].functionName-14.3%
args.data.stackTrace[].lineNumber-14.3%
args.data.stackTrace[].scriptId-14.3%
args.data.stackTrace[].url-14.3%
args.data.url-100.0%undeclared, always present

Observed 13,487 times on 162 of 300 sites.

Behaviour

Every classic script Blink runs goes through CompileAndRunScript, which opens EvaluateScript before compiling and closes it after the run. The slice therefore holds three steps in order: v8.compile, the compile; v8.run, the top-level code itself; and v8.produceCache, where Blink stores the compiled code for the next visit if the script has been run recently enough to be worth caching, and otherwise only records when it saw it. That path covers inline and external scripts in your HTML, defer and async scripts, scripts inserted by other script (inline or with src), inline scripts written with document.write, the string form of setTimeout and setInterval, javascript: URLs, and in a worker the top-level classic script and every file loaded with importScripts(). A script that is not allowed to run returns before the event opens.

Module scripts never write it. <script type=module>, inline or external, is compiled when it is fetched (v8.compileModule) and run later under v8.evaluateModule. eval() and new Function() write nothing of their own either: V8 compiles that code itself, without going through Blink's script runner, so its cost lands inside whatever event was already running.

Where the event sits depends on how the script came to run. When the parser reaches the end of a parser-blocking script that is ready to go, which is every inline script with no stylesheet holding it back and any external script that has already arrived, the script runs inside the ParseHTML slice, as its child. When the parser has to wait, because the file is still downloading or because a stylesheet in the head blocks scripts, the parse run ends, the script runs later in its own task, and parsing resumes in a later task. Then EvaluateScript sits between two ParseHTML slices. defer scripts run after the last parse run, async scripts and script-inserted external scripts run as their own tasks when they arrive, and a script inserted inline or with document.write runs immediately, nested inside the EvaluateScript of the script that inserted it.

There is a second, unrelated emitter. V8's inspector writes an EvaluateScript with no arguments at all, on disabled-by-default-devtools.timeline, for every DevTools Protocol Runtime.evaluate: a line typed into the DevTools console while recording, or a string passed to Puppeteer's page.evaluate. Passing Puppeteer a function uses Runtime.callFunctionOn instead and writes nothing.

Derivations

When an external parser-blocking script runs between two ParseHTML slices, the parser's lost time splits in two. From the end of the earlier ParseHTML to the start of EvaluateScript is the parser waiting, for the download or for a stylesheet; EvaluateScript is the compile and run. defer takes the wait off the parser's path but not the evaluation, which still happens after parsing, so the split tells you how much defer can buy you. In one run on a local fixture, headless Chrome 154 on a Mac, a script whose response was held for 300 ms and whose top-level code ran for 30 ms stopped the parser for about 310 ms.

On an EvaluateScript with an empty url and a stackTrace, the top frame is the line that inserted the script: the appendChild or document.write call in your tag manager or third-party loader. That attributes injected inline code to the script that injected it.

Traps

scriptId is always 0. Blink writes v8::UnboundScript::kNoScriptId because the script has not been compiled when the event opens. The real ID, the one CPU profile frames use, is on the child v8.compile. The scriptId in a stackTrace frame belongs to the script that did the inserting, not to this one.

url is not always the script's URL. An external script has its own URL, an inline script in your HTML has the page URL, and an inline script inserted by script or by document.write, a setTimeout string and a javascript: URL all have an empty url. An empty url is several different things.

lineNumber and columnNumber say where the script's source starts, not where time was spent. They are one-based: 1 and 1 for every external and every injected script, and the position just after the opening <script> tag for an inline script in your HTML. They match the child v8.compile exactly because Blink writes both from the same script with the same helper; the two events nest, they do not swap arguments. trace_engine only declares the fields on v8.compile, and DevTools subtracts one from each before showing them.

stackTrace appears only when JavaScript was already running as the evaluation began, meaning another script ran this one synchronously: an inline script it appended, a document.write, an importScripts(). Scripts started by the parser, a timer (string or not), or the network never have one. Both stackTrace and sampleTraceId need the disabled-by-default-devtools.timeline.stack category, which the Performance panel records. When the JavaScript sampler is recording as well, every EvaluateScript also forces one extra CPU profile sample, tagged with its sampleTraceId.

The duration is more than your code running. It includes the compile and the code-cache step, and for the outermost script the microtask checkpoint at its end, which shows inside it as RunMicrotasks. A queueMicrotask callback appears there as a FunctionCall; a promise reaction (a .then callback, or code after an await) appears as nothing, its time sits in RunMicrotasks itself. A listener for an event the script dispatches synchronously shows as EventDispatch with no FunctionCall beneath it, because Blink writes FunctionCall only when its count of open script entry points (the microtask-scope depth) is zero, and the running script holds that count at one. The count drops back to zero before the end-of-script microtask checkpoint, which is why a queueMicrotask callback there does get a FunctionCall. Functions your top-level code calls directly are not trace events at all; only the JavaScript sampler shows them.

Injected inline scripts and importScripts() files nest inside the EvaluateScript that started them, so adding up EvaluateScript durations counts their time twice.

An EvaluateScript with no args and the category disabled-by-default-devtools.timeline is a DevTools console evaluation or automation, not your page. Leave it out of any per-script total.

Module code is invisible under this name. Look for v8.evaluateModule, which in Chrome 154 carries no arguments, so a trace from that build does not say which module ran.

EvaluateScripts on a worker thread have an empty frame. In the Performance panel, the Summary shows a link to the script only when url is set, so an injected inline script gets none; read the stack trace instead.

Example event from https://www.bbc.com/news
{
  "name": "EvaluateScript",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1108624821,
  "dur": 3546,
  "tdur": 3335,
  "tts": 278721,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "data": {
      "columnNumber": 1,
      "frame": "2EA8810E92C12C680C55B2941251AA98",
      "lineNumber": 1,
      "sampleTraceId": 5267982676863753,
      "stackTrace": [
        {
          "columnNumber": 40894,
          "functionName": "preLoad",
          "lineNumber": 1,
          "scriptId": "12",
          "url": "https://gn-web-assets.api.bbc.com/ngas/latest/dotcom-bootstrap.js"
        },
        {
          "columnNumber": 32813,
          "functionName": "Xe",
          "lineNumber": 1,
          "scriptId": "12",
          "url": "https://gn-web-assets.api.bbc.com/ngas/latest/dotcom-bootstrap.js"
        },
        {
          "columnNumber": 33897,
          "functionName": "",
          "lineNumber": 1,
          "scriptId": "12",
          "url": "https://gn-web-assets.api.bbc.com/ngas/latest/dotcom-bootstrap.js"
        },
        "... [3 more items]"
      ],
      "url": ""
    }
  }
}

See also v8.compile · v8.evaluateModule · ParseHTML · FunctionCall · RunMicrotasks

categories
devtools.timeline
usage
moderate
duration
long
undeclared args
args.data.columnNumber, args.data.frame, args.data.lineNumber, args.data.sampleTraceId, args.data.scriptId, args.data.url, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName and 3 more
references
classic_script.cc, inspector_trace_events.cc, script_loader.cc, source_location.cc, v8-debugger.cc, v8_script_runner.cc and 1 more
EventDispatchEvent

Chrome dispatches a DOM event, such as a click, to the element or document it is aimed at.

Spec

Blink's EventDispatcher runs one DOM event through its capture, target and bubble phases on the renderer's main thread, followed by whatever default action the browser takes for it; a few window events Blink fires itself are recorded the same way by LocalDOMWindow.

Arguments

pathdeclaredobserved
args.data.sampleTraceId-100.0%undeclared, always present
args.data.stackTrace[].columnNumber-23.4%
args.data.stackTrace[].functionName-23.4%
args.data.stackTrace[].lineNumber-23.4%
args.data.stackTrace[].scriptId-23.4%
args.data.stackTrace[].url-23.4%
args.data.typeyes100.0%

Observed 118,328 times on 182 of 300 sites.

Behaviour

Chrome writes one of these for every event dispatched at a node in the DOM (an element, the document, a shadow root), whether or not anything is listening. An event with no listeners still gets a slice; it is just short. So does an event that does not bubble, and so does one your own code fires with dispatchEvent(), click() or focus().

The slice stays open for the whole dispatch: capture listeners, the target, bubbling listeners up to and including window, and then, for events the browser fired itself and for any click, Blink's default action, such as toggling a checkbox, inserting a typed character or following a link to a #fragment. Events that a default action fires are dispatched inside the one that caused them, so they nest: DOMActivate inside click, input and change inside the click on a checkbox, beforeinput and textInput inside keypress, with input inside textInput. Not everything that follows input happens inside it: the focus change on a mouse press runs after the mousedown slice has closed, as separate focus and focusin slices beside it.

Events aimed at window are mostly not recorded. Blink sends a few of its own window events down a separate path that writes this event, among them load, pageshow, pagehide and resize. It does not for message, storage, popstate, hashchange, languagechange, online and offline, nor for anything script sends with window.dispatchEvent(). Targets outside the DOM never write one: XMLHttpRequest, AbortSignal, MessagePort, WebSocket, workers. Their listeners still run. When the browser fired the event, each listener appears as a bare FunctionCall with nothing naming the event; when script fired it synchronously, it may leave no event of its own at all.

Some input is dropped before it reaches the dispatcher. Unless the frame has a pointer event listener somewhere, Chrome does not dispatch pointerdown, pointerup, pointerover or pointermove at all, so a click shows mousedown, mouseup and click and no pointer events. One pointermove listener on an unrelated element is enough to switch all of them on. Boundary events such as mouseenter and pointerleave are only dispatched to targets that listen for them.

scroll, scrollend and resize are queued for the next animation frame and fired at most once per frame for each target: ten scrollBy() calls on two scrollers in one task produce two scroll slices. Chrome's input queue also holds continuous input (mousemove, wheel, touchmove) for the next frame and merges what piles up, unless the page has asked for unbuffered input. How closely the slices then follow the frame rate depends on how the input arrived: in a headless Chrome 154 capture driven through the DevTools protocol, a burst of 40 mouse moves became 8 mousemove slices less than a millisecond apart, between two frames.

Derivations

Event Timing, the API behind INP, never measures mousemove, pointermove, touchmove, wheel or scroll, nor focus events or custom events. For those, this event is the only place in a trace where main-thread time is labelled with the event that caused it. A scroll listener that costs 30 ms every frame is visible here, and INP never counts it as an interaction.

Inside an interaction, the slices split Event Timing's single processing figure by event. For an event with listeners, the slice's duration matched that event's Event Timing processing time to within 0.05 ms in headless Chrome 154 captures on macOS (pointerdown 30.39 ms, click 80.08 ms), and for input the browser delivered, its FunctionCall children name the listener function and its script.

Traps

This is not INP. DevTools builds interactions and the INP breakdown from EventTiming events alone and never reads this one. An interaction's duration also includes input delay and presentation delay, which no EventDispatch covers. And Event Timing records pointer events that were never dispatched, so an interaction on a page with no pointer listeners has a pointerdown with an interactionId and no EventDispatch to match it.

Nested slices double count. Sum only the outermost ones, or click plus the DOMActivate, input and change inside it count the same time twice.

The duration is not listener time. It includes Blink's default action, so on a page with no script at all a keypress contained style recalculation and layout for inserting the character. DevTools treats everything under an EventDispatch as running inside JavaScript when it looks for forced reflows, so once that work adds up to 30 ms in one task it can be flagged as forced although no script asked for it.

No EventDispatch does not mean no listener ran. message, storage, popstate, hashchange, window.dispatchEvent() and every non-DOM target run their listeners with no EventDispatch around them.

type names the event, not the code. The listener is the FunctionCall child, but only when no other script was running at the time. A listener for an event fired by dispatchEvent(), click() or focus() from inside another listener, a timer, a queueMicrotask callback or top-level script runs nested in that script and gets no FunctionCall of its own, so naming it takes the JavaScript sampler. Fired from a promise reaction or from code after an await, the same listener does get one, because Blink counts no script as running while promise reactions drain.

stackTrace is not about the listeners. It is captured as the dispatch begins, before any listener runs, so it is present only when script was running at that moment: dispatchEvent(), click(), focus(), or a DOM change made by script that fires an event synchronously. That includes the trusted blur and focus events a focus() call causes. Input the browser delivers never has one, however long its listeners ran. The field and sampleTraceId need disabled-by-default-devtools.timeline.stack, which DevTools records, and while the JavaScript sampler is running every dispatch also adds a sample to the CPU profile.

Whether a listener was passive is not recorded and does not change the slice: the same 40 ms wheel listener gives the same EventDispatch either way, and nothing here says whether scrolling waited for it. wheel, touchstart and touchmove listeners on window, document, <html> or <body> are passive unless registered with {passive: false}. Event Timing ignores wheel and touchmove, so a slow listener on either never shows up in INP as an interaction.

The input details Blink can attach (x, y, button, buttons, clickCount, key, code, modifier, timestamp, deltaX, deltaY) appear only when disabled-by-default-devtools.timeline.inputs is recorded, and DevTools does not record it. Pointer events other than click, auxclick and contextmenu never carry them.

A dispatch whose start and end carry the same timestamp is exported as an instant, "ph": "I" with no dur, because the trace exporter writes every zero-length slice that way. Code that matches on phase X, as trace_engine's type guard does, skips it.

A trace recorded from a full browser can also contain Chrome's own chrome:// interface pages, which dispatch events too. Filter on the page's renderer process.

Example event from https://www.bbc.com/news
{
  "name": "EventDispatch",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1108494551,
  "dur": 16,
  "tdur": 16,
  "tts": 151477,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "data": {
      "sampleTraceId": 5267982676862248,
      "stackTrace": [
        {
          "columnNumber": 105003,
          "functionName": "n.appendTo",
          "lineNumber": 2,
          "scriptId": "14",
          "url": "https://cdn.optimizely.com/public/4621041136/s/bbcx_prod.js"
        },
        {
          "columnNumber": 187223,
          "functionName": "",
          "lineNumber": 2,
          "scriptId": "14",
          "url": "https://cdn.optimizely.com/public/4621041136/s/bbcx_prod.js"
        },
        {
          "columnNumber": 4150,
          "functionName": "X",
          "lineNumber": 2,
          "scriptId": "14",
          "url": "https://cdn.optimizely.com/public/4621041136/s/bbcx_prod.js"
        },
        "... [7 more items]"
      ],
      "type": "readystatechange"
    }
  }
}

See also EventTiming · FunctionCall

categories
devtools.timeline
usage
high
duration
short
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-debugger.cc, inspector_trace_events.cc, script_loader.cc, heap.cc, event_dispatcher.cc and 1 more
FireAnimationFrameAnimation frame fired

Chrome runs one callback that the page queued with requestAnimationFrame().

Spec

Blink's FrameRequestCallbackCollection invokes one requestAnimationFrame callback of one document, as the last step before style, layout and paint in a main frame (or, in a dedicated worker, in that worker's own frame task), and records the call, together with any style, layout and promise reactions it triggers, as a complete event on that thread.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.idyes100.0%
args.data.sampleTraceId-100.0%undeclared, always present

Observed 18,378 times on 102 of 300 sites.

Behaviour

Chrome runs animation frame callbacks inside a main frame: the task the compositor sends to the main thread when a new frame is due, which starts with BeginMainThreadFrame. Blink's PageAnimator then works through the HTML "update the rendering" steps, and requestAnimationFrame callbacks are the last of them. Everything else runs first, in this order:

BeginMainThreadFrame
  input held for the frame     pointermove, mousemove
  events                       resize, scroll, scrollend, media queries
  animations                   updated, then animationend and similar events
  video frame callbacks        requestVideoFrameCallback
  FireAnimationFrame           one per callback
  style, layout, paint         UpdateLayoutTree, Layout, PrePaint, Paint
Commit

Each step runs for every document in the renderer before the next step starts, so a same-process iframe's scroll events run before the parent's callbacks, and the parent's callbacks run before the iframe's. A document is left out of the pass while it is throttled: a hidden cross-origin iframe, an iframe that has not started rendering yet, a frozen page. A hidden page gets no main frames at all, so its callbacks wait until it is shown again. In one capture a callback requested while its tab was in the background ran 2.15 seconds later, a fraction of a millisecond after the tab came back.

There is one event per callback, not one per frame. Three requestAnimationFrame calls in one task give three FireAnimationFrame events back to back in the next frame, in the order they were requested, each with its own id. Chrome takes a snapshot of the queued callbacks when it reaches this step. A callback requested from inside another callback waits for the next frame. A callback requested earlier in the same frame, from a scroll, resize, pointermove or animationend handler, is already in the snapshot and runs in this frame, about a tenth of a millisecond after it was requested. A callback cancelled by an earlier one in the same frame writes no event.

Chrome queues callbacks of its own in the same list: a <marquee> starting its animation, paint timing after a back/forward cache restore, the frame loop of an inline WebXR session, and a few others. They run after the page's callbacks in the same frame and write the same event. They are C++, so their FireAnimationFrame has no FunctionCall inside it; in one capture a <marquee>'s held several milliseconds of style and layout and no script.

The event wraps more than your function. It includes any style and layout the callback forces by reading geometry after a write, which appear as UpdateLayoutTree and Layout nested inside it, and the promise reactions the callback queues, which run in a RunMicrotasks inside it once the function returns. A callback doing 3 ms of work that queued a 6 ms promise reaction produced a 9 ms FireAnimationFrame. The frame's own style, layout, paint and commit wait in the same task until the last callback is done, so every millisecond here delays the frame.

In a dedicated worker, requestAnimationFrame runs on the worker's thread, in a microtask of the worker's own frame task, with no BeginMainThreadFrame, and the event carries worker instead of frame.

Derivations

Why a callback started late. The RequestAnimationFrame with the same frame (or worker) and id marks when the callback was queued. Split the time to this event at RequestMainThreadFrame, written on the renderer's compositor thread, and BeginMainThreadFrame, written on its main thread. Before RequestMainThreadFrame, the compositor was holding the frame: waiting for the next display refresh, for the previous frame, or because the page was hidden or not rendering yet. Between the two, the frame task sat behind other main-thread work. After BeginMainThreadFrame, the frame was running input, events, animations and earlier callbacks ahead of this one. In an idle requestAnimationFrame loop the whole wait fell before RequestMainThreadFrame; when the call was followed by a 100 ms loop in the same task, the whole 100 ms fell between the two. Both frame events are on disabled-by-default-devtools.timeline.frame, which a Performance panel recording includes.

Traps

The gap from RequestAnimationFrame is not how long the main thread was busy. A callback runs in the next frame by design, so in a smooth loop on a 60 Hz display the gap is about 16.7 ms with nothing else happening, and some gaps in an idle loop were 33 ms. Headless Chrome paces frames on its own schedule: in two idle captures the gaps ran from a few milliseconds to over 100 ms, with almost no main-thread work in between. A background tab gives gaps of seconds. The first callback after an idle stretch can run in about half a millisecond, and one requested from a scroll or input handler in the same frame within a tenth. A long gap points at a busy main thread only when it falls between RequestMainThreadFrame and BeginMainThreadFrame; see derivations.

Callbacks do not run at the start of the frame. Frame-aligned input, resize and scroll events, media query listeners and animation events all run first, in the same task, and may be your own handlers. A FireAnimationFrame that starts late in its frame was waiting for them, not slow itself.

Pair on frame (or worker) and id, never on id alone. Every document and every dedicated worker counts its callbacks from 1, so a page and its iframe both have an id 1. When a parent calls iframe.contentWindow.requestAnimationFrame(), the event carries the iframe's frame. frame also stays the same across navigations. From Chrome 152 Chrome's own callbacks number from a second counter in the same document, so a <marquee>'s callback and your first callback can both be id 1 on the same frame; before 152 they took ids from your page's sequence, which is why a page's ids can skip numbers in older traces. CancelAnimationFrame covers both cases.

No FunctionCall inside means the callback was Chrome's. A page's callback is script, and in every capture its event had a FunctionCall child. One with only style and layout inside was one of Chrome's own callbacks, and that layout is Chrome's work, not something your code forced.

Layout inside your callback's event is forced layout. UpdateLayoutTree or Layout nested under the FunctionCall happened because the callback read layout after changing styles, separately from the frame's own style and layout that follow it. The event's duration includes that work and the callback's promise reactions, so it can be well above the FunctionCall inside it.

No stack, ever. Chrome writes this event with no script on the stack, so it never has stackTrace, even with disabled-by-default-devtools.timeline.stack recorded. To find the code that asked for the callback, read the stack on the matching RequestAnimationFrame; for one of Chrome's callbacks that stack, when there is one, is the script that triggered it, such as the one that inserted the <marquee>. sampleTraceId, present only when that stack category is on, tags a CPU profiler sample for this event; it is not shared with the request.

Workers write worker, not frame. trace_engine types the payload as frame and id only, so a typed reader sees an undefined frame for worker events. They are on the DedicatedWorker thread and have no BeginMainThreadFrame to anchor them.

WebXR callbacks are not here. XRSession.requestAnimationFrame() callbacks are written as ExecuteRAFCallbacks on the xr category. From the source (not captured), an inline WebXR session paces itself with one of Chrome's own animation frame callbacks, so it does leave FireAnimationFrame events, with no script under them.

A whole-browser trace includes Chrome's own pages. A trace recorded with Puppeteer's page.tracing covers every process, and in headful captures a FireAnimationFrame came from Chrome's WebUI Top Renderer, the renderer for the browser's own interface. Filter on the page's renderer process or its frame tokens.

In DevTools the event is labelled "Animation frame fired" in the Scripting colour, with a "Callback ID" row in the details pane. From the source, DevTools links the request to the callback's FunctionCall inside this event, using V8's async-task events on disabled-by-default-v8.inspector, and gives the event itself no initiator; this was not checked in the panel.

Your harness can create these. Puppeteer's page.waitForFunction() polls with requestAnimationFrame by default, so a test that waits that way adds a callback to every frame for as long as it waits.

Example event a local fixture page
{
  "name": "FireAnimationFrame",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103043074,
  "dur": 35,
  "tdur": 24,
  "tts": 133358,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "id": 1,
      "sampleTraceId": 6909218608593039
    }
  }
}

See also RequestAnimationFrame · CancelAnimationFrame · BeginMainThreadFrame · RequestMainThreadFrame · FunctionCall · RunMicrotasks · LocalFrameView::ScheduleAnimation · AnimationFrame

categories
devtools.timeline
usage
moderate
duration
long
undeclared args
args.data.sampleTraceId
references
frame_request_callback_collection.cc, inspector_trace_events.cc
FireIdleCallbackFire idle callback

Chrome runs a callback that was queued for when the main thread is idle, such as one passed to requestIdleCallback().

Spec

Blink's ScriptedIdleTaskController runs one idle callback on a document's main thread, either from the scheduler's idle task during an idle period or from the callback's timeout task once its timeout has passed, and records how much of the idle period was left when it started.

Arguments

pathdeclaredobserved
args.data.allottedMillisecondsyes100.0%
args.data.frameyes100.0%
args.data.idyes100.0%
args.data.sampleTraceId-100.0%undeclared, always present
args.data.timedOutyes100.0%

Observed 3,217 times on 93 of 300 sites.

Behaviour

When a page calls requestIdleCallback(cb), Chrome queues cb on the main thread's idle queue, which only runs during an idle period: a stretch the scheduler declares when there is nothing else to do. After a frame is committed there is a short idle period that ends when the next frame is due. When no frames are expected there is a long idle period of up to 50 ms, cut short if any delayed task, such as a timer, is due sooner. The event's slice covers the callback itself, so a JavaScript callback shows a FunctionCall inside it.

allottedMilliseconds is the time left until the end of the current idle period at the moment the callback started, which is what deadline.timeRemaining() returns on its first line. It is not a budget given to each callback. Callbacks queued before an idle period starts all run in that period, one task each, and each is told what the previous ones left: in Chrome 154 on a Mac, four callbacks of 8 ms each, queued together on a quiet page, were allotted about 50, 42, 34 and 26 ms. A callback queued from inside an idle callback waits for the next idle period.

With {timeout: n}, Chrome also posts an ordinary task for n ms later. Whichever runs first cancels the other, so a callback writes at most one FireIdleCallback. If the timeout task wins, timedOut is true and the callback runs as that ordinary task instead of as an idle task. Chrome passes it a deadline of "now", so allottedMilliseconds is exactly 0 and timeRemaining() returns 0 throughout. The task sits on a queue with the same scheduling rules as a deeply nested setTimeout: it can be throttled, paused and frozen with the page.

Some callbacks never write the event. A cancelled callback writes nothing. So does one whose document goes away first. A callback in a paused or frozen document waits until it resumes, and one that comes up while the scheduler is giving priority to input is put back on the idle queue.

Chrome's own idle work goes through the same controller and writes the same event. A <link rel="compression-dictionary"> is fetched from an idle callback, which shows up as a FireIdleCallback with no FunctionCall inside and a ResourceSendRequest for the dictionary. Spell checking does the same once the user types into an editable field: first with a 200 ms timeout, later without one. These internal callbacks draw their trace id from the same per-document counter as the page's own. In Chrome 155 and earlier that counter is also what requestIdleCallback() returns, so on a page with a dictionary link Chrome 154 returned 2 from the page's first call.

requestIdleCallback exists only on Window, so the event is only ever on a renderer main thread, and it always carries frame. devtools.timeline is recorded by default, so a Performance panel recording always captures it.

Derivations

On a timedOut: false event, dur minus allottedMilliseconds, when positive, is how far the callback ran past the end of its idle period. That is time a frame or an input event may have had to wait for, and it is the work that should check timeRemaining() and split itself.

ts minus the ts of the RequestIdleCallback with the same frame and id is how long the callback queued. Compare it to that event's timeout: a callback that keeps hitting its timeout is on a main thread that did not reach an idle period within that time.

Traps

DevTools flags every timed-out callback. Its rule is dur in milliseconds greater than allottedMilliseconds, and it never checks timedOut. A timed-out callback's allotment is 0, so a 2 ms timed-out callback gets the red warning triangle and "Idle callback execution extended beyond deadline by 2 ms", the same warning as an idle callback that really overran. Only timedOut: false events tell you anything about overrunning.

Not every FireIdleCallback is your code. Chrome's own idle tasks (dictionary fetches, spell checking) write the event and take ids. Look for a FireIdleCallback with no FunctionCall inside whose RequestIdleCallback has no stackTrace; the second check only works if disabled-by-default-devtools.timeline.stack was recorded.

id is not unique across a trace. Each document counts from 1, so join to RequestIdleCallback on frame and id, and split at navigations. DevTools' initiator link uses id alone: when two frames queue a callback with the same id before either runs, its "Idle callback requested" arrow points at the other frame's request. In Chrome 155 and earlier, id is the number requestIdleCallback() returned to the page. From Chrome 156 the page gets numbers from a separate counter and the trace keeps Chrome's internal id, so the two differ once any internal idle task has run in that document.

allottedMilliseconds depends on how the page was producing frames, not on your callback: during an animation it is at most a frame, on a quiet page up to 50 ms. Do not read a small value as a sign of trouble.

The event never has a stackTrace, because no script is running when it starts. Who requested the callback is on the matching RequestIdleCallback. sampleTraceId is present whenever disabled-by-default-devtools.timeline.stack was recorded, though trace_engine does not declare it.

Example event a local fixture page
{
  "name": "FireIdleCallback",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103056100,
  "dur": 51,
  "tdur": 43,
  "tts": 136144,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "allottedMilliseconds": 16.446,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "id": 1,
      "sampleTraceId": 6909218608593143,
      "timedOut": false
    }
  }
}

See also RequestIdleCallback · CancelIdleCallback · FunctionCall · FireAnimationFrame · TimerFire

categories
devtools.timeline
usage
moderate
duration
long
undeclared args
args.data.sampleTraceId
references
scripted_idle_task_controller.cc, inspector_trace_events.cc
FunctionCallFunction call

Chrome calls one of your JavaScript functions, such as an event listener or a timer callback.

Spec

Blink's V8ScriptRunner::CallFunction (or CallAsConstructor) invokes a JavaScript callback while no other Blink script entry is open, on the thread that owns the callback: the renderer's main thread or a worker.

Arguments

pathdeclaredobserved
args.data.columnNumber-100.0%undeclared, always present
args.data.frameopt97.4%
args.data.functionName-99.7%undeclared, always present
args.data.isolateopt100.0%
args.data.lineNumber-100.0%undeclared, always present
args.data.sampleTraceId-100.0%undeclared, always present
args.data.scriptId-100.0%undeclared, always present
args.data.url-100.0%undeclared, always present

Observed 287,447 times on 182 of 300 sites.

Behaviour

Whenever Blink hands control to a JavaScript function it goes through one of two functions in its V8 bindings, and this event brackets that call. That covers event listeners in all three forms (a function, an object with handleEvent, an inline onclick attribute), setTimeout and setInterval callbacks, requestAnimationFrame and requestIdleCallback callbacks, message handlers, queueMicrotask and MutationObserver callbacks, and the constructor of a custom element the parser creates. Workers write it too. A listener therefore shows up as EventDispatch with one FunctionCall per listener inside it, and a timer as TimerFire with one FunctionCall inside it.

It is only written when Blink is not already inside one of its own script entries. Every time Blink runs script, a top-level <script> or a callback, it opens a microtask scope (a counter V8 keeps so that microtasks wait until the outermost entry has finished), and FunctionCall is written only when that counter is zero. So a listener fired by el.click() or dispatchEvent() from inside another handler, a custom element constructor run by document.createElement, or an event dispatched from top-level script gets no FunctionCall: it appears as an EventDispatch nested inside the outer FunctionCall or EvaluateScript, with your code directly inside it. The counter is back at zero by the time microtasks run, so the same click() made from a promise reaction or after an await does get its own FunctionCall, under RunMicrotasks > EventDispatch. The sibling event v8.callFunction, on the v8 category, has no such gate and wraps every function call, nested or not; constructors write v8.callAsConstructor instead.

The slice ends when the function returns. The microtask checkpoint runs as the outermost entry closes, so it follows the FunctionCall as a RunMicrotasks slice beside it, inside the same v8.callFunction. Promise reactions and the code after an await are run by V8 itself in that checkpoint and get no FunctionCall, and an async function's own FunctionCall ends at its first await. queueMicrotask and MutationObserver callbacks run in the same checkpoint but are called through Blink, so each gets one. A timer given a string instead of a function is compiled as a script and appears as EvaluateScript.

The event carries the function's identity. functionName is the name V8 would show in a stack trace, including names it infers from assignment (x.onreadystatechange), onclick for an inline handler and the class name for a constructor. url, scriptId, lineNumber and columnNumber locate the function's definition. The position is 1-based and points at the ( that opens the parameter list, not at the function keyword, and for an inline handler it is on the line of the element's tag in the HTML. A function bound with bind() is unwrapped once, so you get the name and location of the function you wrote.

Derivations

Which function a task ran, without JavaScript sampling. The flame chart labels the slice "Function call" and the details pane shows functionName with a link to url:lineNumber, all from the event itself. Sampling adds the call tree underneath, and sampleTraceId is what lines it up: writing the event forces the V8 CPU profiler to take a sample tagged with that id, so DevTools can start the stack at the moment the call began instead of at the next regular sample.

How much of a callback's work ran as promise code. The FunctionCall ends when the function returns, and the microtask checkpoint runs straight afterwards as a RunMicrotasks slice beside it, inside the same v8.callFunction. Inside that RunMicrotasks, queueMicrotask and MutationObserver callbacks have FunctionCalls of their own; the time they do not cover is promise reactions and await continuations, which nothing else names without JavaScript sampling.

Traps

It does not cover all of a handler's work. A listener that does its real work in a .then() or after an await has a short FunctionCall and a long RunMicrotasks after it. Whether a handler has a FunctionCall at all depends on its caller: one fired by click() or dispatchEvent() from inside another callback or from top-level script has none, while the same handler fired from a promise reaction has one. Summing FunctionCall durations under an interaction undercounts its processing time; read EventDispatch and RunMicrotasks as well, or v8.callFunction, which the Performance panel records by default. DevTools' own interaction breakdown is built from Event Timing data and does not read this event.

The location is the definition, not the hot line. lineNumber and columnNumber say where the function starts. The line that forced a layout or threw is on the child event's own stack trace, and the two differ for any function longer than one line. The numbers are 1-based, unlike a CPU profile's call frames, and DevTools subtracts one before showing them.

Empty is not missing. An anonymous function, including every unnamed arrow function, has functionName: "", which is present; DevTools displays it as "(anonymous)". The field is absent only when the function is not a plain JavaScript function after one unwrapping of bind(): a function bound twice, or a callable Proxy. Built-ins and other native functions have a name but no script: setTimeout(console.log) writes log, an empty url, scriptId "0" and line and column 0, and the twice-bound case writes the same location with no name.

isolate is a string, and must stay one. It is a random 64-bit id written in decimal, so it is almost always above 2^53, and parsing it as a JavaScript number silently rounds it, which breaks the match against the same id on other events. scriptId is a string too.

frame is absent in workers. It is written only when the callback's context is a window that still has a frame. Filter on pid and tid as well as frame.

Not every FunctionCall is the page's. Tracing is browser-wide, and Chrome's own WebUI renderers, such as the omnibox popup, run JavaScript of their own: their FunctionCalls carry chrome:// URLs and sit in a separate process.

An unfinished call has no duration. A FunctionCall whose end is missing from the trace stays a B event, and DevTools draws it as a zero-length slice. Why the end goes missing is unverified; a call still running when recording stopped is the obvious candidate.

The ForcedReflow insight does not detect forced layout from this event. DevTools flags any Layout or UpdateLayoutTree that runs while any JavaScript entry event is open (EventDispatch, EvaluateScript, RunMicrotasks and others as well as FunctionCall), and only when one task's total of them reaches 30ms. FunctionCall's part is naming: the insight walks up from each flagged layout through the sampled JavaScript frames, and if the first trace event above them is a FunctionCall with its name and location all present, that FunctionCall becomes the "Top function call" and is outlined in the flame chart. A layout forced from top-level script, from a promise reaction, or from a handler fired by click() inside another handler has some other event directly above it, so without JavaScript sampling it counts towards no top function; only the layout's own stack trace still names the line, in the insight's list of call frames.

Example event a local fixture page
{
  "name": "FunctionCall",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103042343,
  "dur": 34,
  "tdur": 28,
  "tts": 132806,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "columnNumber": 26,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "functionName": "x.onreadystatechange",
      "isolate": 13986237028531430000,
      "lineNumber": 70,
      "sampleTraceId": 6909218608593055,
      "scriptId": "7",
      "url": "http://127.0.0.1:8801/"
    }
  }
}

See also EventDispatch · TimerFire · FireAnimationFrame · FireIdleCallback · HandlePostMessage · RunMicrotasks · EvaluateScript · ProfileChunk · UpdateCounters · Layout

categories
devtools.timeline
usage
high
duration
typical
undeclared args
args.data.columnNumber, args.data.functionName, args.data.lineNumber, args.data.sampleTraceId, args.data.scriptId, args.data.url
references
inspector_trace_events.cc, v8-inspector-impl.cc, thread_debugger_common_impl.cc, heap.cc, source_location.cc, v8_script_runner.cc
GCEventGC eventno longer emitted

A garbage collection of the JavaScript heap, recorded without saying which kind of collection it was.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Spec

GCEvent was a begin/end trace event that Blink's V8GCController wrote on disabled-by-default-devtools.timeline from V8's GC prologue callback to its epilogue callback, on the main thread or worker whose V8 isolate collected, around every scavenge and full mark-sweep-compact (and, from Chrome 42, the incremental marker's weak-closure step), in Chrome 36 to 44.

Behaviour

Chrome no longer writes it. Blink added it in r173290 (May 2014, first shipped in Chrome 36) so that the DevTools Timeline, then moving onto tracing, could show garbage collections. Blink registered GC prologue and epilogue callbacks on every V8 isolate (the JavaScript engine instance a thread runs, so the main thread and each worker) with no type filter, and wrote a GCEvent begin record in the prologue and the end record in the epilogue. V8 at the time had two kinds of collection: a scavenge of the young generation and a full mark-sweep-compact. Each one wrote a GCEvent. The begin carries usedHeapSizeBefore, the end usedHeapSizeAfter, both the isolate's used JavaScript heap in bytes.

From Chrome 42 to 44, V8 also ran those callbacks outside a collection. When incremental marking (the part of a full GC that runs in small steps between tasks) had finished its marking work, V8 ran a short weak-closure step that called the embedder's prologue and epilogue as if a mark-sweep-compact were happening, so Blink wrote a GCEvent around it. That event collected nothing; the real collection follows as its own GCEvent.

The span is Blink's as much as V8's. The prologue walks Blink's DOM wrappers to tell V8 which objects belong together, the epilogue does Blink's bookkeeping and, when V8 flagged the GC as forced (the source says this is for tests that force GCs from JavaScript), collects Blink's own C++ heap before the end record. V8 work outside the two callbacks is not inside it.

The event never said which kind of collection it was. Blink wrote that separately, as lowercase minorGC and majorGC on the v8 category, nested inside the GCEvent, and the Timeline panel of Chrome 41 to 44 did not record v8, so a DevTools recording showed every collection the same way.

Blink r196147 (29 May 2015) removed it. The same change renamed the lowercase pair to MinorGC and MajorGC, moved them onto devtools.timeline,v8 and gave them the two heap-size arguments. It landed after Chrome 44 branched, so Chrome 44 is the last version that writes GCEvent and Chrome 45 the first that writes MinorGC and MajorGC. Today V8 writes those two itself, from heap.cc, with a type argument naming the reason. No current V8 code writes a GCEvent, nor do the Chromium directories that hold its tracing and GC code, and two Chrome 154 captures with every GC category on contained none.

Traps

A GCEvent in a trace means the trace came from Chrome 36 to 44. In anything newer, look for MinorGC and MajorGC. Those were not split out of GCEvent: the typed events existed alongside it all along, in lowercase on v8, and were renamed when it was removed.

Not every GCEvent is a collection. In Chrome 42 to 44, a full GC that V8 marked incrementally produces a short GCEvent for its weak-closure step before the one for the collection itself. The heap barely moves in the first, and its nested majorGC looks like a real one.

The duration is not V8's pause. It includes Blink's DOM-wrapper walk at the start and Blink's epilogue at the end, which after a forced GC means a full collection of Blink's own heap.

The "Chrome 41" date that circulates for this event belongs to something else with the same name. Before tracing, the DevTools Timeline protocol sent its own GCEvent records, carrying usedHeapSizeDelta, over the protocol rather than into a trace. Blink r189656 removed that implementation in February 2015, so it was last in Chrome 41, and nothing replaced it with MinorGC or MajorGC. The trace event lived on until Chrome 44.

DevTools still knows the name in four places: the GC = 'GCEvent' member of its event-name enum, a style that draws it in the scripting colour labelled "GC event", the set of events the flame chart shows, and the Summary's "Collected" row, which it shares with MajorGC and MinorGC. No handler processes it. The name is there to draw and label an old event; without it a trace would still load, with the event hidden.

That Collected row subtracts usedHeapSizeAfter from usedHeapSizeBefore on one event. An old GCEvent holds them on separate begin and end records, and the trace engine keeps only the begin's arguments when it pairs them: a begin/end GCEvent fed to trace_engine 0.0.65 came out with usedHeapSizeBefore alone. For a real old trace the row has nothing to subtract, so pair the two records yourself. The difference is the change in used JavaScript heap across the span, not memory freed: the weak-closure step frees nothing, and Blink's own heap is not counted.

Lighthouse dropped it from its garbage-collection group, and its comment 'MinorGC', // Previously GCEvent leaves out that MajorGC replaced it too. An old GCEvent falls into its parent task's group, or Other.

Searching V8 for the string finds CodeMovingGCEvent, a code-logging callback, and cppgc's ExtractLastFullGcEvent. Neither is a trace event.

Example event

Chrome last wrote this in Chrome 44. From Chrome 45 collections appear as MinorGC and MajorGC, which the collection recorded.

See also MinorGC · MajorGC · BlinkGC.AtomicPhase

MajorGCMajor GC

V8 runs a major garbage collection, one that covers the whole JavaScript heap rather than only its newest objects.

Spec

One pause of V8's mark-compact (full) garbage collector on the thread that owns the JavaScript heap, written by Heap::DevToolsTraceEventScope in V8's heap.cc on devtools.timeline,v8: when marking already ran incrementally, the final atomic pause that finishes marking (Blink's C++ heap included), clears dead references and moves surviving objects out of the young generation and off the old pages chosen for compaction; otherwise the entire collection.

Arguments

pathdeclaredobserved
args.type-100.0%undeclared, always present
args.usedHeapSizeAfter-100.0%undeclared, always present
args.usedHeapSizeBefore-100.0%undeclared, always present

Observed 261 times on 86 of 300 sites.

Behaviour

V8 collects its whole heap, young and old generations together, with a mark-compact collection: mark every reachable object, clear what is dead, move survivors together. Marking a large heap in one go would freeze the page, so V8 normally does it incrementally. When the heap nears its budget, or V8's memory reducer decides an idle page could give memory back, V8.GCIncrementalMarkingStart opens a cycle. Marking then advances in small V8.GCIncrementalMarking steps on the main thread, some as tasks of their own and some inside your script whenever it allocates, while worker threads mark concurrently. When marking is complete V8 stops the main thread once to finish the job, and that pause is the MajorGC. Inside it V8 completes marking, including Blink's C++ heap (the CppGC.AtomicMark, CppGC.AtomicWeak and CppGC.AtomicSweep slices), clears weak references, and moves every surviving object out of the young generation and the live objects off the old pages it chose to compact. Sweeping the freed memory continues after the pause, on worker threads and in CppGC.IncrementalSweep steps.

The pause is usually started one of two ways, and type says which. Normally the task that runs marking steps finds marking complete and finishes it, so the MajorGC sits in a task with no script around it and type is "finalize incremental marking via task". If marking completes while your script keeps allocating and that task does not get to run soon enough, V8 requests the pause through the stack guard, the interrupt check V8 runs inside JavaScript, and the MajorGC ("finalize incremental marking via stack guard") sits inside the script that was running, under V8.StackGuard and V8.HandleInterrupts on the v8.execute category. Anything else that asks for a full collection while marking is under way also ends the cycle, with its own reason: Blink's C++ heap overshooting its budget ("external finalize"), a young-generation collection that has to become a full one ("allocation failure"), or DevTools' Collect garbage.

A collection with no incremental marking before it does all of its work in one MajorGC. That is what gc() (type "testing") and DevTools' Collect garbage action ("low memory notification") produce when no cycle is running. The child slice on v8 tells the two kinds apart: V8.GCFinalizeMC (or V8.GCFinalizeMCReduceMemory, V8.GCFinalizeMCMeasureMemory) closes an incremental cycle, V8.GCCompactor is a complete collection in one pause.

You get one MajorGC per cycle, and a cycle starts when the heap approaches V8's allocation limits. The global limit counts the JavaScript heap, Blink's C++ heap (DOM nodes and other Blink objects) and external memory such as ArrayBuffer contents, so DOM churn and large buffers bring the next MajorGC closer just as JavaScript objects do. V8.GCIncrementalMarkingStart records which limit it was in args.value.reason.

Recording either devtools.timeline or v8 is enough to get it. It is written on the thread that owns the heap: the main thread for a page, the worker's own thread for a worker. DevTools draws it as "Major GC" in the scripting colour, and its details pane shows a "Collected" row, usedHeapSizeBefore minus usedHeapSizeAfter, in bytes. The panel does not show type.

Derivations

The main-thread cost of a whole major collection, not just its final pause. Every V8.GCIncrementalMarking step carries an epoch, and it is the same epoch as the V8.GC_MARK_COMPACTOR slice inside the MajorGC that ends the cycle; a DevTools recording contains both. Add those steps, the cycle's V8.GCIncrementalMarkingStart and the MajorGC itself. How that total splits depends on how the page allocates. In Chrome 154 captures on a Mac, a page allocating in short timer callbacks spent more main-thread time in the steps than in the pauses, while the same allocation in one long synchronous loop spent more in the pauses. Steps nested inside your script's FunctionCall or EvaluateScript ran inside it and count toward its duration.

Why the cycle started. MajorGC's type explains the pause; the V8.GCIncrementalMarkingStart before it carries the cycle's own gc_reason and reason (for example "approaching global allocation limit") plus the limits and consumed bytes at that moment.

The live size of the JavaScript heap. After a full mark usedHeapSizeAfter is what survived in V8's heap, so its values across successive MajorGCs are that heap's floor over the recording. A floor that keeps rising is JavaScript memory being retained, which a single MajorGC's reclaimed amount cannot tell you.

Traps

It is the pause, not the collection. For an incremental cycle the marking happened before the MajorGC, in V8.GCIncrementalMarking steps on the main thread and in concurrent marking on worker threads, and the sweeping happens after it. A short MajorGC does not mean garbage collection was cheap. A DevTools recording contains the steps, because v8 is one of its default categories, but the Performance panel draws them only when its timeline-show-all-events setting is on, and the ones that run on allocation sit inside your own script.

type is the reason for the pause, not for the collection. A cycle that V8 started because the heap neared its limit, or from its memory reducer, ends in a MajorGC typed "finalize incremental marking via task" or "via stack guard", or with the reason of whatever else forced the end, such as "external finalize" or "allocation failure". The starting reason, "task", "allocation limit", "global allocation limit" or "memory reducer", is on V8.GCIncrementalMarkingStart. Whatever its type, a MajorGC with a V8.GCFinalizeMC child closed an incremental cycle.

The reason list is shared, but not every reason goes to both events. MajorGC and MinorGC take their type from one enum and one string table in V8's globals.h. The two "finalize incremental marking" reasons only ever produce a MajorGC, and the two MinorMS finalization reasons only ever produce a MinorGC. For the rest, V8 picks the collector from the state of the heap rather than from the reason, so "allocation failure" in particular can produce either.

The heap sizes leave out Blink's C++ heap. usedHeapSizeBefore and usedHeapSizeAfter are bytes in V8's own heap spaces for this isolate. They do not include Blink's C++ heap, where DOM nodes and most Web API objects live, external memory such as ArrayBuffer contents, or V8's read-only space. A leak of detached DOM nodes or of buffers can grow without moving these numbers much, even though the same memory is what brings MajorGCs on.

Reclaiming little is not a leak signal. DevTools' Collect garbage action runs at least two full collections back to back, and the later ones reclaim little or nothing by design; a recording in which you pressed it contains MajorGCs that the button caused. A heap that is mostly live data also reclaims little without leaking. Read the trend of usedHeapSizeAfter instead.

The difference is not old-generation garbage. A major collection also empties the young generation, so usedHeapSizeBefore - usedHeapSizeAfter includes short-lived objects a MinorGC would have collected anyway.

Forced collections are a different kind. gc() and Collect garbage do not mark incrementally. With no cycle running, their MajorGC holds the whole collection, marking included, and is longer than a natural pause on the same page; with one running, they cut it short and finish it in one pause. Do not use them to estimate what users pay.

A cut trace can leave half an event. V8 writes MajorGC as a begin/end pair that the trace export joins into one X slice. A trace that stops during the pause keeps only the begin, with no usedHeapSizeAfter; the corpus shows this for MinorGC, which comes from the same code.

The arguments are only in the raw JSON. trace_engine declares the event name but none of its three arguments. DevTools reads the two sizes without a declared type to produce "Collected", and shows type nowhere.

Example event from https://www.nytimes.com
{
  "name": "MajorGC",
  "cat": "devtools.timeline,v8",
  "ph": "X",
  "ts": 1128651815,
  "dur": 4266,
  "tdur": 3980,
  "tts": 941986,
  "pid": 8798,
  "tid": 8798,
  "args": {
    "type": "finalize incremental marking via stack guard",
    "usedHeapSizeAfter": 21610676,
    "usedHeapSizeBefore": 25730556
  }
}

See also MinorGC · CppGC.IncrementalSweep · V8.GCIncrementalMarking · V8.GCIncrementalMarkingStart · BlinkGC.AtomicPhase · GCEvent · FunctionCall

categories
devtools.timeline, v8
usage
low
duration
very long
undeclared args
args.type, args.usedHeapSizeAfter, args.usedHeapSizeBefore
references
heap.cc, globals.h
MarkDOMContentDOMContentLoaded event

This event marks the moment a document's DOMContentLoaded event finished, after the HTML was parsed and every listener for the event had run.

Spec

Blink's Document::FinishedParsing writes this instant on the renderer main thread of the document's frame, straight after it has dispatched DOMContentLoaded and every listener (with the microtasks they queued) has returned, and just before it stamps Navigation Timing's domContentLoadedEventEnd.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.isMainFrameyes100.0%
args.data.isOutermostMainFrameopt100.0%
args.data.pageyes100.0%

Observed 3,743 times on 182 of 300 sites.

Behaviour

When the HTML parser reaches the end of a document, it first runs the scripts that were waiting for parsing to finish, the defer scripts, and then calls Document::FinishedParsing. That function stamps Navigation Timing's domContentLoadedEventStart, dispatches DOMContentLoaded synchronously, and writes this mark only once the dispatch has returned. Then it stamps domContentLoadedEventEnd. So by the time the mark is written, every DOMContentLoaded listener on the document and the window has run, and so have the microtasks those listeners queued. In a capture with a 60 ms listener the mark landed 60 ms after the event started, a few microseconds after the EventDispatch slice for DOMContentLoaded closed, and under 20 microseconds before domContentLoadedEventEnd. It is the end of DOMContentLoaded, not the start.

A stylesheet holds the mark back only through scripts. A defer script will not run while a stylesheet is still loading, wherever the script sits in the markup, and an inline script after a <link> waits for it too. In one capture a page with a 300 ms stylesheet and no script wrote the mark at 5 ms. The same page with a defer script placed before the <link> wrote it at 313 ms, just after the stylesheet arrived.

It is an instant (I), with no duration, on the main thread of the renderer that holds the document. It normally sits inside the parser task that finished the document, right after that EventDispatch slice. Blink also wraps the whole thing in a Document::FinishedParsing slice, but that is on the blink category, which the Performance panel does not record.

Every document that finishes parsing in an attached frame writes one. Each iframe writes its own, a cross-site iframe in its own renderer process, and that includes srcdoc iframes and the about:blank document Chrome commits for an iframe with no src. A reload or any later navigation in the same frame writes a new one under the same frame id. So does document.open(), write() and close() on an existing document: the document is parsed again, and a second mark appears for the frame with no navigationStart before it. Navigation Timing ignores that second pass, because Blink only sets its DOMContentLoaded timestamps once.

Three gates in front of the mark leave a DOMContentLoaded with no mark. The empty document every frame starts with is created while script is forbidden, so it dispatches no DOMContentLoaded at all, though Navigation Timing still records timestamps for it. A document with no frame, such as one from DOMParser, dispatches the event and writes nothing. And a frame that is detached by its own listeners, an iframe that removes itself in DOMContentLoaded, has run the event but writes no mark. A restore from the back/forward cache or a soft navigation parses nothing, so it should write none either; that follows from the single call site and was not captured.

The four fields come from one builder that MarkLoad shares. frame is the document's frame. page is the frame id of the frame's local root: the topmost ancestor frame that lives in the same renderer process. isMainFrame says the frame has no parent. isOutermostMainFrame says it has no parent and is not the root of a <fencedframe>. A fenced frame's root is the only place the two differ, and from Chrome 156, which removes fenced frames, the check returns false outright and the two never differ. Traces from before Chrome 105 have no isOutermostMainFrame at all.

Derivations

DevTools' DCL is this ts minus the ts of the latest navigationStart for the same frame at or before it. Because of where the mark is written, that is the time to domContentLoadedEventEnd, not to domContentLoadedEventStart. DevTools gives it no good or poor rating.

The time the page's own DOMContentLoaded listeners took is this ts minus the start of the EventDispatch slice for DOMContentLoaded just before it, or minus the domContentLoadedEventStart mark carrying the same frame id. The trace gives you this for every frame, including cross-origin iframes whose timing no script on the page can read.

Traps

DevTools' DCL is the end of DOMContentLoaded. If your RUM reports domContentLoadedEventStart, the trace will be later by however long the listeners ran. A late mark is therefore not only a markup or delivery problem: it can be your own DOMContentLoaded handlers. Look at the EventDispatch slice before it.

page is not the page. It is the local root. It equals frame for the main frame, for every out-of-process iframe and for a fenced frame, even one in the host's process, and it equals the main frame's id only for an iframe in the same process. Chromium's own comment on the field says it does not work with out-of-process iframes and that consumers should use frame.

isOutermostMainFrame: true does not mean the page you tested. A speculation-rules prerender writes its mark with both flags true, in its own process, under a frame id that is not the tab's, before the user has navigated. A browser-wide trace can also carry main-frame marks from other renderers. DevTools does not trust the flag. It keeps only marks whose frame matches the main frame named in TracingStartedInBrowser, and draws the DCL marker for that frame alone. In a trace that spans a prerender activation, that is the page the recording started on: trace_engine computed a DCL for the prerendered page in one capture but drew no marker for it.

One frame, two marks. A document.open() rewrite writes a second mark with no navigation of its own, so DevTools pairs it with the frame's last navigationStart and replaces the first. On the main frame that changes the headline number. In one capture a page with a DCL of 95 ms was rewritten by a script 1.2 s later, and trace_engine then reported a DCL of 1213 ms and the panel drew two DCL markers, while Navigation Timing kept the original.

A DOMContentLoaded that ran is not proof of a mark. Initial empty documents, frameless documents and frames detached by their own listeners produce none, so counting marks undercounts the documents that fired the event.

The Performance panel shows this event only as the DCL marker over the timeline, and only for the main frame; the event itself is hidden from the thread track.

There is no navigationId on this event, unlike the paint marks. Join on args.data.frame and the nearest preceding navigationStart, and remember that a reload keeps the frame id. The frame id is at args.data.frame, as on MarkLoad, not at args.frame, where navigationStart and firstContentfulPaint keep theirs.

Example event a local fixture page
{
  "name": "MarkDOMContent",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102947852,
  "tts": 43960,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "isMainFrame": true,
      "isOutermostMainFrame": true,
      "page": "14A831DCB73EB1205B67CDB89FC6EBD7"
    }
  }
}

See also MarkLoad · navigationStart · EventDispatch · domContentLoadedEventStart · domContentLoadedEventEnd · Document::FinishedParsing · ParseHTML

categories
devtools.timeline
usage
moderate
references
inspector_trace_events.cc, document.cc, local_dom_window.cc
MarkLoadOnload event

Chrome has finished firing a document's load event.

Spec

Blink's LocalDOMWindow::DispatchLoadEvent writes this instant on a frame's renderer main thread after the window load event has been dispatched and every listener on it has returned, so for a top-level page its timestamp is loadEventEnd, not the moment load fired.

Arguments

pathdeclaredobserved
args.data.frameyes100.0%
args.data.isMainFrameyes100.0%
args.data.isOutermostMainFrameopt100.0%
args.data.pageyes100.0%

Observed 4,120 times on 182 of 300 sites.

Behaviour

A document fires load when Blink's completion check passes: parsing has finished, no load-blocking request is in flight, the document's load-delay counter is at zero, and no child frame is still loading. The load-blocking requests are images (CSS backgrounds included), stylesheets, fonts, SVG documents and XSL. Scripts and media hold load through the counter instead: an async or script-inserted script holds it until it has run, not just downloaded, and a <video> or <audio> until its first data arrives or it fails. An iframe holds its parent until the iframe's own load event, and so for everything the iframe is waiting on, whether it shares the parent's process or is a cross-site iframe in another one.

What it does not wait for is just as useful to know: fetch() and XHR, preloads nothing used, prefetches, lazy iframes that are not near the viewport, and anything requested after the load event began, such as an image added from a load listener. A loading=lazy image is not requested until Chrome has laid out the page and seen that it is near the viewport, even when it is in the viewport from the start, and the request is marked non-blocking. But once enough of the image has arrived for Chrome to know its dimensions, the image element holds load anyway, until the image finishes. In a fixture where an eager image held load open, an in-viewport lazy PNG that had already received its first 41 bytes stretched load to its own finish; with only its first 33 bytes, or with its response withheld, it did not.

Then comes the order that matters for the timestamp. Blink records loadEventStart, runs every load listener, records loadEventEnd, notifies performance observers, and for an iframe fires the <iframe> element's own load event in the parent: synchronously when the parent is in the same process, by IPC when it is not. Only then does it write MarkLoad, and pageshow follows.

You get one per load event per frame, so a page with iframes writes several. An <iframe> with no src writes one for the about:blank it commits; the empty document every iframe starts with, before its real document commits, writes none. Calling document.open() on a loaded document re-runs its load event and writes a second MarkLoad for the same frame. A restore from the back/forward cache writes none, since it fires only a persisted pageshow, nor does a frame removed during its own load event, nor a load still held open when recording stops.

Derivations

Load time for each iframe, including a cross-origin one that no script on the page can measure: that iframe's MarkLoad minus the navigationStart with the same frame. trace_engine computes exactly this for every frame, though the panel draws a marker only for the main frame. For an iframe in the same process as its parent, the figure includes the time the parent's load handlers on the <iframe> element ran.

Which iframe the page was waiting for. When an iframe is the last thing holding its parent, the parent's own load dispatch begins right after that iframe's MarkLoad: within a millisecond in one capture each of a same-origin and a cross-site iframe, the cross-site one in another process with an IPC hop in between.

Traps

DevTools' "L" is loadEventEnd, not when load fired. Every load listener runs before the mark is written. A fixture with one 200 ms listener fired load at 5 ms and wrote MarkLoad at 205 ms, and DevTools reported L as 205. A RUM tool that reports loadEventStart will show a smaller number for the same load. The EventDispatch with type: load on the window starts at loadEventStart if you want the other one.

An iframe's MarkLoad also waits for the parent's onload handlers on that <iframe> element, when both are in one process. A 100 ms handler in the parent moved the child's mark by 100 ms, and the EventDispatch right before the child's MarkLoad was that parent handler, not the iframe's own load.

It is not an upper bound on anything the user sees. An in-viewport lazy image is requested only after the first layout, and holds load only once its dimensions are known, so load can be long gone before it paints. On a local test page whose image was served after 1.5 s, MarkLoad landed at 5 ms and first contentful paint and LCP, both the image, at 1.58 s. With a heading above the image, FCP was the text at 46 ms and LCP the image at 1.55 s, against MarkLoad at 7 ms. An image inserted from a load listener behaves the same.

isOutermostMainFrame: true does not mean your page. It rules out iframes and, up to Chrome 155, the root of a <fencedframe>. Chrome 156 removes fenced frames, and from then on it always equals isMainFrame. Traces from before Chrome 105 do not have it at all. A page being prerendered by speculation rules writes its own MarkLoad with the flag set, and so do Chrome's own WebUI pages, which turn up in a process named "WebUI Top Renderer" in a browser-wide trace such as one recorded with puppeteer. DevTools does not trust the flag: it keeps only the MarkLoad whose frame matches the main frame named in TracingStartedInBrowser. Do the same.

page is not the page. It is the frame's local root: the main frame for a same-process iframe, but the iframe itself for a cross-site iframe in its own process.

Two MarkLoads on one frame with no navigationStart between them mean document.open() ran after load. A reload also keeps the frame id, but brings its own navigationStart. After document.open(), Navigation Timing keeps the first; DevTools uses the last for L and draws a marker for both.

The Performance panel hides this event in the thread track unless "Show all events" is on, and shows it only as the L marker, for the main frame only.

Example event a local fixture page
{
  "name": "MarkLoad",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102948019,
  "tts": 44114,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "isMainFrame": true,
      "isOutermostMainFrame": true,
      "page": "14A831DCB73EB1205B67CDB89FC6EBD7"
    }
  }
}

See also MarkDOMContent · EventDispatch · navigationStart · largestContentfulPaint::Candidate · TracingStartedInBrowser

categories
devtools.timeline
usage
moderate
references
inspector_trace_events.cc, document.cc, local_dom_window.cc
MinorGCMinor GC

V8 collected garbage in the young generation, the part of the JavaScript heap where new objects are created.

Spec

MinorGC is one young-generation garbage collection in V8: the pause in which the Scavenger (or, only with the experimental --minor-ms flag, Minor Mark-Sweep) frees new objects that died and keeps the ones still in use, written by Heap::CollectGarbage on devtools.timeline,v8 on the thread that owns the JavaScript heap, which is the renderer main thread for a page and the worker's own thread for a worker.

Arguments

pathdeclaredobserved
args.type-100.0%undeclared, always present
args.usedHeapSizeAfter-100.0%undeclared, always present
args.usedHeapSizeBefore-100.0%undeclared, always present

Observed 5,805 times on 179 of 300 sites.

Behaviour

V8 creates new JavaScript objects in a small region of its heap called the young generation, on the bet that most of them die young. A minor collection empties that region of garbage, keeping the objects still in use and freeing the rest; objects that have to be kept may be moved to the old generation (promotion).

Chrome 154 does this with the Scavenger, V8's copying collector: objects nothing points to any more are simply left behind, and each one still in use is copied, within the young generation or out to the old one. The work is in the survivors, not the garbage. In captures of a synthetic page in headless Chrome 154 on an Apple M4 Mac, collections during a loop that dropped every object it made took tens of microseconds, and collections while the page was keeping its objects took from a few hundred microseconds to a few milliseconds. V8 also has a second young-generation collector, Minor Mark-Sweep, which marks live objects where they are instead of copying them. It is experimental, Chrome only runs it when started with --js-flags=--minor-ms, and Chrome 154 has no feature that turns it on. Both write MinorGC, with the same name, category and arguments. The child slice on the v8 category says which one ran: V8.GCScavenger or V8.GCMinorMS.

A minor collection starts in one of a few ways. Your code allocates and the young generation has no room left, and the collection runs right there, inside the FunctionCall, TimerFire or EvaluateScript that was allocating. V8 also tries to get in first: once the young generation is 80% full it posts a task of its own, at user-visible priority, and if that task runs before the region fills up, the MinorGC sits alone in a task with no script around it. Creating an ArrayBuffer once the memory behind recently created buffers has grown past a threshold tied to the young generation's size triggers one too, with type "external memory pressure". So does a task V8 posts when Chrome tells it a JavaScript context was disposed ("idle context disposal"), which only collects if the young generation holds more than 1 MB and V8 expects the collection to take under 5 ms, and gc({type: 'minor'}) when the page has --js-flags=--expose-gc ("testing").

The event covers the pause on the thread that owns the heap, measured as wall-clock time. The Scavenger splits the copying across up to eight threads, the main thread included: the main thread hands work to helper threads, takes part itself, and then waits for all of them before it carries on. The helpers' slices, V8.GC_SCAVENGER_BACKGROUND_SCAVENGE_PARALLEL on worker threads, fall inside the MinorGC window. tdur counts only the owning thread's CPU time, so time spent waiting for helpers is part of why dur is larger than tdur. The callbacks V8 runs for Chrome just before and just after each collection (V8.GC_HEAP_EXTERNAL_PROLOGUE and V8.GC_HEAP_EXTERNAL_EPILOGUE) are outside the slice.

type is the reason V8 gives for the collection, turned into a string by ToString(GarbageCollectionReason) in V8's globals.h. usedHeapSizeBefore and usedHeapSizeAfter are in bytes and come from Heap::SizeOfObjects(), the size of every object in that isolate's V8 heap, in every space except the read-only one and with the old generation included, sampled at the start and the end of the pause.

Every Performance panel recording contains the event, because devtools.timeline is one of its default categories, and the panel draws it as "Minor GC" in the scripting colour. The details pane shows one extra row, "Collected", which is usedHeapSizeBefore minus usedHeapSizeAfter. Lighthouse files it under Garbage Collection.

Derivations

How much of a slow handler was garbage collection. A MinorGC nested inside a FunctionCall, TimerFire or EvaluateScript ran synchronously inside it, so its duration is part of that call's duration. Subtract the nested MinorGCs and what remains is the time spent running the code; the part you subtracted is what the young generation filling up cost during that call.

How much is surviving. usedHeapSizeAfter on successive MinorGCs on one thread is the heap just after each young-generation collection dropped its garbage. It includes the old generation, which no minor collection frees, so it rises with every object that gets promoted. A steep climb means a lot of what your code creates outlives a minor collection, and that brings the next MajorGC closer. It is not proof of a leak, because promoted objects that die later are only freed by a MajorGC. For memory that stays retained, follow usedHeapSizeAfter across MajorGCs instead.

Traps

"allocation failure" does not mean an allocation failed. It is the reason V8 gives when the young generation runs out of room, and from Chrome 147 it is also the reason on the collection V8 schedules itself at 80% full, before anything ran out. Up to Chrome 146 that scheduled collection said "task" instead (checked back to Chrome 120). Comparing type across Chrome versions compares labels, not behaviour. To tell the two kinds apart in a current trace, look at the parent: a MinorGC nested in script, or in other work that was creating JavaScript objects, is the allocation path; a task that contains nothing but the MinorGC is the scheduled one. In a raw trace that recorded toplevel, the ThreadControllerImpl::RunTask around a scheduled one names minor-gc-job.cc as the place that posted it.

The code that pays is not necessarily the code that allocated. An allocation-triggered collection runs inside whatever happened to allocate when the young generation ran out of room. Everything that ran since the previous collection filled it, so a MinorGC inside one handler can be the bill for allocations made by others.

The heap sizes are the whole V8 heap, not the young generation. usedHeapSizeBefore minus usedHeapSizeAfter is what this collection freed, but the numbers themselves include the old generation, so they do not tell you how big the young generation was. They also leave out two things that often dominate a page's memory: ArrayBuffer and typed-array backing stores, which live outside the V8 heap, and the C++ objects behind the DOM, which Blink keeps on a separate heap (see CppGC.IncrementalSweep).

The reason does not decide the event name. V8 has one list of reasons for both kinds of collection. A request for a young-generation collection becomes a full one, written as MajorGC with the same type, when major marking is already under way or the old generation may not have room for the survivors, and a young allocation that still fails after two minor collections is retried with a major one. A MajorGC with type "allocation failure" is normal.

Do not add the helper threads to the main thread. The worker-thread V8.GC_SCAVENGER_BACKGROUND_SCAVENGE_PARALLEL slices run inside the MinorGC window while the main thread waits, so adding them to MinorGC counts the same pause twice. What the main thread lost is MinorGC's dur. A little cleanup does continue on worker threads after the slice ends, such as sweeping dead ArrayBuffers, and it costs the main thread nothing.

Which collector ran is written on a child, and the child needs v8. V8.GCScavenger or V8.GCMinorMS is on the v8 category, while the phase slices inside it, from V8.GC_SCAVENGER or V8.GC_MINOR_MS down to the helper threads' slices, are on devtools.timeline and disabled-by-default-v8.gc. A Performance panel recording has both. A trace that recorded devtools.timeline alone still has MinorGC and the phase slices, so read the collector from the top phase slice's name there. Do not read it from V8.GC_MINOR_MS_BACKGROUND_SWEEPING: the worker-thread sweep of young ArrayBuffers carries that name after a Scavenger collection too.

Check the worker tracks. Each worker has its own isolate and its own heap, so its collections are written on the worker's thread, with heap sizes that describe the worker, not the page. They do not block the main thread.

A lone begin event is a collection the trace cut off. V8 writes MinorGC as a begin and an end, which the JSON export merges into one X. type and usedHeapSizeBefore ride on the begin, usedHeapSizeAfter on the end. If tracing stops during a collection, you are left with a B event that has no usedHeapSizeAfter.

The arguments are only in the raw JSON. trace_engine models none of the three. DevTools reads the two sizes without a declared type to produce "Collected".

Example event a local fixture page
{
  "name": "MinorGC",
  "cat": "devtools.timeline,v8",
  "ph": "X",
  "ts": 1102954420,
  "dur": 991,
  "tdur": 764,
  "tts": 48681,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "type": "allocation failure",
    "usedHeapSizeAfter": 1566416,
    "usedHeapSizeBefore": 2167292
  }
}

See also MajorGC · GCEvent · CppGC.IncrementalSweep · FunctionCall · TimerFire

categories
devtools.timeline, v8
usage
moderate
duration
very long
undeclared args
args.type, args.usedHeapSizeAfter, args.usedHeapSizeBefore
references
heap.cc, globals.h
ProfileCallJS frame

A single JavaScript function on the stack, reconstructed from V8's profiler samples.

ProfileCall represents a JavaScript stack frame reconstructed from V8's CPU profile samples. It is not a real Chrome trace event. DevTools synthesises it by integrating sampled profile data into the event timeline, which is work that SamplesIntegrator does.

That synthesis is why these give you function names and source locations when FunctionCall does not, and why they are sampled rather than exact. A ProfileCall duration is an estimate derived from sample counts, not a measured interval.

Use them to answer which function was hot, and use real events for timing. Do not sum ProfileCall durations and expect them to reconcile exactly with measured event durations.

Example event

DevTools builds this while parsing a trace. It is never written to a trace file, so there is no sample to show.

references
Trace.ts, SamplesIntegrator.ts, SamplesHandler.ts
RequestAnimationFrameRequest animation frame

Your code called requestAnimationFrame() and Chrome queued the callback.

RequestAnimationFrame records a call to requestAnimationFrame(), the moment a callback is queued. It marks the registration only, never the callback running.

It is an instant event. args.data.id is the registration id, frame identifies the frame, and stackTrace is optional in the modelled type.

Reading the stackTrace tells you which code scheduled the callback, and that is often more useful than the callback itself, especially inside third-party bundles. In the 35 site run every registration recorded one, so the attribution is reliably there. The queued callback runs later under FireAnimationFrame, and matching id across the two gives you scheduling latency.

Example event a local fixture page
{
  "name": "RequestAnimationFrame",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1103041064,
  "tts": 131793,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "id": 1,
      "sampleTraceId": 6909218608593005,
      "stackTrace": [
        {
          "columnNumber": 3,
          "functionName": "",
          "lineNumber": 87,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ]
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-debugger.cc, frame_request_callback_collection.cc, inspector_trace_events.cc, script_loader.cc, heap.cc
RequestIdleCallbackRequest idle callback

Your code called requestIdleCallback() to defer a callback until the browser goes idle.

RequestIdleCallback marks a call to requestIdleCallback(), which defers a callback until the browser goes idle.

args.data.id is the registration id. timeout is the deadline after which the callback runs anyway. frame identifies the frame, and stackTrace is optional.

timeout is the important field. It is the promise that the callback will run even if the browser never becomes idle, so a short timeout defeats the purpose of the API and turns deferred work back into work that competes with rendering. Read it before you accept that a page has moved work out of the way.

InitiatorsHandler uses the event to link the registration to the eventual FireIdleCallback.

Example event a local fixture page
{
  "name": "RequestIdleCallback",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1103041104,
  "tts": 131827,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "id": 1,
      "sampleTraceId": 6909218608593006,
      "stackTrace": [
        {
          "columnNumber": 35,
          "functionName": "",
          "lineNumber": 88,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "timeout": 0
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
scripted_idle_task_controller.cc, source_location.cc, v8-debugger.cc, inspector_trace_events.cc, script_loader.cc, heap.cc
RunMicrotasksRun microtasks

Chrome drains the microtask queue, running promise continuations and other microtasks.

RunMicrotasks covers a microtask checkpoint: promise continuations, queueMicrotask callbacks and mutation observer callbacks draining after a task completes.

trace_engine models no arguments. Real traces record args.microtask_count, which counts the microtasks drained in this checkpoint.

That count is the signal, because the queue drains completely before the browser can render and can be extended while it drains. A long RunMicrotasks therefore delays the next frame just as surely as a long task does, and it is easy to miss because the work is not attributed to whatever scheduled it. Promise-heavy code that chains many continuations, or a recursive queueMicrotask, shows up here.

Example event a local fixture page
{
  "name": "RunMicrotasks",
  "cat": "v8.execute",
  "ph": "X",
  "ts": 1102937773,
  "dur": 1760,
  "tdur": 846,
  "tts": 38300,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "microtask_count": 7
  }
}
categories
v8.execute
usage
moderate
duration
typical
undeclared args
args.microtask_count
references
microtask-queue.cc
RunPostTaskCallbackFire postTask

The browser's scheduler runs a callback that was handed to it with postTask().

RunPostTaskCallback covers the scheduler running a task it has picked up. It repeats taskId, priority and delay from the scheduling event, and frame identifies the frame.

The interval between the matching SchedulePostTaskCallback and this event, compared against delay, shows whether the scheduler honoured the priority or the main thread was simply too busy. Background-priority tasks running promptly during a busy load is a sign the page is not as busy as it looks. User-blocking tasks running late is a sign it is worse.

Example event a local fixture page
{
  "name": "RunPostTaskCallback",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103046262,
  "dur": 45,
  "tdur": 36,
  "tts": 135621,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "delay": 20,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "priority": "background",
      "taskId": 0
    }
  }
}
categories
devtools.timeline
usage
moderate
duration
long
references
dom_task.cc, inspector_trace_events.cc, dom_scheduler.cc, dom_scheduler.h
SchedulePostTaskCallbackSchedule postTask

Your code called postTask() to hand a callback to the browser's scheduler.

SchedulePostTaskCallback marks a call to postTask(), which hands a callback to the browser's scheduler.

args.data.taskId is the id the scheduler will run the task under. priority is user-blocking, user-visible or background. delay is the requested delay before the task becomes eligible. frame and stackTrace are both optional.

priority is what distinguishes this API from setTimeout. Code using postTask with explicit priorities is cooperating with the scheduler. Code using it at default priority everywhere is not getting much beyond setTimeout.

Seeing the event at all already tells you something. Three of the 35 sites in the corpus scheduled anything this way, so a page that uses it is unusual, and the priorities it picked will tell you how deliberate its scheduling is. taskId pairs the registration with RunPostTaskCallback.

Example event a local fixture page
{
  "name": "SchedulePostTaskCallback",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102951455,
  "tts": 46242,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "delay": 20,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "priority": "background",
      "sampleTraceId": 6909218608594506,
      "stackTrace": [
        {
          "columnNumber": 17,
          "functionName": "",
          "lineNumber": 52,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "taskId": 0
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
dom_task.cc, inspector_trace_events.cc, source_location.cc, v8-debugger.cc, dom_scheduler.cc, dom_scheduler.h and 2 more
TimerFireTimer fired

Chrome runs a timer callback now that its delay has elapsed.

TimerFire covers a timer callback running once its delay has elapsed. args.data.timerId and frame are the only payload.

Match timerId back to TimerInstall to recover both the requested delay and the installing stack. The difference between the requested timeout and the actual gap is timer lag. If it is large, the main thread was blocked, and any code assuming timer precision is already misbehaving. setTimeout(fn, 0) used as a yield shows up here too, and whether it actually yielded is visible in the same gap.

Example event a local fixture page
{
  "name": "TimerFire",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103043776,
  "dur": 150,
  "tdur": 136,
  "tts": 133891,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "timerId": 1
    }
  }
}
categories
devtools.timeline
usage
high
duration
typical
references
dom_timer.cc
TimerInstallInstall timer

Your code called setTimeout or setInterval, and Chrome registered the timer.

TimerInstall records a setTimeout or setInterval registration. Both APIs register through this one event, and args.data.singleShot is what tells them apart.

timerId is the id used when the timer fires or is cleared. timeout is the requested delay. frame identifies the frame, and stackTrace is optional in the modelled type but present on almost every real event.

The stackTrace is the attribution path back to whatever installed the timer, which is usually the question you have. InitiatorsHandler pairs the event with TimerFire through timerId. A singleShot: false timer with a small timeout is a recurring main-thread tax, and these often survive long after the code that needed them.

Example event a local fixture page
{
  "name": "TimerInstall",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102951549,
  "tts": 46330,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "sampleTraceId": 6909218608594508,
      "singleShot": true,
      "stackTrace": [
        {
          "columnNumber": 7,
          "functionName": "",
          "lineNumber": 55,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "timeout": 60,
      "timerId": 1
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
dom_timer.cc, source_location.cc, v8-debugger.cc, script_loader.cc, heap.cc
TimerRemoveRemove timer

Your code called clearTimeout or clearInterval to cancel a timer.

TimerRemove marks a call to clearTimeout or clearInterval. It is untyped in trace_engine, so the timerId being cleared is not modelled, although the raw trace does record it.

Its value is reconciliation. It accounts for installed timers that never fire, which is what you need when auditing for leaked intervals: a repeating timer with no matching TimerRemove is exactly that leak.

Example event from https://www.bbc.com/news
{
  "name": "TimerRemove",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1108608925,
  "tts": 266324,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "frame": "2EA8810E92C12C680C55B2941251AA98",
      "sampleTraceId": 5267982676862134,
      "stackTrace": [
        {
          "columnNumber": 13686,
          "functionName": "_clearTimeouts",
          "lineNumber": 1,
          "scriptId": "41",
          "url": "https://static.files.bbci.co.uk/bbcdotcom/web/20260907-085256-bea8b058c0-web-3.21.0/_next/static/chunks/0a7h_kuj3flvo.js"
        },
        {
          "columnNumber": 13015,
          "functionName": "retry",
          "lineNumber": 1,
          "scriptId": "41",
          "url": "https://static.files.bbci.co.uk/bbcdotcom/web/20260907-085256-bea8b058c0-web-3.21.0/_next/static/chunks/0a7h_kuj3flvo.js"
        },
        {
          "columnNumber": 12959,
          "functionName": "d",
          "lineNumber": 1,
          "scriptId": "41",
          "url": "https://static.files.bbci.co.uk/bbcdotcom/web/20260907-085256-bea8b058c0-web-3.21.0/_next/static/chunks/0a7h_kuj3flvo.js"
        },
        "... [6 more items]"
      ],
      "timerId": 0
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.frame, args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url, args.data.timerId
references
source_location.cc, v8-debugger.cc, dom_timer.cc, script_loader.cc, heap.cc
UserTimingUser timingnever written by Chrome

Your own performance.mark() or performance.measure() entry, under the name DevTools files it by.

No Chrome build writes this name into a trace file. It exists in the model, not on the wire.

UserTiming is the DevTools name for a performance.mark() or performance.measure() entry, but Chrome never writes an event called that. TraceFilter maps every event on the blink.user_timing category to the type UserTiming, exactly as it maps blink.console events to ConsoleTime.

In the trace itself, core/timing/performance_user_timing.cc writes each mark and measure as an event named after the mark or measure, on blink.user_timing. A measure also writes UserTiming::Measure on devtools.timeline.

The names are author supplied rather than chosen by the browser. This is the mechanism for getting application level semantics into a trace. Labels such as "route change started" and "hydration complete" express meaning the browser could not possibly infer. When you are analysing a framework heavy app, these are often the only way to map browser events onto application phases. Most major frameworks emit them, so check before assuming you need to add your own.

Example event

Chrome never writes this name, which DevTools gives to any event on the blink.user_timing category. A fixture page calling performance.mark and performance.measure produced one event named after each mark and measure, plus UserTiming::Measure.

references
performance_user_timing.cc, TraceFilter.ts
V8.BytecodeBudgetInterruptnot in DevTools' model

V8 stops a running function to decide whether to optimise it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

V8.BytecodeBudgetInterrupt fires when V8 stops to decide whether to optimise a function. Interpreted and Sparkplug functions burn an interrupt budget as they run, and when it hits zero they call into the runtime, which emits this from BytecodeBudgetInterrupt in v8/src/runtime/runtime-internal.cc and then calls isolate->tiering_manager()->OnInterruptTick(function, code_kind).

code_kind says which tier tripped: INTERPRETED_FUNCTION for Ignition, BASELINE for Sparkplug, MAGLEV for Maglev. A sibling event, V8.BytecodeBudgetInterruptWithStackCheck, folds a stack overflow check into the same trip.

This is the visible marker for "this function is now hot enough to tier up". Each tick is cheap, faster than most trace events, but a V8.MaglevTask or V8.OptimizeConcurrentPrepare following one is not. Use them as anchors: a tick inside an interaction is where V8 started spending your INP budget on compilation instead of on the work you asked for. No profiler in DevTools marks that moment.

Example event a local fixture page
{
  "name": "V8.BytecodeBudgetInterrupt",
  "cat": "v8.execute",
  "ph": "X",
  "ts": 1102953017,
  "dur": 5,
  "tdur": 4,
  "tts": 47312,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
v8.execute
usage
very high
duration
short
references
runtime-internal.cc
V8.CollectSourcePositionsnot in DevTools recordingsnot in DevTools' model

V8 reparses a function it already compiled, to rebuild the source position table it discarded.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

V8.CollectSourcePositions measures V8 reparsing a function it already compiled, purely to rebuild the source position table it threw away. Compiler::CollectSourcePositions in v8/src/codegen/compiler.cc emits it, sets flags.set_is_reparse(true) and set_collect_source_positions(true), and runs a fresh NewSourcePositionCollectionJob.

Nothing is recorded but the duration. V8 discards source positions by default to save memory and pays them back lazily. The triggers are all things your code does: capturing Error.stack, throwing, attaching a debugger, opening DevTools, running a profiler. One call costs several times what generating that function's bytecode cost in the first place.

Reading these events puts a cost on your error handling, which nothing else will. A moderate volume of them on all 35 real sites is a lot of reparsing for stack traces nobody reads. Clustered ones point at a library constructing Error objects on a hot path: error based control flow, an eager logging wrapper, or a monitoring SDK sampling stacks.

Example event a local fixture page
{
  "name": "V8.CollectSourcePositions",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102929806,
  "dur": 51,
  "tdur": 46,
  "tts": 33166,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
high
duration
typical
references
compiler.cc
v8.compileCompile script

V8 compiles one script into code it can run.

v8.compile records one script being compiled by V8.

args.data tells the full code-cache story:

  • consumedCacheSize, cacheRejected and cacheKind only appear when V8
  • consumed a code cache for this script. When all three are missing, no cache was used and the script was compiled from source.

  • consumedCacheSize is the size of the cache V8 consumed, in bytes.
  • cacheRejected is true when a cache existed but V8 refused it, typically
  • because the script's content or V8 itself changed.

  • cacheKind is full when V8 consumed a full code cache, which Chrome
  • produces for example for scripts a service worker stores in Cache Storage, and normal otherwise.

  • streamed says whether V8 compiled the script as it downloaded, and
  • notStreamedReason says why not when it did not.

  • eager is true when functions were compiled eagerly rather than lazily.

url, lineNumber and columnNumber locate the script.

No other V8 event in the trace gives you this much diagnostic detail, so start here and reach for notStreamedReason first. Streaming compilation overlaps compile with download, and losing it means compile time lands entirely after the bytes have arrived. Inline scripts and very small scripts are the common causes. Across the 35 site run, a little over half of all v8.compile events recorded a notStreamedReason at all.

cacheRejected: true on a repeat view is a real finding. You are paying full compile cost on every visit despite the cache existing. Aggressive cache-busting in build output is the usual cause.

Example event from https://stackoverflow.com
{
  "name": "v8.compile",
  "cat": "v8,devtools.timeline",
  "ph": "X",
  "ts": 1186295713,
  "dur": 16,
  "tdur": 15,
  "tts": 235936,
  "pid": 9580,
  "tid": 9580,
  "args": {
    "data": {
      "cacheKind": "normal",
      "cacheRejected": false,
      "columnNumber": 1,
      "consumedCacheSize": 177776,
      "lineNumber": 1,
      "notStreamedReason": "Backgound streaming will be used",
      "streamed": false,
      "url": "https://stackoverflow.com/Content/Js/webpack-chunks/svelte.en.js?v=f2f4cf305940"
    },
    "fileName": "https://stackoverflow.com/Content/Js/webpack-chunks/svelte.en.js?v=f2f4cf305940"
  }
}
categories
v8, devtools.timeline
usage
moderate
duration
typical
undeclared args
args.data.scriptId
references
inspector_trace_events.cc, v8_script_runner.cc, classic_script.cc, script_loader.cc, v8_code_cache.cc, cache.cc and 3 more
V8.CompileCodeCompile codenot in DevTools recordings

V8 carries out one compilation job, below the level of a whole script.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

V8.CompileCode measures a single V8 compilation job. It is untyped in trace_engine and no args are modelled.

It sits at a lower level than v8.compile, so treat it as a cost bucket rather than a source of attribution. It is useful when summing total compile cost, since not all compilation surfaces as v8.compile. In the 35 site run it is one of the most numerous events in the set, on all 35 sites, at a typical per event duration, so whatever it costs a page, it costs in aggregate rather than in any one event. If you want to change something, work from v8.compile instead.

Example event a local fixture page
{
  "name": "V8.CompileCode",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102929130,
  "dur": 47,
  "tdur": 44,
  "tts": 32695,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
very high
duration
typical
references
compiler.cc
V8.CompileIgnitionnot in DevTools recordingsnot in DevTools' model

V8 generates the bytecode for one function, for its interpreter to run.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

V8.CompileIgnition measures bytecode generation for one function, emitted by InterpreterCompilationJob::ExecuteJobImpl in v8/src/interpreter/interpreter.cc. Ignition is V8's interpreter, and this job produces the BytecodeArray it will run.

V8.CompileIgnition sits one level below the v8.compile event. DevTools shows v8.compile as one opaque block. V8.CompileIgnition is the per function breakdown inside it, with V8.CompileIgnitionFinalization as the matching FinalizeJobImpl that installs the result. The body calls local_isolate_->ParkIfOnBackgroundAndExecute, so these can run off the main thread during streaming compilation.

What that buys you is separating parse cost from codegen cost, since V8.ParseFunction and V8.CompileIgnition are siblings rather than one lump. These run faster than most trace events, which says codegen is rarely the problem. If v8.compile is heavy and these are not, the cost is parsing or source position collection.

Example event a local fixture page
{
  "name": "V8.CompileIgnition",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102929163,
  "dur": 3,
  "tdur": 3,
  "tts": 32726,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
very high
duration
short
references
interpreter.cc
V8.CompileModuleCompile modulenever written by Chrome

V8 compiles one ES module.

No Chrome build writes this name into a trace file. It exists in the model, not on the wire.

V8.CompileModule is the DevTools name for compiling an ES module, but neither V8 nor Blink writes that string. Blink writes the work as v8.compileModule, with a lower case v, on v8,devtools.timeline from V8ScriptRunner::CompileModule in bindings/core/v8/v8_script_runner.cc. Older DevTools used v8.compileModule too, until the enum value was capitalised. In the real event, args.data names the module url and says whether it was streamed.

Module graphs compile per module, so a deep import graph produces many of these rather than one large compile. That fragmentation is why module-heavy pages can show substantial aggregate compile cost without any single event looking expensive. Sum them before concluding compilation is cheap.

Example event

Neither V8 nor Blink writes this name. A fixture page loading an ES module produced v8.compileModule, which is how Blink writes module compilation, and V8.CompileStreamedModule.

references
v8_script_runner.cc, TraceEvents.ts
V8.CompileScriptnot in DevTools recordings

V8 compiles one whole script, timed inside V8 rather than by Blink.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

V8.CompileScript measures the script-level compile step inside V8. It is untyped in trace_engine and not consumed by any handler.

It overlaps conceptually with v8.compile, which is the Blink-side event with the useful args. If both appear, prefer v8.compile for analysis and use this one only to account for time.

Example event a local fixture page
{
  "name": "V8.CompileScript",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102934802,
  "dur": 97,
  "tdur": 91,
  "tts": 36773,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
moderate
duration
typical
references
api.cc
v8.deserializeOnBackgroundDeserialize code cache

V8 unpacks cached bytecode for a script on a background thread.

v8.deserializeOnBackground records V8 deserialising cached bytecode on a background thread, which is the code cache being consumed rather than produced.

The event has no arguments.

A working code cache looks like this: bytecode restored off thread instead of source compiled on the main thread. Pair it with v8.compile's consumedCacheSize to confirm how much was actually reused.

Example event

Not seen. Consuming the code cache needs a repeat visit, and every page in the collection was loaded cold.

categories
v8, devtools.timeline, disabled-by-default-v8.compile
references
script_cache_consumer.cc
v8.evaluateModuleEvaluate module

V8 runs the body of an ES module.

v8.evaluateModule covers the body of an ES module being evaluated. It has no typed arguments.

Module evaluation is ordered by the import graph, so a slow module blocks everything that imports it. When tracing a module-based app, follow the evaluation order here rather than assuming network order. The two frequently differ.

For classic scripts the equivalent event is EvaluateScript, so a page that mixes both needs reading through both.

Example event a local fixture page
{
  "name": "v8.evaluateModule",
  "cat": "v8,devtools.timeline",
  "ph": "X",
  "ts": 1102947560,
  "dur": 243,
  "tdur": 228,
  "tts": 43689,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
v8, devtools.timeline
usage
low
duration
long
references
v8_script_runner.cc
V8.Executeno longer emitted

Chrome used to record V8 running your JavaScript under this name.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

V8.Execute marked a span of V8 executing JavaScript, and no shipping Chrome writes it. The call sites are still in v8/src/api/api.cc, in Script::Run, Module::Evaluate, Object::CallAsFunction and others, as TRACE_EVENT_CALL_STATS_SCOPED on the v8 category. That macro compiles to nothing unless the build defines V8_RUNTIME_CALL_STATS. Release branches already left it off, and since V8 commit 90a44a1c93 in April 2026 every branch does, so trunk builds such as Canary stopped writing it too.

A trace that shows V8.Execute came from a trunk build older than April 2026 or from a custom build with runtime call stats turned on.

It is untyped in trace_engine, no handler consumes it, and it is not given a DevTools label, although isJSInvocationEvent still counts it as a JavaScript entry point. It is a broad container rather than a specific operation. For attribution, read the events nested inside it: FunctionCall, EvaluateScript and ProfileCall.

Example event

Only builds with V8 runtime call stats turned on write this event, and no shipping Chrome has them. The Chrome 141 stable build that recorded the collection could not produce it.

references
api.cc, trace-event.h
V8.FinalizeDeserializationProfiling overheadnot in DevTools recordings

V8 finishes the last step of unpacking compiled code from the code cache.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

V8.FinalizeDeserialization marks the last step of code cache deserialisation, which completes on the main thread.

trace_engine models no arguments for it.

It is short by design, but it is the part of cache consumption that is not free of the main thread. Count it when you are accounting for where load time main-thread cost goes.

Example event

Not seen, for the same reason as v8.deserializeOnBackground: every load was cold.

categories
disabled-by-default-v8.compile
usage
rare
duration
very long
references
code-serializer.cc
V8.MaglevTasknot in DevTools recordingsnot in DevTools' model

A background worker wakes up to run Maglev, V8's mid-tier optimising compiler.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

V8.MaglevTask records a Maglev compilation worker waking up. MaglevConcurrentDispatcher::JobTask::Run in v8/src/maglev/maglev-concurrent-dispatcher.cc emits it once per worker run, wrapping however many jobs that worker drains from the queue.

Maglev is V8's mid tier optimising compiler, sitting between Sparkplug and TurboFan, and the nested events are the ones you want. V8.MaglevBackground is one job compiling, and a perfetto::Flow::ProcessScoped(job->trace_id()) ties it to the steps around it. V8.MaglevConcurrentPrepare (from v8/src/codegen/compiler.cc) and V8.MaglevConcurrentFinalize sit at either end of that flow and both run on the main thread. Background compilation is the expensive part of the three.

Following the flow pays, because it splits optimisation into the part you are charged for and the part you are not: prepare and finalize are your INP cost, background is not. V8.MaglevPrepare in place of V8.MaglevConcurrentPrepare means the concurrent dispatcher was unavailable and V8 compiled on the main thread.

Example event a local fixture page
{
  "name": "V8.MaglevTask",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102947863,
  "dur": 1942,
  "tdur": 708,
  "tts": 208,
  "pid": 8257,
  "tid": 8305,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
high
duration
typical
references
maglev-concurrent-dispatcher.cc, compiler.cc
V8.OptimizeCodenot in DevTools recordings

V8 optimises a hot function, compiling it to faster machine code.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

V8.OptimizeCode measures V8 optimising a hot function, compiling it to optimised machine code after the interpreter has seen it run enough times.

The event is untyped, so you cannot tell which function was optimised from the event alone. Individual events are short, landing in the typical band for trace event duration, at moderate volume on 20 of the 35 sites.

Its presence is normal and generally good, because it means code is hot enough to deserve optimising. Look instead for repeated optimisation of the same code. That suggests deoptimisation cycles: a function being optimised, hitting a type it was not optimised for, bailing out, and being optimised again. That pattern costs more than never optimising at all.

Example event a local fixture page
{
  "name": "V8.OptimizeCode",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102956998,
  "dur": 452,
  "tdur": 228,
  "tts": 50962,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
moderate
duration
typical
references
compiler.cc
V8.ParseFunctionnot in DevTools recordingsnot in DevTools' model

V8 parses one function body in full and builds the syntax tree for it.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

V8.ParseFunction measures the real parse of one function body. Parser::ParseFunction in v8/src/parsing/parser.cc emits it, and the source notes it only ever runs on the main thread.

No arguments are attached. It fires when a previously preparsed function is finally called, the second half of the lazy compilation deal, so this is not load time work, it is first call work. It builds the AST that V8.CompileIgnition turns into bytecode, which is why the counts track each other, at roughly three bytecode compilations across the corpus for every two parses. Each parse is quick relative to other trace events.

What matters is where in the timeline they cluster. A dense band inside an interaction task is first call parsing on the critical path, and the fix is to warm the code earlier or ship less of it, not to make functions smaller. A dense band during load means your bundle's top level calls almost everything it defines, defeating lazy parsing entirely. DevTools rolls all of it into compile and script evaluation blocks that hide which of the two you have.

Example event a local fixture page
{
  "name": "V8.ParseFunction",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102929814,
  "dur": 29,
  "tdur": 27,
  "tts": 33173,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
very high
duration
short
references
parser.cc
v8.parseOnBackgroundParsingParse and compile

V8 parses a script on a background thread rather than on the main thread.

v8.parseOnBackgroundParsing records script parsing running on a background thread instead of on the main thread.

No arguments are attached. Duration is the measure. These run slower than most trace events, and the spread is wide, with the tail running far longer than the middle.

Presence is the good case. Parsing overlapped with download and stayed off the main thread, which means streaming compilation is working. Absence for large scripts is the finding, and v8.compile's notStreamedReason tells you why.

Inline scripts are not streamed in default Chrome. Blink records them under an explicit reason, kInlineScript, because there is no separate network response to parse off the main thread. Chromium does have an experimental InlineScriptStreamer behind the PrecompileInlineScripts flag, but it ships disabled. That is one concrete reason to avoid large inline bundles.

Streaming is not automatic for external scripts either. Blink suppresses it for a list of reasons including kScriptTooSmall, kHasCodeCache, kNotHTTP, kModuleScript and kSecondScriptResourceUse.

Example event a local fixture page
{
  "name": "v8.parseOnBackgroundParsing",
  "cat": "v8,devtools.timeline,disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102942276,
  "dur": 360,
  "tdur": 353,
  "tts": 946,
  "pid": 8257,
  "tid": 8272,
  "args": {
  }
}
categories
v8, devtools.timeline, disabled-by-default-v8.compile
usage
moderate
duration
long
references
script_streamer.cc
v8.parseOnBackgroundWaitingWaiting for network

A background parse sits blocked, waiting for more script bytes to arrive from the network.

v8.parseOnBackgroundWaiting measures a background parse sitting blocked, waiting on more bytes to arrive from the network.

There are no typed arguments. The duration is the wait itself, and these land in the slowest tenth of trace events by duration.

The parser outran the download wherever one of these appears. None of that time is main-thread cost. What it tells you is that script delivery is the constraint for that resource, not script processing.

Example event from https://www.bbc.com/news
{
  "name": "v8.parseOnBackgroundWaiting",
  "cat": "v8,devtools.timeline,disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1108382883,
  "dur": 102,
  "tdur": 13,
  "tts": 560,
  "pid": 8407,
  "tid": 8466,
  "args": {
  }
}
categories
v8, devtools.timeline, disabled-by-default-v8.compile
usage
moderate
duration
very long
references
script_streamer.cc
V8.PreParsenot in DevTools recordingsnot in DevTools' model

V8 skims a function body for its shape rather than parsing it in full.

A DevTools Performance recording does not contain this event. Record disabled-by-default-v8.compile with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

V8.PreParse measures V8 skimming a function body without building an AST. It is emitted from Parser::SkipFunction in v8/src/parsing/parser.cc, immediately before that function calls reusable_preparser()->PreParseFunction(...).

There are no arguments, only a duration. Lazy compilation is the default, so on first pass V8 records only what it needs to compile the function later: scope shape, variable references, whether it is a generator. The source comment is explicit, "with no cached data, we partially parse the function, without building an AST". Individually it is among the faster events in the corpus, and it is also one of the most numerous, seen on every site.

Read total preparse time against total V8.ParseFunction time, because every function later parsed for real was preparsed first and paid twice. That comparison exists nowhere in DevTools. A bundle where the two are comparable ships a lot of code that runs immediately, and wants splitting rather than micro optimisation. A preparse error V8 cannot classify sets allow_lazy_ = false and forces a full eager reparse.

Example event a local fixture page
{
  "name": "V8.PreParse",
  "cat": "disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102929824,
  "dur": 15,
  "tdur": 14,
  "tts": 33182,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
disabled-by-default-v8.compile
usage
very high
duration
short
references
parser.cc
v8.produceCacheCache script code

V8 writes the compiled bytecode for a classic script into the code cache.

v8.produceCache measures V8 writing compiled bytecode to the code cache for a classic script.

Arguments appear only on the events that also sit in the devtools.timeline category, 269 of the 1860 in the corpus. On those, args.data.producedCacheSize is the size of the cache entry written, args.data.url and args.data.scriptId identify the script, and args.data.lineNumber and args.data.columnNumber locate it. The other 1591 arrive with nothing attached.

This is the cost side of code caching. You pay a little now to save compile time on subsequent visits, so seeing it on a first view is correct and desirable. Seeing it on every view means the cache is never being consumed, which points at changing URLs or headers that prevent reuse. Cross-check with v8.compile's consumedCacheSize and cacheRejected.

Example event from https://www.nytimes.com
{
  "name": "v8.produceCache",
  "cat": "v8,devtools.timeline",
  "ph": "X",
  "ts": 1130706242,
  "dur": 1417,
  "tdur": 1416,
  "tts": 2962526,
  "pid": 8798,
  "tid": 8798,
  "args": {
    "data": {
      "columnNumber": 1,
      "lineNumber": 1,
      "producedCacheSize": 208096,
      "url": "https://pagead2.googlesyndication.com/tag/js/gpt.js"
    },
    "fileName": "https://pagead2.googlesyndication.com/tag/js/gpt.js"
  }
}
categories
v8, devtools.timeline
usage
moderate
duration
typical
undeclared args
args.data.columnNumber, args.data.lineNumber, args.data.producedCacheSize, args.data.scriptId, args.data.url, args.fileName
references
inspector_trace_events.cc, classic_script.cc, script_loader.cc, v8_code_cache.cc, v8_script_runner.cc
v8.produceModuleCacheCache module code

V8 writes the compiled bytecode for an ES module into the code cache.

v8.produceModuleCache records V8 writing compiled bytecode to the code cache for an ES module.

The same argument set applies as for classic scripts, and it is almost never populated. A tiny minority of the events in the corpus arrived with producedCacheSize and a URL attached.

Module caching is per module, so a large import graph produces many of these, and all 35 sites in the corpus emitted some. Read them exactly as you read v8.produceCache, which covers the same operation for classic scripts. Repeated production without consumption means the cache is not working.

Example event a local fixture page
{
  "name": "v8.produceModuleCache",
  "cat": "v8",
  "ph": "X",
  "ts": 1102948845,
  "dur": 2,
  "tdur": 1,
  "tts": 44610,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
v8, devtools.timeline
usage
moderate
duration
very short
undeclared args
args.data.columnNumber, args.data.lineNumber, args.data.producedCacheSize, args.data.scriptId, args.data.url, args.fileName
references
v8_code_cache.cc
v8.wasm.cachedModuleCached Wasm module

A WebAssembly module goes into or comes out of the Wasm code cache.

v8.wasm.cachedModule marks a WebAssembly module moving through the Wasm code cache. One event name covers both directions, the module being written into the cache and the module being served from it.

The event is untyped, so the direction is not recorded. You infer it from whether this is a first load or a repeat one.

Wasm caching matters more than JavaScript code caching, because the compile cost being avoided is larger. Its presence on a first load is the cache being populated.

Example event

Not seen. Writing to the WebAssembly code cache needs a repeat visit that reuses it, which a single cold load cannot produce.

categories
disabled-by-default-devtools.timeline
references
v8_wasm_response_extensions.cc
v8.wasm.compiledModuleCompiled Wasm module

V8 has finished compiling a WebAssembly module from its bytes.

v8.wasm.compiledModule marks a WebAssembly module that V8 has finished compiling from its bytes.

Nothing is attached to the event, so module size and identity stay out of the trace.

Wasm compilation cost scales with module size and is substantial for large modules. Seeing this event on every load means no caching is in effect. Compare it against v8.wasm.moduleCacheHit to see which of the two paths a repeat visit took.

Example event

Not seen on a fixture page that compiled and instantiated a WebAssembly module. That produced wasm.AsyncCompile, wasm.BaselineFinished and v8.wasm.compileConsume.

categories
disabled-by-default-devtools.timeline
references
v8_wasm_response_extensions.cc
v8.wasm.moduleCacheHitWasm module cache hit

A cached WebAssembly module is reused, so V8 skips compiling it.

v8.wasm.moduleCacheHit fires when a cached WebAssembly module is reused and compilation is skipped entirely.

The event has no arguments. Its presence is the whole message.

This is the outcome you want on repeat visits. Absence, combined with v8.wasm.compiledModule appearing every time, means you are recompiling a large module on every load.

Example event

Not seen. A cache hit needs a repeat visit, which a single cold load cannot produce. The collection did record wasm.GetNativeModuleFromCache.

categories
disabled-by-default-devtools.timeline
references
v8_wasm_response_extensions.cc
v8.wasm.moduleCacheInvalidWasm module cache invalid

V8 rejects a cached WebAssembly module as invalid and compiles it again from scratch.

v8.wasm.moduleCacheInvalid fires when V8 rejects a cached WebAssembly module as invalid and compiles the module from scratch instead.

The event is untyped, as every event in the Wasm cache group is.

A cache entry existed and could not be used, usually because the module changed or the V8 version did. Both of those are normal invalidation. Recurring on deployments where the module has not changed, it points at a caching configuration problem instead.

Example event

Not seen. It requires a cached module that Chrome then rejects.

categories
disabled-by-default-devtools.timeline
references
v8_wasm_response_extensions.cc
v8.wasm.streamFromResponseCallbackStreaming Wasm response

V8 starts compiling WebAssembly straight from a fetch response, as instantiateStreaming() does.

v8.wasm.streamFromResponseCallback fires when streaming WebAssembly compilation starts from a fetch response, which is what WebAssembly.instantiateStreaming() and compileStreaming() do.

No arguments are attached, so the event marks which path was taken rather than measuring it.

This is the preferred path, because compilation overlaps with download. Its presence means the module is being compiled as it arrives rather than after. If you are loading Wasm and do not see this event, the code is probably buffering the response into an ArrayBuffer first, which serialises download and compile.

Example event a local fixture page
{
  "name": "v8.wasm.streamFromResponseCallback",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102949854,
  "tts": 45133,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
  }
}
categories
disabled-by-default-devtools.timeline
usage
rare
references
v8_wasm_response_extensions.cc
V8Console::runTaskRun console task

V8 runs a task through its console and inspector machinery.

V8Console::runTask records a task run through V8's console and inspector task mechanism. args.data.sampleTraceId is its only argument.

AsyncJSCallsHandler consumes it to reconstruct async causality, linking a callback back to the async operation that scheduled it across an await or a promise boundary. That reconstruction is what makes async stack traces work in the Performance panel.

It is rarely something you optimise and frequently something you rely on to understand what caused what.

Example event from https://web.dev
{
  "name": "V8Console::runTask",
  "cat": "disabled-by-default-v8.inspector",
  "ph": "X",
  "ts": 1236450911,
  "dur": 4604,
  "tdur": 4085,
  "tts": 228477,
  "pid": 10353,
  "tid": 10353,
  "args": {
    "data": {
      "sampleTraceId": 87
    }
  }
}
categories
disabled-by-default-v8.inspector
usage
moderate
duration
typical
references
v8-console.cc, trace-id.h
WebSocketCreateCreate WebSocket

The page constructs a WebSocket.

WebSocketCreate marks the construction of a WebSocket.

  • args.data.identifier is the socket id used by every later event for this
  • socket.

  • url is the socket endpoint.
  • websocketProtocol is the subprotocol negotiated for it.
  • frame identifies the frame, and is optional.
  • workerId is set when the socket was opened inside a worker.
  • stackTrace is optional, and points at the code that opened it.

identifier is the join key for the whole socket lifecycle. InitiatorsHandler and NetworkRequestsHandler both consume the event, so sockets appear in network analysis rather than being invisible to it. workerId being present tells you the socket belongs to a worker, which matters because worker sockets do not contend for the main thread the way page sockets do.

What these tell you first is when the connection opened relative to everything else in the load. None of the 35 sites in the corpus opened a socket during the traced page load, so to see socket events at all you generally have to trace an app session rather than a cold start.

Example event a local fixture page
{
  "name": "WebSocketCreate",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102952120,
  "tts": 46667,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "identifier": 5,
      "sampleTraceId": 6909218608594544,
      "stackTrace": [
        {
          "columnNumber": 16,
          "functionName": "",
          "lineNumber": 61,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "url": "ws://127.0.0.1:8801/ws"
    }
  }
}
categories
devtools.timeline
usage
rare
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url, args.data.webSocketProtocol
references
source_location.cc, v8-debugger.cc, inspector_websocket_events.cc, unique_identifier.cc, websocket_common.cc, script_loader.cc and 2 more
WebSocketDestroyDestroy WebSocket

A WebSocket closes and is torn down.

WebSocketDestroy marks a socket closing. args.data.identifier is the socket id and frame identifies the frame that owned it.

Matched with WebSocketCreate on identifier, it gives you connection lifetime. The more useful reading is the negative one: an identifier that is created and keeps sending with no WebSocketDestroy anywhere in the trace is a socket nobody closed, which on long-lived single-page app sessions is a real leak.

Example event

Not seen, including on a fixture page that opened a socket, exchanged a message and closed it. The other five WebSocket events all appeared there.

categories
devtools.timeline
usage
rare
undeclared args
args.data.sampleTraceId
references
websocket_channel_impl.cc
WebSocketReceiveReceive WebSocket message

One message arrives from the server over a WebSocket.

WebSocketReceive marks one message arriving from the server. identifier is the socket id, url is the endpoint, and dataLength is the size of the message in bytes. frame and workerId are optional.

The same per-socket aggregation applies inbound as on WebSocketSend, and comparing the two on one identifier tells you which direction the chatter runs. Line these events up against main-thread work as well. A socket delivering messages faster than the page can process them produces a steadily growing backlog, and that surfaces as jank with no obvious cause.

Example event a local fixture page
{
  "name": "WebSocketReceive",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1103045469,
  "tts": 135154,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "dataLength": 29,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "identifier": 5,
      "sampleTraceId": 6909218608593094
    }
  }
}
categories
devtools.timeline
usage
low
undeclared args
args.data.sampleTraceId
references
websocket_channel_impl.cc, inspector_websocket_events.cc, unique_identifier.cc
WebSocketSendSend WebSocket message

The page sends one message over a WebSocket.

WebSocketSend marks one message the page sent over a socket. identifier is the socket id, url is the endpoint, and dataLength is the size of the message in bytes. frame and workerId are optional.

Summed per identifier, dataLength gives you outbound volume per socket. The spacing between the events tells you the rest: high-frequency small sends are usually a batching opportunity, and each one still costs a main-thread trip.

No handler consumes this event, so nothing in DevTools aggregates it for you. Per-socket volume is something you count in the raw trace JSON yourself.

Example event a local fixture page
{
  "name": "WebSocketSend",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1103044203,
  "tts": 134272,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "dataLength": 22,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "identifier": 5,
      "sampleTraceId": 6909218608593060,
      "stackTrace": [
        {
          "columnNumber": 28,
          "functionName": "ws.onopen",
          "lineNumber": 62,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ]
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-debugger.cc, websocket_channel_impl.cc, inspector_websocket_events.cc, unique_identifier.cc, script_loader.cc and 1 more
XHRLoad`XHR` load

An XMLHttpRequest has finished loading.

XHRLoad marks an XMLHttpRequest completing. It is untyped in trace_engine, although real traces record args.data.url, the requested URL, and frame.

XHR is legacy and far from extinct: it appeared on 15 of the 35 sites in the corpus, mostly because plenty of third-party tags still use it. Synchronous XHR in particular blocks the main thread outright, so if you see one of these inside a long task with no network overlap, check for async: false.

Example event a local fixture page
{
  "name": "XHRLoad",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103042670,
  "dur": 11,
  "tdur": 5,
  "tts": 133098,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "sampleTraceId": 6909218608593028,
      "url": "http://127.0.0.1:8801/mod.mjs"
    }
  }
}
categories
devtools.timeline
usage
low
duration
typical
undeclared args
args.data.frame, args.data.sampleTraceId, args.data.url, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
inspector_trace_events.cc, xml_http_request.cc
XHRReadyStateChange`XHR` `readyState` change

An XMLHttpRequest moves to a new readyState.

XHRReadyStateChange marks one readyState transition on an XMLHttpRequest. It is untyped, although the raw trace records args.data.readyState as the new state and args.data.url as the request.

Most of these are noise. A dense cluster of them with handler work attached is not: it means a readystatechange listener is doing work on every transition rather than only on completion. That is a small but real waste, and it is easy to fix.

Example event a local fixture page
{
  "name": "XHRReadyStateChange",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1102952240,
  "dur": 14,
  "tdur": 11,
  "tts": 46784,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "readyState": 1,
      "sampleTraceId": 6909218608594546,
      "stackTrace": [
        {
          "columnNumber": 5,
          "functionName": "",
          "lineNumber": 69,
          "scriptId": "7",
          "url": "http://127.0.0.1:8801/"
        }
      ],
      "url": "http://127.0.0.1:8801/mod.mjs"
    }
  }
}
categories
devtools.timeline
usage
moderate
duration
typical
undeclared args
args.data.frame, args.data.readyState, args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url and 1 more
references
xml_http_request.cc, source_location.cc, v8-debugger.cc, inspector_trace_events.cc, script_loader.cc, heap.cc

Page load metrics and vitals

Navigation, layout shift and interaction timing.

CommitLoad

The renderer commits a navigation and starts building the new document.

CommitLoad records the renderer committing a navigation, meaning the point at which it starts building the new document.

url is the document being committed and frame is its frame id. isMainFrame marks the main frame. page is the page id. parent names the parent frame, name is the frame's name attribute, and nodeId identifies the node the frame is attached to.

The phase is one to watch. trace_engine types the event as instant, but every instance across the 35 site run is phase X with a duration, and those durations are typical for a trace event. Code that switches on the declared phase will not find these events where it expects them.

Its real value is the frame tree. parent plus nodeId lets you reconstruct which iframe sits inside which, and PageFramesHandler exists for precisely this.

On a timeline it marks the boundary between still showing the old page and building the new one, which is the honest start of rendering work even though the metrics are measured from navigationStart.

Example event from https://www.bbc.com/news
{
  "name": "CommitLoad",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1108626385,
  "dur": 12,
  "tdur": 12,
  "tts": 280151,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "data": {
      "frame": "C2F2BAF874B0B58792981F585E0AC5A3",
      "isMainFrame": false,
      "isOutermostMainFrame": false,
      "name": "__tcfapiLocator",
      "nodeId": 1617,
      "page": "2EA8810E92C12C680C55B2941251AA98",
      "parent": "2EA8810E92C12C680C55B2941251AA98",
      "url": "about:blank"
    }
  }
}
categories
devtools.timeline
usage
low
duration
typical
undeclared args
args.data.isOutermostMainFrame
references
inspector_trace_events.cc, tracing_handler.cc, document.cc, local_dom_window.cc, document_loader.cc
EventTimingEvent timing

Chrome times one dispatched event, from when it arrived, through its handlers, to the screen.

EventTiming records one entry of the Event Timing API: a single dispatched event, when it arrived, when its handlers ran, and when the result reached the screen.

trace_engine models only the end interface, so the fields below are from observed traces:

  • type is the DOM event name and timeStamp is when the event occurred.
  • processingStart and processingEnd bracket the handlers running.
  • duration is the whole event, from timeStamp through to presentation.
  • interactionId groups the events of one interaction under a single id.
  • nodeId identifies the target element.
  • enqueuedToMainThreadTime is when the event was queued to the main thread,
  • and commitFinishTime is when the resulting frame committed.

  • cancelable, interactionOffset and frame complete the shape.

These are the source of INP, and the three phases of an interaction come straight out of them:

  • input delay = processingStart - timeStamp
  • processing = processingEnd - processingStart
  • presentation delay = (timeStamp + duration) - processingEnd

interactionId: 0 means it is not an interaction. Hover, pointermove, pointerover and scroll all emit EventTiming with a zero id and are excluded from INP by design. A trace of a page load alone can produce dozens of these and still have no INP at all. Filter on interactionId > 0 before you compute anything, or you will report a number that does not exist.

Several events share one interactionId (pointerdown, pointerup, click for a tap; keydown, keypress, keyup for a keystroke). The interaction's duration is the longest of them, not the sum.

Note timeStamp is milliseconds from the time origin, already relative to navigation. It is not a trace timestamp and does not need converting.

Example event a local fixture page
{
  "name": "EventTiming",
  "cat": "devtools.timeline",
  "ph": "b",
  "ts": 1104754827,
  "pid": 8257,
  "tid": 8257,
  "id": "0x97d6cfef",
  "scope": "devtools.timeline",
  "args": {
    "data": {
      "cancelable": true,
      "commitFinishTime": 1839.039,
      "duration": 1.735,
      "enqueuedToMainThreadTime": 1838.179,
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "interactionId": 0,
      "interactionOffset": 0,
      "nodeId": 8,
      "processingEnd": 1838.712,
      "processingStart": 1838.699,
      "timeStamp": 1837.316,
      "type": "pointerover"
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.cancelable, args.data.commitFinishTime, args.data.duration, args.data.enqueuedToMainThreadTime, args.data.frame, args.data.interactionId, args.data.interactionOffset, args.data.nodeId and 4 more
references
performance_event_timing.cc, window_performance.cc, performance_event_timing.h, performance_timeline_entry_id_generator.h
InteractiveTime

Chrome marks the moment it considers the page interactive, the point known as Time to Interactive.

InteractiveTime marks Time to Interactive. Phase R (mark).

args.args.total_blocking_time_ms is Total Blocking Time, reported alongside TTI on the same event.

Treat this one with care. TTI was removed from the Lighthouse performance score in Lighthouse 10, which shipped in Chrome 112. Lighthouse's own reason was that "the way it's defined makes it overly sensitive to outlier network requests and long tasks". Its 10% weight moved to CLS. The raw value survives in the Lighthouse JSON at weight zero, and the trace event still fires, so you will still see it. It is rare in practice: exactly one appears across the 35 sites in the corpus.

The total_blocking_time_ms field is still useful. TBT is the sum of the blocking portion of every long task between FCP and TTI, and it is the best lab proxy for INP that a load trace can give you, since a load trace usually has no interactions in it at all.

Report the TBT. Be cautious about reporting the TTI.

Example event from https://github.com
{
  "name": "InteractiveTime",
  "cat": "loading,rail",
  "ph": "R",
  "ts": 1164471963,
  "tts": 1647230,
  "pid": 9307,
  "tid": 9307,
  "s": "t",
  "args": {
    "args": {
      "had_user_input_before_interactive": false,
      "total_blocking_time_ms": 254.181
    },
    "frame": "3037F57D3EE99AA57D01190FF40E7359"
  }
}
categories
loading, rail
usage
rare
undeclared args
args.args.had_user_input_before_interactive
references
interactive_detector.cc
LayoutShiftLayout shift

Chrome records one rendered frame in which visible content moved position on screen.

LayoutShift records one frame in which visible content moved. Phase I, emitted during PrePaint, and the raw material of CLS.

trace_engine types args.data loosely, so the shape below is from observed traces and from layout_shift_tracker.cc, which writes it:

  • score is this shift's score: the share of the viewport the moving content
  • covered, multiplied by how far it moved as a share of the viewport's larger side. weighted_score_delta is the same score weighted for the frame, and only differs from score inside an iframe.

  • cumulative_score is a running total of score for this frame, and it is
  • not CLS. It never resets, it is not weighted, and every frame keeps its own. CLS takes the worst five second session window of weighted shifts across the whole page. On a page with one burst of shifts and no iframes the two happen to match, which is why reading the last cumulative_score as the page's CLS works right up until it does not.

  • had_recent_input is true when the shift followed a user input closely
  • enough to be excluded from CLS. last_input_timestamp is when that input happened, in milliseconds on the page's own clock, and 0 when there has been none.

  • impacted_nodes lists up to five of the nodes that moved, as `{node_id,
  • old_rect, new_rect}. Rects are [x, y, width, height]` in viewport pixels.

  • region_rects is the area the moving content covered, before and after

together. frame_max_distance is the furthest anything moved in this shift and overall_max_distance the furthest this frame has recorded so far. is_main_frame is true when the shift happened in the top-level frame. Reading these tells you exactly what moved. Go to impacted_nodes first. It gives you the before and after geometry per node, which means you can name the element and state how far it moved and how its box changed, rather than reporting a score nobody can act on. Chrome stops at five, so when a whole list of cards moves you get some of them, not all of them. There are rarely many shifts to read, because shifts are a low volume event and only 25 of the 35 sites in the corpus produced any.

The hard part is attribution, because the shift is reported in the rendering lifecycle, not in the task that caused it. Walking up the event tree from a LayoutShift reaches PrePaint and RunTask, never the script. To find the cause you correlate backwards: invalidations on the same thread since that frame's previous PrePaint, and resources that finished in the same window. Nothing in the trace format links the two, so that join is an inference you build, not a fact the trace gives you.

One reliable shortcut exists. PaintImage uses the same node id space as impacted_nodes, so an image that painted into a node that moved is a node-id-level match, not a timing guess.

Example event from https://www.bbc.com/news
{
  "name": "LayoutShift",
  "cat": "loading",
  "ph": "I",
  "ts": 1108583389,
  "tts": 242293,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "cumulative_score": 0.0029692105611165364,
      "frame_max_distance": 32.078125,
      "had_recent_input": false,
      "impacted_nodes": [
        {
          "new_rect": [262, 81, 756, 41],
          "node_id": 37,
          "old_rect": [279, 81, 722, 41]
        },
        {
          "new_rect": [262, 123, 756, 32],
          "node_id": 95,
          "old_rect": [272, 123, 736, 32]
        },
        {
          "new_rect": [16, 566, 300, 271],
          "node_id": 278,
          "old_rect": [16, 583, 300, 238]
        }
      ],
      "is_main_frame": true,
      "last_input_timestamp": 0,
      "overall_max_distance": 32.078125,
      "region_rects": [
        [262, 81, 756, 41],
        [262, 123, 756, 32],
        [16, 566, 300, 271]
      ],
      "score": 0.0029692105611165364,
      "weighted_score_delta": 0.0029692105611165364
    },
    "frame": "2EA8810E92C12C680C55B2941251AA98"
  }
}
categories
loading
usage
low
undeclared args
args.data.cumulative_score, args.data.frame_max_distance, args.data.had_recent_input, args.data.impacted_nodes[].new_rect[], args.data.impacted_nodes[].node_id, args.data.impacted_nodes[].old_rect[], args.data.is_main_frame, args.data.last_input_timestamp and 4 more
references
layout_shift_tracker.cc, layout_shift.h
SoftNavigationStart

Chrome marks a route change in a single-page app that it treats as navigation-like.

SoftNavigationStart marks a route change in a single-page app that Chrome considers navigation-like rather than a real document load.

args.context is unusually rich. softNavContextId identifies the soft navigation and performanceTimelineNavigationId ties it to the performance timeline. URL is the new route and timeOrigin its measurement origin. domModifications, paintedArea and repaintedArea quantify what changed. firstContentfulPaint is FCP measured for the new route.

domModifications and paintedArea are the heuristic Chrome uses to decide a soft navigation happened at all: enough of the DOM changed, and enough pixels were repainted, following a user interaction and a URL change.

Reading these matters because Core Web Vitals are per-navigation. On a single-page app a soft navigation starts a new measurement window, so a shift or an interaction after it is attributed to the new route rather than to the initial load.

Example event

Not seen. It is only written when a single-page app performs a soft navigation, and the collection recorded page loads without one.

categories
scheduler, devtools.timeline, loading
references
soft_navigation_context.cc
SyntheticLayoutShiftLayout shiftsynthetic

One layout shift as DevTools rebuilds it, with the weighted score and cluster filled in.

DevTools builds this event while parsing a trace. You will not find it in a trace file.

SyntheticLayoutShift is built by DevTools while it parses a trace, wrapping a raw LayoutShift in resolved data.

The resolved data is what the raw event leaves implicit: the computed weighted score, the cluster the shift belongs to, and screenshot references.

You will never find it in a trace JSON file. If you are reading a raw trace, work from LayoutShift. If you are working inside trace_engine, this is what LayoutShiftsHandler actually hands you.

Know it exists so that you do not go hunting for the name in a trace and conclude your capture is broken.

Example event

DevTools builds this while parsing a trace. It is never written to a trace file, so there is no sample to show.

references
LayoutShiftsHandler.ts
SyntheticLayoutShiftClusterLayout shift clustersynthetic

A run of layout shifts that DevTools groups into a single session window.

DevTools builds this event while parsing a trace. You will not find it in a trace file.

SyntheticLayoutShiftCluster groups raw layout shifts into session windows. DevTools constructs it while parsing, so like SyntheticLayoutShift it never appears in a trace file.

The windowing rule is what people get wrong about CLS. A cluster ends after a gap of 1 second with no shift, or after 5 seconds total, whichever comes first. LayoutShiftsHandler states the constant in a comment: "the maximum time we will allow a cluster to go before we reset it".

CLS is not the sum of every shift on the page. It is the score of the worst cluster. A page that shifts steadily for 30 seconds is scored on its worst 5-second window, not the total. When your summed shift scores do not match the CLS a tool reports, this is usually why.

Example event

DevTools builds this while parsing a trace. It is never written to a trace file, so there is no sample to show.

references
LayoutShiftsHandler.ts

Compositor, raster and GPU

Frame production, image decode and everything off the main thread.

ActivateLayerTree

The compositor switches over to the new copy of the page's layers and can start drawing it.

ActivateLayerTree marks the compositor activating a newly committed layer tree, meaning the pending tree becomes the active one and its content can be drawn. Phase I, with args.layerTreeId and args.frameId.

It marks the boundary between "the main thread has handed over new content" and "the compositor can now present it". Together with Commit and BeginCommitCompositorFrame it lets you measure handover latency: the gap between the main thread finishing and the compositor being able to use the result.

Consumed by FramesHandler, the adapted TimelineFrameModel, which is what builds the frames track in DevTools.

Example event a local fixture page
{
  "name": "ActivateLayerTree",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1102924040,
  "tts": 1794,
  "pid": 8257,
  "tid": 8278,
  "s": "t",
  "args": {
    "frameId": 2,
    "layerTreeId": 1
  }
}
categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, layer_tree_impl.cc, layer_tree_host.cc
BeginCommitCompositorFrame

Chrome starts handing the next frame of page content over to the compositor.

BeginCommitCompositorFrame marks the start of committing a compositor frame. Phase I, with args.frame and args.is_mobile_optimized.

is_mobile_optimized reflects whether the page is considered mobile-optimised, which feeds decisions about how aggressively the compositor may handle input without consulting the main thread.

Its consumer is UserInteractionsHandler, which is the tell: this event sits on the path between an interaction and the pixels that answer it, so it is part of measuring presentation delay, the phase of INP that gets the least attention.

Example event a local fixture page
{
  "name": "BeginCommitCompositorFrame",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102923601,
  "tts": 28668,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
    "is_mobile_optimized": true
  }
}
categories
disabled-by-default-devtools.timeline
usage
moderate
references
web_frame_widget_impl.cc, mobile_optimized_viewport_util.cc
cc::DisplayItemListno longer emitted

This captured the list of drawing commands Chrome had recorded for one part of the page.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

cc::DisplayItemList snapshotted a recorded paint list as a traced object from Chrome 47 to Chrome 148, and Chrome 149 renamed it cc::DisplayItemList:snapshot. It replaced cc::Picture when it arrived. The rename landed on main on 2026-05-01 in "[tracing] Migrate legacy TRACE_EVENT_OBJECT macros in cc". Chrome 141, the version behind the examples on this page, still wrote the old name. Look for cc::DisplayItemList in traces from Chrome 148 and earlier, and for cc::DisplayItemList:snapshot from Chrome 149 on.

DisplayItemList::EmitTraceSnapshot in cc/paint/display_item_list.cc writes it under both names on three categories at once: disabled-by-default-cc.debug.display_items, disabled-by-default-cc.debug.picture and disabled-by-default-devtools.timeline.picture, the last of which is what DevTools turns on for advanced paint instrumentation. Up to Chrome 148 it was a phase O object snapshot. From Chrome 149 it is an instant with a flow keyed on the display list.

Under args.snapshot, params.layer_rect and a top-level skp64 are always present, under either name. skp64 is the base64 serialised SkPicture of the whole list. Only if disabled-by-default-cc.debug.display_items is also enabled does params.items appear, one entry per PaintOp with name, visual_rect and its own skp64. That array is what makes these events enormous.

It fires from RecordingSource::FinishDisplayItemListUpdate when a recording is finalised, and again for every layer at the start of a new trace via RasterSource::DidBeginTracing.

Example event

Chrome 141 still wrote this name, but only on disabled-by-default-devtools.timeline.picture and two disabled-by-default-cc.debug categories, none of which the collection enabled. From Chrome 149 the same snapshot is written as cc::DisplayItemList:snapshot.

references
display_item_list.cc
cc::LayerTreeHostImplno longer emitted

This captured the state of the compositor's own copy of the page's layers.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

cc::LayerTreeHostImpl snapshotted the compositor's impl-side tree up to Chrome 148, and Chrome 149 renamed it LayerTreeHostImpl:snapshot. The rename landed on main on 2026-05-01 in "[tracing] Migrate legacy TRACE_EVENT_OBJECT macros in cc". The example on this page comes from Chrome 141, so it shows the old name. Look for cc::LayerTreeHostImpl in traces from Chrome 148 and earlier, and for LayerTreeHostImpl:snapshot from Chrome 149 on.

Under either name the event is state rather than a unit of work. Up to Chrome 148, TRACE_EVENT_OBJECT_SNAPSHOT_WITH_ID in cc/trees/layer_tree_host_impl.cc wrote it as phase O, with an id equal to the LayerTreeHostImpl id and the whole payload under args.snapshot. Its dur is meaningless. The old name also marked the object being created and deleted, on disabled-by-default-cc.debug. From Chrome 149 the snapshot is an instant with a process-scoped flow on the same id, the payload stays under args.snapshot, and creation and deletion are LayerTreeHostImpl:created and LayerTreeHostImpl:deleted.

Both names use the categories from frame_viewer_instrumentation::CategoryLayerTree(): disabled-by-default-cc.debug, disabled-by-default-viz.quads and disabled-by-default-devtools.timeline.layers. The payload is built by AsValueWithFrameInto: activation_state, device_viewport_size, an active_tiles array of every prioritized tile, tile_manager_basic_state, active_tree, pending_tree and frame. One is written per draw, which makes it one of the heaviest things in a trace.

Example event from https://www.bbc.com/news
{
  "name": "cc::LayerTreeHostImpl",
  "cat": "disabled-by-default-cc.debug,disabled-by-default-viz.quads,disabled-by-default-devtools.timeline.layers",
  "ph": "O",
  "ts": 1111534383,
  "tts": 37131,
  "pid": 8407,
  "tid": 8421,
  "id": "0x1",
  "args": {
    "snapshot": {
      "activation_state": {
        "lthi": {
          "id_ref": "0x276400553000"
        },
        "tile_manager": {
          "did_oom_on_last_assign": false,
          "global_state": {
            "hard_memory_limit_in_bytes": 536870912,
            "memory_limit_policy": "ALLOW_PREPAINT_ONLY",
            "num_resources_limit": 10000000,
            "soft_memory_limit_in_bytes": 536870912,
            "tree_priority": "SMOOTHNESS_TAKES_PRIORITY"
          },
          "tile_count": 63
        }
      },
      "active_tiles": [
        {
          "cat": "disabled-by-default-cc.debug",
          "combined_priority": {
            "distance_to_visible": 0,
            "is_occluded": false,
            "priority_bin": "NOW",
            "resolution": "HIGH_RESOLUTION"
          },
          "content_rect": [0, 0, 512, 128],
          "contents_scale": 1,
          "draw_info": {
            "is_solid_color": false,
            "is_transparent": false
          },
          "gpu_memory_usage": 262144,
          "has_resource": true,
          "id": "cc::Tile/0x27640167a4c0",
          "is_using_gpu_memory": true,
          "layer_id": 11,
          "picture_pile": {
            "id_ref": "0x276400563a20"
          },
          "raster_transform": {
            "scale": [1, 1],
            "translation": [0, 0]
          },
          "resolution": "HIGH_RESOLUTION",
          "scheduled_priority": 1,
          "use_picture_analysis": true
        },
        {
          "cat": "disabled-by-default-cc.debug",
          "combined_priority": {
            "distance_to_visible": 0,
            "is_occluded": false,
            "priority_bin": "NOW",
            "resolution": "HIGH_RESOLUTION"
          },
          "content_rect": [510, 0, 512, 128],
          "contents_scale": 1,
          "draw_info": {
            "is_solid_color": false,
            "is_transparent": false
          },
          "gpu_memory_usage": 262144,
          "has_resource": true,
          "id": "cc::Tile/0x27640167a920",
          "is_using_gpu_memory": true,
          "layer_id": 11,
          "picture_pile": {
            "id_ref": "0x276400563a20"
          },
          "raster_transform": {
            "scale": [1, 1],
            "translation": [0, 0]
          },
          "resolution": "HIGH_RESOLUTION",
          "scheduled_priority": 2,
          "use_picture_analysis": true
        },
        {
          "cat": "disabled-by-default-cc.debug",
          "combined_priority": {
            "distance_to_visible": 0,
            "is_occluded": false,
            "priority_bin": "NOW",
            "resolution": "HIGH_RESOLUTION"
          },
          "content_rect": [1020, 0, 512, 128],
          "contents_scale": 1,
          "draw_info": {
            "is_solid_color": false,
            "is_transparent": false
          },
          "gpu_memory_usage": 262144,
          "has_resource": true,
          "id": "cc::Tile/0x276400bd3800",
          "is_using_gpu_memory": true,
          "layer_id": 11,
          "picture_pile": {
            "id_ref": "0x276400563a20"
          },
          "raster_transform": {
            "scale": [1, 1],
            "translation": [0, 0]
          },
          "resolution": "HIGH_RESOLUTION",
          "scheduled_priority": 3,
          "use_picture_analysis": true
        },
        "... [60 more items]"
      ],
      "active_tree": {
        "id": "cc::LayerTreeImpl/0x2764000bcc00",
        "layers": [
          {
            "base_type": "cc::LayerImpl",
            "bounds": {
              "height": 0,
              "width": 0
            },
            "cat": "disabled-by-default-cc.debug",
            "clip_tree_index": 1,
            "contents_opaque": false,
            "draws_content": 0,
            "effect_tree_index": 1,
            "gpu_memory_usage": 0,
            "hit_testable": false,
            "id": "cc::LayerImpl/0x27640005aa00",
            "layer_id": 7,
            "layer_name": "root",
            "layer_quad": [0, 0, 0, 0, 0, 0, 0, 0],
            "opacity": 1,
            "opaque_to_hit_test": false,
            "position": [0, 0],
            "scroll_tree_index": 1,
            "sorting_context_id": 0,
            "transform_tree_index": 1
          },
          {
            "base_type": "cc::LayerImpl",
            "bounds": {
              "height": 900,
              "width": 1280
            },
            "cat": "disabled-by-default-cc.debug",
            "clip_tree_index": 1,
            "compositing_reason_ids": ["OverflowScrolling"],
            "compositing_reasons": ["Is a scrollable overflow element using accelerated scrolling."],
            "contents_opaque": false,
            "draws_content": 0,
            "effect_tree_index": 1,
            "element_id": {
              "id_": 644
            },
            "gpu_memory_usage": 0,
            "hit_testable": true,
            "id": "cc::LayerImpl/0x2764013b9180",
            "layer_id": 8,
            "layer_name": "LayoutView #document",
            "layer_quad": [0, 0, 1280, 0, 1280, 900, 0, 900],
            "opacity": 1,
            "opaque_to_hit_test": true,
            "owner_node": 7,
            "position": [0, 0],
            "scroll_tree_index": 3,
            "sorting_context_id": 0,
            "transform_tree_index": 4
          },
          {
            "base_type": "cc::LayerImpl",
            "bounds": {
              "height": 6660,
              "width": 1280
            },
            "can_have_tilings_state": {
              "can_have_tilings": true,
              "draws_content": true,
              "max_contents_scale": 1,
              "min_contents_scale": 0.0007812500116415322,
              "raster_source_has_recordings": true,
              "raster_source_solid_color": false
            },
            "cat": "disabled-by-default-cc.debug",
            "clip_tree_index": 2,
            "compositing_reason_ids": ["RootScroller", "OverflowScrolling"],
            "compositing_reasons": ["Is the document.rootScroller.", "Is a scrollable overflow element using accelerated scrolling."],
            "contents_opaque": true,
            "coverage_tiles": [
              {
                "...": "[nested too deep]"
              },
              {
                "...": "[nested too deep]"
              },
              {
                "...": "[nested too deep]"
              },
              "... [52 more items]"
            ],
            "draws_content": 1,
            "effect_tree_index": 1,
            "geometry_contents_scale": 1,
            "gpu_memory_usage": 12582912,
            "hit_testable": true,
            "id": "cc::PictureLayerImpl/0x27640002bc00",
            "ideal_contents_scale": 1,
            "ideal_scales": {
              "contents_scale": ["..."],
              "device_scale": 1,
              "page_scale": 1,
              "source_scale": ["..."]
            },
            "invalidation": [1024, 4903, 1, 1, 964, 4904, 51, 14, "... [268 more items]"],
            "layer_id": 9,
            "layer_name": "Scrolling background of LayoutView #document",
            "layer_quad": [0, -900, 1280, -900, 1280, 5760, 0, 5760],
            "lcd_text_disallowed_reason": "none",
            "opacity": 1,
            "opaque_to_hit_test": false,
            "owner_node": 7,
            "pictures": [
              {
                "...": "[nested too deep]"
              }
            ],
            "position": [0, 0],
            "raster_scales": {
              "contents_scale": ["..."],
              "device_scale": 1,
              "page_scale": 1,
              "source_scale": ["..."]
            },
            "screen_space_transform": [1, 0, 0, 0, 0, 1, 0, -900, "... [8 more items]"],
            "scroll_tree_index": 3,
            "sorting_context_id": 0,
            "tile_priority_rect": [0, 900, 1280, 900],
            "tilings": [
              {
                "...": "[nested too deep]"
              }
            ],
            "transform_tree_index": 5,
            "visible_rect": [0, 900, 1280, 900]
          },
          "... [6 more items]"
        ],
        "render_surface_layer_list": [
          {
            "id_ref": "0x27640005c980"
          },
          {
            "id_ref": "0x2764033eda00"
          },
          {
            "id_ref": "0x2764013b9180"
          },
          "... [1 more items]"
        ],
        "source_frame_number": 28,
        "swap_promise_trace_ids": [0, 0]
      },
      "device_viewport_size": {
        "height": 900,
        "width": 1280
      },
      "frame": {
        "has_no_damage": false
      },
      "pending_tree": {
        "id": "cc::LayerTreeImpl/0x2764006e9400",
        "layers": [
          {
            "base_type": "cc::LayerImpl",
            "bounds": {
              "height": 0,
              "width": 0
            },
            "cat": "disabled-by-default-cc.debug",
            "clip_tree_index": 1,
            "contents_opaque": false,
            "draws_content": 0,
            "effect_tree_index": 1,
            "gpu_memory_usage": 0,
            "hit_testable": false,
            "id": "cc::LayerImpl/0x2764013b8380",
            "layer_id": 7,
            "layer_name": "root",
            "layer_quad": [0, 0, 0, 0, 0, 0, 0, 0],
            "opacity": 1,
            "opaque_to_hit_test": false,
            "position": [0, 0],
            "scroll_tree_index": 1,
            "sorting_context_id": 0,
            "transform_tree_index": 1
          },
          {
            "base_type": "cc::LayerImpl",
            "bounds": {
              "height": 900,
              "width": 1280
            },
            "cat": "disabled-by-default-cc.debug",
            "clip_tree_index": 1,
            "compositing_reason_ids": ["OverflowScrolling"],
            "compositing_reasons": ["Is a scrollable overflow element using accelerated scrolling."],
            "contents_opaque": false,
            "draws_content": 0,
            "effect_tree_index": 1,
            "element_id": {
              "id_": 644
            },
            "gpu_memory_usage": 0,
            "hit_testable": true,
            "id": "cc::LayerImpl/0x2764013b9500",
            "layer_id": 8,
            "layer_name": "LayoutView #document",
            "layer_quad": [0, 0, 1280, 0, 1280, 900, 0, 900],
            "opacity": 1,
            "opaque_to_hit_test": true,
            "owner_node": 7,
            "position": [0, 0],
            "scroll_tree_index": 3,
            "sorting_context_id": 0,
            "transform_tree_index": 4
          },
          {
            "base_type": "cc::LayerImpl",
            "bounds": {
              "height": 6660,
              "width": 1280
            },
            "can_have_tilings_state": {
              "can_have_tilings": true,
              "draws_content": true,
              "max_contents_scale": 1,
              "min_contents_scale": 0.0007812500116415322,
              "raster_source_has_recordings": true,
              "raster_source_solid_color": false
            },
            "cat": "disabled-by-default-cc.debug",
            "clip_tree_index": 2,
            "compositing_reason_ids": ["RootScroller", "OverflowScrolling"],
            "compositing_reasons": ["Is the document.rootScroller.", "Is a scrollable overflow element using accelerated scrolling."],
            "contents_opaque": true,
            "coverage_tiles": [
              {
                "...": "[nested too deep]"
              }
            ],
            "draws_content": 1,
            "effect_tree_index": 1,
            "geometry_contents_scale": 1,
            "gpu_memory_usage": 0,
            "hit_testable": true,
            "id": "cc::PictureLayerImpl/0x276400028000",
            "ideal_contents_scale": 1,
            "ideal_scales": {
              "contents_scale": ["..."],
              "device_scale": 1,
              "page_scale": 1,
              "source_scale": ["..."]
            },
            "layer_id": 9,
            "layer_name": "Scrolling background of LayoutView #document",
            "layer_quad": [0, -900, 1280, -900, 1280, 5760, 0, 5760],
            "lcd_text_disallowed_reason": "none",
            "opacity": 1,
            "opaque_to_hit_test": false,
            "owner_node": 7,
            "pictures": [
              {
                "...": "[nested too deep]"
              }
            ],
            "position": [0, 0],
            "raster_scales": {
              "contents_scale": ["..."],
              "device_scale": 1,
              "page_scale": 1,
              "source_scale": ["..."]
            },
            "screen_space_transform": [1, 0, 0, 0, 0, 1, 0, -900, "... [8 more items]"],
            "scroll_tree_index": 3,
            "sorting_context_id": 0,
            "tile_priority_rect": [0, 900, 1280, 900],
            "tilings": [
              {
                "...": "[nested too deep]"
              }
            ],
            "transform_tree_index": 5,
            "visible_rect": [0, 900, 1280, 900]
          },
          "... [6 more items]"
        ],
        "render_surface_layer_list": [
          {
            "id_ref": "0x27640005b480"
          },
          {
            "id_ref": "0x2764033f0c00"
          },
          {
            "id_ref": "0x2764013b9500"
          },
          "... [1 more items]"
        ],
        "source_frame_number": 29,
        "swap_promise_trace_ids": [0]
      },
      "tile_manager_basic_state": {
        "did_oom_on_last_assign": false,
        "global_state": {
          "hard_memory_limit_in_bytes": 536870912,
          "memory_limit_policy": "ALLOW_PREPAINT_ONLY",
          "num_resources_limit": 10000000,
          "soft_memory_limit_in_bytes": 536870912,
          "tree_priority": "SMOOTHNESS_TAKES_PRIORITY"
        },
        "tile_count": 63
      }
    }
  }
}
references
layer_tree_host_impl.cc, picture_layer_impl.cc, layer_impl.cc, math_util.cc, picture_layer_impl.h, traced_value.cc and 23 more
cc::Pictureno longer emitted

This captured one recorded drawing, saved so it could be replayed when that part of the page was painted.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

cc::Picture snapshotted one recorded drawing as a traced object, and Chrome has not written it since Chrome 46. The change "cc: Remove Picture" deleted it on 2015-09-24. cc/playback/picture.cc is present in Chrome 46 and gone in Chrome 47, when the Picture and PicturePile model was replaced by DisplayItemList. The event to read instead is cc::DisplayItemList in Chrome 47 to 148, and cc::DisplayItemList:snapshot from Chrome 149 on.

It came from Picture::EmitTraceSnapshot, phase O, on disabled-by-default-cc.debug.picture and disabled-by-default-devtools.timeline.picture. The payload was small: params.layer_rect plus skp64, a base64 serialised SkPicture of the recorded drawing, which is what the old DevTools paint profiler replayed. A sibling EmitTraceSnapshotAlias wrote the same name for pictures that merely pointed at another recording.

If you are parsing a trace from any supported Chrome you will never see this event, and any tool still keyed on it is reading a 2015 format.

Example event

Deleted in September 2015, so Chrome 47 and later never write it. Its successor is cc::DisplayItemList, renamed cc::DisplayItemList:snapshot in Chrome 149.

references
picture.cc
Decode LazyPixelRef

Chrome turns one compressed image into pixels, on a thread other than the main one.

Decode LazyPixelRef measures one image being decoded on a raster thread. Phase X, so the duration is real decode cost. The main thread has its own version of this event, Decode Image.

args.LazyPixelRef is a numeric id, not a URL. That is the whole difficulty with this event: on its own it tells you an image was decoded and nothing about which one. ImagePaintingHandler exists to solve exactly this, joining the id back to a PaintImage so the decode can be blamed on a specific element. The id can legitimately be 0, which is observed in real traces, so treat 0 as a valid key rather than a missing value.

Reading these events tells you about decode work that never appears in main thread totals, because it happens off the main thread. If your scripting and rendering numbers look fine but frames are still being missed, look at the raster threads. See also Decode Image, the main-thread counterpart, whose arguments name the image URL.

Example event from https://www.bbc.com/news
{
  "name": "Decode LazyPixelRef",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1108423665,
  "dur": 162,
  "tdur": 158,
  "tts": 877,
  "pid": 8407,
  "tid": 8471,
  "args": {
    "LazyPixelRef": 3
  }
}
categories
disabled-by-default-devtools.timeline
usage
moderate
duration
very long
references
image.cc, paint_image.cc, ImagePaintingHandler.ts, decoding_image_generator.cc
DirectRenderer::DrawRenderPassnot in DevTools recordingsnot in DevTools' model

Chrome draws one pass of the finished frame, either onto the screen or into an offscreen buffer.

A DevTools Performance recording does not contain this event. Record viz with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

DirectRenderer::DrawRenderPass measures viz drawing one render pass of the aggregated compositor frame, from components/viz/service/display/direct_renderer.cc. The root pass is the screen, and every additional pass is an intermediate texture that something forced.

args.id is the AggregatedRenderPassId and args.NumberOfQuads is render_pass->quad_list.size().

Count the distinct id values in a frame and you have counted the offscreen buffers your CSS is asking for. Backdrop filters, masks, non trivial opacity on a group, and rounded corner clipping of composited content each create one, and each costs an allocate, a draw and a sample. Nothing in DevTools puts a number on that. The function early returns via CanSkipRenderPass(), so undamaged passes still emit a tiny slice. These run slower than most trace events, and the fat instances are almost always the root pass with a large quad list, or a filter pass redrawn every frame.

Example event a local fixture page
{
  "name": "DirectRenderer::DrawRenderPass",
  "cat": "viz",
  "ph": "X",
  "ts": 1102925542,
  "dur": 991,
  "tdur": 605,
  "tts": 3172,
  "pid": 8211,
  "tid": 8266,
  "args": {
    "NumberOfQuads": 13
  }
}
categories
viz
usage
high
duration
typical
references
direct_renderer.cc
DisplayItemList::Rasternot in DevTools recordingsnot in DevTools' model

Chrome replays its recorded drawing commands to produce the actual pixels for one patch of the page.

A DevTools Performance recording does not contain this event. Record cc with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

DisplayItemList::Raster records the playback of one recorded paint op buffer onto a raster canvas, from cc/paint/display_item_list.cc. There is one per tile, on a raster worker thread.

Two args are recorded at opposite ends of the slice. total_op_count is written at BEGIN and is every paint op in the list. rastered_op_count is written at END and counts only the ops that survived the r-tree query for this tile's clip bounds, including extras unfolded by PaintOp::OpAdditionalOpCount.

The ratio measures how badly your layer spatialises. A tile that rasters most of the list's ops has an r-tree that culled almost nothing, meaning the layer's paint ops overlap across its whole area and every tile pays for the whole page. Full width backgrounds, box shadows and absolutely positioned overlays do this. Duration is typical for a trace event and the volume is high, so a handful of bad tiles is what a slow raster pass usually turns out to be.

Example event a local fixture page
{
  "name": "DisplayItemList::Raster",
  "cat": "cc",
  "ph": "X",
  "ts": 1102928342,
  "dur": 11,
  "tdur": 10,
  "tts": 1440,
  "pid": 8178,
  "tid": 8209,
  "args": {
    "rastered_op_count": 13,
    "total_op_count": 13
  }
}
categories
cc
usage
high
duration
typical
references
display_item_list.cc, paint_op.h, paint_op_buffer.h
Draw LazyPixelRef

Chrome draws an already decoded image into one patch of the page's pixels.

Draw LazyPixelRef marks a decoded image being drawn into a raster tile. Phase I. Its only argument is args.LazyPixelRef, the image id, which is also all its decode counterpart gives you.

Use it as a join key. ImagePaintingHandler connects a pixel ref id to the PaintImage that produced it, which is what makes off-main-thread decode attributable to a real element.

Multiple draws of the same id are normal and expected: one decoded image drawn into several tiles. Counting them as separate images will overstate your image work considerably.

Example event from https://www.bbc.com/news
{
  "name": "Draw LazyPixelRef",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1108423863,
  "tts": 91991,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "LazyPixelRef": 2
  }
}
categories
disabled-by-default-devtools.timeline
usage
moderate
references
image.cc, paint_image.cc, ImagePaintingHandler.ts, bitmap_image.cc
DroppedFrame

This event tells you a frame never made it to the screen.

DroppedFrame marks a frame that was not presented. Phase I, with args.layerTreeId, args.frameSeqId and args.hasPartialUpdate.

hasPartialUpdate changes the interpretation. true means some of the frame did make it to screen, so the user saw an update, just not a complete one. false is a frame entirely lost.

frameSeqId is a monotonic counter, so gaps between consecutive presented frames tell you how many were missed in a row. One dropped frame is invisible. Six consecutive is a visible stutter, and the sequence ids are how you tell them apart.

The event requires disabled-by-default-devtools.timeline.frame. Its absence from a trace means the category was off, not that no frames were dropped. For the reason a frame was dropped rather than the fact of it, read the PipelineReporter covering the same frameSeqId.

Example event from https://www.bbc.com/news
{
  "name": "DroppedFrame",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1108805646,
  "tts": 24480,
  "pid": 8407,
  "tid": 8421,
  "s": "t",
  "args": {
    "frameSeqId": 37,
    "hasPartialUpdate": true,
    "layerTreeId": 1
  }
}
categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, compositor_frame_reporter.cc, FramesHandler.ts
GPUTaskGPU

Chrome runs a piece of work in the GPU process, the part that talks to the graphics hardware.

GPUTask measures work running in the GPU process. Phase X.

args.data.renderer_pid attributes the work to a specific renderer, which matters when a trace covers several tabs or a page with out-of-process iframes. args.data.used_bytes reports GPU memory in use, sampled per task.

Reading these events gives you the only routine window into a process most tooling ignores, and they are among the most numerous events in a trace. Tracked over a recording, used_bytes gives you a memory curve for free, and a steadily climbing one during interaction is a real finding: it usually means layers or textures are being created and not released.

Individual tasks are short. Their significance is aggregate: a large total here with a healthy main thread means your bottleneck is compositing or texture upload, not JavaScript.

Example event from https://www.nytimes.com
{
  "name": "GPUTask",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1128617562,
  "dur": 25,
  "tdur": 22,
  "tts": 113166,
  "pid": 8768,
  "tid": 8768,
  "args": {
    "data": {
      "renderer_pid": 8798,
      "used_bytes": 16515072
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
moderate
duration
typical
references
command_buffer_stub.cc, gpu_channel.cc
Graphics.Pipelinenot in DevTools recordingsnot in DevTools' model

One step on the path from Chrome asking for a frame to that frame reaching the screen.

A DevTools Performance recording does not contain this event. Record viz, benchmark or graphics.pipeline with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

Graphics.Pipeline marks one step of frame production, somewhere between the begin frame request and pixels on screen. One emitter is CompositorFrameSinkSupport::OnBeginFrame in components/viz/service/frame_sinks/compositor_frame_sink_support.cc.

args.chrome_graphics_pipeline.step is the discriminator, a StepName enum in base/tracing/protos/chrome_track_event.proto: STEP_ISSUE_BEGIN_FRAME, STEP_RECEIVE_BEGIN_FRAME, STEP_SUBMIT_COMPOSITOR_FRAME, STEP_SURFACE_AGGREGATION, STEP_DRAW_AND_SWAP, STEP_SWAP_BUFFERS_ACK, and the failure outcome STEP_DID_NOT_PRODUCE_FRAME.

Two ids stitch it together and confusing them is the usual mistake. surface_frame_trace_id links the client side steps up to frame submission, display_trace_id links everything after surface aggregation, and STEP_SURFACE_AGGREGATION emits aggregated_surface_frame_trace_ids to join the halves. More than twenty five call sites across cc, viz and the GPU process all emit this one name, so the step field and the ids are the only way to tell them apart. Chase one id and you get true begin frame to pixels latency for a single frame, which is the number DevTools never shows you.

Example event a local fixture page
{
  "name": "Graphics.Pipeline",
  "cat": "viz,benchmark,graphics.pipeline",
  "ph": "X",
  "ts": 1102922635,
  "dur": 138,
  "tdur": 119,
  "tts": 1896,
  "pid": 8211,
  "tid": 8266,
  "args": {
    "chrome_graphics_pipeline": {
      "possible_deadlines": {
        "frame_time_us": 1102922548
      },
      "step": "STEP_ISSUE_BEGIN_FRAME",
      "surface_frame_trace_id": 4269900944451596300
    },
    "current_task": {
      "event_offset_from_task_start_time_us": 27,
      "task_queued_time_us": 1102905949,
      "task_queueing_time_us": 16692,
      "task_start_time_us": 1102922614
    }
  }
}
categories
viz, benchmark, graphics.pipeline
usage
very high
duration
typical
references
chrome_track_event.proto, compositor_frame_sink_support.cc, task_annotator.cc, proxy_impl.cc, layer_tree_host_impl.cc, async_layer_tree_frame_sink.cc and 2 more
ImageDecodeTask

The task Chrome queues up to decode one image, run on a worker thread.

ImageDecodeTask measures a raster-thread task that decodes an image. Phase X.

trace_engine does not model its arguments, but real traces show args.pixelRefId on it. Note the field name differs from the LazyPixelRef events, which use args.LazyPixelRef for the same kind of identifier. Tooling that looks for one name will silently miss the other.

It is the scheduling wrapper around decode work, so it typically encloses the actual Decode LazyPixelRef. Use ImageDecodeTask for "how much raster thread time went on decoding" and the inner event for per-image attribution.

Observed durations are short individually. The cost shows up in aggregate on image-heavy pages, and it competes with raster for the same threads.

Example event from https://www.bbc.com/news
{
  "name": "ImageDecodeTask",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1108421996,
  "dur": 1834,
  "tdur": 303,
  "tts": 733,
  "pid": 8407,
  "tid": 8471,
  "args": {
    "pixelRefId": 43310469240832
  }
}
categories
disabled-by-default-devtools.timeline
usage
moderate
duration
typical
undeclared args
args.pixelRefId
references
devtools_instrumentation.cc, software_image_decode_cache.cc, gpu_image_decode_cache.cc
ImageUploadTask

Chrome uploads an already decoded image to the graphics hardware so it can be drawn.

ImageUploadTask measures the GPU texture upload of an already decoded image. It is a begin/end pair from ScopedImageUploadTask in cc/base/devtools_instrumentation.cc, category disabled-by-default-devtools.timeline, so the duration is real.

There is exactly one construction site, ImageUploadTaskImpl::RunOnWorkerThread in cc/tiles/gpu_image_decode_cache.cc. You therefore only see it under GPU raster. The software cache emits ImageDecodeTask and no upload event.

args.pixelRefId is a trap. It is a raw pointer cast to an integer, not an image id: the constructor is handed the PaintImage address and stores reinterpret_cast<uint64_t>(image_ptr). It will not join to args.LazyPixelRef, and ImageDecodeTask supplies a different pointer again. Use it only to pair an upload with its decode inside the same cache.

Sub-millisecond values are normal. The task declares SupportsConcurrentExecution::kNo, so a burst of uploads serialises onto one worker and reads as a raster-thread queue rather than one slow event.

Example event

Not seen. It is only written when images are uploaded for GPU rasterization, and the headless Chrome that recorded the collection composited in software.

categories
disabled-by-default-devtools.timeline
references
devtools_instrumentation.cc, gpu_image_decode_cache.cc
LazyPixelRefno longer emitted

Chrome tracked one image it was holding to draw but had not decoded yet.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

LazyPixelRef tracked the lifetime of a lazily decoded image in Blink until August 2014, and no Chrome since has written an event under that bare name. It was an object lifetime marker rather than a timed event. Blink's PlatformInstrumentation::LazyPixelRefTracker wrapped a TraceScopedTrackableObject that wrote object-created and object-deleted pairs named LazyPixelRef on the plain devtools category.

Its only user, LazyDecodingPixelRef, was deleted on 2014-08-21 in "Image decoding: Remove deprecated code path". The tracker class stayed behind unused until October 2018, when it was removed too.

The id survives in two other events. Draw LazyPixelRef is an instant written from third_party/blink/renderer/platform/graphics/image.cc and bitmap_image.cc when a lazily decoded image is drawn. Decode LazyPixelRef is a begin/end pair from decoding_image_generator.cc.

Both write args.LazyPixelRef, which is PaintImage::stable_id(), a process-unique image id. That id is the join key that ties decode work on a raster thread back to the PaintImage event that names the image URL and node.

Example event

Chrome stopped writing this bare name in August 2014, when its only user was deleted. The collection recorded Decode LazyPixelRef and Draw LazyPixelRef, which use the same image id.

references
image.cc, PlatformInstrumentation.h
NeedsBeginFrameChanged

The compositor changes its mind about whether the page needs new frames drawn at all.

NeedsBeginFrameChanged fires when the compositor toggles whether it wants frames at all. Phase I, with args.layerTreeId and args.data.needsBeginFrame.

needsBeginFrame: 0 means nothing on the page requires updating and Chrome stops producing frames. 1 means something does.

Use it to explain apparent gaps in a trace. A long stretch with no frame activity is usually not a stall: it is the browser correctly idling because nothing changed. Reading those gaps as jank is a common and avoidable misdiagnosis.

Conversely, needsBeginFrame stuck at 1 on a visually static page means something is requesting frames forever, typically a requestAnimationFrame loop or a CSS animation on an offscreen element. That is real battery cost with nothing on screen to show for it.

Example event a local fixture page
{
  "name": "NeedsBeginFrameChanged",
  "cat": "disabled-by-default-devtools.timeline.frame",
  "ph": "I",
  "ts": 1103089474,
  "tts": 8529,
  "pid": 8257,
  "tid": 8278,
  "s": "t",
  "args": {
    "data": {
      "needsBeginFrame": 0
    },
    "layerTreeId": 1
  }
}
categories
disabled-by-default-devtools.timeline.frame
usage
moderate
references
devtools_instrumentation.h, scheduler.cc
PipelineReporter

Chrome follows one whole attempt to produce a frame, from the request to the outcome.

PipelineReporter covers one frame production attempt from end to end. It is an async pair, phase b and e, one pair per attempt, which makes it one of the highest-volume events present.

In real traces every argument sits under args.frame_reporter. The fields there, among others:

  • state and frame_type, which record whether the frame was presented,
  • dropped, or partially updated

  • has_high_latency and affects_smoothness
  • checkerboarded_needs_raster and checkerboarded_needs_record
  • has_compositor_animation, has_main_animation, has_smooth_input_main
  • has_missing_content, scroll_state, frame_sequence,

layer_tree_host_id, display_trace_id, surface_frame_trace_id Reading one gives you a per-frame verdict from the compositor's own scheduler. Reach for affects_smoothness first, because it distinguishes a dropped frame nobody could perceive from one that broke an animation. checkerboarded_needs_raster means the compositor had to show blank space because raster had not caught up, which is the technical name for the grey flash during fast scrolling.

Two things limit its reach today. It lives in the cc, benchmark and disabled-by-default-devtools.timeline.frame categories, so a default recording may not include it. And trace_engine models none of its arguments, with ScreenshotsHandler as its only consumer there. For the most information-dense event in a Chrome trace, the analysis value is almost entirely untapped by existing tooling.

Example event a local fixture page
{
  "name": "PipelineReporter",
  "cat": "cc,benchmark,disabled-by-default-devtools.timeline.frame",
  "ph": "b",
  "ts": 1104989132,
  "pid": 8257,
  "tid": 8278,
  "id2": {
    "local": "0x38"
  },
  "args": {
    "frame_reporter": {
      "affects_smoothness": false,
      "checkerboarded_needs_raster": false,
      "checkerboarded_needs_record": false,
      "display_trace_id": 4269900944451596000,
      "frame_sequence": 130,
      "frame_source": 4294967296,
      "frame_type": "FORKED",
      "has_compositor_animation": false,
      "has_high_latency": false,
      "has_main_animation": true,
      "has_missing_content": false,
      "has_smooth_input_main": false,
      "layer_tree_host_id": 1,
      "scroll_state": "SCROLL_NONE",
      "state": "STATE_PRESENTED_ALL",
      "surface_frame_trace_id": 4269900944451596000
    }
  }
}
categories
cc, benchmark, disabled-by-default-devtools.timeline.frame
usage
high
undeclared args
args.frame_reporter.affects_smoothness, args.frame_reporter.checkerboarded_needs_raster, args.frame_reporter.checkerboarded_needs_record, args.frame_reporter.display_trace_id, args.frame_reporter.frame_sequence, args.frame_reporter.frame_source, args.frame_reporter.has_compositor_animation, args.frame_reporter.has_high_latency and 8 more
references
chrome_track_event.proto, compositor_frame_reporter.cc
Rasterizenever written by Chrome

Turning your page's drawing commands into pixels, under the name DevTools lists it by.

No Chrome build writes this name into a trace file. It exists in the model, not on the wire.

Rasterize is a name DevTools still lists, but Chrome has never written it as a trace event. It lives only as Name.RASTERIZE in front_end/models/trace/types/TraceEvents.ts, and its single use is a switch case in TimelineUIUtils.ts that labels the related node "Layer root". There is no interface, no type guard, no handler and no style entry, because nothing produces it.

The name is a leftover from the DevTools Timeline that predates tracing. From 2013 to early 2015, Blink's InspectorTimelineAgent read the RasterTask trace events cc wrote and turned them into Timeline protocol records of type Rasterize. That record type was never a trace event name, and the enum entry outlived the agent.

The event Chrome writes is RasterTask, from ScopedRasterTask in cc/tiles/frame_viewer_instrumentation.cc on cc,disabled-by-default-devtools.timeline, with args.tileData giving tileId, tileResolution, sourceFrameNumber and layerId.

Example event

Chrome has never written this name as a trace event. cc writes the work as RasterTask, which the collection recorded.

references
TraceEvents.ts, frame_viewer_instrumentation.cc
RasterizerTaskImpl::RunOnWorkerThreadnot in DevTools recordingsnot in DevTools' model

The whole job of turning one patch of the page into pixels, run on a background worker thread.

A DevTools Performance recording does not contain this event. Record cc with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

RasterizerTaskImpl::RunOnWorkerThread measures a single tile raster task executing on a cc worker thread. The trace name does not match the class that emits it: the call site is RasterTaskImpl::RunOnWorkerThread in cc/tiles/tile_manager.cc, so searching Chromium for "RasterizerTaskImpl" finds only the string literal.

args.source_prepare_tiles_id is prepare_tiles_count_ at the moment the task was created, the same counter TileManager::PrepareTiles emits as prepare_tiles_id.

Group raster tasks by that id and you have every tile scheduled by one PrepareTiles pass, the real unit of "this scroll caused this much raster", and a grouping DevTools has no view for. One construction detail explains the scheduling you see: GPU rasterised tasks are built with SupportsConcurrentExecution::kNo, software ones with kYes. Wide fan out across worker threads means you are on software raster.

Example event a local fixture page
{
  "name": "RasterizerTaskImpl::RunOnWorkerThread",
  "cat": "cc",
  "ph": "X",
  "ts": 1102928313,
  "dur": 45,
  "tdur": 41,
  "tts": 1412,
  "pid": 8178,
  "tid": 8209,
  "args": {
    "source_prepare_tiles_id": 3
  }
}
categories
cc
usage
high
duration
typical
references
tile_manager.cc
RasterSource::PerformSolidColorAnalysisnot in DevTools recordingsnot in DevTools' model

Chrome checks whether one patch of the page is nothing but a single flat colour.

A DevTools Performance recording does not contain this event. Record cc with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

RasterSource::PerformSolidColorAnalysis records Chrome checking whether a tile is just one colour, in cc/raster/raster_source.cc. It scales the layer rect by the recording scale factor and calls DisplayItemList::GetColorIfSolidInRect with a max_ops_to_analyze cap.

There are no arguments. When the analysis succeeds the tile needs no raster buffer, no worker task and no GPU memory, because cc just draws a solid colour quad. Every one that returns true is a tile you did not have to raster, which is why flat colour sections really are cheaper than gradients that look identical.

Cost is trivial, faster than most trace events, so it never shows up as a problem itself. Read it as an indicator instead. A run of these with no matching DisplayItemList::Raster is the compositor skipping work, and a page where they barely appear is a page where nearly every tile has real content. DevTools shows you neither the skipped tiles nor the skipping.

Example event a local fixture page
{
  "name": "RasterSource::PerformSolidColorAnalysis",
  "cat": "cc",
  "ph": "X",
  "ts": 1102927340,
  "dur": 3,
  "tdur": 2,
  "tts": 187789,
  "pid": 8178,
  "tid": 8178,
  "args": {
  }
}
categories
cc
usage
high
duration
short
references
raster_source.cc
SetLayerTreeId

Chrome ties the compositor's id for a set of layers to the document they belong to.

SetLayerTreeId associates a layer tree id with a frame. Phase I.

It is pure bookkeeping, and you will not analyse it directly. It matters because almost every compositor event identifies itself by layerTreeId rather than by frame or URL, and this event is the mapping that makes those ids meaningful.

Without it you can measure compositor work but cannot say which page it belongs to, which becomes important as soon as a trace covers more than one frame.

Example event a local fixture page
{
  "name": "SetLayerTreeId",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102923256,
  "tts": 28441,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "layerTreeId": 1
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
moderate
references
local_frame_view.cc
SoftwareRenderer::DoDrawQuadnot in DevTools recordingsnot in DevTools' model

The CPU, rather than the graphics hardware, paints one rectangle of the composited page.

A DevTools Performance recording does not contain this event. Record viz with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

SoftwareRenderer::DoDrawQuad measures one quad painted by the CPU renderer, in components/viz/service/display/software_renderer.cc.

There are no arguments, and the duration is not the point either. The body does an SkAutoCanvasRestore save whenever there is a draw region, an enabled scissor, or a rounded corner clip, then sets a full matrix per quad. Each quad is of typical duration for a trace event, and this is one of the most numerous events in the corpus, seen on every site, so the aggregate is real even though each quad is cheap.

Presence is the headline. SoftwareRenderer runs when there is no GPU compositing: blocklisted drivers, --disable-gpu, headless, a lost context, or a fallback after a GPU process crash. If these appear in a trace, that machine composited the page on the CPU, and nothing you conclude about compositor cost transfers to GPU users. That matters most for automated trace collection, where headless Chrome quietly takes the software path unless configured otherwise, and where no tool will warn you.

Example event a local fixture page
{
  "name": "SoftwareRenderer::DoDrawQuad",
  "cat": "viz",
  "ph": "X",
  "ts": 1102925575,
  "dur": 611,
  "tdur": 243,
  "tts": 3203,
  "pid": 8211,
  "tid": 8266,
  "args": {
  }
}
categories
viz
usage
very high
duration
typical
references
software_renderer.cc
ZeroCopyRasterBuffer::Playbacknot in DevTools recordingsnot in DevTools' model

Chrome draws one patch of the page straight into memory the graphics hardware can read.

A DevTools Performance recording does not contain this event. Record cc with your own trace configuration to get it.

DevTools has no entry for this event in its trace model, so the Performance panel shows its raw name with no label and no explanation.

ZeroCopyRasterBuffer::Playback records the raster of one tile straight into a GPU mappable buffer, from cc/raster/zero_copy_raster_buffer_provider.cc. Zero copy means cc maps the shared image, rasters into that memory, and never uploads a separate texture.

No arguments are recorded, so read the body of the function instead. If resource_has_previous_content_ is set the playback rect is intersected with raster_dirty_rect, so a re-rastered tile only repaints the damaged part. Otherwise the whole tile is repainted.

Reading these events tells you whether partial raster is working. A tile that keeps rastering at full size every frame is failing partial raster, usually because its content id changed wholesale rather than a sub rect. The source has a TODO saying partial raster is not implemented for GPU compositing, so on that path the dirty rect optimisation does not apply. Its duration is near identical to that of its parent RasterizerTaskImpl::RunOnWorkerThread, because playback is the whole raster task.

Example event a local fixture page
{
  "name": "ZeroCopyRasterBuffer::Playback",
  "cat": "cc",
  "ph": "X",
  "ts": 1102928326,
  "dur": 30,
  "tdur": 29,
  "tts": 1424,
  "pid": 8178,
  "tid": 8209,
  "args": {
  }
}
categories
cc
usage
high
duration
typical
references
zero_copy_raster_buffer_provider.cc

Frames, input and messaging

Long animation frames, input latency and postMessage.

AnimationFrame

Chrome builds and renders one animation frame, timed end to end by the Long Animation Frames API.

AnimationFrame records one Long Animation Frame, meaning one rendering frame measured end to end by the Long Animation Frames API. Phase s/f (async), which is why it is easy to miss: it will not show up if you are only scanning complete events.

trace_engine models it as a bare pairable async event with no args. Real traces populate args.animation_frame_timing_info:

  • duration_ms is the whole frame.
  • blocking_duration_ms is the portion that blocked the main thread.
  • num_scripts counts the scripts involved.
  • begin_frame_id (sequence_number and source_id) joins to the

compositor's frame events. This is LoAF surfaced in the trace, the successor to Long Tasks as the diagnostic behind INP, and it fixes the main weakness of the older metric: a slow interaction is often not one long task but several short ones inside a single frame, which Long Tasks misses entirely and LoAF catches.

Read blocking_duration_ms first. duration_ms includes time the frame legitimately spent waiting, while the blocking portion is what actually delayed a response.

num_scripts of zero on a long frame is a real finding: the frame was slow with no script involved, which points at style, layout or raster instead.

Join to AnimationFrame::Presentation by id for when the frame reached the screen.

Example event a local fixture page
{
  "name": "AnimationFrame",
  "cat": "devtools.timeline",
  "ph": "b",
  "ts": 1102906537,
  "pid": 8257,
  "tid": 8257,
  "id2": {
    "local": "0x6"
  },
  "args": {
    "animation_frame_timing_info": {
      "begin_frame_id": {
        "sequence_number": 6,
        "source_id": 4294967296
      },
      "blocking_duration_ms": 0,
      "duration_ms": 16,
      "num_scripts": 0
    },
    "id": "d9b88be5d0d73d6d"
  }
}
categories
devtools.timeline
usage
high
undeclared args
args.animation_frame_timing_info.begin_frame_id.sequence_number, args.animation_frame_timing_info.begin_frame_id.source_id, args.animation_frame_timing_info.blocking_duration_ms, args.animation_frame_timing_info.duration_ms, args.animation_frame_timing_info.num_scripts, args.id
references
animation_frame_timing_monitor.cc, begin_frame_args.h, chrome_track_event.proto, animation_frame_timing_info.h, animation_frame_timing_info.cc
AnimationFrame::Presentation

Chrome presented an animation frame to the screen.

AnimationFrame::Presentation marks the moment an animation frame was presented to the screen. Phase n (async instant).

args.id matches the AnimationFrame this frame belongs to. args.begin_frame_id matches the compositor's frame identity.

It closes the loop on AnimationFrame. That event tells you what the frame cost, and this one tells you when the user could see the result.

The gap between the two is presentation delay, which is the phase of INP most analysis ignores. A handler that finishes in well under a millisecond, on a frame that presents tens of milliseconds later, is not a slow handler. Optimising that JavaScript will achieve nothing. Real causes are a busy compositor, raster that has not finished, or simply waiting for the next display refresh.

begin_frame_id.sequence_number matches the compositor's own frame sequence, so this is the join between main-thread work and the frame pipeline in PipelineReporter and DroppedFrame.

Example event a local fixture page
{
  "name": "AnimationFrame::Presentation",
  "cat": "devtools.timeline",
  "ph": "n",
  "ts": 1102955679,
  "pid": 8257,
  "tid": 8257,
  "id2": {
    "local": "0x6"
  },
  "args": {
    "begin_frame_id": {
      "sequence_number": 6,
      "source_id": 4294967296
    },
    "id": "d9b88be5d0d73d6d"
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.begin_frame_id.sequence_number, args.begin_frame_id.source_id
references
animation_frame_timing_monitor.cc, begin_frame_args.h, frame_timing_details.h, animation_frame_timing_info.cc
HandlePostMessageOn message

Chrome runs the handler for a postMessage on the receiving side.

HandlePostMessage measures a postMessage handler running on the receiving side. Phase X, so its duration is the real cost of that handler.

args.data.traceId matches the SchedulePostMessage that sent the message.

Two numbers come out of the pair. The gap between the two events is transit plus queueing, meaning how long the message waited before anything ran. The duration of this event is the handler cost.

They fail differently. A long gap with a fast handler means the receiving thread was busy with something else, and optimising the handler is pointless. A short gap with a long handler is your code.

As trace events go these run slower than most, and the spread is wide, with the tail running far longer than the middle. That tail is where to look. They appeared on 15 of the 35 sites.

DevTools labels it "On message".

Example event from https://www.bbc.com/news
{
  "name": "HandlePostMessage",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1109117136,
  "dur": 113,
  "tdur": 89,
  "tts": 728203,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "data": {
      "traceId": "11003058272715615593"
    }
  }
}
categories
devtools.timeline
usage
moderate
duration
long
references
inspector_trace_events.cc, local_dom_window.cc, worker_global_scope.cc
InputHandlerProxy::HandleGestureFling::startedno longer emitted

The compositor started a fling, the inertial scroll that carries on after the finger lifts.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

InputHandlerProxy::HandleGestureFling::started marked the compositor thread beginning a fling, the inertial scroll that continues after the finger or trackpad lifts. The event no longer exists.

It was an async begin on category input,benchmark,rail in InputHandlerProxy::HandleGestureFlingStart(), ended from CancelCurrentFlingWithoutNotifyingClient(). args.vx and args.vy were the velocity components of the fling, because for years the compositor thread ran that decaying velocity curve itself.

It shipped last in Chrome 68. Chrome 69 moved fling handling to the browser process wholesale and deleted the function along with the trace event. Today input_handler_proxy.cc explicitly drops kGestureFlingStart and kGestureFlingCancel, commented "handled only in the browser process".

The replacement is FlingController::HandlingGestureFling on category input, from components/input/fling_controller.cc, still reporting vx and vy, with ProgressFling instants on the same track per generated scroll update.

Finding this name in a trace dates the trace to before Chrome 69.

Example event

Deleted in July 2018, so Chrome 69 and later never write it. Fling now runs in the browser process and is traced as FlingController::HandlingGestureFling.

references
fling_controller.cc, input_handler_proxy.cc
InputLatency::MouseMove

Chrome follows one mouse move through its input pipeline, from arrival to the frame that shows the result.

InputLatency::MouseMove covers one mouse move through Chrome's input pipeline, from the browser process receiving the event to the frame presenting its result. It is an async slice.

The name is generated rather than a literal. GetTraceNameFromType() in components/input/render_input_router_latency_tracker.cc expands every blink::WebInputEvent::Type into "InputLatency::" #t, so the family has one member per enum value, about forty of them. No literal "InputLatency::MouseMove" string exists anywhere in the tree.

Begin fires when INPUT_EVENT_LATENCY_BEGIN_RWH_COMPONENT is added, backdated to the platform timestamp. End comes from LatencyInfo::Terminate() in ui/latency/latency_info.cc, and the payload is a typed ChromeLatencyInfo2 proto rather than JSON args. trace_id identifies the event, is_coalesced says whether it was merged into another, and a component list gives one timestamp per stage through to FRAME_SWAP. Coalesced moves terminate at ack and never reach a frame.

Gated on the category group benchmark,latencyInfo,rail,input.scrolling, which DevTools never requests. Use EventLatency for per-stage input analysis instead.

Example event a local fixture page
{
  "name": "InputLatency::MouseMove",
  "cat": "benchmark,latencyInfo,rail,input.scrolling",
  "ph": "b",
  "ts": 1104754827,
  "pid": 8178,
  "tid": 8178,
  "id": "0x76",
  "args": {
    "chrome_latency_info": {
      "component_info": [
        {
          "component_type": "COMPONENT_INPUT_EVENT_LATENCY_BEGIN_RWH",
          "time_us": 1104755271
        },
        {
          "component_type": "COMPONENT_INPUT_EVENT_LATENCY_ORIGINAL",
          "time_us": 1104754827
        },
        {
          "component_type": "COMPONENT_INPUT_EVENT_LATENCY_RENDERER_MAIN",
          "time_us": 1104756056
        }
      ],
      "is_coalesced": false,
      "trace_id": 2669798973887637500
    }
  }
}
categories
benchmark, latencyInfo, rail, input.scrolling
usage
low
undeclared args
args.chrome_latency_info.component_info[].component_type, args.chrome_latency_info.component_info[].time_us, args.chrome_latency_info.is_coalesced, args.chrome_latency_info.trace_id
references
latency_info.cc, render_input_router_latency_tracker.cc, chrome_track_event.proto, latency_info.h
InputLatency::MouseWheel

Chrome follows one wheel event (not the scroll it causes) through its input pipeline to the screen.

InputLatency::MouseWheel covers one kMouseWheel event through Chrome's input pipeline, from the browser process receiving it to the frame presenting its result.

Emitted by components/input/render_input_router_latency_tracker.cc and gated on benchmark,latencyInfo,rail,input.scrolling, with a ChromeLatencyInfo2 payload rather than JSON args. The name is generated from the blink::WebInputEvent::Type enum, like every other InputLatency:: slice.

Wheel behaves unlike the rest of the family, because a wheel event is not a scroll. components/input/mouse_wheel_event_queue.cc queues the wheel, sends it to the renderer for a blocking wheel listener ack, and only then synthesises GestureScrollBegin and GestureScrollUpdate from it. One notch therefore produces InputLatency::MouseWheel for the DOM event plus a separate InputLatency::GestureScrollUpdate for the scroll it caused.

The jank a user feels lives in the second one. A passive wheel listener lets the queue generate the scroll without waiting on the ack, which is the whole point of passive: true.

Modern equivalent: EventLatency, from cc/metrics/event_latency_tracing_recorder.cc.

Example event a local fixture page
{
  "name": "InputLatency::MouseWheel",
  "cat": "benchmark,latencyInfo,rail,input.scrolling",
  "ph": "b",
  "ts": 1104956465,
  "pid": 8178,
  "tid": 8178,
  "id": "0x81",
  "args": {
    "chrome_latency_info": {
      "component_info": [
        {
          "component_type": "COMPONENT_INPUT_EVENT_LATENCY_BEGIN_RWH",
          "time_us": 1104972852
        },
        {
          "component_type": "COMPONENT_INPUT_EVENT_LATENCY_ORIGINAL",
          "time_us": 1104956465
        }
      ],
      "is_coalesced": false,
      "trace_id": 2669798973887637500
    }
  }
}
categories
benchmark, latencyInfo, rail, input.scrolling
references
render_input_router_latency_tracker.cc, mouse_wheel_event_queue.cc, event_latency_tracing_recorder.cc, chrome_track_event.proto, latency_info.cc, latency_info.h
SchedulePostMessageSchedule postMessage

This event tells you a postMessage has been sent.

SchedulePostMessage marks a postMessage being sent. Phase I.

args.data.traceId is the join key. Real traces also populate a full stackTrace and a sampleTraceId.

traceId matches the HandlePostMessage that receives this message. That pairing is what lets you measure the latency of a message crossing a boundary, and attribute work in a worker back to the code that asked for it.

The stackTrace is the sender, named. For a page using a Web Worker, this is how you follow a chain from application code, across the boundary, into the worker and back, rather than seeing two unrelated islands of activity.

DevTools labels it "Schedule postMessage" under a messaging category.

Example event from https://www.bbc.com/news
{
  "name": "SchedulePostMessage",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1109114892,
  "tts": 726339,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "sampleTraceId": 5267982676864360,
      "stackTrace": [
        {
          "columnNumber": 32,
          "functionName": "",
          "lineNumber": 158,
          "scriptId": "89",
          "url": "https://uk-script.dotmetrics.net/door.js?d=www.bbc.com&t=newsstudio"
        },
        {
          "columnNumber": 21,
          "functionName": "",
          "lineNumber": 15,
          "scriptId": "89",
          "url": "https://uk-script.dotmetrics.net/door.js?d=www.bbc.com&t=newsstudio"
        }
      ],
      "traceId": "11003058272715615593"
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc, inspector_trace_events.cc, local_dom_window.cc, dedicated_worker.cc

Profiling and counters

Sampled CPU profiles, JIT bookkeeping and page counters.

CpuProfile

A saved CPU profile that DevTools has loaded in place of a trace.

CpuProfile is a synthetic event that trace_engine builds when DevTools loads a CPU profile instead of a trace. No Chrome writes it. SamplesIntegrator.createFakeTraceFromCpuProfile takes a CDP Profiler.Profile, for example a saved .cpuprofile file, and wraps it in a fake trace of one event, with the whole profile under args.data.cpuProfile.

Its doc comment is unusually explicit about this: a fake trace event created to support CDP.Profiler.Profiles, deliberately not extending the normal synthetic interface because there is no raw event behind it.

You will not find it in a trace that Chrome recorded. A profile inside a real trace arrives as Profile plus its ProfileChunks, and SamplesHandler reads both shapes into the same profile data. MetaHandler also counts the name, alongside TracingStartedInBrowser, as a sign that the data came from Chrome rather than from some other Trace Event producer.

Example event

DevTools builds this when it loads a CPU profile instead of a trace. It is never written to a trace file, so there is no sample to show.

references
SamplesIntegrator.ts
CpuProfiler::StartProfilingProfiling overhead

V8 is starting up its CPU profiler.

CpuProfiler::StartProfiling measures the profiler starting up. DevTools labels it "Profiling overhead" and its description is candid: time spent in an operation that only happens when the profiler is active.

That makes it one of the few events whose correct use is to be subtracted. It is measurement cost, not page cost. Anything attributed to it would not exist if you were not recording.

Surface it rather than hiding it, because it sets the noise floor for the trace. If profiling overhead is large relative to what you are investigating, the trace is not telling you much about real users.

Example event a local fixture page
{
  "name": "CpuProfiler::StartProfiling",
  "cat": "v8",
  "ph": "X",
  "ts": 1102929435,
  "dur": 3025,
  "tdur": 2557,
  "tts": 32892,
  "pid": 8257,
  "tid": 8257,
  "args": {
  }
}
categories
v8
usage
low
duration
very long
references
cpu-profiler.cc
EmbedderCallbackEmbedder callback

Android WebView is calling back into the app that hosts it.

EmbedderCallback measures time Android WebView spends calling back into the app that hosts it, rather than time spent on the page. Only Android WebView writes it, from ScopedEmbedderCallbackTask in android_webview/common/devtools_instrumentation.h, used by aw_contents.cc, aw_contents_client_bridge.cc and aw_contents_io_thread_client.cc. Desktop Chrome, Chrome on Android and other Chromium embedders do not write it. DevTools categorises it under scripting and describes it as time in the embedder of the WebView.

Its arguments are unmodelled.

Where it matters is an Android app that shows web content in a WebView, including in-app browsers built on WebView. There, time attributed here is the host application's, not the page's. If you are profiling a page inside someone else's Android app and the numbers do not add up, this event is where the missing time may be.

Example event

Only Android WebView writes this event. Every trace in the collection came from desktop Chrome.

categories
Java, devtools, disabled-by-default-devtools.timeline
JitCodeAddedno longer emitted

V8 added a newly compiled piece of machine code.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

JitCodeAdded recorded a code map entry rather than a performance event. The old sampling profiler installed a v8::JitCodeEvent handler and forwarded every CODE_ADDED to the trace as metadata on disabled-by-default-v8.cpu_profile. It died with content/renderer/devtools/v8_sampling_profiler.cc in October 2016, so no current Chrome emits it.

Four fields sat under args.data. code_start gave the instruction address as a hex string. code_len gave the length. name used V8's mangled form, with a * prefix for optimized, ~ for optimizable and Script: for a whole script. script_id appeared when V8 had one. Its only consumer was DevTools, which used it to turn V8Sample addresses into call frames.

V8 still builds these events: JitLogger in v8/src/logging/log.cc populates CODE_ADDED for both JIT code and bytecode, and include/v8-callbacks.h still exposes SetJitCodeEventHandler. Nothing in Chromium routes that to tracing any more. The only remaining caller is the VTune JIT bridge, behind a build flag that is off in shipping Chrome.

Example event

Deleted in October 2016 with the legacy sampling profiler.

references
v8_sampling_profiler.cc, log.cc, v8-callbacks.h
JitCodeMovedno longer emitted

V8's garbage collector moved a piece of compiled code to a new address.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

JitCodeMoved recorded V8's GC relocating generated code. Chrome stopped emitting it in October 2016, when the legacy sampling profiler was deleted, and it appears in no modern trace.

Relocation invalidates any address-to-function map built from earlier CODE_ADDED events, so CODE_MOVED patched the map. Three fields sat under args.data: code_start, code_len and new_code_start. DevTools did exactly one thing with it: codeMap.moveEntry(code_start, new_code_start, code_len). A third sibling, JitCodeRemoved, gave code_start and code_len, and DevTools never read it at all. All of them went with JitCodeAdded and the profiler itself.

The problem they solved no longer exists. V8 symbolizes inside the profiler, and ProfileChunk ships cpuProfile.nodes with stable node ids and resolved callFrame objects, so a moving code address never reaches the trace consumer in the first place.

Example event

Deleted in October 2016 with the legacy sampling profiler.

references
v8_sampling_profiler.cc
JSSample

One sample from the V8 CPU profile, placed on the timeline alongside everything else.

JSSample is a synthetic event that trace_engine creates by expanding a sampled profile into one event per sample, so samples can sit on the same timeline as everything else. Each one gets args.data.stackTrace. The doc comment is one line: a JS Sample reflects a single sample from the V8 CPU Profile.

Its purpose is making sampled data joinable with instrumented data. A trace gives you exact durations for instrumented events like FunctionCall, and statistical attribution from sampling for everything inside them. JSSample is how the second gets laid against the first.

Remember what a sample is: evidence that a stack was on the CPU at one instant. Ten samples in a function is not ten milliseconds, it is ten observations at the profiler's interval. Treat sampled numbers as estimates with error bars, and instrumented durations as measurements.

Example event

DevTools builds this while parsing a trace. It is never written to a trace file, so there is no sample to show.

references
SamplesIntegrator.ts
Profile

V8 opens a CPU profile, with the start time its samples are counted from.

Profile opens a V8 CPU profile and acts as its header. Phase P (sample), with args.data.startTime and an optional source.

It records no samples itself. ProfileChunk events fill those in, and this event's id is what ties the chunks to a thread.

source matters more than it looks. Per SamplesHandler, several profiles can exist for the same thread from different origins: Internal when the Performance panel started it, Inspector when a user or automation started one via CDP. Merging them produces double-counted time, so the handler prioritises one source. If you are parsing profiles yourself, do the same.

Example event a local fixture page
{
  "name": "Profile",
  "cat": "disabled-by-default-v8.cpu_profiler",
  "ph": "P",
  "ts": 1102929272,
  "tts": 32832,
  "pid": 8257,
  "tid": 8257,
  "id": "0x1",
  "args": {
    "data": {
      "startTime": 1102929267
    }
  }
}
categories
disabled-by-default-v8.cpu_profiler
usage
low
references
profile-generator.cc, SamplesHandler.ts
ProfileChunk

V8 delivers a batch of CPU profile samples, with the part of the call tree they point into.

ProfileChunk records a batch of CPU profile samples. Phase P, and in real traces easily among the highest-volume events present.

args.data.cpuProfile.nodes is the call tree, each node with an id, a parent and a callFrame of functionName, scriptId, url, lineNumber, columnNumber and codeType. samples is an array of node ids. Alongside them sit timeDeltas, plus optional lines and columns.

The shape is the thing to understand. Samples are ids into a tree that is being built incrementally across chunks, and timeDeltas are gaps between consecutive samples, not absolute times. You reconstruct a timeline by accumulating the deltas from the parent Profile's startTime, and you reconstruct a stack by walking parent links up the node tree. Read a chunk in isolation and the ids will not resolve.

codeType distinguishes JS from other execution. Synthetic frames like (root), (program) and (idle) appear as real nodes and must be filtered before you attribute time to anyone's code.

This is the data behind the flame chart's bottom-up and call-tree views, and the only source of function-level attribution in a trace.

Example event from https://www.nytimes.com
{
  "name": "ProfileChunk",
  "cat": "disabled-by-default-v8.cpu_profiler",
  "ph": "P",
  "ts": 1128567258,
  "tts": 78923,
  "pid": 8798,
  "tid": 8857,
  "id": "0x1",
  "args": {
    "data": {
      "columns": [42718, 42718, 42718, 42718, 42718, 42718, 42718, 42718, "... [92 more items]"],
      "cpuProfile": {
        "nodes": [
          {
            "callFrame": {
              "codeType": "JS",
              "columnNumber": 42713,
              "functionName": "n",
              "lineNumber": 1,
              "scriptId": 304,
              "url": "https://dd.nytimes.com/tags.js"
            },
            "id": 3574,
            "parent": 3570
          },
          {
            "callFrame": {
              "codeType": "JS",
              "functionName": "setTimeout",
              "scriptId": 0
            },
            "id": 3575,
            "parent": 3574
          },
          {
            "callFrame": {
              "codeType": "JS",
              "functionName": "setTimeout",
              "scriptId": 0
            },
            "id": 3576,
            "parent": 3570
          }
        ],
        "samples": [3573, 3573, 3573, 3573, 3573, 3573, 3573, 3573, "... [92 more items]"],
        "trace_ids": {
          "482": 3573,
          "483": 3573,
          "484": 3573,
          "485": 3573,
          "486": 3573,
          "487": 3573,
          "488": 3573,
          "489": 3573,
          "490": 3575,
          "491": 3576,
          "492": 3576,
          "493": 3576,
          "494": 3576,
          "495": 3576,
          "496": 3576,
          "497": 3576,
          "498": 3576,
          "499": 3576,
          "500": 3576,
          "501": 3576,
          "502": 3576,
          "503": 3576,
          "504": 3576,
          "505": 3576,
          "506": 3576,
          "507": 3576,
          "508": 3576,
          "509": 3576,
          "510": 3576,
          "511": 3576,
          "512": 3576,
          "513": 3576,
          "514": 3576,
          "515": 3576,
          "516": 3576,
          "517": 3576,
          "518": 3576,
          "519": 3576,
          "520": 3576,
          "521": 3576,
          "522": 3576,
          "523": 3576,
          "524": 3576,
          "525": 3576,
          "526": 3576,
          "527": 3576,
          "528": 3576,
          "529": 3576,
          "5289008964953680": 3576,
          "5289008964953681": 3576,
          "5289008964953682": 3573,
          "5289008964953683": 3575,
          "5289008964953684": 3576,
          "5289008964953685": 3576,
          "5289008964953686": 3576,
          "5289008964953687": 3576,
          "5289008964953688": 3573,
          "5289008964953689": 3573,
          "5289008964953692": 3573,
          "5289008964953693": 3573,
          "5289008964953694": 3573,
          "5289008964953695": 3573,
          "5289008964953738": 3576,
          "5289008964953739": 3576,
          "5289008964953760": 3576,
          "5289008964953761": 3576,
          "5289008964953762": 3576,
          "5289008964953763": 3576,
          "5289008964953764": 3576,
          "5289008964953765": 3576,
          "5289008964953766": 3576,
          "5289008964953767": 3576,
          "5289008964953768": 3576,
          "5289008964953769": 3576,
          "5289008964953770": 3576,
          "5289008964953771": 3576,
          "5289008964953772": 3576,
          "5289008964953773": 3576,
          "5289008964953774": 3576,
          "5289008964953775": 3576,
          "5289008964953776": 3576,
          "5289008964953777": 3576,
          "5289008964953778": 3576,
          "5289008964953779": 3576,
          "5289008964953780": 3576,
          "5289008964953781": 3576,
          "5289008964953782": 3576,
          "5289008964953783": 3576,
          "5289008964953784": 3576,
          "5289008964953785": 3576,
          "5289008964953786": 3576,
          "5289008964953787": 3576,
          "5289008964953788": 3576,
          "5289008964953789": 3576,
          "5289008964953790": 3576,
          "5289008964953791": 3576
        }
      },
      "lines": [2, 2, 2, 2, 2, 2, 2, 2, "... [92 more items]"],
      "timeDeltas": [4, 13, 2, 10, 2, 10, 2, 9, "... [92 more items]"]
    }
  }
}
categories
disabled-by-default-v8.cpu_profiler
usage
high
undeclared args
args.data.cpuProfile.nodes[].callFrame.codeType, args.data.cpuProfile.nodes[].callFrame.functionName, args.data.cpuProfile.nodes[].callFrame.scriptId, args.data.cpuProfile.nodes[].id, args.data.cpuProfile.samples[], args.data.cpuProfile.trace_ids.2514011177137445, args.data.cpuProfile.nodes[].callFrame.columnNumber, args.data.cpuProfile.nodes[].callFrame.lineNumber and 79 more
references
profile-generator.cc, SamplesIntegrator.ts, profiler-listener.cc, compiler.cc, log.cc, symbolizer.cc and 7 more
UpdateCounters

Chrome takes a periodic reading of page counters like heap size, DOM nodes, documents and event listeners.

UpdateCounters samples page-level counters on a periodic tick. Phase I, emitted continuously through a recording.

args.data reports jsHeapSizeUsed, nodes, documents and jsEventListeners, plus gpuMemoryLimitKB when present.

Because the event repeats, these are time series, not snapshots, which is what makes them far more useful than the attention they get:

  • jsHeapSizeUsed climbing and never dropping across several GCs is a leak
  • signature.

  • nodes climbing steadily is DOM growth, which raises the cost of every
  • subsequent style recalc and layout. It is also the number behind the DOM size insight.

  • jsEventListeners climbing without bound is a listener leak: handlers
  • attached on every render and never removed.

  • documents counts live documents, so a climbing value means detached

documents are being retained, usually by a reference from an iframe. Plotting these four against the timeline costs nothing and routinely finds problems no single event reveals.

Example event a local fixture page
{
  "name": "UpdateCounters",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102939550,
  "tts": 39159,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "documents": 3,
      "jsEventListeners": 0,
      "jsHeapSizeUsed": 1187656,
      "nodes": 40
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
high
references
inspector_trace_events.cc, instance_counters.h, document.cc, js_based_event_listener.cc, node.cc
v8.parseOnBackgroundStreaming compile task

V8 parses a script on a background thread while it is still downloading.

v8.parseOnBackground covers V8 parsing a script on a background thread while it is still downloading. Phase X.

Real traces put url and requestId under args.data, which is unusually convenient: requestId joins directly to the network events, so you can line background parse up against the request that produced it without guessing.

DevTools labels it "Streaming compile task", and its presence is good news. Streaming means parse overlapped download instead of starting after it. When streaming does not happen, v8.compile gives a notStreamedReason explaining why, and the cost lands on the main thread after the bytes have arrived.

So the useful check is comparative: for your large scripts, is there a v8.parseOnBackground, or is there a notStreamedReason instead?

Example event a local fixture page
{
  "name": "v8.parseOnBackground",
  "cat": "v8,devtools.timeline,disabled-by-default-v8.compile",
  "ph": "X",
  "ts": 1102942273,
  "dur": 364,
  "tdur": 355,
  "tts": 944,
  "pid": 8257,
  "tid": 8272,
  "args": {
    "data": {
      "requestId": "8257.2",
      "url": "http://127.0.0.1:8801/mod.mjs"
    }
  }
}
categories
v8, devtools.timeline, disabled-by-default-v8.compile
usage
moderate
duration
very long
undeclared args
args.data.requestId, args.data.url
references
script_streamer.cc, inspector_trace_events.cc, extension_script_streamer.cc
V8Sampleno longer emitted

Chromium's old sampling profiler recorded one stack sample, as raw code addresses.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

V8Sample recorded one raw stack sample from Chromium's renderer-side V8 sampling profiler, emitted by content/renderer/devtools/v8_sampling_profiler.cc under disabled-by-default-v8.cpu_profile. Note the missing trailing "r": that is not today's v8.cpu_profiler category. The module landed in December 2014 and was deleted in October 2016 by "Remove legacy V8 sampling profiler", so no current Chrome emits it. Sampling moved into V8 itself as Profile plus ProfileChunk on disabled-by-default-v8.cpu_profiler, emitted from v8/src/profiler/profile-generator.cc, streaming cpuProfile.nodes with resolved callFrame objects so no address fixup is needed.

Two fields sat under args.data. vm_state was one of js, gc, compiler, other, external or idle. stack was an array of raw instruction pointers as hex strings. Those addresses meant nothing alone. DevTools built a code map from JitCodeAdded and JitCodeMoved, resolved each address, and rewrote the sample as a JSSample.

V8Sample now exists only as an unreferenced constant in DevTools' event name enum.

Example event

Deleted in October 2016 with the renderer-side V8 sampling profiler. The collection recorded Profile and ProfileChunk instead.

references
v8_sampling_profiler.cc, profile-generator.cc

Trace metadata

Threads, frames, workers and screenshots.

domLoading

This event tells you the document object now exists and has entered its loading state, so parsing can begin.

domLoading marks the document's readyState reaching loading, which is the point at which the document object exists and parsing can begin. It is a TRACE_EVENT_MARK_WITH_TIMESTAMP1 from DocumentTiming::MarkDomLoading in core/dom/document_timing.cc, on blink.user_timing,rail, with a single arg frame.

It is still emitted in current Chromium, unlike the Navigation Timing attribute it is named after. domLoading was dropped from PerformanceNavigationTiming in Navigation Timing Level 2 and survives in the API only on the deprecated performance.timing. The trace mark stayed, because Blink keeps the whole family in that one file: domInteractive, domContentLoadedEventStart, domContentLoadedEventEnd, domComplete.

Do not go looking for it in the Timings track. DevTools lists it among the ignored nav timing names in UserTimingsHandler.ts precisely to keep it out. Read it from the raw trace, one per frame per document.

Example event a local fixture page
{
  "name": "domLoading",
  "cat": "blink.user_timing,rail",
  "ph": "R",
  "ts": 1102925859,
  "tts": 29962,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "frame": "14A831DCB73EB1205B67CDB89FC6EBD7"
  }
}
categories
blink.user_timing, rail
usage
moderate
references
document_timing.cc
FrameStartedLoadingFrame started loading

Chrome switches a frame's loading state on, which is the spinner starting to turn.

FrameStartedLoading fires when a frame's loading state turns on, which is the spinner, not the parser. It is a TRACE_EVENT_INSTANT on devtools.timeline from InspectorTraceEvents::FrameStartedLoading in core/inspector/inspector_trace_events.cc, driven by the probe::FrameStartedLoading hook.

The frame id is a top-level arg, args.frame, not args.data.frame. It is the DevTools frame token, the same string TracingStartedInBrowser, FrameCommittedInBrowser and navigationStart use, so it joins across them.

The call site is ProgressTracker::ProgressStarted in core/loader/progress_tracker.cc, guarded by if (!frame_->IsLoading()). You get one per frame per load, at navigation start, before any response byte, and nothing at all if the frame was already loading. ProgressCompleted emits the matching FrameStoppedLoading.

DevTools gives it the label "Frame started loading" but marks it hidden, so you only ever see it in the raw JSON.

Example event a local fixture page
{
  "name": "FrameStartedLoading",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102924537,
  "tts": 29174,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "frame": "14A831DCB73EB1205B67CDB89FC6EBD7"
  }
}
categories
devtools.timeline
usage
low
undeclared args
args.frame
references
inspector_trace_events.cc, progress_tracker.cc
MetaCharsetCheck

Chrome checks which character encoding the document declares.

MetaCharsetCheck fires when Chrome checks the document's character encoding declaration. Phase I, with args.data.disposition and frame.

disposition records what was found: a declaration in a meta tag, one from the HTTP header, or none, in which case Chrome sniffs.

It matters more than it sounds. If the encoding is not declared early, the parser may have to restart once it discovers the document is not the encoding it assumed, throwing away parsing already done. Consumed by PageLoadMetricsHandler, which is where that cost would surface.

The fix is old advice that still holds: declare the charset in the first 1024 bytes of the document, or in the Content-Type header.

Example event

No trace in the collection produced this event.

categories
devtools.timeline
usage
moderate
references
decoded_data_document_parser.cc
ParseMetaViewport

Chrome parses the viewport meta tag and records the value it was given.

ParseMetaViewport marks the viewport meta tag being parsed. Phase I, with args.data.content, the literal attribute value, plus node_id and frame.

Having the raw string is what makes it useful. user-scalable=no and maximum-scale=1 are accessibility failures that are trivially detectable here, and a missing or malformed viewport explains an entire class of mobile layout problems at a stroke.

Its consumer is UserInteractionsHandler, and the reason is tap handling. On a page Chrome does not consider mobile-optimized, double-tap-to-zoom stays enabled and a tap is held back by the double-tap timeout, 300ms on Android from Android's own ViewConfiguration. Since Chrome 32 a mobile-optimized viewport disables double-tap zoom outright, so taps dispatch immediately.

Chrome counts a page as mobile-optimized if the content already fits the viewport, or the page scale is fixed, or the meta viewport says so (cc/trees/mobile_optimized_viewport_util.cc). So this tag directly affects measured interaction latency.

Example event from https://www.bbc.com/news
{
  "name": "ParseMetaViewport",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1108345866,
  "tts": 41386,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "content": "width=device-width, initial-scale=1",
      "frame": "2EA8810E92C12C680C55B2941251AA98",
      "node_id": 5
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
rare
references
mobile_optimized_viewport_util.cc, html_meta_element.cc
RenderFrameImpl::createChildFrame

Chrome creates a child frame, which in practice means an iframe coming into existence.

RenderFrameImpl::createChildFrame records a child frame being created, which means an iframe coming into existence. Phase is untyped, and args has two fields, frame_token and child_frame_token.

Those two tokens are a parent-child edge, so a trace's worth of these reconstructs the frame tree as it was built rather than as it was at recording start.

Its consumer is the surprise: LayoutShiftsHandler. The reason is sound. An iframe appearing is a classic shift cause, and shifts inside a child frame need attributing to the parent that created it. Ads and embeds are the usual offenders.

Example event from https://www.bbc.com/news
{
  "name": "RenderFrameImpl::createChildFrame",
  "cat": "navigation,rail",
  "ph": "X",
  "ts": 1108493846,
  "dur": 901,
  "tdur": 842,
  "tts": 150808,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "child_frame_token": "579F80F6245DF32A5438B7C3C5DF03C3",
    "frame_token": "2222CA87E8277AFC343EE54482341A96"
  }
}
categories
navigation, rail
usage
low
duration
very long
references
render_frame_impl.cc, frame.cc
Screenshot

Chrome captures a picture of the viewport, carried in the event as a JPEG.

Screenshot is one captured frame of the viewport. args.dataUri is a base64 JPEG.

Note the interface name: LegacySyntheticScreenshot. DevTools wraps the raw event, and the "legacy" marks an older capture path still present in traces.

Keep two practical points in mind when you read them. Screenshots are sampled, not per-frame, typically tens of milliseconds apart, so the frame nearest a metric is a neighbour of the moment you care about, not the moment itself. Never present one as "the frame in which X happened".

And they are scaled down, uniformly. That is useful. Because the scale is uniform, one factor (rendered width divided by viewport width) maps trace rectangles such as LayoutShift.impacted_nodes onto the image exactly, which is how you draw a shift onto a real screenshot.

They are also a large contributor to trace file size. Recording without them produces noticeably smaller traces.

Example event a local fixture page
{
  "name": "Screenshot",
  "cat": "disabled-by-default-devtools.screenshot",
  "ph": "I",
  "ts": 1102955957,
  "tts": 202801,
  "pid": 8178,
  "tid": 8178,
  "s": "t",
  "args": {
    "expected_display_time": 1102955880,
    "frame_sequence": 6,
    "snapshot": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgo... [1500 more chars]",
    "source_id": 4294967296
  }
}
categories
disabled-by-default-devtools.screenshot
usage
moderate
undeclared args
args.expected_display_time, args.frame_sequence, args.snapshot, args.source_id
references
tracing_handler.cc, begin_frame_args.h, traced_value_support.h, devtools_traceable_screenshot.cc
SyntheticNetworkRequestsynthetic

One whole network request, as DevTools assembles it from the separate events Chrome records.

DevTools builds this event while parsing a trace. You will not find it in a trace file.

SyntheticNetworkRequest is constructed by DevTools while it parses a trace, and it never appears in a trace file. DevTools assembles it from the five raw network events (ResourceWillSendRequest, ResourceSendRequest, ResourceReceiveResponse, ResourceReceivedData, ResourceFinish) correlated by requestId, producing one object per request with timing phases resolved.

If you are reading a raw trace you must do that correlation yourself. There is no single "request" event in the file.

NetworkRequestsHandler also notes one thing that catches people out: its index is URL to requestId[], plural, because the same URL can be requested several times in one trace. Keying your own analysis by URL rather than by request id will silently merge distinct requests.

It is also consumed by the third-party insight, which classifies requests by origin to attribute cost to code the site does not control.

Example event

DevTools builds this while parsing a trace. It is never written to a trace file, so there is no sample to show.

references
NetworkRequestsHandler.ts
thread_name

This event tells you the name of one thread.

thread_name is a metadata record that declares the name of one thread. Phase M, and args.name is the name itself.

These records have no timing and no payload beyond that name, and nothing else in the trace replaces them. Every other event identifies its thread only by pid/tid, so without these you can measure per-thread work but cannot say which thread. The names you will care about are CrRendererMain (the main thread), Compositor, CrBrowserMain, CrGpuMain, CompositorTileWorker (raster), and DedicatedWorker thread.

Use them to build the thread map before you interpret anything else, and mind two traps. A trace contains several CrRendererMain threads, one per renderer process, so "the main thread" is only meaningful once you know which renderer owns the page. Metadata events can also appear anywhere in the file rather than at the start, so build the map in a full pass before resolving anything.

RendererHandler uses these to build the per-thread event hierarchy that the flame chart is drawn from.

Example event a local fixture page
{
  "name": "thread_name",
  "cat": "__metadata",
  "ph": "M",
  "ts": 0,
  "pid": 0,
  "tid": 0,
  "args": {
    "name": "swapper"
  }
}
categories
__metadata
usage
moderate
references
export_json.cc, export_json.cc
TracingSessionIdForWorker

Chrome ties a worker to the page and the script that started it.

TracingSessionIdForWorker ties a worker to the page that created it. Phase I, and args.data has four fields: workerId, workerThreadId, url and frame.

Without it, worker activity is an unlabelled thread doing unattributable work. With it you can say which script the worker runs and which frame started it, which is the difference between knowing that some thread was busy and knowing which of your workers was busy.

WorkersHandler exists for this. On pages that move work off the main thread deliberately, this event is what lets you confirm the work actually moved rather than merely being duplicated.

Example event from https://stackoverflow.com
{
  "name": "TracingSessionIdForWorker",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1184930633,
  "tts": 1332,
  "pid": 9564,
  "tid": 9595,
  "s": "t",
  "args": {
    "data": {
      "frame": "8343B9DBEF932C61609598ED6CF43CDA",
      "url": "blob:https://stackoverflow.com/7720b4e0-d93d-4be0-9f29-6522c3295349",
      "workerId": "1540A612C89860B1E86EF468DC102834",
      "workerThreadId": 9595
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
low
references
worker_inspector_controller.cc, WorkersHandler.ts, inspector_trace_events.cc, dedicated_worker.cc, worker_thread.cc
TracingStartedInBrowser

Chrome's browser process announces that trace recording has started.

TracingStartedInBrowser declares that recording has started, and the browser process emits it. Phase I.

args.data has three fields: frameTreeNodeId, persistentIds and frames. frames is an array describing the frame tree at the moment recording began, giving each frame's id, its URL, its name and its parent.

That snapshot is the seed for frame identity. Nearly every event identifies its frame with an opaque frame hash and nothing else, and this is where those hashes first get attached to URLs. Skip it and you can tell that some frame did something, without being able to say which.

persistentIds indicates whether frame ids remain stable across navigations, which determines whether you can follow a frame through a navigation or must treat it as new.

Example event a local fixture page
{
  "name": "TracingStartedInBrowser",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1102915651,
  "tts": 179472,
  "pid": 8178,
  "tid": 8178,
  "s": "t",
  "args": {
    "data": {
      "frameTreeNodeId": 2,
      "frames": [
        {
          "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
          "isInPrimaryMainFrame": true,
          "isOutermostMainFrame": true,
          "name": "",
          "processId": 8257,
          "url": "about:blank"
        }
      ],
      "persistentIds": true
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
rare
undeclared args
args.data.frames[].frame, args.data.frames[].isInPrimaryMainFrame, args.data.frames[].isOutermostMainFrame, args.data.frames[].name, args.data.frames[].processId, args.data.frames[].url
references
tracing_handler.cc, MetaHandler.ts, inspector_trace_events.cc
TracingStartedInPageno longer emitted

The renderer used to announce that a DevTools recording had started.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

TracingStartedInPage declared the start of a DevTools recording from inside the renderer. Blink's InspectorTracingAgent emitted it on disabled-by-default-devtools.timeline as a thread-scoped instant. Its final payload was args.data, whose fields were sessionId, page (the frame id of the local frame root), persistentIds: true and a frames array.

It is gone. The emitter is present in branch-head 3497 (Chrome 69) at core/inspector/inspector_tracing_agent.cc and absent in 3538 (Chrome 70). The browser process took the job over with TracingStartedInBrowser, emitted by TracingHandler::EmitFrameTree in content/browser/devtools/protocol/tracing_handler.cc.

Treat it as a version stamp. The only thing the modern MetaHandler does with it is count it, alongside TracingStartedInBrowser and CpuProfile, as proof the trace came from Chrome rather than some other Trace Event producer.

Example event

Removed in Chrome 70, when the browser process took over with TracingStartedInBrowser. The collection recorded TracingStartedInBrowser on every site.

references
inspector_tracing_agent.cc, tracing_handler.cc
UserTiming::Measure

One of your own performance.measure() calls from the page, recorded in the trace.

UserTiming::Measure records a performance.measure() call made by the page.

This is your own instrumentation, surfacing in the trace alongside Chrome's. Its value is that it is the only event in the file that knows what your application considers a meaningful unit of work: "cart updated", "route rendered", "search results painted".

Chrome measures what the browser did. User timing measures what your application thinks it did. Correlating the two is how you turn a layout cost into a statement about which part of your application paid it.

Per the UserTimingsHandler source there is a UserTimings.md alongside it in trace_engine documenting the parsing rules. Read that if you are implementing this yourself.

Example event a local fixture page
{
  "name": "UserTiming::Measure",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1103071587,
  "dur": 8,
  "tdur": 8,
  "tts": 137154,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "sampleTraceId": 3744044438,
    "traceId": 3744044438
  }
}
categories
devtools.timeline
usage
moderate
duration
typical
references
performance_user_timing.cc

Tasks, fonts and hints

The event loop, async plumbing, web fonts and resource hints.

AsyncTaskAsync taskno longer emitted

Chrome linked an async task being scheduled to the moment it later ran.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

AsyncTask was Blink's flow event tying the scheduling of an async task to the task that eventually ran, until Chromium deleted it in January 2021. AsyncTaskScheduled in core/probe/core_probes.cc wrote a TRACE_EVENT_FLOW_BEGIN named AsyncTask on devtools.timeline.async, with args.data.name set to the probe name and the task pointer as the flow id. Running the task wrote a flow step for a recurring task and a flow end otherwise, and cancelling it wrote a flow end. Only DevTools rendered it.

The change "[devtools] Remove devtools.timeline.async category" removed the events and the whole category. Today Blink writes AsyncTask Scheduled from core/probe/async_task_context.cc and AsyncTask Run from core/probe/core_probes.cc, both on the plain blink category using Perfetto flows, and those two names do show up in current traces.

What you will actually find in a DevTools trace is V8's pair: v8::Debugger::AsyncTaskScheduled and v8::Debugger::AsyncTaskRun on disabled-by-default-v8.inspector, from v8/src/inspector/v8-debugger.cc, which put taskName in a top-level argument and join schedule to run with a process-scoped flow.

Example event

Removed in January 2021. The collection recorded AsyncTask Scheduled and AsyncTask Run on the blink category.

references
core_probes.cc, async_task_context.cc, v8-debugger.cc
BeginRemoteFontLoad

Chrome has started downloading a web font.

BeginRemoteFontLoad fires when a web font starts downloading. Phase X, with args.url, args.id and args.display.

display is the CSS font-display value in effect, observed directly rather than inferred from the stylesheet. swap, block, optional and fallback each imply a different visual outcome, and this is the trace telling you which one Chrome actually applied. id joins to RemoteFontLoaded.

Read display first, because that one value decides whether a slow font blocks text or swaps it, and everything else you conclude about the font hangs off it. Its consumer is LayoutShiftsHandler, which is the point. font-display: swap means text paints in a fallback face and then reflows when the real font arrives, which is a layout shift by design. optional avoids the shift. block trades the shift for text the user cannot read while the download runs.

So an entry here with display: swap, followed by a shift affecting text nodes, is a font-swap shift with the mechanism documented in the trace rather than guessed at. An entry with display: block and a long gap to its matching RemoteFontLoaded is the other failure, invisible text, and it costs you nothing in CLS while costing the reader everything.

Example event from https://www.bbc.com/news
{
  "name": "BeginRemoteFontLoad",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1108361321,
  "dur": 86,
  "tdur": 84,
  "tts": 54919,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "display": "block",
    "id": 188,
    "url": "https://static.files.bbci.co.uk/fonts/reith/2.512/BBCReithSans_W_Md.woff2"
  }
}
categories
devtools.timeline
usage
low
duration
typical
references
remote_font_face_source.cc, font_resource.cc, resource.h, identifiers_factory.cc, css_font_face.cc
CancelIdleCallbackCancel idle callback

Chrome cancels an idle callback your page had queued.

CancelIdleCallback fires on a cancelIdleCallback() call. It is an instant event on devtools.timeline from ScriptedIdleTaskController::CancelCallback in core/scheduler/scripted_idle_task_controller.cc.

args.data is {id, frame}, built by GenericIdleCallbackEvent in core/inspector/inspector_trace_events.cc, the same shape shared with RequestIdleCallback and FireIdleCallback. The id is what matches the three up. SetCallStack adds sampleTraceId and stackTrace when disabled-by-default-devtools.timeline.stack is enabled, which DevTools does by default, so you usually get the calling site too.

One trap: the event is emitted before IsValidCallbackId(id) and before the task is looked up, so cancelling a bogus or already-fired id still records an event.

The pattern to hunt for is a RequestIdleCallback whose id is followed by a cancel and never a FireIdleCallback.

Example event from https://github.com
{
  "name": "CancelIdleCallback",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1169242252,
  "tts": 1628869,
  "pid": 9307,
  "tid": 9307,
  "s": "t",
  "args": {
    "data": {
      "frame": "3037F57D3EE99AA57D01190FF40E7359",
      "id": 11,
      "sampleTraceId": 6501970321791987,
      "stackTrace": [
        {
          "columnNumber": 13690,
          "functionName": "cancelScheduledFlush",
          "lineNumber": 2,
          "scriptId": "20",
          "url": "https://github.githubassets.com/assets/app-runtime-79275629e212a8de.js"
        },
        {
          "columnNumber": 13058,
          "functionName": "flushBatch",
          "lineNumber": 2,
          "scriptId": "20",
          "url": "https://github.githubassets.com/assets/app-runtime-79275629e212a8de.js"
        },
        {
          "columnNumber": 12007,
          "functionName": "boundFlush",
          "lineNumber": 2,
          "scriptId": "20",
          "url": "https://github.githubassets.com/assets/app-runtime-79275629e212a8de.js"
        }
      ]
    }
  }
}
categories
devtools.timeline
usage
moderate
undeclared args
args.data.frame, args.data.id, args.data.sampleTraceId, args.data.stackTrace[].columnNumber, args.data.stackTrace[].functionName, args.data.stackTrace[].lineNumber, args.data.stackTrace[].scriptId, args.data.stackTrace[].url
references
scripted_idle_task_controller.cc, inspector_trace_events.cc, source_location.cc, v8-stack-trace-impl.cc, v8-debugger.cc
LinkPreconnect

Chrome acts on a <link rel="preconnect"> and opens a connection to that origin early.

LinkPreconnect fires when Chrome acts on a <link rel="preconnect">. Phase I, with args.data.url and node_id.

Use these to verify preconnects rather than trust them. Preconnect is easy to get wrong: pointing at the wrong origin, missing crossorigin for fonts, or preconnecting to an origin nothing subsequently requests. This event tells you which preconnects Chrome actually performed, and the network events tell you which origins were actually used.

The diff is the finding. A preconnect with no matching request wasted a connection. A heavily used origin with no preconnect is a missed opportunity, visible as DNS and connect time on its first request.

Example event from https://www.bbc.com/news
{
  "name": "LinkPreconnect",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "I",
  "ts": 1108346393,
  "tts": 41725,
  "pid": 8407,
  "tid": 8407,
  "s": "t",
  "args": {
    "data": {
      "frame": "2EA8810E92C12C680C55B2941251AA98",
      "node_id": 6,
      "url": "https://www.bbc.com/"
    }
  }
}
categories
disabled-by-default-devtools.timeline
usage
low
references
html_link_element.cc, identifiers_factory.cc
PreloadRenderBlockingStatusChange

This event tells you a preloaded resource has changed whether it blocks rendering.

PreloadRenderBlockingStatusChange marks a preloaded resource changing its render-blocking status. Phase I, with args.data.requestId, url and renderBlocking. It joins to the request by requestId.

The scenario is this. A resource is preloaded, then later discovered by the parser in a position that makes it render-blocking, or its blocking status is otherwise revised. Chrome records the change rather than only the final state.

That matters because renderBlocking on ResourceSendRequest is the status at request time. If it changed afterwards, only this event knows. Any analysis that reads render-blocking status once and never revisits it can be wrong on exactly the resources you most need to understand.

Example event from https://www.bbc.com/news
{
  "name": "PreloadRenderBlockingStatusChange",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1109023633,
  "tts": 21706,
  "pid": 8475,
  "tid": 8475,
  "s": "t",
  "args": {
    "data": {
      "renderBlocking": "in_body_parser_blocking",
      "requestId": "8475.2",
      "url": "https://cdn.privacy-mgmt.com/Notice.1c267.css"
    }
  }
}
categories
devtools.timeline
usage
moderate
references
resource_load_observer_for_frame.cc, resource_fetcher.cc, inspector_trace_events.cc, render_blocking_behavior.h, preload_helper.cc
ProgramOtherno longer emitted

Chrome wrapped one task on the renderer main thread's message loop.

Chrome no longer emits this event. The code that wrote it was found in the history and then found to have been deleted.

Program bracketed one renderer main thread message loop task, as a bare begin/end duration pair with no arguments whatsoever. Blink emitted it from WebDevToolsAgentImpl::willProcessTask and didProcessTask on disabled-by-default-devtools.timeline. That was its entire job.

Blink removed it in May 2015, in "Timeline: use toplevel event category instead of Program record to mark top-level task". Its successor is RunTask, emitted on the same category from base/task/sequence_manager/thread_controller_with_message_pump_impl.cc. DevTools keeps a Program entry labelled "Other" only so that decade-old trace files still render.

The V8 profiler frame is a separate mechanism. (program) is CodeEntry::kProgramEntryName in v8/src/profiler/profile-generator.cc, a synthetic frame substituted when a sample's vm_state is OTHER, EXTERNAL, IDLE_EXTERNAL or LOGGING. It arrives inside ProfileChunk node data, never as an event name. Treating the two as the same thing will send you hunting for scripting time that was never there.

Example event

Removed in May 2015. The collection recorded RunTask, its successor.

references
thread_controller_with_message_pump_impl.cc, profile-generator.cc
RemoteFontLoaded

Chrome has finished downloading a web font.

RemoteFontLoaded fires when a web font download has completed. Phase X, with args.url and args.name, the resolved font family name as Blink knows it.

Having the real family name is more useful than it sounds. It survives the indirection of @font-face blocks, CSS variables and font stacks, so you can say which face actually loaded rather than which one you think you asked for.

Pair it with BeginRemoteFontLoad and measure the gap. That gap is the window during which text either was invisible or was showing a fallback, depending on font-display. For a text LCP that window is frequently the thing gating the metric, since text cannot paint in a font that has not arrived.

LayoutShiftsHandler consumes this event too, for the swap-induced reflow. One slow font can therefore cost you twice, once in LCP while the download runs and again in CLS when the text reflows into the face that finally arrived.

Example event from https://www.bbc.com/news
{
  "name": "RemoteFontLoaded",
  "cat": "devtools.timeline",
  "ph": "X",
  "ts": 1108495652,
  "dur": 7,
  "tdur": 3,
  "tts": 152399,
  "pid": 8407,
  "tid": 8407,
  "args": {
    "name": "彿",
    "url": "https://static.files.bbci.co.uk/fonts/reith/2.512/BBCReithSans_W_Bd.woff2"
  }
}
categories
devtools.timeline
usage
low
duration
short
references
css_font_face.cc, font_custom_platform_data.cc, remote_font_face_source.cc
RunTaskTask

Chrome runs one task on a thread's event loop.

RunTask wraps one task on a thread's event loop. Phase X, and it is the outermost container for nearly everything else on the main thread.

It has no arguments at all. Its value is entirely structural: every FunctionCall, Layout, ParseHTML and PrePaint is nested inside one, so RunTask is how you attribute work to a task and measure the task's total cost rather than the cost of its parts.

It is also the most numerous event in a trace by a wide margin, mostly very short. Filtering to tasks over 50ms gives you long tasks, the definition behind TBT: every task over 50ms contributes its excess above 50ms to Total Blocking Time.

A long task blocks input for its entire duration, which is the whole reason INP suffers on busy pages. But note the modern refinement: several short tasks inside one frame can ruin an interaction without any single task crossing 50ms, which is exactly the gap AnimationFrame and LoAF exist to close.

WarningsHandler consumes it to flag long tasks, and FramesHandler uses it to decide which work belongs to which frame.

Example event a local fixture page
{
  "name": "RunTask",
  "cat": "disabled-by-default-devtools.timeline",
  "ph": "X",
  "ts": 1102913025,
  "dur": 20,
  "tdur": 18,
  "tts": 2325,
  "pid": 8211,
  "tid": 8262,
  "args": {
  }
}
categories
disabled-by-default-devtools.timeline
usage
very high
duration
typical
references
thread_controller_with_message_pump_impl.cc
TimeStampTimestamp

Chrome records a console.timeStamp() call made by your code.

TimeStamp marks a console.timeStamp() call. Phase untyped. args.data always has message and name, and optionally adds start, end, track, trackGroup, color and devtools.

Those last fields are the modern extended API, and they do considerably more than the event name suggests. console.timeStamp() can place a named, coloured entry on its own custom track in the Performance panel, with an explicit start and end. It is no longer just a marker.

That makes it the cheapest way to get application-level structure into a trace. Your router, your hydration phases, your data fetches can each appear as their own track next to Chrome's, without any external tooling.

Parsed by UserTimingsHandler, alongside performance.measure().

Example event a local fixture page
{
  "name": "TimeStamp",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1102936210,
  "tts": 37738,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "message": "fixture-stamp",
      "name": "fixture-stamp",
      "sampleTraceId": 6909218608594394
    }
  }
}
categories
devtools.timeline
usage
low
undeclared args
args.data.frame
references
inspector_trace_events.cc, v8-console.cc, thread_debugger_common_impl.cc
v8::Debugger::AsyncTaskRun

V8 runs an async continuation that was queued earlier.

v8::Debugger::AsyncTaskRun records a scheduled async continuation actually running. Phase X, in the disabled-by-default-v8.inspector category.

AsyncJSCallsHandler consumes it, pairing each run with the v8::Debugger::AsyncTaskScheduled that queued it. Its job is exactly that: reconstructing a logical async call chain from two events that are structurally unrelated in the event tree.

The gap between scheduled and run is queueing delay for that continuation. A promise that resolves immediately but whose continuation runs 200ms later was not slow to resolve; the thread was busy. That distinction matters, because the fix for one is optimising the promise and the fix for the other is unblocking the thread.

Requires disabled-by-default-v8.inspector.

Example event a local fixture page
{
  "name": "v8::Debugger::AsyncTaskRun",
  "cat": "disabled-by-default-v8.inspector",
  "ph": "X",
  "ts": 1102940419,
  "dur": 12,
  "tdur": 10,
  "tts": 39920,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "sampleTraceId": 1
    }
  }
}
categories
disabled-by-default-v8.inspector
usage
very high
duration
typical
undeclared args
args.data.sampleTraceId
references
v8-debugger.cc, trace-id.h
v8::Debugger::AsyncTaskScheduled

V8 queues an async continuation to run later.

v8::Debugger::AsyncTaskScheduled marks an async continuation being scheduled inside V8. Phase X, in the disabled-by-default-v8.inspector category, and in real traces two arguments appear: args.taskName and args.data.sampleTraceId.

taskName is a short label for the kind of async work: a promise, a timer, an event. Observed values include framework and platform names such as pagereveal.

This is the event that makes await traceable. Ordinary containment breaks at an async boundary because the continuation runs in a later, unrelated task. These events, paired with AsyncTaskRun by id, are the link across that gap, and they are numerous: over a thousand in a single ordinary page load.

If you have ever lost a causal chain at an await and concluded the trace could not answer the question, this is the pair that answers it.

Example event a local fixture page
{
  "name": "v8::Debugger::AsyncTaskScheduled",
  "cat": "disabled-by-default-v8.inspector",
  "ph": "X",
  "ts": 1102925977,
  "dur": 3,
  "tdur": 2,
  "tts": 30075,
  "pid": 8257,
  "tid": 8257,
  "args": {
    "data": {
      "sampleTraceId": 0
    },
    "taskName": "pagereveal"
  }
}
categories
disabled-by-default-v8.inspector
usage
high
duration
short
undeclared args
args.data.sampleTraceId
references
v8-debugger.cc, trace-id.h
WebSocketReceiveHandshakeResponse

Chrome has received the server's reply to a WebSocket handshake.

WebSocketReceiveHandshakeResponse fires when the server's handshake response arrives and the WebSocket connection is open. Phase I.

Its arguments are the same WebSocketInfo shape as WebSocketSendHandshakeRequest, and identifier matches the two up.

The gap between the send and this response is connection establishment latency for the socket, which is a real user-facing number on anything realtime. A chat or live-updating page cannot show anything until this completes.

Consumed by NetworkRequestsHandler and InitiatorsHandler, so the socket appears in the network picture rather than being invisible to it, which is a common blind spot in performance tooling.

Example event a local fixture page
{
  "name": "WebSocketReceiveHandshakeResponse",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1103043996,
  "tts": 134076,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "identifier": 5,
      "sampleTraceId": 6909218608593087
    }
  }
}
categories
devtools.timeline
usage
rare
undeclared args
args.data.sampleTraceId
references
inspector_websocket_events.cc, unique_identifier.cc, websocket_channel_impl.cc
WebSocketSendHandshakeRequest

Chrome sends the opening handshake asking a server to upgrade the connection to a WebSocket.

WebSocketSendHandshakeRequest records a WebSocket opening handshake being sent, which is the HTTP upgrade request that precedes a WebSocket connection.

args.data always has identifier and url, and optionally frame and workerId. identifier is the connection id that ties handshake, frames and close together. workerId matters because sockets are frequently opened from workers, and this is how you attribute one to the worker rather than the page.

It shares an interface with its response counterpart, one WebSocketInfo shape under two wire names. That is one of the five alias cases in trace_engine's enum, and a reason the enum count overstates the number of distinct events.

Example event a local fixture page
{
  "name": "WebSocketSendHandshakeRequest",
  "cat": "devtools.timeline",
  "ph": "I",
  "ts": 1103042994,
  "tts": 133286,
  "pid": 8257,
  "tid": 8257,
  "s": "t",
  "args": {
    "data": {
      "frame": "14A831DCB73EB1205B67CDB89FC6EBD7",
      "identifier": 5,
      "sampleTraceId": 6909218608593036
    }
  }
}
categories
devtools.timeline
usage
rare
undeclared args
args.data.frame, args.data.identifier, args.data.sampleTraceId
references
inspector_websocket_events.cc, unique_identifier.cc, websocket_channel_impl.cc

Trace categories

Every event is filed under one or more categories, and the categories switched on when tracing starts decide what ends up in the file. An event is recorded when any one of its categories is on.

devtools.timeline

devtools.timeline is the category Blink's inspector instrumentation writes its main timeline events to: ParseHTML, UpdateLayoutTree, Layout, Paint, EventDispatch, FunctionCall, TimerFire, and the network events from ResourceSendRequest to ResourceFinish. Page milestones such as CommitLoad, MarkDOMContent and MarkLoad are filed here too, as are EventTiming and AnimationFrame. RunTask is not; it is in disabled-by-default-devtools.timeline.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: AbortPostTaskCallback, Animation, AnimationFrame, AnimationFrame::Presentation, BeginRemoteFontLoad, CancelAnimationFrame, CancelIdleCallback, CommitLoad, EvaluateScript, EventDispatch, EventTiming, FireAnimationFrame, FireIdleCallback, firstContentfulPaint, firstPaint, FrameStartedLoading, FunctionCall, HandlePostMessage, HitTest, largestContentfulPaint::Candidate, largestContentfulPaint::CandidateForSoftNavigation, Layerize, Layout, LayoutImageUnsized, MajorGC, MarkDOMContent, MarkLoad, MetaCharsetCheck, MinorGC, Paint, ParseAuthorStyleSheet, ParseHTML, PreloadRenderBlockingStatusChange, PrePaint, RemoteFontLoaded, RequestAnimationFrame, RequestIdleCallback, ResourceFinish, ResourceMarkAsCached, ResourceReceivedData, ResourceReceiveResponse, ResourceSendRequest, ResourceWillSendRequest, RunPostTaskCallback, SchedulePostMessage, SchedulePostTaskCallback, ScrollLayer, SoftNavigationStart, TimerFire, TimerInstall, TimerRemove, TimeStamp, UpdateLayoutTree, UserTiming::Measure, v8.compile, v8.deserializeOnBackground, v8.evaluateModule, v8.parseOnBackground, v8.parseOnBackgroundParsing, v8.parseOnBackgroundWaiting, v8.produceCache, v8.produceModuleCache, WebSocketCreate, WebSocketDestroy, WebSocketReceive, WebSocketReceiveHandshakeResponse, WebSocketSend, WebSocketSendHandshakeRequest, XHRLoad, XHRReadyStateChange

references
inspector_trace_events.h
disabled-by-default-devtools.timeline

disabled-by-default-devtools.timeline records the lower level half of the timeline: RunTask around each scheduled task, the compositor's Commit and RasterTask, image decoding (Decode Image, ImageDecodeTask, PaintImage), and invalidation markers such as InvalidateLayout and ScheduleStyleRecalculation. It also writes TracingStartedInBrowser, which maps frame ids to URLs, and UpdateCounters with DOM node and JS heap counts.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: BeginCommitCompositorFrame, Commit, Decode Image, Decode LazyPixelRef, DoDecrypt, DoDecryptReply, DoDigest, DoDigestReply, DoEncrypt, DoEncryptReply, DoSign, DoSignReply, DoVerify, DoVerifyReply, Draw LazyPixelRef, EmbedderCallback, GPUTask, ImageDecodeTask, ImageUploadTask, InvalidateLayout, LinkPreconnect, PaintImage, ParseMetaViewport, RasterTask, RunTask, ScheduleStyleRecalculation, SetLayerTreeId, TracingSessionIdForWorker, TracingStartedInBrowser, UpdateCounters, UpdateLayer, v8.wasm.cachedModule, v8.wasm.compiledModule, v8.wasm.moduleCacheHit, v8.wasm.moduleCacheInvalid, v8.wasm.streamFromResponseCallback

references
thread_controller_with_message_pump_impl.cc, inspector_trace_events.cc
disabled-by-default-devtools.timeline.frame

disabled-by-default-devtools.timeline.frame records the compositor's frame bookkeeping: BeginFrame, NeedsBeginFrameChanged, RequestMainThreadFrame, BeginMainThreadFrame, ActivateLayerTree, DrawFrame and DroppedFrame. PipelineReporter and its stage spans, from BeginImplFrameToSendBeginMainFrame to SwapEndToPresentationCompositorFrame, are filed here as well. Use it to tell frames that were presented from frames that were dropped.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: ActivateLayerTree, BeginFrame, BeginMainThreadFrame, Commit, DrawFrame, DroppedFrame, NeedsBeginFrameChanged, PipelineReporter, RequestMainThreadFrame

references
devtools_instrumentation.h, compositor_frame_reporter.cc
disabled-by-default-devtools.timeline.stack

disabled-by-default-devtools.timeline.stack adds no events of its own. It makes Blink add a stackTrace and a sampleTraceId to timeline events such as TimerInstall, EvaluateScript, InvalidateLayout, ScheduleStyleRecalculation and ResourceSendRequest, and asks V8 for a CPU profile sample at that moment. Without it those events say what happened but not which script did it.

DevTools records this category on every Performance panel recording.

references
inspector_trace_events.cc
disabled-by-default-devtools.timeline.invalidationTrackingDevTools setting

disabled-by-default-devtools.timeline.invalidationTracking records why Blink marked style or layout dirty: ScheduleStyleInvalidationTracking, StyleInvalidatorInvalidationTracking, StyleRecalcInvalidationTracking, LayoutInvalidationTracking and StyleResolver::ResolveStyle. Each names the node and a reason, such as the class or attribute that changed, and most add the JavaScript stack that was running. InvalidateLayout and ScheduleStyleRecalculation tell you an invalidation happened; these events tell you what triggered it.

DevTools records this category only with Enable CSS selector stats (slow) or Invalidation tracking turned on. Those settings are off by default.

Events in this manual filed under it: LayoutInvalidationTracking, ScheduleStyleInvalidationTracking, StyleInvalidatorInvalidationTracking, StyleRecalcInvalidationTracking, StyleResolver::ResolveStyle

references
inspector_trace_events.cc, style_resolver.cc
disabled-by-default-devtools.screenshot

disabled-by-default-devtools.screenshot records Screenshot instant events: JPEG captures of the page, base64 encoded, each tagged with a frame_sequence and an estimated display time. The browser takes them from a video capture of the page's compositor output and stops after a maximum number, so a long recording can run out of screenshots before it ends.

DevTools records this category while the Screenshots checkbox is ticked, which it is by default.

Events in this manual filed under it: Screenshot

references
tracing_handler.cc, devtools_traceable_screenshot.cc
disabled-by-default-layout_shift.debug

disabled-by-default-layout_shift.debug adds no events of its own. It makes Blink add a debug_name to each entry in a LayoutShift event's impacted_nodes, so a moved node can be read by name instead of only by node_id.

DevTools records this category on every Performance panel recording.

references
layout_shift_tracker.cc
disabled-by-default-devtools.target-rundown

disabled-by-default-devtools.target-rundown records ScriptCompiled and ModuleEvaluated events that tie each script id to the frame, URL, V8 isolate and execution context it ran in. They let a trace reader tell which frame a script belongs to, and which JavaScript world it ran in, the page's own or an isolated one.

DevTools records this category on every Performance panel recording.

references
v8_script_runner.cc, inspector_trace_events.cc
disabled-by-default-devtools.timeline.layersDevTools setting

disabled-by-default-devtools.timeline.layers records a LayerTreeHostImpl:snapshot event each time the compositor generates a frame, with the whole layer tree as its data. Turning it on also makes Blink keep extra paint debug information while the trace runs.

DevTools records this category only with Enable advanced paint instrumentation (slow) turned on. That setting is off by default.

references
frame_viewer_instrumentation.h, layer_tree_host_impl.cc, local_frame_view.cc
disabled-by-default-devtools.timeline.pictureDevTools setting

disabled-by-default-devtools.timeline.picture records cc::DisplayItemList:snapshot events whenever a layer's recorded paint is updated, with the paint serialized as a base64 Skia picture (skp64). That picture is what lets a viewer replay the draw commands behind a Paint.

DevTools records this category only with Enable advanced paint instrumentation (slow) turned on. That setting is off by default.

references
display_item_list.cc, recording_source.cc
v8.execute

v8.execute records V8's execution plumbing rather than script calls: RunMicrotasks around each microtask checkpoint, V8.StackGuard and V8.HandleInterrupts when V8 services an interrupt, and V8.BytecodeBudgetInterrupt when a function has used up its budget and V8 considers optimizing it. For the call into a script itself, look at FunctionCall and v8.callFunction.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: RunMicrotasks, V8.BytecodeBudgetInterrupt

references
microtask-queue.cc, stack-guard.cc, runtime-internal.cc
v8

v8 records Blink's entry points into V8, such as v8.compile, v8.run, v8.callFunction and v8.produceCache, along with context setup (LocalWindowProxy::Initialize), deoptimization (V8.DeoptimizeCode) and a few GC spans such as V8.GCScavenger. MinorGC and MajorGC are filed here together with devtools.timeline.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: CpuProfiler::StartProfiling, MajorGC, MinorGC, v8.compile, v8.deserializeOnBackground, v8.evaluateModule, v8.parseOnBackground, v8.parseOnBackgroundParsing, v8.parseOnBackgroundWaiting, v8.produceCache, v8.produceModuleCache

references
v8_script_runner.cc, local_window_proxy.cc
disabled-by-default-v8.compilenot in DevTools

disabled-by-default-v8.compile breaks compilation down per function and per tier: V8.ParseProgram, V8.PreParse, V8.ParseFunction and V8.CompileIgnition for bytecode, V8.MaglevBackground and V8.OptimizeBackground for optimized code, and script streaming spans such as V8.CompileStreamedScript. It is one of the largest categories by event count, and the one that separates parse and compile cost from execution.

DevTools never asks for this category. Record it with your own trace configuration, for example in Perfetto or Puppeteer.

Events in this manual filed under it: V8.CollectSourcePositions, V8.CompileCode, V8.CompileIgnition, V8.CompileScript, v8.deserializeOnBackground, V8.FinalizeDeserialization, V8.MaglevTask, V8.OptimizeCode, V8.ParseFunction, v8.parseOnBackground, v8.parseOnBackgroundParsing, v8.parseOnBackgroundWaiting, V8.PreParse

references
trace-categories.h
disabled-by-default-v8.cpu_profiler

disabled-by-default-v8.cpu_profiler starts V8's sampling profiler and records its output as Profile and ProfileChunk events, phase P, which a trace viewer stitches into a call tree over time. Without it you see which script a task ran but not which functions inside it used the time.

DevTools records this category unless Disable JavaScript samples is ticked, and that setting is off by default.

Events in this manual filed under it: Profile, ProfileChunk

references
tracing-cpu-profiler.cc, profile-generator.cc
disabled-by-default-v8.cpu_profiler.hires

disabled-by-default-v8.cpu_profiler.hires is not registered or checked anywhere in current Chromium or V8 source, so turning it on changes nothing. The JavaScript samples come from disabled-by-default-v8.cpu_profiler.

DevTools records this category on every Performance panel recording.

references
builtin_categories.h, trace-categories.h
disabled-by-default-devtools.v8-source-rundown

disabled-by-default-devtools.v8-source-rundown records a ScriptCatchup event for each top level script V8 compiles, and for every script already loaded when profiling starts: its scriptId, URL, execution context, and sourceMapUrl when the script declares one. The sourceMapUrl is what lets a trace viewer map minified functions in the CPU profile back to their original names after the recording.

DevTools records this category on every Performance panel recording.

references
script.cc, compiler.cc, cpu-profiler.cc
disabled-by-default-devtools.v8-source-rundown-sources

disabled-by-default-devtools.v8-source-rundown-sources records the source text of those same scripts: ScriptCatchup for scripts up to 1 MB, LargeScriptCatchup in 1 MB pieces for bigger ones, and TooLargeScriptCatchup with no text above 25 MB. It makes a trace self contained, at the price of writing every script on the page into the file.

DevTools records this category on every Performance panel recording.

references
script.cc, compiler.cc, cpu-profiler.cc
disabled-by-default-v8.gcnot in DevTools

disabled-by-default-v8.gc records V8 garbage collection in detail: the V8.GC_* phase scopes for scavenges, incremental marking and sweeping (V8.GC_SCAVENGER, V8.GC_MC_INCREMENTAL), the Marking, Sweeping and ObservablePause spans, and V8.GCTraceGCNVP summaries. Many of its V8.GC_* scopes are also filed under devtools.timeline.

DevTools never asks for this category. Record it with your own trace configuration, for example in Perfetto or Puppeteer.

references
trace-categories.h
cppgc

cppgc records garbage collection of Oilpan, the C++ heap Blink allocates DOM and other garbage collected objects on: CppGC.AtomicMark, CppGC.IncrementalMark, CppGC.IncrementalSweep, CppGC.ConcurrentMark and related scopes, with .Minor appended for minor collections. The finer grained scopes are written to disabled-by-default-cppgc instead.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: CppGC.IncrementalSweep

references
stats-collector.h, trace-categories.h
disabled-by-default-v8.inspector

disabled-by-default-v8.inspector records V8 inspector work: v8::Debugger::AsyncTaskScheduled, v8::Debugger::AsyncTaskRun and v8::Debugger::AsyncTaskCanceled, which link async work back to the code that scheduled it, console calls such as V8Console::Log, and V8StackTraceImpl::capture.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: v8::Debugger::AsyncTaskRun, v8::Debugger::AsyncTaskScheduled, V8Console::runTask

references
v8-debugger.cc, v8-stack-trace-impl.cc
disabled-by-default-v8.stack_tracenot in DevTools

disabled-by-default-v8.stack_trace records the cost of V8 walking the JavaScript stack: V8StackTraceImpl::capture and toFramesVector in the inspector, and spans around building error.stack and looking up the current script. It measures stack capture; it does not add stack traces to other events.

DevTools never asks for this category. Record it with your own trace configuration, for example in Perfetto or Puppeteer.

references
isolate.cc, messages.cc, v8-stack-trace-impl.cc
ccnot in DevTools

cc is the Chromium compositor's own instrumentation: commits and tree activation (LayerTreeHostImpl::CommitComplete), tile management and raster (TileManager::PrepareTiles), the frame scheduler (Scheduler::BeginImplFrame), and compositor side scrolling (ScrollTree::SetScrollOffset). It is often the largest category in a trace by event count, and the one to open when the main thread is idle but frames still arrive late.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: Commit, DisplayItemList::Raster, LocalFrameView::ScheduleAnimation, PipelineReporter, RasterizerTaskImpl::RunOnWorkerThread, RasterSource::PerformSolidColorAnalysis, RasterTask, ZeroCopyRasterBuffer::Playback

benchmarknot in DevTools

benchmark is a label Chromium adds next to other categories on compositor, viz and input pipeline events, and it almost never appears alone. PipelineReporter, EventLatency, Graphics.Pipeline and Scheduler::BeginFrame are all filed under it, which makes it more useful than its name suggests.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: Animation, Commit, Graphics.Pipeline, InputLatency::MouseMove, InputLatency::MouseWheel, PipelineReporter

references
builtin_categories.h
graphics.pipelinenot in DevTools

graphics.pipeline records Graphics.Pipeline events, one per step of a frame's journey: issuing and receiving the BeginFrame, generating and submitting the compositor frame, surface aggregation, and draw and swap. The renderer compositor and viz tag their steps with shared trace ids and flow arrows, so one frame can be followed across processes.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: Graphics.Pipeline

references
chrome_track_event.proto, display.cc, async_layer_tree_frame_sink.cc
viznot in DevTools

viz records the display compositor, which runs in the GPU process and turns the compositor frames every renderer submits into what is on screen: DisplayScheduler::BeginFrame, Surface::CommitFrame, Display::DrawAndSwap, DirectRenderer::DrawFrame.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: DirectRenderer::DrawRenderPass, Graphics.Pipeline, SoftwareRenderer::DoDrawQuad

references
display.cc
disabled-by-default-display.framedisplayednot in DevTools

disabled-by-default-display.framedisplayed records Display::FrameDisplayed, an instant event viz writes at the presentation time reported for a frame it drew. It is always filed together with benchmark and viz.

DevTools never asks for this category. Record it with your own trace configuration, for example in Perfetto or Puppeteer.

references
display.cc
gpunot in DevTools

gpu records GPU process and GPU client work: the GPU scheduler (Scheduler::RunTask), command buffer flushes (CommandBuffer::Flush), shared image creation (SharedImageStub::CreateSharedImage), and GL calls from the renderer such as GLES2::ReadPixels.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

raf_investigationnot in DevTools

raf_investigation records a single event, EarlyOut_NoUpdates, written when a main frame the compositor asked for ended with nothing to commit. It is always filed together with cc.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
proxy_main.cc
inputnot in DevTools

input records input routing across processes: InputRouterImpl::FilterAndSendWebInputEvent and TouchActionFilter::SetTouchAction in the browser, WidgetInputHandlerManager::DidHandleInputEventSentToMain and MainThreadEventQueue::HandleEvent in the renderer, and the EventLatency spans that time an input from its generation to the frame that presented it.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: Commit, HitTest

references
event_latency_tracing_recorder.cc
input.scrollingnot in DevTools

input.scrolling labels the input events that matter for scroll and input latency: EventLatency and its stage spans such as RendererCompositorProcessing and RendererMainProcessing, compositor scroll results such as InputHandlerProxy::HandleGestureScrollUpdate_Result, and scroll jank tracking. The stages show whether an input was handled on the compositor or had to reach the main thread.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: Commit, InputLatency::MouseMove, InputLatency::MouseWheel

references
event_latency_tracing_recorder.cc, scroll_jank_dropped_frame_tracker.cc, input_handler_proxy.cc
latencyInfonot in DevTools

latencyInfo records InputLatency async spans, such as InputLatency::MouseDown, that follow one input by trace id from the browser to the frame swap that showed its result, plus LatencyInfo.Flow instants at each hop in between. The spans list every latency component with its timestamp.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

Events in this manual filed under it: InputLatency::MouseMove, InputLatency::MouseWheel

references
latency_info.cc, widget_input_handler_manager.cc
loading

loading records the page load milestones and the paint timing behind the metrics: firstContentfulPaint, largestContentfulPaint::Candidate, LargestImagePaint::Candidate and LayoutShift. It also follows document and resource loading in the renderer, such as DocumentLoader::CommitNavigation and ThrottlingURLLoader::OnReceiveResponse.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: firstContentfulPaint, firstPaint, InteractiveTime, largestContentfulPaint::Candidate, largestContentfulPaint::CandidateForSoftNavigation, LayoutShift, SoftNavigationStart

references
layout_shift_tracker.cc, largest_contentful_paint_calculator.cc
navigation

navigation follows a navigation on both sides of the process boundary: NavigationRequest in the browser, from NavigationRequest::BeginNavigation to NavigationRequest::DidCommitNavigation, and RenderFrameImpl in the renderer, including RenderFrameImpl::didFinishSameDocumentNavigation. It also writes timing spans such as NavigationTotal and StartToCommit.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: RenderFrameImpl::createChildFrame

interactionsnot in DevTools

interactions labels, together with loading, the page load metric spans Chrome's browser process writes, such as PageLoadMetrics.NavigationToFirstContentfulPaint and PageLoadMetrics.NavigationToLargestContentfulPaint. Despite the name, the event timing behind INP is not here; that is EventTiming, in devtools.timeline.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
uma_page_load_metrics_observer.cc
rail

rail is a label from the RAIL performance model that Chromium adds next to other categories and does not use on its own. It marks events tied to loading, input response and animation, such as firstContentfulPaint, RenderFrameImpl::didFinishLoad, WebFrameWidgetImpl::HandleInputEvent and Animation.

DevTools records this category on every Performance panel recording.

Events in this manual filed under it: Animation, domLoading, firstContentfulPaint, firstPaint, InputLatency::MouseMove, InputLatency::MouseWheel, InteractiveTime, largestContentfulPaint::Candidate, largestContentfulPaint::CandidateForSoftNavigation, RenderFrameImpl::createChildFrame

references
builtin_categories.h
toplevelnot in DevTools

toplevel records the outermost span around each task a Chromium thread runs: ThreadControllerImpl::RunTask on scheduler threads such as the renderer main thread, ThreadPool_RunTask on pool workers, and message pump events such as EpollEvent. It is the category to read for task length before you know what ran inside.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
task_annotator.h, thread_controller_with_message_pump_impl.cc, task_tracker.cc
toplevel.flownot in DevTools

toplevel.flow links tasks to the code that posted them: Chromium writes an instant event when a task is posted and a matching flow id on the task when it runs, so a viewer can draw an arrow between the two. Mojo message sends and the GPU scheduler use it the same way.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
task_annotator.cc, send_message_helper.cc, scheduler.cc
browsernot in DevTools

browser records browser process work outside any renderer, much of it Chrome UI such as TabSearchPageHandler:GetProfileTabs, plus navigation calls filed together with navigation, such as Navigator::Navigate and NavigationControllerImpl::LoadURLWithParams.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
tab_search_page_handler.cc
contentnot in DevTools

content labels events from Chromium's content layer, the multi process browser core between Chrome's UI and Blink. In a page trace it mostly marks WebContentsImpl calls filed together with navigation, such as WebContentsImpl::DidNavigateMainFramePostCommit.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
web_contents_impl.cc
renderernot in DevTools

renderer records widget work on the renderer side: WidgetBase::WasShown, WidgetBase::WasHidden and WidgetBase::UpdateVisualProperties, and input handling such as WidgetBaseInputHandler::OnHandleInputEvent.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
widget_base.cc, widget_base_input_handler.cc
renderer_hostnot in DevTools

renderer_host records the browser side of the renderer relationship: RenderWidgetHostImpl::WasShown, RenderWidgetHostImpl::WasHidden, RenderViewHostImpl::CreateRenderView, and RenderProcessHostImpl::UpdateProcessPriority.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
render_widget_host_impl.cc, render_view_host_impl.cc, render_process_host_impl.cc
medianot in DevTools

media records audio and video work: WebMediaPlayerImpl calls such as WebMediaPlayerImpl::DoLoad, the audio and video renderers and decoders, and the Rendering span a video frame compositor writes while it is rendering video.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
web_media_player_impl.cc, video_frame_compositor.cc
WebCorenot in DevTools

WebCore is a category name left over from WebKit, which Blink was forked from. In a page trace its one event is LoadFinished, written by RenderFrameImpl when a frame finishes loading.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
render_frame_impl.cc
disabled-by-default-lighthouse

disabled-by-default-lighthouse is not registered or written to anywhere in current Chromium source, so turning it on adds nothing. Chrome once filed RunTask under it for Lighthouse; RunTask is now in disabled-by-default-devtools.timeline.

DevTools records this category on every Performance panel recording.

references
builtin_categories.h, thread_controller_with_message_pump_impl.cc, PageDependencyGraph.ts
mojomnot in DevTools

mojom records Mojo IPC, the message system Chrome's processes and services talk over: Receive mojo message and Receive mojo reply around each incoming message a thread handles, filed together with toplevel. It is plumbing: useful for chasing a slow cross process call, noise for anything about the page.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
interface_endpoint_client.cc
startupnot in DevTools

startup marks browser and GPU process startup work, such as gpu_info_collector::CollectDawnInfo and DriverGL::InitializeStaticBindings. It describes Chrome starting up, not the page.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
gpu_init.cc
shutdownnot in DevTools

shutdown marks teardown in the browser and compositor, such as Compositor::destructor and BrowserMainLoop::ShutdownThreadsAndCleanUp. It describes Chrome closing things down, not the page.

DevTools only asks for this category when Show all events is on. Its events still show up in a normal recording when they are also filed under a category DevTools does ask for.

references
compositor.cc, browser_main_loop.cc
__metadata

__metadata is not a category any page event uses: the JSON trace exporter writes it on the thread_name, process_name and process_uptime_seconds records, phase M with ts 0, that put names to each pid and tid. Chrome's trace config always enables it. Without these records every thread in your trace is a number.

Events in this manual filed under it: thread_name

references
export_json.cc, consumer_host.cc, trace_config.cc

How this was built

Three independent sources, and they disagree with each other more than you might expect.

1. The event set. Extracted mechanically from @paulirish/trace_engine 0.0.65, the same package DevTools itself uses to parse traces. That yields 174 enum entries, which reduce to 169 distinct wire names: some entries are aliases, and 3 are synthetic events DevTools constructs while parsing that never appear in a trace file.

2. Real traces. 300 sites were traced with a wide set of categories: most of what a DevTools recording asks for, plus several it never asks for, such as cc, blink, toplevel and disabled-by-default-v8.compile. That produced 5,223 distinct event names, of which 144 matched a modelled event and confirmed its arguments against reality.

3. Chromium source. Where the package models nothing, or where the traces disagreed with it, the emitter was read directly in Chromium and V8. Entries cite the file that emits the event. That is how the dead events here are known to be dead: the code that emitted them was found, and then found to have been deleted.

4. Example events. 147 of the 189 entries show a real event lifted straight out of a trace file, with long strings and long arrays shortened and every cut marked. Where an ordinary page load cannot produce an event, the sample comes from a local fixture page that calls the API directly: WebSocket, Web Crypto, WebAssembly, scheduler.postTask, User Timing. The rest say plainly that the collection never produced one, and why. None of them is reconstructed.

5. Reading usage and duration. Every trace here was recorded on one machine, one Chrome build, headless, on one network. Exact counts and durations from that setup are not something you could reproduce, so this page does not print them. What survives the machine is the ranking, so each entry says where the event sits against the other 188: usage is how numerous it is, duration how long it runs. Both are quantile bands over the events on this page, so "long" means long for a trace event, not long in any absolute sense. Treat them as a relative ordering, never as a benchmark. The raw figures are in corpus.json for anyone rebuilding this.

6. When, and against what. Everything here was checked against the main branches of Chromium (V8 included), Perfetto and devtools-frontend, between 11 to 13 September 2026. Entries list the files they were checked against, and those links go to the main branch, so a file can have moved on since. The example events were recorded on Chrome 141.0.7390.54, so a field Chrome added or dropped after that build will not show in a sample. The event set comes from @paulirish/trace_engine 0.0.65. That number is the package's own release counter and does not correspond to any Chrome version.

Checked against the main branches of Chromium (V8 included), Perfetto and devtools-frontend, 11 to 13 September 2026. Example events recorded on Chrome 141.0.7390.54.

Event names, interfaces and argument shapes come from @paulirish/trace_engine 0.0.65 and Chromium, both BSD-3 licensed, and from DevTools' own UI strings. Argument shapes the package does not model were verified against real Chrome traces. Explanations are original.

169 distinct event names. The enum contains 174 entries, but 5 of those are aliases or synthetic events that DevTools constructs while parsing and that never appear in a trace file.