← Volver al blog

March 9, 2026·10 min read

When Metro lies: caches worth clearing first

You changed the file, saved, and the simulator still shows the old screen. Before you rewrite half the app, Metro’s cache is the usual suspect. Here’s the reset ladder I climb before I assume the bug is “real.” Follow it top to bottom: the cheap steps fix most issues, and you’ll know when it is time to reinstall dependencies instead of guessing.

Start with the smallest hammer

Restart the dev server. Toggle fast refresh off and on. Close the simulator and reopen. Sounds obvious; it fixes an embarrassing percentage of “impossible” bugs.

If you’re on Expo, `npx expo start -c` clears the bundler cache in one flag. That alone often unsticks stale transforms or Babel plugin weirdness.

Watchman and derived data

On macOS, Watchman can get into a bad state. `watchman watch-del-all` then start again. Xcode “Derived Data” cleanup helps when native builds act haunted even though JS looks fine.

Document the exact commands in your README so the next teammate isn’t guessing which ritual you performed.

The nuclear options

Delete `node_modules`, reinstall, maybe clear Metro’s temp dir. It’s slow but cheaper than a day chasing ghosts. After a major SDK bump, assume caches until proven otherwise.

If only one platform breaks, compare native build steps—not everything is Metro’s fault.

Quick checklist before you blame your code

Run through this in order: (1) restart Metro with a clean cache (`expo start -c` or your project’s equivalent), (2) reset Watchman if you are on macOS and file watches feel stale, (3) clear Xcode Derived Data when native binaries and JS disagree, (4) reinstall `node_modules` only after the above fail—note package lock versions so you can bisect.

If the bug is Android-only or iOS-only, suspect Gradle pods or codegen before you conclude Metro is “random.” One bad native import can look like a bundler problem in the simulator.

Why stale bundles feel like logic bugs

When Metro serves an old bundle, every symptom looks like your code is wrong. State might appear stuck, feature flags might look reversed, and console logs might not match what you see on screen. That mismatch wastes hours because we trust our editor and save actions—rightfully—more than we trust caches. The first skill is recognizing cache suspicion early: did you change a Babel plugin, a Metro config, an environment file, or a native module version? Those are high-risk moments. The second skill is separating platform symptoms. If iOS is correct and Android is not, Metro might still be involved, but Gradle caches, ABI splits, or Hermes bytecode could also be in play. If both platforms disagree with your expectations equally, Metro and shared JS caches are prime suspects. Document your reset recipe in the README so anyone on call can run through it without inventing steps under stress. Time-box investigation: if fifteen minutes of cache clearing does not change behavior, start profiling whether your code path is actually executing—add a loud one-line log with a build stamp so you cannot fool yourself.

Fast refresh, dev clients, and production parity

Development builds optimize for iteration speed, not fidelity. Fast Refresh preserves component state, which is brilliant until state masks that you changed initial conditions elsewhere. When debugging initialization issues, disable Fast Refresh temporarily or add a full reload path. Expo Dev Client adds another layer: native code can update separately from JavaScript bundles. If native modules changed, you may need a rebuild, not just a Metro restart. Production builds use different minification, different env inlining, and sometimes different Hermes settings. Reproduce issues in release configuration when they only appear “in TestFlight.” Symlinks and monorepo setups introduce additional caching layers—Metro’s `watchFolders`, pnpm’s hoisting, and duplicate React copies can all masquerade as cache bugs while actually being resolution bugs. When in doubt, print `__DEV__`, build IDs, and Metro’s project root from a diagnostic screen in internal builds. Knowing which world you are in saves hours.

Watchman, file watchers, and macOS specifics

On macOS, Watchman is the invisible hero until it is not. Large repos, thousands of small files, or aggressive antivirus scanning can delay or drop events. `watchman watch-del-all` followed by restarting Metro is a standard play. Corporate security tools sometimes inject latency into file system calls; if your team uses them, keep a known-good baseline machine for comparisons. Xcode Derived Data deserves its own mention: stale native builds, especially after changing bridging headers or Swift files, can leave you staring at a JS-only fix that never shows because the binary never updated. Clean Derived Data when native symptoms do not match JS expectations. On Linux CI, ensure inotify limits are high enough for monorepos; silent drops there look like flaky tests rather than flaky code. Windows paths and case sensitivity occasionally bite cross-platform teams—normalize imports and avoid relying on case-insensitive luck.

Cleaning node_modules without losing reproducibility

Deleting `node_modules` is the hammer. Before you swing it, capture lockfile state and note whether you use npm, pnpm, or Yarn—each resolves differently. After reinstall, run your typecheck and a minimal smoke test; transitive updates can sneak in even with a lockfile if someone bumped ranges. If you rely on `patch-package`, ensure patches still apply cleanly; a failed patch might silently skip depending on tooling. For Expo projects, align `expo`, `react-native`, and community packages to the versions the SDK expects; drifting packages cause bizarre Metro transform errors that look like syntax mistakes. Consider `expo doctor` after big reinstalls. In monorepos, clear local package caches and verify workspace protocol dependencies resolved to the correct versions. Document the exact reinstall commands for CI parity—nothing is worse than “works locally after rm -rf” that CI cannot replicate.

When it is not Metro at all

Sometimes the bundler is innocent. Native crashes, white screens after splash, or hooks violations can originate from incompatible library versions, duplicate React, or incorrect peer dependencies. Use your crash reporting tool to see if the JS thread ever started. Inspect Metro logs for transform errors and for warnings about dynamic imports or asset size. Network issues fetching bundles in dev can also look like stale UI—verify the packager URL and that your device can reach your machine. If you use tunnel mode, remember it adds latency and another failure point. Keep a list of past incidents: what looked like cache, what the root cause was. Teams that share that log move faster next time. Finally, teach new hires the reset ladder on day one—saves mentorship time and reduces superstition about “magic” fixes.

