Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
28dd827
Add markPaintTime() explainer
Apr 16, 2026
c96c76d
Add rendering pipeline diagrams and timing section
Apr 22, 2026
8576cf1
Move WebIDL to appendix, simplify Proposed API for web developers
Apr 22, 2026
3403a91
Refine Problem section and update paint-time diagram
Apr 22, 2026
38a3bfe
Add rPAF code example and soften alternative assessment
Apr 22, 2026
ad796bc
Add Behavior section to clarify markPaintTime() semantics
Apr 22, 2026
be104b5
Add Open Questions section: paintTime vs post-paint timestamp
Apr 22, 2026
20e7d58
Fix presentationTime - paintTime description
Apr 22, 2026
a91ea71
Add code example for rAF + setTimeout workaround
Apr 22, 2026
e8969d9
Update paint-time-pipeline.png diagram
Apr 22, 2026
f92598f
Add hyperlinks to spec references in explainer
Apr 27, 2026
4fc20f3
Clarify multi-call same-frame behavior in explainer
Apr 27, 2026
27f0f2e
Add open question on API naming alternatives
Apr 27, 2026
e83e30f
Add optional startTime override aligned with performance.mark()
Apr 27, 2026
448bc54
Document presentationTime nullable behavior
Apr 27, 2026
8a252ac
Soften absolute claims and account for nullable presentationTime
Apr 27, 2026
b605252
Add hyperlink for First Paint to spec reference
Apr 27, 2026
ee75a9d
Qualify presentationTime as UA-dependent in introduction
Apr 27, 2026
406e96b
Add backticks to First Paint for consistent formatting
Apr 27, 2026
3daa2e2
Clarify presentationTime independence from rendering opportunities
Apr 27, 2026
47f6cb9
Fix paintTime - startTime description to be precise
Apr 27, 2026
8a69c50
Replace test exmaple with an end-to-end example
Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions performanceMarkPaintTime/explainer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# performance.markPaintTime() Explainer

