Skip to content

Instantly share code, notes, and snippets.

@SudoPlz
Last active November 27, 2025 11:30
Show Gist options
  • Select an option

  • Save SudoPlz/9826fea8e2c5f141ff1a06727f7b09c1 to your computer and use it in GitHub Desktop.

Select an option

Save SudoPlz/9826fea8e2c5f141ff1a06727f7b09c1 to your computer and use it in GitHub Desktop.
React Native Reanimated Microtask Bug

Reanimated Animations Stalling?

How Szymon and I traced it to an Infinite Loop & Fixed It

tl;dr – A tiny helper (infiniteThenable) inside react-freeze made React’s Fabric scheduler loop forever, so the JS → UI microtask bridge never got a chance to flush. We patched the scheduler to flush manually and replaced the culprit with React 19’s Offscreen – animations are smooth again.


1. The Symptoms

  • Animations powered by Reanimated randomly froze.
  • We could see global.queueMicrotask enqueueing work, but the microtask never firing up.
  • Dropping a single console.log into the code “fixed” the bug – classic Heisenbug territory.

At this point I was all over the place, trying to figure out what Hermes queueMicrotask is doing below the hood and who's responsible for flushing it.


2. The Culprit discovery - an Infinite Scheduler Loop

2.1 The Chain of Events

Step What happens Where in code
A react-freeze (v 1.0.4) suspends a subtree by throwing an infiniteThenable. library code
B React Native’s renderer hits commitRootImpl over and over again because the promise never resolves (link). JS
C Each commit schedules new work via Scheduler.unstable_scheduleCallback. C++ binding
D Native side enters RuntimeScheduler_Legacy::startWorkLoop C++
E …and gets stuck inside while (!taskQueue_.empty()) { … } – tasks are re-queued endlessly, the loop never exits. same file

Result: the JS thread never regains control → microtasks stay unflushed → Reanimated’s animation worklets never run.


2.2 Why a console.log Seemed to Help

console.log forced a flush on the JS queue, accidentally running the starved microtasks – a textbook Heisenbug.


3. Digging into the Microtask Pipeline

  1. Polyfill setupsetUpTimers.js wires queueMicrotask to RN internals (link).
  2. Queue storageJSTimers.js holds queueReactNativeMicrotask.
  3. FlushingMessageQueue.__callReactNativeMicrotasks (link) runs the queue only when native yields back to JS (e.g. callFunctionReturnFlushedQueue).
    The endless C++ loop meant JS never got that chance.

4. The Fixes

4.1 Immediate Patch – Manual Flush in C++

We exposed a helper to JS:

// injected on the JS side
global._flushReactNativeMicrotasks = () => {
  // reuse existing RN flush
  return JSTimers.callReactNativeMicrotasks();
};

…and called it native-side once the work-loop should have finished:

// RuntimeScheduler_Legacy.cpp  (after the endless while-loop)
flushReactNativeMicrotasks(runtime);  // <— new line

Full context: file link.

4.2 Real Fix – Replace infiniteThenable

React 19 gives us <Offscreen />, so we rewrote Freeze:

import {Offscreen as OffscreenType} from 'react';

export function Freeze({freeze, children}) {
  return (
    <OffscreenType mode={freeze ? 'hidden' : 'visible'}>
      {children}
    </OffscreenType>
  );
}

No more dangling promises – the scheduler loop completes, and the microtask pipeline flows naturally.


5. Key Repos & Files (for the curious)

Purpose File / Directory
React core (scheduler, renderer) https://github.com/facebook/react
React Native Fabric scheduler (C++) RuntimeScheduler_Legacy.cpp
Scheduler ↔ JS binding RuntimeSchedulerBinding.cpp
Timers & microtasks setUpTimers.js
Bridge flush MessageQueue.js
Reanimated’s queue (JS → UI) threads.ts
react-freeze culprit src/index.tsx
Hermes JS engine https://github.com/facebook/hermes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment