DEV Community

Maxim Berenshtein
Maxim Berenshtein

Posted on AI-assisted

Same Spec, Two Renderers: Where React Beats Angular at Streamed UI, and Where It Doesn't

json-render has renderers for React, Vue, Solid, Svelte and, since I wrote ngx-json-render, Angular. They all consume the same spec and the same core, so a question I kept getting is fair: which one is faster?

I measured the two I know best. Same specs, same catalog shape, same headless Chromium, same machine. The short answer has two halves.

  • React creates and destroys UI far cheaper. Mounting a 4,000-element tree takes 29 ms in React and 249 ms in Angular. Unmounting it: 3 ms against 25 ms. The Angular tree also holds four times the memory.
  • Angular does less work once the tree is on screen. Changing one text, writing one bound state path, appending one row to a repeat: 20 to 50 percent less JavaScript time than React at the same size. Streaming a spec patch by patch: 20 percent less.
  • When everything on screen changes at once, React wins again, by a factor of two, because Angular's precision has a price and this is the case where it buys nothing.
  • Neither renderer is fast enough at 4,000 elements while streaming, and both are fast enough below a few hundred, which is where most generated UIs live.

This is part one of two. It compares the renderers as they ship. Part two takes the renderers away, renders the same trees by hand in each framework, and measures how far above its framework's floor each renderer sits. That result changes how some of the tables below should be read, and I will point at it where it matters.

One core, two update models

@json-render/core owns the spec grammar, the expression evaluator, the state store and the patch primitives. A renderer is the layer that turns a spec into framework components and keeps them in sync when the spec or the state changes. The React renderer is the reference implementation; ngx-json-render implements the same contract, not a dialect. So the question is not "is Angular faster than React", which has no answer, but a narrower one: for a UI that a model is streaming and a user is editing, which renderer does less work per change, and where does each one pay for it?

The two mechanisms predict most of the numbers.

React: every change is a full pass over the tree. ElementRenderer in @json-render/react is wrapped in React.memo, and it receives the whole spec as a prop. Every patch produces a new spec object, so on every patch the memo comparison fails for every element in the tree. Each element then runs its body again, its hooks, resolveBindings and resolveElementProps on its props, fresh JSX for its children, and the reconciler compares the output with the previous fibers to find the DOM nodes whose values changed. State goes through context: StateProvider subscribes to the store with useSyncExternalStore and publishes a new context value on every write, every element reads it, every element re-renders. What React gets in exchange is a very light element: a memo wrapper, an error boundary and the catalog component itself.

Angular: change stops at the element that owns it. Each jr-element reads its own entry from the spec through a computed:

readonly rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()]);
Enter fullscreen mode Exit fullscreen mode

A patch copies only the objects on the path it touched, so for every element the patch did not touch the lookup returns the same object as before, and nothing below it recomputes. Since 0.6.0 each element also works out which state paths its expressions read and keeps one signal per path, so a write to /items/3/7 reaches one element. Since 0.5.1 a resolved element keeps its previous props object when the values are the same, so a write that changes nothing visible re-runs no catalog template. The price is the element: a component with its own view, an @if block, an injector with three providers, around fifteen computed signals, four effects, and a jr-children component with an @for block underneath.

The prediction, then: React wins where the work is to create or destroy elements, Angular wins where the tree is already on screen and something small changes. What the numbers add is the size of each gap, and where the prediction breaks.

How I measured

Two small apps, one per renderer, built for production and driven by one Playwright script in the same headless Chromium.

  • React: @json-render/react 0.20.0 on React 19.3, built with Vite 8.
  • Angular: ngx-json-render 0.7.1 on Angular 21.2, zoneless, built with the Angular CLI.
  • Both on @json-render/core 0.20.0. Chromium 141, on a 4-vCPU Intel Xeon at 2.1 GHz. Slower than a laptop, so read the ratios first and the milliseconds second.

The catalog is the same three components on each side: Card, List and Text, each reading its props the way the renderer intends. The specs come from one shared generator, so both renderers receive byte-identical input: a card holding lists of nineteen texts, at about 100, 1,000 and 4,000 elements; a 3-ary tree eight levels deep for the depth test; a list repeating over a state array for repeat.

Each scenario times one synchronous update including the framework's own render and commit: flushSync around the change in React, ApplicationRef.tick() after the signal write in Angular. Timings are medians of 7 to 9 repetitions, the whole matrix ran twice with the order of the apps swapped, and performance.now() runs at 5 µs resolution because the pages are cross-origin isolated. Painting and layout are outside the timing on both sides; both renderers produce the same DOM. Every scenario checks the DOM after the update, the number of rendered leaves or the text of the leaf that should have changed, so a renderer that skipped the work would have failed the check rather than posted a good time.

