gger protocol alignment, and native integration. The following implementation targets React Native 0.76+ with Hermes enabled.
Step 1: Runtime Configuration (Hermes)
Hermes is the default runtime. Verify it is enabled and configure source maps correctly.
android/app/build.gradle
project.ext.react = [
entryFile: "index.js",
enableHermes: true,
hermesFlagsRelease: ["-O", "-output-source-map"]
]
ios/Podfile
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => true
)
Step 2: Metro Bundler Debug Configuration
Metro must generate accurate source maps and expose the debugger WebSocket. Disable minification in development to preserve variable names and enable inline sources for stack trace resolution.
metro.config.js
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const defaultConfig = getDefaultConfig(__dirname);
const config = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
server: {
enhanceMiddleware: (middleware) => {
return middleware;
},
},
resolver: {
sourceExts: [...defaultConfig.resolver.sourceExts, 'mjs', 'cjs'],
},
};
module.exports = mergeConfig(defaultConfig, config);
Step 3: TypeScript & Babel Debug Alignment
Ensure TypeScript emits source maps and Babel preserves debugger statements. Misaligned source maps are the #1 cause of breakpoint skipping.
tsconfig.json
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true,
"noEmit": false,
"allowJs": true,
"jsx": "react-native",
"moduleResolution": "node",
"strict": true
},
"exclude": ["node_modules", "babel.config.js", "metro.config.js"]
}
babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
['@babel/plugin-transform-runtime', {regenerator: true}],
'react-native-reanimated/plugin' // Required if using Reanimated
],
env: {
development: {
plugins: ['transform-react-remove-prop-types', 'react-native-debugger']
}
}
};
Hermes exposes a Chrome DevTools Protocol (CDP) compatible endpoint, but only via the Hermes debugger worker. React DevTools connects to Metro’s WebSocket and synchronizes component trees.
Launch configuration for VS Code (launch.json):
{
"version": "0.2.0",
"configurations": [
{
"name": "React Native: Hermes",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/react-native/Libraries/Core/InitializeCore.js",
"cwd": "${workspaceFolder}",
"sourceMaps": true,
"trace": true,
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"]
}
]
}
Architecture Rationale
- Hermes over JSC: Hermes compiles JS to bytecode, reducing startup time and memory. The trade-off is CDP incompatibility with Chrome DevTools. React DevTools + Hermes debugger worker is the official path.
- Source Maps at Build Time: Runtime source map generation in Metro is deprecated for production parity. Pre-generating maps ensures stack traces match deployed bundles.
- Fabric/JSI Requires Native Debugging: Fabric renders synchronously on the UI thread. JSI bindings bypass the bridge entirely. Debugging layout crashes, native module memory leaks, or JSI exceptions requires LLDB (iOS) or Android Studio’s native debugger. JS-only tools cannot inspect native call stacks.
Pitfall Guide
-
Relying on console.log for State Inspection
console.log serializes objects synchronously, blocking the JS thread. In Hermes, large object serialization triggers GC pauses. Use react-native-debugger or structured logging with level-based filtering. Never log circular references; Hermes will throw a serialization error and crash the debug session.
-
Assuming Chrome DevTools Works with Hermes
Chrome DevTools expects V8’s debugging protocol. Hermes implements a subset of CDP but requires the hermes-inspector-proxy or React DevTools. Attempting to attach Chrome directly results in ERR_CONNECTION_REFUSED or silent breakpoint failures.
-
Misaligned Source Maps Between Metro and CI
Metro generates maps during bundling. If your CI pipeline uses react-native bundle without --sourcemap-output, production stack traces will point to minified lines. Always generate maps in both dev and release pipelines. Verify with source-map-explorer.
-
Debugging Async/Await Without Microtask Awareness
Hermes optimizes microtask scheduling. Breakpoints inside async functions may skip if the debugger doesn’t pause on microtask boundaries. Enable pauseOnExceptions and step through await explicitly. Use debugger; statements instead of UI breakpoints for deterministic pauses.
-
Ignoring Hermes GC Semantics
JSC uses a cycle collector; Hermes uses a mark-and-sweep collector with no cycle detection by default. Circular references in closures or event listeners cause silent memory leaks that only appear after 50+ screen navigations. Use global.HermesInternal.getHeapSnapshot() in dev to detect retained objects.
-
Over-Reliance on HMR State Preservation
HMR patches component functions but does not preserve module-level state, Redux stores, or native module instances. A full reload resets the entire JS VM. Treat HMR as a UI hotfix tool, not a state debugging environment. Use Redux Persist or Zustand middleware for state inspection across reloads.
-
Port Conflicts Between Multiple Debuggers
Metro (8081), React DevTools (8097), Hermes inspector (8088), and native debuggers all compete for localhost ports. Running them simultaneously without explicit port assignment causes WebSocket handshake failures. Assign static ports in package.json scripts and Metro config.
Best Practices from Production:
- Isolate bridge vs native issues by toggling
bridgeless mode in Metro.
- Use
react-native-debugger as the primary JS debugger; it bundles React DevTools, Redux inspector, and Hermes CDP proxy.
- Profile memory leaks with
hermes-profile-transformer and native allocation trackers.
- Generate source maps in CI and upload to Sentry/Monitoring with
sentry-cli sourcemaps upload.
- Disable Hermes optimization flags (
-O) in development to preserve debuggability.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| JS logic bugs, state issues | React DevTools + Hermes inspector | Direct CDP access, component tree sync | Low (dev-only overhead) |
| Memory leaks, GC pauses | Hermes heap snapshot + native profiler | Hermes lacks cycle collector; native tracks allocations | Medium (requires CI map generation) |
| Fabric/JSI crashes, layout glitches | LLDB (iOS) / Android Studio (Android) | Native thread execution bypasses JS debugger | High (requires native expertise) |
| Rapid UI iteration | Metro HMR + React DevTools | Hot patches avoid full VM restart | Low (state loss risk) |
| Production stack trace analysis | CI-generated source maps + Sentry | Maps minified bundles to original TS/JS | Medium (CI storage + upload time) |
Configuration Template
Copy this into your project root. Adjust ports and paths as needed.
package.json (debug scripts)
{
"scripts": {
"debug:metro": "react-native start --port 8081",
"debug:devtools": "react-devtools --port 8097",
"debug:inspect": "node --inspect-brk node_modules/react-native/Libraries/Core/InitializeCore.js",
"debug:full": "concurrently \"npm run debug:metro\" \"npm run debug:devtools\" \"npm run debug:inspect\""
}
}
metro.config.js (debug presets)
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const config = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
server: {
port: 8081,
enhanceMiddleware: (middleware) => middleware,
},
resolver: {
sourceExts: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
tsconfig.json (debug alignment)
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true,
"noEmit": false,
"allowJs": true,
"jsx": "react-native",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "babel.config.js", "metro.config.js", "android", "ios"]
}
Quick Start Guide
- Install Dependencies: Run
npm install --save-dev @react-native/metro-config react-native-debugger source-map-explorer.
- Enable Hermes: Ensure
enableHermes: true in Gradle and :hermes_enabled => true in Podfile. Run cd android && ./gradlew clean and cd ios && pod install.
- Launch Debug Stack: Execute
npm run debug:full. Open react-native-debugger, connect to http://localhost:8081/debugger-ui.
- Validate Breakpoints: Add
debugger; to a component render function. Trigger the render. Confirm the debugger pauses, variables are accessible, and source maps resolve to original TypeScript files.