Skip to content

perf(common): avoid re-setting unchanged attributes in NgOptimizedImage - #71875

Open
jsaguet wants to merge 1 commit into
angular:mainfrom
jsaguet:perf/ng-optimized-image-skip-unchanged-attrs
Open

jsaguet wants to merge 1 commit into
angular:mainfrom
jsaguet:perf/ng-optimized-image-skip-unchanged-attrs

Conversation

@jsaguet

@jsaguet jsaguet commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

PR Checklist

PR Type

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe: performance

What is the current behavior?

Part of #71873

During hydration, NgOptimizedImage writes all of its host attributes again, with the values that the server rendered. The browser selects the image source again after a write to src, srcset, or sizes, even when the value doesn't change.

What is the new behavior?

 private setHostAttribute(name: string, value: string): void {
-  this.renderer.setAttribute(this.imgElement, name, value);
+  if (this.placeholder || this.imgElement.getAttribute(name) !== value) {
+    this.renderer.setAttribute(this.imgElement, name, value);
+  }
 }

An image that still has a placeholder keeps today's writes. An image that failed before hydration then fires error again, which removes the placeholder. An image that already loaded clears its placeholder before the writes, so it skips them.

Does this PR introduce a breaking change?

  • Yes
  • No

The public API doesn't change. The visible differences are repeated events that were a side effect of the rewrite, described under Risks.

Other information

Evidence

On a page with 200 images, hydration takes 28.1 ms instead of 34.8 ms. With 4× CPU throttling, it takes 198.6 ms instead of 247.9 ms. With bound [width], [height], and [sizes], it takes 9.6 ms instead of 23.4 ms. results/timings.txt in the reproduction has the measurements. The benchmark pages have no placeholders, so the this.placeholder condition doesn't affect them.

The extra reads also run during client-side rendering. They add 96–164 ns per image in Chrome 157, which is 19–33 µs for 200 images, compared with about 175 µs per image for hydration on the page above. So they aren't limited to hydration.

Microbenchmark

Each sample runs the 8 writes of setHostAttribute() on 50,000 new images, in an inert document so that the images aren't fetched. The median of 31 samples, in 3 runs:

Case Without the check With the check Difference
Static width and height, already written by core 696–732 ns 828–840 ns 96–144 ns
Bound [width] and [height] 652–664 ns 798–828 ns 146–164 ns
<!doctype html>
<meta charset="utf-8">
<pre id="out">running</pre>
<script>
const SRC = 'https://cdn.example.com/hero.jpg';
const WRITES = [
  ['width', '400'], ['height', '300'], ['loading', 'lazy'], ['fetchpriority', 'auto'],
  ['decoding', 'auto'], ['ng-img', 'true'], ['src', SRC], ['srcset', SRC + ' 1x, ' + SRC + ' 2x'],
];
const IMAGES = 50000;
const placeholder = false;
const doc = document.implementation.createHTMLDocument('');

function sample(kind, guarded) {
  const imgs = new Array(IMAGES);
  for (let i = 0; i < IMAGES; i++) {
    const img = doc.createElement('img');
    if (kind === 'static') {
      img.setAttribute('ngsrc', 'hero.jpg');
      img.setAttribute('width', '400');
      img.setAttribute('height', '300');
    }
    imgs[i] = img;
  }
  const start = performance.now();
  for (let i = 0; i < IMAGES; i++) {
    const img = imgs[i];
    for (let k = 0; k < WRITES.length; k++) {
      const name = WRITES[k][0], value = WRITES[k][1];
      if (!guarded || placeholder || img.getAttribute(name) !== value) img.setAttribute(name, value);
    }
  }
  return ((performance.now() - start) * 1e6) / IMAGES; // ns per image
}

const median = (a) => a.slice().sort((x, y) => x - y)[a.length >> 1];
const result = {};
for (const kind of ['static', 'bound']) {
  for (let i = 0; i < 3; i++) { sample(kind, true); sample(kind, false); }
  const stock = [], guarded = [];
  for (let i = 0; i < 31; i++) {
    if (i % 2) { stock.push(sample(kind, false)); guarded.push(sample(kind, true)); }
    else { guarded.push(sample(kind, true)); stock.push(sample(kind, false)); }
  }
  result[kind] = {stock_ns: median(stock), guarded_ns: median(guarded)};
}
document.getElementById('out').textContent = JSON.stringify(result);
</script>

Specs in full_app_hydration_spec.ts:

  • Attributes rendered on the server aren't written again. This spec fails without the check.
  • An image with a placeholder is written again. This spec fails without the this.placeholder condition.
  • A value that differs from the server is still written.

A spec in event_replay_spec.ts checks that a load event that fired before hydration is replayed, under domino, which doesn't load images. It passes with and without this change.

No spec covers the placeholder image that already loaded, because setting complete and naturalWidth under domino also runs the dev-mode distortion check, which needs layout.