Author: [Wangsong Jin](https://github.com/JosephJin0815) - Engineer at Microsoft Edge

## Status of this Document

This document is a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization. As the solutions to problems described in this document progress along the standards-track, we will retain this document as an archive and use this section to keep the community up-to-date with the most current standards venue and content location of future work and discussions.

* This document status: **Active**
* Expected venue: [W3C Web Incubator Community Group](https://wicg.io/)
* Current version: this document

## Table of Contents

- [Introduction](#introduction)
- [Goals](#goals)
- [Non-goals](#non-goals)
- [The Problem](#the-problem)
- [Proposed API](#proposed-api)
- [Rendering Pipeline and Timing](#rendering-pipeline-and-timing)
- [Key Design Decisions](#key-design-decisions)
- [Alternatives Considered](#alternatives-considered)
- [Open Questions](#open-questions)
- [Security and Privacy Considerations](#security-and-privacy-considerations)
- [Appendix: WebIDL](#appendix-webidl)

## Introduction

Web developers need to measure when their visual updates actually render — not just the browser-detected milestones like First Paint or Largest Contentful Paint, but any update they care about: a component mount, a state transition, a style change.

The platform already captures paint and presentation timestamps for key moments via PaintTimingMixin, but only for entries the browser selects automatically. `performance.markPaintTime()` extends this capability to let developers capture the same `paintTime` and `presentationTime` for any visual update, on demand.
Comment thread
JosephJin0815 marked this conversation as resolved.
Outdated

## Goals
- Give developers on-demand access to `paintTime` and `presentationTime` for any visual update.
- Deliver timestamps through `PerformanceObserver`, consistent with modern performance APIs.

## Non-goals
- **Replacing existing paint timing entries.** FP, FCP, LCP, Event Timing, and LoAF continue to serve their existing purposes.
Comment thread
JosephJin0815 marked this conversation as resolved.
Outdated
- **Forcing a rendering update.** `markPaintTime()` does not cause a rendering opportunity — it tags the next one that naturally occurs.

## The Problem

Without an on-demand API, developers resort to workarounds like double-rAF or rAF+setTimeout to approximate when the rendering update completes, but these workarounds are unreliable (see [Nolan Lawson's analysis](https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/)). Furthermore, no workaround can provide `presentationTime` — the actual time when pixels appear on screen. For example, a developer wants to measure when a chat input box appears after the page loads, but the component is rendered asynchronously by a framework. A typical pattern uses `IntersectionObserver` to detect when the element enters the viewport, then `requestAnimationFrame` to approximate the paint time:

### Single requestAnimationFrame

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be helpful to include an end-to-end example covering a page, the area of interest, and the solution.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Thanks!

```javascript
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
requestAnimationFrame(() => {
performance.mark('chat-input-visible');
});
}
});
observer.observe(document.querySelector('.chat-input'));
```

Since `requestAnimationFrame` fires before the browser paints, the recorded timestamp is earlier than when the content is actually rendered. It is better than logging at the moment of the DOM update, but still only an approximation.

### Double requestAnimationFrame

```javascript
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
performance.mark('chat-input-visible');
});
});
}
});
observer.observe(document.querySelector('.chat-input'));
```

The second rAF fires after the first frame's paint, getting closer to the actual paint time. However, there is no guarantee that this captures the frame that corresponds to the change. This gets worse when observers (e.g., `ResizeObserver`, `IntersectionObserver`) are present — their callbacks add work between frames, making the second rAF even less likely to land on the expected frame.

### requestAnimationFrame + setTimeout

```javascript
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
requestAnimationFrame(() => {
setTimeout(() => {
performance.mark('chat-input-visible');
}, 0);
});
}
});
observer.observe(document.querySelector('.chat-input'));
```

This defers the mark to the next task after the rAF callback, which is more likely to land after the paint. However, the overshoot is non-deterministic due to other queued tasks — the timestamp ends up well past the actual frame, making the measurement less precise.

### With markPaintTime

```javascript
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
performance.markPaintTime('chat-input-visible');
}
});
observer.observe(document.querySelector('.chat-input'));

new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`Paint: ${entry.paintTime}ms`);
console.log(`Presented: ${entry.presentationTime}ms`);
}
}).observe({ type: 'mark-paint-time' });
```

- **Accurate**: `paintTime` is captured at the rendering update, not approximated by rAF.
- **End-to-end**: `presentationTime` tells you when pixels actually appeared on the display.
- **Stable**: No rAF variance — the timestamp comes directly from the rendering pipeline.

## Proposed API

`performance.markPaintTime(markName)` tags the next rendering update with a developer-chosen name. The browser then delivers a `PerformancePaintTimeMark` entry through `PerformanceObserver` with the following properties:

| Attribute | Description |
|-----------|-------------|
| `entryType` | Always `"mark-paint-time"` |
| `name` | The mark name passed to `markPaintTime()` |
| `startTime` | `performance.now()` at the time `markPaintTime()` was called |
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please mark startTime in the Rendering Pipeline figure?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startTime is recorded at the moment markPaintTime() is called. So there's no corresponding fixed point.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m unclear about the meaning of startTime. If it’s recorded from an IntersectionObserver callback, it seems more like a pre‑paint timestamp, since it wouldn’t include earlier stages such as animation, styling, and layout. In Chromium, this would effectively correspond to pre‑paint timing. However, if markPaintTime() is invoked from a different point in the pipeline, the semantics of startTime could vary.

Copy link
Copy Markdown
Author

@JosephJin0815 JosephJin0815 Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startTime follows the same semantics as performance.mark() — it's always performance.now() at the time markPaintTime() is called, not a rendering-pipeline timestamp. It doesn't change based on where in the event loop the call occurs. I've updated the explainer to make this clearer and link to the User Timing spec. See step 5: https://w3c.github.io/user-timing/#the-performancemark-constructor

I think other paint timing entries (FP, FCP, LCP) set startTime to a rendering timestamp because they are browser-detected milestones — there's no developer call site to anchor to. Our API is on-demand, so startTime as call time is meaningful: it lets developers measure paintTime - startTime to see how long from their code to the rendering update ended.

| `duration` | Always `0` |
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of duration if it's always zero?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm following the same convention used by LCP and FP/FCP, which are also delivered asynchronously but set duration to 0. We considered setting duration to a computed interval (e.g., presentationTime - startTime), but since the entry already exposes startTime, paintTime, and presentationTime individually, developers can compute whichever interval is meaningful to their use case — making a pre-computed duration redundant.
Existing duration set to 0 cases:

| `paintTime` | The rendering update end time — same as FP/FCP/LCP `paintTime` |
| `presentationTime` | When pixels were actually shown on the display — same as FP/FCP/LCP `presentationTime` |

**Behavior:**
- On-demand — no data is collected until `markPaintTime()` is called.
- One-shot — each call tags the next rendering update and produces exactly one entry.
- Multiple calls before a rendering update each produce their own entry with distinct `paintTime` and `presentationTime`.

The entry reuses [`PaintTimingMixin`](https://w3c.github.io/paint-timing/#sec-PerformancePaintTiming) from the Paint Timing spec, so `paintTime` and `presentationTime` have identical semantics to the timestamps developers already see on FP, FCP, and LCP entries.

## Rendering Pipeline and Timing

`markPaintTime()` captures timestamps at specific points in the browser's rendering pipeline.

### paintTime

`paintTime` is the rendering update end time, captured after style recalculation and layout. This is the same timestamp that FP/FCP/LCP use via [PaintTimingMixin](https://w3c.github.io/paint-timing/#sec-PerformancePaintTiming), defined at [step 11.14.21 of the event loop](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model).

![paintTime in the rendering pipeline](paint-time-pipeline.png)

### presentationTime

`presentationTime` is the time when the composited frame is actually presented to the display — the next hardware display refresh that contains the updated content.

![presentationTime in the rendering pipeline](presentation-time-pipeline.png)

### What developers can measure

- **`startTime`**: `performance.now()` at the time `markPaintTime()` is called.
- **`paintTime - startTime`** = main-thread rendering cost (how long until the browser finished processing the visual update)
- **`presentationTime - startTime`** = end-to-end visual latency (how long until the user actually sees the update)
- **`presentationTime - paintTime`** = pipeline cost from rendering update to display (includes paint, compositing, and GPU presentation)

## Key Design Decisions
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have a section on architecture challenges, made as generic as possible. Something like "Some browsers perform all painting in a separate process. In this case, there may be additional timing overhead due to cross-process data processing."


- **Reuses PaintTimingMixin**: No new timestamp concepts — `paintTime` and `presentationTime` are the same timestamps that FP/FCP/LCP already expose. Developers who understand paint timing milestones already understand this API.
- **On-demand**: Unlike FP/FCP/LCP which fire automatically for browser-detected milestones, `markPaintTime()` is triggered by the developer for any visual update at any time.
- **PerformanceObserver-based**: Consistent with modern performance APIs (LoAF, FCP, LCP).

## Alternatives Considered

### requestPostAnimationFrame (rPAF)

`requestPostAnimationFrame` fires immediately after the rendering update completes. Using it for the same chat-input example:

```javascript
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
requestPostAnimationFrame(() => {
performance.mark('chat-input-visible');
});
}
});
observer.observe(document.querySelector('.chat-input'));
```

This would approximate `paintTime` more accurately than double-rAF, since the callback fires right after paint rather than at the start of the next frame. However:

- **No `presentationTime`** — rPAF fires on the main thread, before compositor and GPU work. There is no way to know when pixels actually appeared on the display.
- **Not being pursued** — the proposal's original author has noted that a post-animation callback may not be useful for optimizing rendering latency, as downstream graphics pipeline latency matters more than hitting a specific VSYNC deadline, and the [proposal is not being pursued](https://github.com/WICG/request-post-animation-frame).

## Open Questions

### paintTime vs. a new "post-paint" timestamp

The current design reuses `paintTime` from PaintTimingMixin, which is captured at [step 11.14.21 of the rendering update](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model) — right before the browser performs the actual paint. This means it does not include the cost of paint itself, so it is not truly the last piece of main-thread work for the frame.

A "post-paint" timestamp — captured after paint completes — would more accurately reflect the total main-thread rendering cost. However:

- **Security concerns**: a post-paint timestamp could expose more precise timing information about rendering complexity, potentially enabling new side-channel attacks.
- **Interoperability**: the HTML spec's update-the-rendering steps do not define a "post-paint" point. This concept does not exist as a spec-level primitive today, making cross-browser agreement uncertain.

We welcome feedback on whether `paintTime` is sufficient for developer needs or whether a post-paint timestamp is worth pursuing despite these tradeoffs.

## Security and Privacy Considerations

- `paintTime` and `presentationTime` are subject to the same cross-origin coarsening as existing paint timing entries.
- Timestamps are coarsened to mitigate timing side-channel attacks, consistent with `performance.now()` resolution restrictions.

## Appendix: WebIDL

```webidl
// Extends Paint Timing spec — https://w3c.github.io/paint-timing/
partial interface Performance {
undefined markPaintTime(DOMString markName);
};

[Exposed=Window]
interface PerformancePaintTimeMark : PerformanceEntry {
[Default] object toJSON();
};
PerformancePaintTimeMark includes PaintTimingMixin;

// PaintTimingMixin already defined in Paint Timing spec:
// interface mixin PaintTimingMixin {
// readonly attribute DOMHighResTimeStamp paintTime;
// readonly attribute DOMHighResTimeStamp? presentationTime;
// };
```
Binary file added performanceMarkPaintTime/paint-time-pipeline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.