This post is part of a series. Start here if you haven't already.
After the benchmarks post, I got a question that comes up every time I talk about Inglorious Web's rendering model:
"If the whole tree re-renders on every state change, wouldn't it be better if you memoized render functions the way React does with
React.memo?"
It's a reasonable question. It's also solving the wrong problem.
What memoization actually costs
Memoization isn't free. A cache lookup happens on every call — whether or not the cached value is stale. In a busy UI, that's constant overhead, paid regardless of whether the memo ever saves you anything.
And it saves you less than you'd think, because lit-html already does its own caching at the template level. It tracks which parts of a template changed between renders and only patches those DOM nodes. Adding a JS-side memo layer on top means you're diffing twice: once in your cache, then again in lit-html. The second diff doesn't know what the first already handled.
So memoizing render functions in Inglorious Web would mean: always paying a cache lookup cost, sometimes saving a function call, while lit-html does its own DOM diffing regardless. The math doesn't work out.
What signals actually cost
The usual counter-proposal is: "fine, skip memoization, but use signals instead. Only update the parts of the tree that depend on what changed."
Signals are magic. Not in the fun sense — in the sense described in the first post of this series. Each reactive primitive maintains its own list of subscribers, built and updated at runtime, invisible to you as you write code. When a signal changes, it pushes to that list. Fine-grained reactivity works by having many small signals, each with its own observer registry, each encoding a piece of a dependency graph you didn't write and can't read.
That's memory. Multiple signals means multiple registries. And those registries encode a dependency graph that's implicit, assembled at runtime, and opaque by design. You can't grep for it, you can't draw it statically, and when it behaves unexpectedly — a component updates that shouldn't, or doesn't when it should — you're debugging something that doesn't exist as readable code anywhere.
In Inglorious Web, the only observer of state is the root render function. One observer, no registries, no hidden graph.
The actual benchmark
I've measured the worst case for full-tree rendering: a tree 8 levels deep, 3 children per node, one random node firing an event 3 times per second, over 10 seconds.
Compared to React, Vue, and Svelte — which all use different levels of fine-grained reactivity to update only the affected node — Inglorious Web walks the whole tree every time.
The result: ~100ms of extra scripting time over 10 seconds. 1% of wall-clock time, on a workload specifically designed to be as punishing as possible for full-tree rendering. All four frameworks hold 120 FPS throughout.
The profiler can measure it. No user would notice it. And it comes with a tradeoff in the other direction: Inglorious Web's JS heap runs substantially lower than Svelte's in this scenario. Full-tree rendering trades a small, bounded CPU cost for lower memory pressure and no observer registries to maintain.
The right distinction
Here's the thing that often gets missed: the concern about "wasting renders" is almost never really about render functions being called. It's about expensive work happening inside render functions.
Those are different problems with different solutions.
If your render function runs something expensive — a sort, a filter, a derived calculation — that work should be lifted out into compute(). Not because it makes the render faster to call, but because that computation doesn't belong in a render function in the first place. Render functions should describe structure. Structure is cheap.
compute() works exactly like Redux's createSelector: you pass it an array of input selectors, and a function that receives their results:
import { compute } from "@inglorious/web";
const items = (state) => state.taskList.items;
const filter = (state) => state.taskList.filter;
const visibleItems = compute(
(items, filter) => items.filter((item) => item.status === filter),
[items, filter]
);
visibleItems is now a memoized selector. It only reruns when items or filter actually change. Everything else — every render caused by unrelated state changes — skips the computation entirely and returns the cached result.
You use it in render like any other selector:
const TaskList = {
render(entity, api) {
const items = api.select(visibleItems);
return html`
<ul>
${items.map((item) => html`<li>${item.text}</li>`)}
</ul>
`;
},
};
compute() memoizes the calculation, not the render. The render function stays a pure, cheap description of structure. lit-html handles the DOM delta. Everyone does their job.
Memoizing the render function itself would be treating the symptom — "this function is called too often" — rather than the cause — "this function is doing too much." And it would do so at the cost of a cache layer that's always present, always paying its overhead, for a benefit that only materializes in specific circumstances lit-html already handles at a lower level.
The caching tradeoff, plainly stated
Signals waste memory to save CPU time. Full-tree rendering wastes a little CPU time to save memory. Both are fine choices. Neither is the absence of a tradeoff.
The difference is that full-tree rendering is explicit and measurable. You can run the benchmark. You can read the profiler output. You know exactly what you're trading and why.
A hidden dependency graph built at runtime by a reactive system is harder to audit. It's magic in exactly the sense this series has been pushing against: invisible machinery doing things on your behalf, breaking in ways you didn't anticipate, in places you didn't author.
I'll take the 100ms.
Top comments (0)