RetainerBoxInvalidationBox

After looking at draw calls on the GPU side with Static Mesh vs Instanced Static Mesh, I wanted to look at the other half of the picture: UMG. “Wrap your static UI in a Retainer Box or Invalidation Box” is common advice, but I’d never actually measured what either one buys you, or how they differ from each other. So I built a test to find out.

The setup


The idea: build a UI with 10,000 widgets, update exactly one of them, and see what it costs — with and without caching.

  • WBP_Card widget: a Size Box (fixed at 80×40) containing a Border with a Horizontal Box inside, holding an Image and a TextBlock. Border and Image use different brushes/materials from the text, on purpose — a flat grid of only TextBlocks tends to batch well on its own, which would hide the effect I was trying to measure.
  • WBP_Main widget that spawns 10,000 WBP_Card instances into a Wrap Box at Event Construct, plus one separate WBP_Card outside the grid and an “Update One Card” button that rewrites that one card’s text to a random number on click.
  • Three versions of the container: no wrapper (Case A, baseline), the 9,999 static cards wrapped in a Retainer Box (Case B), and the same 9,999 cards wrapped in an Invalidation Box (Case C). In both B and C, the one card that actually changes is kept outside the wrapper — wrapping the thing that changes every frame would defeat the point of a cache.

Measured with stat unit and stat slate in Standalone Game mode, averaged over three runs per case, both before and after clicking Update.

Case A: the baseline is already expensive, and clicking barely matters

Total Slate Tick Time:   34.29 ms (avg of 6 runs)
Draws:                    109
SWidget::Paint Count:    4,487

The first thing that stood out: clicking “Update One Card” changed almost nothing. Total Slate Tick Time before and after the click were within noise of each other, and SWidget::Paint Count didn’t move at all. Updating one card out of 10,000 doesn’t add a new cost — because the baseline is already paying full price, every frame, for all 10,000 cards, whether anything changed or not. That’s the actual problem Retainer Box and Invalidation Box exist to solve: not “the update is expensive,” but “doing nothing is expensive.”

Case B: Retainer Box, and a wrong turn worth mentioning

I wrapped the 9,999 static cards in a Retainer Box and ran the same test expecting a big drop. Instead, the first run came back at 50.93 ms — slower than the baseline. Invalidation: Recached Elements was stuck at 2,136 every single frame, which meant the cache was being rebuilt constantly instead of reused.

The cause turned out to be one checkbox: Render on Phase was enabled on the Retainer Box, which forces it to re-render on a fixed schedule regardless of whether its contents actually changed — defeating the entire point of the cache. Turning it off (leaving only Render on Invalidation checked) fixed it immediately:

Total Slate Tick Time:   0.35 ms (avg)
Draws:                    105
SWidget::Paint Count:    29

Total Slate Tick Time dropped from 34.29 ms to 0.35 ms — about a 99.0% reduction! And stayed flat whether the button was clicked or not, which is exactly the behavior a correctly configured cache should have.

Case C: Invalidation Box, no wrong turn this time

Same swap, different wrapper: I put the 9,999 static cards in an Invalidation Box instead, with Can Cache enabled (its default).

Total Slate Tick Time:   0.39 ms (avg)
Draws:                    107
SWidget::Paint Count:    29

This landed at almost the same place as Retainer Box — roughly a 98.9% reduction in Total Slate Tick Time — and, like Case B, showed no meaningful difference between the before-click and after-click numbers.

Where the two actually differ (and where they don’t)

I’d expected a clearer split going in: Retainer Box caching to a render target should hit the GPU side (Draws) harder, and Invalidation Box skipping layout recalculation should hit the CPU side (Total Slate Tick Time) harder. The numbers only partly agree with that:

                       Retainer Box    Invalidation Box
Total Slate Tick Time:    0.35 ms          0.39 ms
Draws:                     105               107

On the CPU side, both perform almost identically — Invalidation Box doesn’t lag noticeably behind Retainer Box the way I expected. On the GPU side, Retainer Box does come out slightly ahead, which matches the mechanism (one cached texture draws in fewer calls than the still-individually-drawn Invalidation Box contents) — but the gap is 105 vs 107. That’s a 1.9% difference. Worth noticing, not worth building a conclusion on — the kind of gap where the mechanism explains a direction, but the number itself is inside the noise.

Summary

MetricCase A (Baseline)Case B (Retainer Box)Case C (Invalidation Box)
Total Slate Tick Time34.29 ms0.35 ms0.39 ms
Draws109105107
SWidget::Paint Count4,4872929
Reduction (Tick Time)~99.0%~98.9%

Both wrappers get you to essentially the same place, and both get you there by solving the same underlying problem: a 10,000-widget UI, left completely untouched, was still paying full layout-and-paint cost on every single frame. Wrapping the part that doesn’t change turns that into a one-time cost. Which one to reach for in practice probably comes down to secondary factors this test didn’t isolate — Retainer Box’s extra texture memory, Invalidation Box’s simpler setup with no render-target — rather than raw performance, since on that axis they’re close enough to call a tie.


Tested on Windows 11, Unreal Engine 5.7.4, Intel Core i7-8700 / RTX 2060 (6GB) / 32GB RAM, in Standalone Game mode using stat unit and stat slate, averaged over three runs per case, before and after triggering the update.

If you’re trying this yourself: check Render on Phase on your Retainer Box before you conclude it isn’t working. It was the entire difference between “50ms and worse than nothing” and “0.35ms” here.

Leave a Comment