Turning incident notes into prevention

After a painful cache hunt, invest an hour in prevention. Add a `make clean` or `npm run reset` script that encodes your team’s order of operations. Add CI steps that fail fast on duplicate React or mismatched native modules. Pin known-good versions of Babel plugins if upgrades have burned you. Consider documenting minimum hardware or OS versions for devs if corporate images cause issues. None of this is glamorous; it is how senior teams keep velocity. Metro will keep caching because caching is essential for performance—your job is to know when to invalidate it deliberately rather than randomly. When you ship a fix, mention in the PR whether caches needed clearing so reviewers know to test fresh. Small habits accumulate into reliable pipelines.

CI machines versus laptops: closing the gap

CI often runs Linux while developers use macOS; caches and file watchers diverge. If a bug appears only on CI, suspect install reproducibility before Metro. Lock Node versions with `.nvmrc` or `engines` fields, cache dependencies deterministically, and surface Metro logs as artifacts on failure. Parallel jobs can race when they share caches—namespace caches per job or use immutable installs. Android emulator tests may exercise different bytecode paths than local simulators; keep an eye on Hermes flags matching between local release builds and CI release builds. When CI is slower than local, developers may skip running full suites—communicate which commands are mandatory pre-push versus nightly. A fifteen-minute reliable CI beats a five-minute flaky one. Document how to download CI bundles for local reproduction when a minified stack trace is all you have. Bridging CI and laptop reduces ‘works on my machine’ to a rare joke instead of a daily ritual.

Shipping and reliability habits (1)

Push notifications walk a line between helpful and intrusive. Prime users with context, respect notification channels on Android, and measure opt-outs after campaigns—spikes mean copy or frequency problems. Payload design affects background behavior; test killed and locked-device states. Tokens belong server-side with rotation strategies; never treat the client as authoritative for subscription state.

Images and maps look simple in mocks and expensive in production. Decode sizes should match display sizes; static map thumbnails are not interchangeable with interactive MapView gestures. Quotas, API keys, and offline behavior need explicit fallbacks—addresses as text, retry buttons, and calm error copy. Monitor vendor dashboards for spikes that indicate bugs or abuse.

Platform differences worth rehearsing (2)

OTA updates are powerful and risky: runtime compatibility, rollback plans, and user-visible behavior changes need governance. Channels should map to release maturity—staging versus production—with access controls on publish credentials. Large assets over cellular need care; silent failures erode trust more than a frank ‘update failed, retry’ message.

Keyboard and form UX separate polished apps from ‘works on desktop simulators.’ Platform differences in soft input modes matter; test smallest phones and Android gesture navigation. Primary actions must remain reachable when the keyboard is visible—scroll containers and keyboard controllers exist because this problem is universal.

Security, privacy, and data handling (3)

In-app purchases require server validation, restore flows, and support tooling that respects privacy. Sandbox quirks are normal—budget QA time. Subscriptions interact with family sharing, regional pricing, and refunds; engineering must stay aligned with finance and legal narratives users see in receipts.

Privacy nutrition labels and Play Data Safety forms should reflect actual SDK behavior—inventory dependencies each release and remove dead code. Drift between claims and telemetry is legal and store risk, not just embarrassment. Involve legal early when adding analytics or ads.

Performance and measurement discipline (4)

Testing onboarding changes with funnel metrics beats debating opinions. Segment by acquisition channel and platform; back behavior differs. Skip paths must be genuine—dark patterns may win short metrics and destroy brand trust. Localization length tests prevent clipped CTAs in verbose languages.

Background execution policies change with OS updates—revalidate after major iOS and Android releases. Misused background modes invite rejection. Persist user work frequently; the OS can kill you anytime after backgrounding. Uploads and timers should tolerate pause and resume without corrupting state.

Team process and long-term maintenance (5)

Project structure should make ownership obvious: routes as backbone, feature folders for product areas, thin screens, and shared infrastructure that is deliberately named. Refactor in vertical slices with device-tested releases—big-bang rewrites without tests are how teams lose weeks.

Helper modules concentrate glue code—storage, navigation, permissions—so screens stay readable. Split helpers by topic before files become merge-conflict magnets, and document each module’s contract. Good helpers answer ‘where do we save tokens?’ in one glance—not ‘ask Sarah.’

Shipping and reliability habits (6)

Internationalization is a product feature, not a string swap. Plural rules, RTL layout, and locale-aware formatting change behavior—not just copy length. Pseudolocale helps find clipping early, but real Arabic and German QA catches nuance. Avoid concatenating translated fragments; context matters. Document glossary terms so translators do not invent inconsistent product names.

Monorepos amplify both leverage and failure modes: duplicate React versions cause mysterious hook errors, and Metro misconfiguration blocks local packages from resolving. Invest in workspace discipline—single React version, documented `watchFolders`, and lint rules preventing packages from importing app navigators accidentally. CI must mirror local installs; ‘works on my laptop’ with different package managers is a time bomb.

Platform differences worth rehearsing (7)

Error boundaries catch render failures, not native crashes or async mistakes. Pair them with platform crash reporting and structured client logs. Fallback UI should include build identifiers and humane copy—never raw stack traces for end users. Test fallbacks with screen readers; a broken error screen is still broken UX.

Storage is not a database. AsyncStorage and MMKV excel at key-value preferences; SQLite or remote APIs belong elsewhere for relational data. Migrations should be incremental, logged, and non-blocking for UI. Secure tokens need secure storage when your model demands it—speed is not a substitute for correctness on auth material.

Patrocinado

Promoción breve