Test targets for common and platform-server pass, including the Chromium and Firefox suites for common. I couldn't run image-directive:webdriver_tests in my environment. That target fails the same way without this change.

Risks

It affects apps that use NgOptimizedImage.

When nothing else writes the attributes of an image that loaded before hydration, load no longer fires a second time. That's the case for an image with no static sizes attribute (measured in Chrome). A (load) handler on that image then runs once, through event replay, which is on by default. Without event replay, it doesn't run. Listeners added imperatively, such as fromEvent or renderer.listen, aren't covered by event replay and no longer receive that second event.

An image without a placeholder that failed before hydration no longer fires error a second time, and the browser doesn't try to load it again during hydration. (error) handlers use the same event replay path as (load), but no spec covers it.

This change doesn't address the sizes value that changes twice on lazy images. #71873 describes that case.

@angular-robot angular-robot Bot added area: performance Issues related to performance area: core Issues related to the framework runtime labels Nov 8, 2026
@google-cla

google-cla Bot commented Nov 8, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@angular-robot angular-robot Bot added the area: common Issues related to APIs in the @angular/common package label Nov 8, 2026
@ngbot ngbot Bot added this to the Backlog milestone Nov 8, 2026
@jsaguet
jsaguet force-pushed the perf/ng-optimized-image-skip-unchanged-attrs branch 2 times, most recently from c700c0d to 029e495 Compare November 8, 2026 13:29
@jsaguet jsaguet changed the title perf(common): avoid unnecessary attribute writes in NgOptimizedImage perf(common): avoid re-setting unchanged attributes in NgOptimizedImage Nov 8, 2026
@JeanMeche

Copy link
Copy Markdown
Member

This seems like a local fix of a much larger problem.

@JeanMeche JeanMeche left a comment

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.

AGENT: Left a comment regarding how this change relates to the broader hydration handling in @angular/core (#71874).

private setHostAttribute(name: string, value: string): void {
this.renderer.setAttribute(this.imgElement, name, value);
// The browser reacts to identical writes, e.g. by selecting the image source again.
if (this.imgElement.getAttribute(name) !== value) {

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.

AGENT: This feels like a local workaround in NgOptimizedImage for the broader hydration issue being tackled in #71874:

  1. Calling this.imgElement.getAttribute(name) here runs 7–8 synchronous DOM reads per NgOptimizedImage even during normal client-side rendering (CSR), and bypasses Renderer2.
  2. Even with this change, static width, height, and sizes attributes on <img ngSrc ...> are still rewritten during hydration by setupStaticAttributes in @angular/core (which is why width and height had to be excluded from directiveAttrs in full_app_hydration_spec.ts, and why sizes="auto, ..." still mutates twice in NgOptimizedImage makes the browser select each image source again during hydration #71873).
  3. Standard <img> tags without NgOptimizedImage suffer from the exact same source re-selection and duplicate load event during hydration unless handled at the core/hydration level.

@jsaguet jsaguet Nov 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that this is local and that the larger problem remains. A plain <img> and static attributes on <img ngSrc> go through core, and this change doesn't touch them. #71971 tracks those writes.

#71874 now only skips resource URLs on <iframe>, <embed>, <video>, <audio>, and <object>, so it doesn't reach <img>. The directive writes src and srcset itself through Renderer2. Hydration time in ms, median of 7 page loads, from timings.txt:

Page Stock Directive fix
200 images, static attributes 34.8 28.1 (−19%)
200 images, bound attributes 23.4 9.6 (−59%)

On your three points:

  1. The reads add 96–164 ns per image in client-side rendering, or 19–33 µs for 200 images. The PR description has the microbenchmark. That's well under 1% of hydration time, so I didn't limit them to hydration. Renderer2 can't read attributes, and removePlaceholderOnLoad() already reads the element directly.
  2. Agreed. Static width, height, and sizes are written by setupStaticAttributes in core.
  3. Agreed. A plain <img> needs a fix in core.

If core ends up skipping identical writes in the renderer, this check can be removed, apart from the handling of failed images with a placeholder.

@jsaguet
jsaguet force-pushed the perf/ng-optimized-image-skip-unchanged-attrs branch from 029e495 to b8b0c91 Compare November 8, 2026 17:29
During hydration, `NgOptimizedImage` wrote all of its host attributes
again, with the values that the server had already rendered. The browser
reacts to a write to `src`, `srcset`, or `sizes` even when the value
doesn't change, and it selects the image source again.

`setHostAttribute` now skips the write when the element already has the
value. An image that still has a placeholder keeps its writes, so that an
image that failed before hydration fires `error` again and loses its
placeholder.
@jsaguet
jsaguet force-pushed the perf/ng-optimized-image-skip-unchanged-attrs branch from b8b0c91 to 5cce7a2 Compare November 11, 2026 20:49
@jsaguet
jsaguet marked this pull request as ready for review November 11, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: common Issues related to APIs in the @angular/common package area: core Issues related to the framework runtime area: performance Issues related to performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants