The Re Render Cascade From a Context That Changes Every Tick

A context value rebuilt on every render made every consumer re render on every tick, and the consumers included the provider's own parent, so the cascade…

Share
The Re Render Cascade From a Context That Changes Every Tick. Abstract performance illustration in orange and dark grey on debugly.dev

The profiler showed the whole tree re rendering four times a second, on a timer, with no user interaction, which is the signature of a state change on a clock, and the clock was a context provider whose value object was rebuilt on every render, and the rebuild made the value new by identity on every render, and the context's contract is that consumers re render when the value changes, and identity change is change, so every consumer re rendered on every render of the provider, and one of the consumers was an ancestor that re rendered the provider, which closed the loop, and the loop, driven by the timer, was the cascade.

This is the anatomy of the context churn cascade, and of the identity equality that makes it, because the context compares by identity, and the rebuilt object is never identical, so the optimisation that would have stopped the cascade, memoisation, was absent, and its absence was invisible in the code, which read as a normal provider.

This was React 19.1 with a context carrying a settings object and an update function, and the cascade is the composition of three ordinary things, a rebuilt value, an identity comparison, and a consumer above the provider, none of which is a bug alone.

The identity contract

A context consumer re renders when the context's value is not the previous value, and the comparison is Object.is, identity, so a value that is rebuilt, a new object literal, a new array, a new function, on every render of the provider, is a new value every time, and every consumer re renders every time, regardless of whether the contents changed, because the contents are not compared, the identity is. The provider that builds its value inline, value equals open brace, is therefore a provider that changes on every render, by construction, and the construction is the default way providers are written, which is why the cascade is common.

The churn alone is a tax, every consumer re rendering on the provider's every render, but it is bounded, the tree re renders once per provider render. The cascade needs the loop, and the loop is the consumer above.

The loop that feeds itself

A consumer that sits above the provider in the tree, a layout component reading the context for a class name, re renders when the value changes, and its re render re renders its children, which include the provider, which rebuilds the value, which changes the value, which re renders the consumer, which re renders the provider, and the loop is closed, and the loop needs only a tick to run forever, which the timer provided, a polling interval updating one field, so the cascade ran at the poll rate, four times a second, re rendering the whole tree, which is the profiler's picture, the tree breathing on a clock.

The loop is the reason the cascade is catastrophic rather than merely wasteful: the churn without the loop is O tree per provider render, the churn with the loop is the tree re rendering itself continuously, which is the main thread held by the framework, which is the interaction latency, because the taps queue behind the cascade, which is the INP tax, the same hostage as the third party script that held the main thread hostage, with the framework as the third party.

Why the profiler was needed

I would like to say the cascade was the problem, because that would be a shorter post, but it started at the identity equality.

This is the composition defect family from reviewing shared state by asking what happens under contention, same mechanism, different costume.

The fix, in order

Stabilise the value's identity. The provider memoises the value, building it only when its contents change, so the identity is stable across renders that do not change the contents, which breaks the churn at the source, and the memoisation's dependency array is the contents, which is the contract made explicit, the value changes when and only when the contents change.

Split the context by rate of change. The settings and the update function change at different rates, the function never, the settings rarely, the polled field often, and a single context couples their rates, so every poll churns the settings' consumers. Splitting into contexts by rate means the fast value's consumers re render fast and the slow value's consumers do not, which is the isolation fix, the same shape as moving the slow customer out of the shared pool in how the libuv threadpool starves your DNS lookups, in rendering form.

Break the loop. The consumer above the provider is the loop's edge, and the edge is removed by moving the consumption below the provider, or by deriving the class name without the context, and where the edge must remain, the memoised value breaks the loop anyway, because the stable identity means the consumer's re render does not change the value, so the loop has no fuel, which is the composition fixed by the identity.

Select, do not subscribe. The consumers that need one field should select it, through a selector or a derived context, so the poll of one field re renders only the consumers of that field, which is the demand side, the consumers shrinking to their actual dependency, which is the cascade's blast radius reduced to its truth.

The test that catches it

The regression test renders the tree with the timer mocked, advances it, and counts the renders of a sentinel consumer that depends on nothing the timer changes, asserting the count stays at one, which fails on the original code, because the sentinel re renders on every tick via the churn, which is the cascade made a number, and the number is the assertion, and the test is the profiler's rhythm converted into a gate.

What I now do

A context compares its value by identity, so a value rebuilt on every render changes on every render, re rendering every consumer, and a consumer above the provider closes the loop that makes the churn a cascade, breathing the tree on the timer's rhythm. Stabilise the identity, split by rate of change, break the loop, and select instead of subscribe.

The tree that re rendered four times a second was not busy with work. It was busy with identity, four new objects a second, each indistinguishable in content and distinct in address, and the framework, honouring its contract, re rendered the world for an address change. Name that one property in review and this class of bug gets hard to ship.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud