Skip to content

fix(ios): perf issues border masks#57413

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

fix(ios): perf issues border masks#57413
hannojg wants to merge 6 commits into
react:mainfrom
discord:fix/ios-perf-issues-image-masks-2

Conversation

@hannojg

@hannojg hannojg commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary:

Note

Follow up fix to:

In discord we noticed performance regression when switching to the new arch in a few of our iOS surfaces, which after profiling turned out to be related to how borders are handled on the new architecture.

Before, on the old arch, none uniform border would be drawn as UIImages and would be drawn as background layer, where on new arch we start to use masks for none uniform borders. Using masks has the draw back of iOS needing to do offscreen render passes, which are expensive in the rendering pipeline, leading to hitches / frame drops.

In #57399 I addressed the issue by making sure to not masks UIImages explicitly if borders are uniform & circular. In this PR I extend the fix to also work for border radii that are only applied to few edges, but are otherwise uniform & circular.

I created this synthetic benchmark example, where we render many images with only bottom border radii, which highlights the perf issue. See before, after the fix:

Code for `RNTesterPlayground.js`
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict-local
 * @format
 */

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 = 10;
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%',
    backgroundColor: 'red',
    // borderRadius: 4,
    borderBottomRightRadius: 14,
    borderBottomLeftRadius: 14,
  },
  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;
Before ✨ After ✨
before_compressed.mp4
after_compressed.mp4
  • Visible hitches
  • Not at 120hz or 60fps even
  • Almost no hitches
  • Profiler confirms maintaining 60 FPS
  • Investigating the issue with the xcode performance profiling tools we can see that with the mask approach we have over >300 offscreen passes which cause very long frames on the render server, averaging around ~20ms:

    Left before, right after: only 1 offscreen pass & no hitches

    Screenshot 2026-07-02 at 12 55 23

    Performance comparison in numbers:

    metric not optimized optimized delta
    hitches 326 0 -100%
    steady FPS 36.2 60.0 +65.7%
    steady GPU util 77.2% 15.1% -80.4%
    avg render duration 21.39ms 4.40ms -79.4%
    p95 render duration 22.72ms 7.77ms -65.8%
    max render duration 58.00ms 15.57ms -73.2%
    renders > 8.33ms 348 44 much lower
    renders > 16.67ms 348 0 -100%
    avg offscreen passes/render 361.9 1.0 -99.7%
    offscreen passes/sec 12,050 91 -99.2%
    avg update duration 0.496ms 0.352ms -29.1%
    p95 update duration 1.93ms 1.18ms -39.1%

    This fix is also more generic, so addressing any view extending RCTViewComponentView and using not-all-edges border radii.

    Changelog:

    [IOS] [FIXED] - Performance: improve performance for border radii in RCTViewComponentView that are uniform but don't cover all edges

    Test Plan:

    • Added more examples in RNTester for image border radii and made sure they don't regress
    • Performance profiles with the provided RNTester playground snippet above

    @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 2, 2026
    @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 2, 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