The numbers

Milliseconds of JavaScript time for one update, at about 4,000 elements. The ratio is Angular divided by React, so values above 1 are React wins.

Scenario React Angular Ratio
Mount 4,001 elements 28.7 ms 249.0 ms 8.7×
Mount 3,280 elements nested 8 levels 19.3 ms 233.7 ms 12.1×
Unmount 4,001 elements 2.9 ms 24.5 ms 8.6×
Heap held by the mounted tree 9.4 MB 39.8 MB 4.2×
Change one leaf's text (spec patch) 24.4 ms 18.4 ms 0.75×
Same, in the 8-level tree 18.4 ms 14.6 ms 0.80×
New spec object, nothing changed 22.9 ms 18.9 ms 0.82×
Write one state path, one reader 25.3 ms 12.7 ms 0.50×
Append one item to a 4,000-row repeat 25.8 ms 19.4 ms 0.75×
Write one state path that every leaf reads 23.5 ms 52.6 ms 2.24×
Stream the spec from empty, flush per patch 82.4 s 66.3 s 0.80×
Last hundred patches of that stream, each 22.5 ms 16.0 ms 0.71×

The ratios hold at 1,000 and at 100 elements; only the milliseconds shrink, and below a few hundred elements every update on either side is under 2 ms, so for a chat message or a generated form the choice of renderer does not show up in a profile. What does not shrink is the shape: every update in both renderers grows linearly with the number of elements on screen, including the updates that change one thing. That linear term is the story of the next three sections.

Where React wins: building and tearing down

Per element, at a thousand elements and up, mounting costs React 7 to 9 µs and Angular 62 to 76 µs. Nesting widens the gap: on the 8-level tree React stays at 6 µs per element while Angular rises to 71.

The Angular element is a heavier object. For every key in the spec, ngx-json-render creates a jr-element with its own view and an @if block, an injector so the catalog component can ask for its render context, fifteen computeds, four effects, and a jr-children with an @for. Each is small; together they are what makes later updates cheap, and Angular has to build all of it before anything is on screen. Destroying it is the same work in reverse, which is why unmount is nine times slower too. Memory says the same: about 2.4 KB of heap per element in React against 10.2 KB in Angular, stable across sizes.

The first mount in a fresh page, before the JIT has seen the code, is where a user feels this: 13 ms against 33 ms for a 100-element spec, 42 ms against 252 ms for 4,000. For a chat UI that mounts a renderer per message and throws old ones away as the conversation scrolls, React's side of the table is the one that matters.

Where Angular wins: touching what is already there

Change the text of one leaf in a 4,000-element tree and both renderers spend tens of milliseconds, which is already the surprise. Angular spends a quarter less. Write one bound state path and Angular spends half.

Both are linear in the tree size, for different reasons. In React the spec prop defeats the memo, so every one of the 4,000 elements runs its body again and hands the same new spec down; a state write does the same through context. Nothing in that pass is expensive; the pass itself is. In Angular the pass exists too, but it is thinner. When the spec object changes, each element's rawElement computed runs: one lookup, one reference comparison against the previous result. Change detection still visits every view, because Angular marks the tree for traversal, but it refreshes only the one view whose signal produced a new value. A state write is narrower still: a write to /items/199/18 bumps one signal, one element re-resolves its props, and the other 3,999 do a reference comparison each. That is the 12.7 ms against 25.3 ms line, and per element it is 3 µs against 6.

The same shape explains repeat and the no-op spec. Appending an item to a 4,000-row repeat makes Angular's @for create one view and leave the rest; React re-renders the rows and reconciles them by key. A new spec object with the same elements inside costs Angular 4.7 µs per element and React 5.7, and neither is zero.

Part two puts a number on that "neither is zero". Rendered by hand, the same one-leaf change costs Angular 0.085 ms and React 1.9 ms. The renderers sit at 18 and 24. Keep that in mind when reading Angular's wins in this section: they are wins over the React renderer, not evidence that the Angular renderer uses what its framework offers.

Streaming: both quadratic, Angular a fifth cheaper

A model streams a spec as JSON patches, one per line. My stream scenario applies the patches for a 4,000-element spec in the order a model would produce them (the element, then its key appended to the parent) and flushes the renderer after every patch: 8,000 flushes, each one against a growing tree.

