Week 18: Performance: Lists, Re-renders & Bundle Size

A safety net of tests doesn't make an app feel fast — it just keeps it from silently breaking. This week closes out the testing-and-performance phase by profiling and fixing the three most common sources of a sluggish React Native app: unoptimized lists, unnecessary re-renders, and a bloated JS bundle.

Phase 7 of 8 Week 18 of 22 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Tune a FlatList with getItemLayout and windowing props for a long list
  • Profile unnecessary re-renders and fix them with memoization
  • Explain what Hermes does and how it affects startup performance
  • Reduce a bundle's size and identify what's contributing most to it

1. FlatList Virtualization

FlatList already virtualizes by default — it only renders items near the visible viewport — but a few props make a real, measurable difference on long or complex lists.

a tuned FlatList
<FlatList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={renderItem}
  getItemLayout={(data, index) => (
    { length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index }
  )}
  windowSize={5}
  maxToRenderPerBatch={10}
  removeClippedSubviews
/>

getItemLayout tells FlatList each row's exact position without having to measure it after render — for a fixed-height row, this is the single highest-leverage tuning prop, and it's also what makes scrollToIndex reliable. windowSize and maxToRenderPerBatch trade memory for smoother scrolling on long lists; lower them on memory-constrained lists, raise them if scrolling shows blank cells before content pops in.

2. Profiling & Preventing Re-renders

React DevTools' Profiler (usable against a React Native app the same way as web) highlights components that re-render more than their visible output justifies — usually because a parent re-renders and passes a new inline function or object as a prop every time.

before — a new function every render
<FlatList
  data={items}
  renderItem={({ item }) => <Row item={item} onPress={() => select(item.id)} />}
/>
after — a stable callback, a memoized row
const Row = memo(function Row({ item, onPress }: RowProps) {
  return <Pressable onPress={() => onPress(item.id)}><Text>{item.title}</Text></Pressable>;
});

const renderItem = useCallback(
  ({ item }) => <Row item={item} onPress={handleSelect} />,
  [handleSelect]
);

memo only helps if the props it receives are themselves stable — wrapping Row in memo while still passing a fresh inline arrow function as onPress every render defeats the optimization entirely, since the props object is "different" on every render regardless.

3. Hermes

Hermes is the JS engine React Native ships with by default — instead of parsing and compiling JavaScript at app startup, it precompiles to bytecode at build time, so the app on the device just loads and runs bytecode directly.

The practical effect is faster startup (time-to-interactive), a smaller memory footprint, and a smaller app size, compared to shipping raw JS and compiling it on first launch. It's enabled by default on modern Expo/React Native projects — there's nothing to configure to benefit from it, but it's worth knowing it's the reason a cold start feels as fast as it does.

4. Reducing Bundle Size

terminal — see what's actually in the bundle
npx expo export
npx source-map-explorer dist/_expo/static/js/ios/*.js.map

source-map-explorer renders the bundle as a visual, sized treemap — the fastest way to find a single oversized dependency, a duplicated package pulled in by two different libraries at different versions, or an entire library imported for one small function.

  • Import only what's used — import { debounce } from 'lodash-es', not the whole library.
  • Check for duplicate versions of the same dependency in the tree (common with deeply nested transitive dependencies).
  • Lazy-load screens that aren't needed on first launch with React.lazy where your navigation setup supports it.

5. Hands-on Exercise

Hands-on

Profile and optimize a slow screen

Take the longest list screen built so far and measurably improve it.

Requirements:

  1. Pick a list screen from an earlier week (posts, notes, or cart) and seed it with at least 200 items for testing.
  2. Record a React DevTools Profiler session scrolling and interacting with the unoptimized list; note which components re-render on every scroll or interaction.
  3. Apply getItemLayout (for a fixed-height row), memo on the row component, and a stable renderItem/onPress via useCallback.
  4. Re-record the same interaction and compare re-render counts and dropped frames before and after.
  5. Run npx expo export and source-map-explorer, and identify the single largest contributor to the JS bundle.
Hint

If memo doesn't reduce re-renders even after wrapping the row component, check every prop it receives for hidden instability — an inline object literal (style={{ margin: 8 }}) or array creates a new reference every render just like an inline function does, and defeats memo's shallow comparison the same way.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does getItemLayout make scrollToIndex reliable, where FlatList without it can misbehave?

Without getItemLayout, FlatList has to estimate an item's position (since it hasn't measured items outside the render window) and can jump or misjudge on scrollToIndex. Providing exact, computable layout tells it precisely where every item sits without measuring, which both speeds up rendering and makes programmatic scrolling land exactly right.

Q2

Why does wrapping a row component in memo sometimes fail to reduce re-renders at all?

memo skips a re-render only when all props are shallowly equal to the previous render — if a parent still passes a fresh inline function or object as a prop every render (a new reference every time, even with identical contents), memo's comparison always finds a difference and re-renders anyway. The fix requires stabilizing every prop, typically with useCallback/useMemo, not just wrapping the child.

Q3

What does Hermes precompiling JS to bytecode at build time save, compared to compiling on the device at launch?

It removes the parse-and-compile step from the critical path of app startup — the device loads and executes ready-made bytecode directly instead of doing that work itself on every cold start, which measurably speeds up time-to-interactive and also reduces memory use and app size.

Q4

What is source-map-explorer actually showing you, and why is it more useful than just checking overall bundle size?

It visualizes which specific modules and dependencies make up the bundle and how large each one is, as a sized treemap — a single overall size number tells you the bundle is big, but not why. The treemap points directly at the actual offender (an oversized library, a duplicated dependency), which is what you need to know to fix it.