skip to content

Re-Render Hot Spots

On mobile the usual jank sources are console calls left in release builds, fresh props into list rows every render, and heavy screens mounting mid-transition. Interviewers ask which one to hunt first.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

4

Why can console.log calls slow a React Native release build, and how do you strip them with babel-plugin-transform-remove-console?

level: juniorimportance: must knowfreq 58%

answer

  1. console still runs in release
  2. arguments formatted on the JS thread
  3. objects inspected up to depth 10
  4. env.production in babel.config.js
  5. clear the Metro cache after

basics

~20 s

React Native keeps console calls in release bundles, formatting every argument on the JS thread and passing it to the native logger. Add babel-plugin-transform-remove-console under env.production in babel.config.js to strip every console call from release builds.

solid answer

~50 s

`console.*` is not free in a shipped React Native app. React Native's console polyfill is installed whenever the native logging hook exists, not only in development, and for each call it formats the arguments on the **JS thread** - non-string values go through an inspector that walks objects up to 10 levels deep - before handing the string to the native logger. Logging a large state object on every render or scroll event, or a logging middleware in a store, can eat the frame budget. The docs' fix is `babel-plugin-transform-remove-console`: install it as a dev dependency and list it under `env.production.plugins` in `babel.config.js`. Metro's Babel transform uses the `production` env name for non-dev bundles, so only release builds lose the calls. Clear the Metro cache afterwards, and remember it also strips library calls and `console.error`.

code

javascript · 10 lines
javascript
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  env: {
    // Metro's Babel transform uses 'development' for dev bundles
    // and BABEL_ENV || 'production' for release bundles.
    production: {
      plugins: ['transform-remove-console'],
    },
  },
};

go deeper

for a junior

Remember that console calls still run in a React Native release build and that babel-plugin-transform-remove-console under env.production strips them.

for a middle

Explain the mechanism: argument formatting with a depth-10 inspector and native logging on the JS thread, and how Metro's env name selects the production block.

for a senior

Show judgement about trade-offs: stripped console.error, side effects inside log calls, third-party logging, and verifying the change on a real release bundle.

for a principal

Discuss a logging policy for the app: what may log in hot paths, how diagnostics reach you in production, and how the build enforces it.

## Why a log line costs frames In a browser, a stray `console.log` is mostly harmless. In a React Native release build it can show up as jank, because of where the work happens: - React Native replaces the global `console` with a **polyfill** whenever the runtime provides a native logging hook. That condition is not tied to `__DEV__`, so the polyfill runs in release builds too. - For every call, the polyfill turns the arguments into one string. A single string argument is used as is; any other argument is formatted by an **inspector** that walks objects and arrays up to **10 levels deep**. - The resulting string is passed to the native logger (the platform log), which is extra work beyond the formatting. - All of that runs **synchronously on the JS thread**, the same thread that renders components and handles touches. A single log is cheap. The trouble is logs in **hot paths**: inside a component body that renders often, in a scroll or gesture handler, in a list row, or in a store middleware that logs every action with the whole state. The React Native performance guide calls this out explicitly, including logging from debugging libraries, as a big bottleneck on the JavaScript thread in bundled apps. ## Stripping calls at build time The documented fix is a Babel plugin, **`babel-plugin-transform-remove-console`**, which removes `console.*` calls from the code during the build: 1. Install it as a dev dependency: `npm i babel-plugin-transform-remove-console --save-dev`. 2. Add it under `env.production.plugins` in `babel.config.js` (see the code example). In an Expo project, run `npx expo customize babel.config.js` first if the file does not exist; the default preset there is `babel-preset-expo`. 3. **Clear the Metro cache** so already-transformed files are rebuilt: `npx react-native start --reset-cache` or `npx expo start --clear`. The `env` key works because of how Metro calls Babel: | Bundle | Babel env name | Plugin applied? | |---|---|---| | Development (`dev=true`) | `development` | no | | Release (`dev=false`) | `BABEL_ENV` if set, otherwise `production` | yes | The docs recommend the plugin even for projects that make no console calls themselves, because third-party libraries in the bundle may. ## Trade-offs to decide deliberately - **It removes every `console.*` call**, including `console.error` and `console.warn`. If any error-reporting path in your app relies on `console.error` being called, stripping it removes that signal too. - **Arguments go with the call.** Code such as `console.log(markSeen(post))` loses the `markSeen` call in release. Never put required work inside a console call. - **A custom `BABEL_ENV`** on your release build machine changes the env name, so the `production` block would not apply. Keep release builds on the default or match the key. - **Debug builds keep their logs**, so development is unaffected. ## Beyond the plugin The plugin is a safety net, not a logging strategy. Good habits that go with it: - Keep logs out of render bodies, list rows and per-frame handlers even in development; they slow the dev loop as well. - Log identifiers and small summaries rather than whole objects; the inspector's cost grows with object size. - Gate verbose diagnostics behind `__DEV__` when you do want them during development. - Remember that removing logs fixes one cost; re-render cost itself still needs profiling. ## Common mistakes - Assuming release builds drop console output automatically. - Adding the plugin to the top-level `plugins` array, which strips logs from development too. - Forgetting the cache reset and concluding the plugin "does nothing". - Wrapping side effects in log calls, which silently vanish in release. A good interview answer names the mechanism (formatting and native logging on the JS thread in release), the fix (the plugin under `env.production`) and at least one trade-off (it also strips `console.error` and anything inside the call).

  • Why does the plugin affect only release builds when it is listed under env.production?
    Metro's Babel transform sets the env name to `development` for dev bundles and to `BABEL_ENV`, or `production` when that is unset, for release bundles. Babel then merges the matching `env` block, so the plugin only runs when the release bundle is built.
  • The plugin is installed but release logs still appear. What would you check?
    That Metro's cache was cleared, since cached transforms predate the change; that the plugin sits under `env.production` rather than a misspelled key; and that the release build does not set a custom `BABEL_ENV`, which would change the env name Babel matches.

saying these in an interview costs you the question

  • Release builds drop console output automatically
  • console.log only costs time while a debugger is attached
  • The plugin keeps console.error calls by default
  • Putting the plugin in top-level plugins affects only release
  • A console call's arguments still run after the plugin removes it
open as a page

With React Navigation's native stack in React Native, how do you keep a heavy screen from mounting during its push transition?

level: middleimportance: should knowfreq 40%

basics

~10 s

Render a light shell first, then mount the expensive subtree after the native stack's transitionEnd event fires with data.closing false, subscribing in an effect and returning the unsubscribe function.

open as a page

In a React Native FlatList, why do an inline renderItem arrow and inline style objects in each row make every parent re-render expensive?

level: middleimportance: should knowfreq 52%

basics

~20 s

FlatList is a PureComponent, so a new renderItem function on each render makes it re-render every mounted cell. Fresh style objects and arrow handlers then give each row new props, so memoized rows cannot skip their work.

open as a page