The last hundred patches cost 22.5 ms each in React and 16.0 ms in Angular. Summed over the whole stream: 82 seconds against 66. The per-patch cost grows with the tree, so the total grows with its square, and the renderer that does less per element wins by the same fifth at the end as in the single-patch test.

The real hooks do not flush per line. React batches the setSpec calls of one network chunk into one render; the zoneless scheduler coalesces the signal writes of one chunk into one change-detection pass. I ran the same stream with five patches per flush. At 1,000 elements the two renderers land within 10 percent of each other, Angular the slower one; at 4,000 React takes 17.4 s and Angular 13.9 s. Per flush at the end of the 4,000-element stream both sit at 21 to 22 ms: React's pass costs the same however many patches it carries, and Angular's grows a little with each extra element that changed, so batching narrows the gap rather than widening it.

In practice: a 300-element dashboard streams in well under a second of JavaScript on either renderer, on this slow machine. A 4,000-element one is a bad idea on both, and the difference between them is not what will save it.

Where the precision costs: when everything changes

Bind every one of 4,000 leaves to the same state path and write it. Now every element really does have new props, and Angular takes 52.6 ms where React takes 23.5.

This is the price of the machinery from the previous section. For each element Angular runs the state cell, the readable snapshot, the resolution context, resolveElementProps, the equality check on the resolved props, which fails, the props signal and the template. React runs one function and reconciles. When nothing can be skipped, the renderer built to skip things does more work per element: 13 µs against 6.

That case is rarer than it sounds. A bound value that every element reads is a theme switch or a locale change, not a keystroke. But it is the honest edge of the "Angular is more precise" claim: precision is bought per element, and it pays back only when most elements can stay quiet.

Load: the smaller bundle is mostly the bundler's doing

Bundle Raw Gzip
React 19.3 + @json-render/react 0.20 + core + zod, Vite 8 372 KB 112 KB
Angular 21.2 + ngx-json-render 0.7.1 + core + zod, Angular CLI 659 KB 160 KB

Over localhost the time from navigation to a ready renderer is the same on both, 97 ms against 103. The difference shows one step later: the first mount of a 1,000-element spec in a fresh page, before the JIT has warmed up, takes 36 ms in React and 142 ms in Angular.

Most of the 48 KB gap is not the framework. @json-render/core builds its zod schemas at module load and declares no sideEffects, and the two bundlers react differently to that. The Angular CLI's esbuild pipeline keeps the whole of zod, about 89 KB gzipped; I checked by counting zod's own validators in the output, and the ipv6, jwt and emoji checks that no catalog ever uses are all there. Vite 8's rolldown drops the three quarters of zod that core never calls, and core plus what remains of zod come to 42 KB. The framework runtimes are close: React and ReactDOM are 67 KB gzipped, Angular's core, common and platform-browser plus the renderer roughly 60. Fix the flag upstream, or build the Angular app with a bundler that prunes zod, and the two bundles land within a few kilobytes of each other.

What I would pick, today

  • A chat that shows many generated UIs, each mounted once and rarely edited: React. Creation is the cost, and React creates nine times cheaper.
  • A long-lived generated screen with bound inputs, live data and a stream that updates it: Angular. Updates are the cost, and Angular touches less.
  • Anything under a few hundred elements: whichever framework the rest of the app is written in. Every number at that size is under 2 ms.
  • Anything streaming thousands of elements: neither, until both renderers stop re-visiting the whole tree per patch. Split the spec, or paginate what the model generates.

None of this is a property of the framework. Both are properties of the two implementations at the versions I tested, and each gap has a fix: React's update cost comes from handing spec to every element, Angular's mount cost from the per-element machinery and its update cost from every element polling the one spec signal. Part two measures the floors those fixes could reach and what each would take.

Limits of this measurement

  • JavaScript time only, in a headless browser. Layout and paint are the same DOM on both sides and are not included.
  • Three trivial catalog components and specs of one shape. A Material catalog would add its own cost on either side and hide the renderer's; the expression evaluator is shared code, so I expect the ratios to hold on richer specs, but I measured what I measured.
  • One slow machine and the versions named above. The milliseconds will be two to three times smaller on a laptop; the ratios should not move. The React renderer moves fast, and so does mine.

The harness is small: one shared spec generator, one shared scenario file, an adapter of forty lines per framework, and a Playwright script that runs both in one browser and checks the DOM after every update. It isn't public yet. Once it is, I would like to see it run on a Vue or Svelte renderer.


Top comments (0)