Skip to content

fix(ios): perf issues w/ image masks#57399

Open
hannojg wants to merge 3 commits into
react:mainfrom
discord:fix/ios-perf-issues-image-masks
Open

fix(ios): perf issues w/ image masks#57399
hannojg wants to merge 3 commits into
react:mainfrom
discord:fix/ios-perf-issues-image-masks

Conversation

@hannojg

@hannojg hannojg commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary:

Two years ago, in this PR:

a fix for this issue was introduced:

This fix can cause performance issues as we are applying a mask which is rendering wise a heavy operation, often causing the rendering phase to do multiple offscreen draw passes.
We noticed this to a considerable, measure-able effect in the discord app.

This specific case for UIImageViews:

if ([subview isKindOfClass:[UIImageView class]]) {
RCTCornerInsets cornerInsets = RCTGetCornerInsets(
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii),
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths));
// If the subview is an image view, we have to apply the mask directly to the image view's layer,
// otherwise the image might overflow with the border radius.
subview.layer.mask = [self createMaskLayer:subview.bounds cornerInsets:cornerInsets];

is really only needed when:

  • We actually draw a visible border (ie it has a color)
  • the border is not uniform and not circular

The reason we need this handling is for content to not draw over the border like this:

Screenshot 2026-07-01 at 15 08 45

However, when the user just wants to add a border radius (e.g. borderRadius: 10), applying a mask is not needed. The image will be correctly "cut out" by the parent RCTViewComponentView applying the cornerRadius, which is much better performance wise.

Hence my fix here is to simply disable this special handling when its not needed.

The performance gain

Code for `RNTesterPlayground.js`
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
import type {ImageSource, ListRenderItemInfo} from 'react-native';

import RNTesterText from '../../components/RNTesterText';
import * as React from 'react';
import {FlatList, Image, StyleSheet, View} from 'react-native';

type AvatarItem = Readonly<{
  id: string,
  source: ImageSource,
}>;

const IMAGE_COUNT = 10_000;
const NUM_COLUMNS = 12;
const IMAGE_SOURCES: ReadonlyArray<ImageSource> = [
  require('../../assets/bunny.png'),
  require('../../assets/hawk.png'),
  require('../../assets/rubber-ducky.png'),
  require('../../assets/flowers.png'),
];

function createAvatarItem(index: number): AvatarItem {
  const id = `rn-tester-${index}`;
  const source = IMAGE_SOURCES[index % IMAGE_SOURCES.length];

  return {
    id,
    source,
  };
}

function createAvatarItems(): ReadonlyArray<AvatarItem> {
  const avatars: Array<AvatarItem> = [];

  for (let index = 0; index < IMAGE_COUNT; index++) {
    const avatar = createAvatarItem(index);
    avatars.push(avatar);
  }

  return avatars;
}

const AVATARS = createAvatarItems();

function renderAvatarItem({item}: ListRenderItemInfo<AvatarItem>): React.Node {
  return (
    <View style={styles.imageCell}>
      <Image source={item.source} style={styles.image} />
    </View>
  );
}

function Playground() {
  return (
    <View style={styles.container}>
      <RNTesterText>
        Local image grid for measuring Image clipping performance.
      </RNTesterText>
      <FlatList
        contentContainerStyle={styles.listContent}
        data={AVATARS}
        initialNumToRender={NUM_COLUMNS * 4}
        keyExtractor={item => item.id}
        maxToRenderPerBatch={NUM_COLUMNS * 4}
        numColumns={NUM_COLUMNS}
        renderItem={renderAvatarItem}
        style={styles.list}
        windowSize={5}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 10,
  },
  image: {
    height: '100%',
    width: '100%',
    borderRadius: 4,
    backgroundColor: '#eee',
  },
  imageCell: {
    aspectRatio: 1,
    backgroundColor: '#eee',
    flex: 1,
    margin: 2,
  },
  list: {
    flex: 1,
  },
  listContent: {
    paddingTop: 10,
  },
});

export default {
  title: 'Playground',
  name: 'playground',
  description: 'Test out new features and ideas.',
  render: (): React.Node => <Playground />,
} as RNTesterModuleExample;

I created an example case where we render a list of images that have borderRadius: 4 and scrolled it for a bit and performance profiled it using the xcode instruments:

Screenshot 2026-07-01 at 15 21 24

Here are the differences when it comes to render & offscreen passes, as well as CPU and GPU usage:

metric before after fix delta
hitches 52 7 -86.5%
hitch time total 600ms 88ms -85.4%
hitches > 16.67ms 14 0 eliminated
render rows 970 1221 +25.9%¹
render rows/sec 69.7/s 81.0/s +16.2%
avg render duration 11.77ms 6.31ms -46.4%
p95 render duration 13.79ms 11.21ms -18.7%
render rows > 8.33ms 966 384 -60.2%
render rows > 16.67ms 5 0 eliminated
total render work 11.41s 7.71s -32.5%
total offscreen passes 253,442 1,221 -99.5%
avg offscreen/render 261.3 1.0 -99.6%
render rows with >100 offscreen passes 966 0 eliminated
avg GPU duration 10.17ms 1.22ms -88.0%
GPU rows > 8.33ms 763 0 eliminated
total GPU work 8.18s 1.48s -81.9%
avg update commit 0.73ms 0.52ms -28.7%
p95 update commit 2.49ms 1.54ms -38.2%
avg CPU 65.8% 58.5% -7.3pp
weighted avg FPS 53.5 60.0 +6.5 FPS²
avg GPU/device utilization 51.8% 15.7% -36.1pp
  1. We are able to render more in the same 15s time frame
  2. The profiling tool only records up to 60 fps it seems

Changelog:

[IOS] [FIXED] - Performance: don't unnecessarily apply masks to UIImageView

Test Plan:

In RNTester, where i added two new example cases for image borders, you can verify that the border radii examples are still working as expected:

Screenshot 2026-07-01 at 15 38 00

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 1, 2026
_props = defaultProps;

_imageView = [RCTUIImageViewAnimated new];
_imageView.clipsToBounds = YES;

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.

This is not really needed as the outer RCTViewComponentView will properly do the clipping / apply a mask, so this is actually redundant here

@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant