I spent an evening with one of my wallpaper apps tethered to Xcode, watching the memory gauge. Right after launch it sat a few dozen megabytes higher than I expected. Not high enough to crash, but the line was clearly not where it used to be.
My first suspect was my own code. Image cache ceilings, prefetch counts, list cell recycling. I trimmed each one, measured again, and the line barely moved.
The cause was outside anything I had written.
What grew was debug metadata, not animation state
Hermes V1 is not a polished version of legacy Hermes. It is a rebuilt engine with a different way of evaluating code at runtime, and it carries extra metadata that is genuinely useful when debugging.
react-native-reanimated uses react-native-worklets underneath so that animation math can run off the JS thread. In the traditional approach, now called Legacy Eval Mode, the animation function is sent to a second JavaScript runtime as a string and evaluated there, directly on the UI thread.
That means production builds go through an eval-shaped path. And Hermes V1 was attaching its debug metadata to exactly that path.
It is worth pausing on why nobody caught this earlier. Most engines assume eval in production is rare, and that when it appears it is there to sandbox untrusted code — not to drive the frame-by-frame behaviour of a user interface. Under that assumption, spending half a megabyte to make evaluated code debuggable is a reasonable trade. Reanimated's architecture quietly falls outside it, and the two reasonable decisions collided.
According to the investigation published by Software Mansion, who maintain Reanimated, the cost is 512KB per unique worklet. Their write-up notes that Reanimated and Worklets evaluate more than 100 worklets just to initialise, which is at least 50MB at app startup. They also checked the Expensify bundle and found over 1,000 unique worklets, which could exceed 0.5GB if every one of them were evaluated — though evaluation is lazy, so a real user would have to visit every screen in a single session to get anywhere near that.
The image cache I had been trimming was two orders of magnitude away from the actual problem.
Three numbers to count before you suspect yourself
Get the order wrong and you spend the evening I spent. There are three numbers worth counting first.
1. Are worklets even in your dependency tree?
npm ls react-native-reanimated react-native-workletsProjects generated by Rork almost always touch both, because screen transitions and gestures are built on them. A transitive dependency counts here just as much as a direct import.
2. How many worklets did you write yourself?
grep -rhoE "['\"]worklet['\"];" src | wc -l
grep -rcE "['\"]worklet['\"];" srcThe first line gives you a total, the second a per-file breakdown. Treat both as a lower bound. The Worklets Babel plugin automatically workletises callbacks passed to useAnimatedStyle or Gesture even when you never typed the directive, so this only catches the ones you wrote by hand.
3. What order of magnitude should you expect?
python3 -c "n=3; base=100; print(f'{n+base} worklets -> about {(n+base)*512/1024:.0f} MB')"
# 103 worklets -> about 52 MBPut your own count in n and the library's initialisation footprint — roughly 100 — in base. If the number that comes out matches the order of magnitude you are seeing on the device, the thing to fix is not in your code.
That calculation is where I stopped searching. There was nothing left on my side worth deleting.
Four fixes, and the question that picks one
The Hermes-side fix already exists. What differs is how you take delivery of it.
| Route | What you do | When it fits |
|---|---|---|
| Upgrade Expo | Move to expo@57.0.9 or later, which brings React Native 0.86.2 | You are already on SDK 57 and can move. The shortest path |
| Enable Bundle Mode | Switch Worklets over to Bundle Mode | You also want the newer Worklets features. Requires Metro-side setup |
| Bytecode option | Use the experimental hermesBytecode option in the Babel plugin | You cannot touch Metro and cannot bump React Native |
| Pin Hermes | Pin the 0.15 line, where the fix was backported | You want Worklets' internal behaviour left exactly as it is |
Bundle Mode works by exposing the whole bytecode bundle to the secondary runtimes instead of shipping individual code strings. Because Hermes loads bytecode with mmap and does it lazily, the extra cost is close to nothing. Software Mansion recommends it on performance grounds regardless of this particular regression.
There is a fifth option: wait for React Native 0.87 upstream. If you pick it, be honest with yourself that you are shipping those 50MB to users for the entire waiting period.
Which of the four you pick comes down to a single question: what do you least want to move? If the answer is your native configuration, upgrading Expo is uncomfortable and pinning Hermes is easy. If the answer is your build pipeline, the Babel option barely touches anything. If the answer is your dependency versions, Bundle Mode leaves them alone. There is no route that changes nothing, so the useful decision is choosing which part of the project stays still.
I chose the upgrade, and cleared the blockers first
I went with upgrading Expo. The reason is unglamorous: any route that changes Worklets configuration leaves behind a setting that somebody — quite possibly future me — will find in six months and no longer understand. A version bump explains itself in the commit history.
There is a catch. SDK 57 changed the default behaviour of expo prebuild, which now clears ios and android before regenerating them. If you have hand-edited native configuration, the upgrade itself hands you a second problem. I wrote that part up separately in Find the native edits expo prebuild will erase before you upgrade to SDK 57, and it is worth walking through before you bump anything.
After the upgrade, two checks are enough.
npm ls expo react-native
# expo@57.0.9 or later / react-native@0.86.2 or later
npx expo-doctor@latestThen open the same screens on a real device, in the same order as before, and watch the memory. A different path through the app is not a comparison.
That last point sounds obvious and is the one I get wrong most often. Because worklet evaluation is lazy, memory climbs as screens are visited for the first time in a session. Open three animated screens before the upgrade and five after it, and you will read the difference as a partial fix when it may be a complete one, or the reverse. I now write the screen order down before I measure and follow the list on both runs.
On Android the equivalent read is the memory profiler in Android Studio, and the numbers will not match iOS. That is fine — you are comparing each platform against its own earlier self, not against the other platform.
Counting first changes what you decide afterwards
What I took away from this had less to do with Hermes than with sequence. When memory grows, the first move is not to start deleting your own code. It is to estimate the order of magnitude of the increase. If the magnitudes do not line up, the cause lives in a different layer.
If you are staring at unexplained memory in an app that uses Reanimated right now, run npm ls expo react-native before anything else. That one command decides where your next few hours go.
Designing the memory ceiling itself — staged release under pressure, and reading terminations that never show up as crashes — is a separate topic I covered in memory pressure handling for Rork iOS apps. It reads better once the runtime-level cause is already off the table.