We received an issue report about an Android paywall for RevenueCat Android SDK, with a screen recording attached: a bottom sheet with two subscription plans over a blurred background. The user taps the second plan, the ripple starts, and the screen pauses for about 0.4 seconds before the new plan is selected. The paywall was built with RevenueCat Paywalls, a server driven UI that the Android SDK renders with Jetpack Compose. So what does one tap ask a Compose tree to do, and how can that take longer than twenty frames?
In this article, you'll start from that recording and trace the cost of one package tap inward: the override model behind a server driven paywall, an instrumented test and a set of kill switches for measuring a tap, what derivedStateOf skips and what it doesn't, lazy condition inputs, a regex cache, fonts without equals, work moved into the draw phase, an invisible spinner, and the limits of reordering code inside one main thread task.
The fundamental problem: One selection, read by every component
A RevenueCat paywall is designed in the dashboard and arrives in the app as a tree of components: stacks, texts, images, packages, buttons. Any component can carry overrides, which are partial property sets applied when a condition holds. If you've used a <selector> drawable with state_selected, it's the same idea, applied to any property of any component. The condition can be "the package this component belongs to is selected", "the user is eligible for an intro offer", "the selected package, wherever it is, is the annual one", or a window size. A package card that gets a thicker, colored border when it's selected looks like this in the SDK's model:
Resolving overrides means checking each override's conditions against the current state and merging the matching ones over the base style. The result is the set of properties the component draws with. Every component has a state holder that does this inside a derivedStateOf, so the resolution reruns only when something it read changes. The inputs it reads, such as whether this component's package is selected or which package is selected overall, are backed by Compose state, and a derived state records every state object it reads as a dependency. Before October 2026, StackComponentState resolved its overrides like this, and every other component state did the same:
Notice the order: isSelected, offerEligibility, and the selected package's identifier are all read before buildPresentedPartial looks at a single condition. A component with no overrides at all still read the selection. There's a difference between "the selection changed" and "this component's inputs changed". Every tap is the first, and it should be the second only for components whose overrides mention the selection. The old code made it the second for every component.
If you've written a selectable list in Compose, you've probably written the same shape yourself:
For a plan whose badge rules never look at the selection, the read is wasted, and the derived state now depends on every tap anyway. In a list of five items that's harmless. The rest of this article is about what it costs when the list is a whole screen.
Measuring before guessing: The recording and an instrumented test
The first theory was the background: maybe every tap re-rendered and re-blurred the paywall behind the sheet. The recording made that unlikely. Sampled at 30 frames per second, each tap showed the ripple starting, then about a dozen identical frames, then the new selection in one frame. Across this and a second recording, the pauses lasted 0.33 to 0.50 seconds. The background didn't change on the frame where the selection flipped, and in the second recording, where it animated, the animation paused during every delay. When unrelated animations pause at the same moment, the main thread is the usual suspect.
A blocked main thread right after a state change points at composition, layout, or something they call. To find out which, we wrote an instrumented test that renders a paywall with the real SDK on an Android runtime, changes the selected package on the UI thread, and waits for Compose to go idle:
Each run does 6 warmup selections and 20 measured ones and reports the median, and every timing from this test below is the median of at least three such runs. The paywall is synthetic: N plain texts with no overrides, plus two packages, and no blur at all. The cost reproduced anyway, so it doesn't need the blur. Changing N answers the first question to ask about any slowdown: does the cost grow with the number of components, or is it a constant paid once per tap?
Two caveats apply to every timing in this article. The runs use an Android 15 emulator and a debug build, so the values are only meaningful as before and after comparisons on the same bench, and phones aren't necessarily faster: during the investigation, an earlier build on a different paywall took 190 ms per tap on a Galaxy S23 against 112 ms on an Android 14 emulator. And waitForIdle advances in whole frames, so the resolution is one frame (16.7 ms) and the floor is two frames, about 33 ms.
To see what each fix bought on its own, the fixes that touch the tap got kill switches: flags in a measurement build that restore the old behavior at one choke point. With every flag off, the build is the current SDK, so every "before" number in this article is the current SDK with a fix switched off, not an old release. Next to the switches sit counters for the work a tap causes: override resolutions, text recompositions, font file loads, and variable passes, which count how many times a text template was processed. Here's one tap on 300 texts, with #4383, the main fix this article explains, switched off and on:
- #4383 switched off: 700 ms per tap, with 308 override resolutions, 0 text recompositions, and 600 variable passes.
- Current SDK: 184 ms per tap, with 0 override resolutions, 0 text recompositions, and 600 variable passes.
The chart below repeats the measurement at 20, 100, and 300 texts:

The orange and blue lines climb with the number of texts, so this is work every component does on every tap, not a one off like a file read. And the counters show where it went: 308 override resolutions and 600 variable passes per tap, against zero text recompositions. Compose correctly skipped every text on the screen, because none of them looked any different. Almost all of the 700 ms went into recalculating values only to find out they hadn't changed.
Skipping in derivedStateOf: Recomposition, not recalculation
To see why skipping can be expensive, look at what the Compose runtime does when a state object read by a derived state changes. Composition puts every scope that read the derived state on a "maybe changed" list, and right before it would recompose one of them, it checks whether the derived value really changed. In the runtime that the SDK ships with (1.11.4) and the latest stable release (1.12.1), that check is checkDerivedStateChanged in RecomposeScopeImpl:
currentRecord is where the work happens: if any dependency changed since the last calculation, reading it runs the calculation again. So a single tap goes through these steps:
- The selected package changes.
- Every derived state that read it is now out of date.
- Before recomposing each reader, Compose recalculates the derived state and compares the result with the value the reader last saw, using
==. - If the two are equal, the reader is skipped.
Step 4 is what derivedStateOf is known for, and it worked: nothing recomposed. Step 3 is the part that's easy to forget, and there's no shortcut around it, because the only way to learn that the new value equals the old one is to compute the new value.
One more detail multiplies the cost. A derivedStateOf without an explicit policy stores a new record every time it recalculates, even when the result is equal, so any derived state built on top of it sees a changed dependency and recalculates too. In TextComponentState, presentedPartial is private, and composition never reads it directly. It reads about a dozen properties derived from it, such as visible, text, color, fontSize, padding, and fontFamily, and each of those recalculated and was compared on every tap, for every text.
Reading less: Condition inputs as lambdas
The first half of #4383 doesn't make that chain cheaper. It removes the dependency for every component that can't be affected. The condition inputs became lambdas, and each condition calls only the one it needs. Looking at the evaluation after the change:
The real when covers more condition types, and each branch follows the same rule: a lambda runs only inside the branch that needs its value. The call sites now pass state = { if (packageAwareDelegate.isSelected) SELECTED else DEFAULT } instead of the computed value.
Why does a lambda change the dependencies? Because a derived state learns its dependencies by watching what its calculation reads, not by looking at what it could read. Inside DerivedSnapshotState, the calculation runs under a read observer, a callback Compose invokes every time a state object is read inside the block. The real code also tracks how deeply derived states are nested, but the core of it is this:
Every state object read during calculation lands in newDependencies, and nothing else does. A lambda that is never invoked never reads the selection, so the selection never becomes a dependency. A component whose overrides don't mention the selection no longer resolves them on a tap, because nothing it read has changed.
This is the habit to take into your own code. Inside derivedStateOf, or any scope that tracks reads, such as a composable body, snapshotFlow, or a layout or draw block, read a state only on the path that uses it. Here resolveBadgeStyle takes a () -> Boolean and calls it only when a rule checks the selection:
The kill switch for this half sits at the top of buildPresentedPartial. Calling every lambda up front recreates the dependencies the old code had:
With only this half switched off, the 300 text paywall took 450 ms per tap against 184 ms. On one of RevenueCat's own paywall templates, an 89 component design with selected state overrides and price variables, override resolutions per tap dropped from 99 to 22. The ones that remain belong to components whose overrides really do depend on the selection, such as the plan cards.
The other half of #4383: 3,000 regex compilations per tap
The counters exposed a second cost. Text in a paywall is a template, where {{ product.price_per_period }} is replaced with the real price, and a variable outside a package refers to the selected package. So every text outside a package reads the selection and reprocesses its template on every tap, twice: once for the screen, and once for a spoken version that screen readers read, so that "$9.99/mo" becomes "$9.99 monthly". That's where the 600 variable passes for 300 texts come from.
The spoken pass expands period abbreviations with regular expressions. Compiling a Regex costs far more than running one, which is why patterns are normally built once. Before the fix, the spoken pass compiled them on every call:
spokenPeriodKeys has ten entries, from "day" to "annual" in their short and long forms, and English defines all ten. So every spoken pass compiled ten patterns, and 300 texts compiled 3,000 Regex instances per tap. The second half of #4383 keeps the compiled list next to the word pairs it was built from and reuses it while the words stay the same, which in a paywall means the patterns are compiled once.
That makes each variable pass cheaper, not rarer, which is why the pass count above doesn't move. With only the cache switched off, a tap took 433 ms against 184 ms. The two halves of #4383 cost about the same, and they add up: with both switched off, the 300 text paywall took 700 ms. On the 89 component template, the same comparison was 100 ms against 50 ms.
When derivedStateOf can't tell two values apart: Fonts without equals
A recalculation that ends in "equal, skip" is the cheaper case. Now consider one that can never end in "equal".
Fonts uploaded in the dashboard are downloaded by the SDK and loaded from a file. FontSpec.Downloaded.resolve() builds a FontFamily out of new LocalFileFont instances on every call, and neither LocalFileFont nor its parent, Compose's AndroidFont, overrode equals. A class without equals is compared by identity, so two fonts pointing at the same file are still different objects, and because FontFamily compares its list of fonts, two families resolved from the same file were never equal.
The text's fontFamily is a derived state over the font spec, and the font spec is derived from the resolved overrides. Before #4383, those read the selection, so a tap reached the font too, two derived states downstream. Every recalculation built a new font object that Compose couldn't recognize as the same font, so every tap looked like a font change:
- The new
FontFamilyisn't equal to the previous one, socheckDerivedStateChangedreports a change and the text recomposes. - The text is laid out again, and
FontFamilyResolver, the part of Compose that turns aFontFamilyinto an AndroidTypeface, looks the family up in its cache. - The cache key is a data class that includes the family. A data class compares every field, so the key matches only if the families are equal, and they never were.
- The per font cache below it is keyed by
Fontequality, so it misses too. - The font's loading strategy is
Blocking, which means it must be loaded before the text can be drawn, on the thread that asks. The file is parsed again on the main thread.
The fix in #4245 gives LocalFileFont value based equality, with a matching hashCode over the same four fields:
On a 300 text paywall with a downloaded font, a build with both #4245 and #4383 switched off took 833 ms per tap, with 300 text recompositions and 300 font file loads. With only #4245 restored, it took 667 ms, with zero recompositions and zero loads.
The font fix shipped while we were still investigating the bug report, and the reporter saw no visible difference with that release. The defect was real and so was its cost, but proving that a defect exists is not the same as proving it caused the symptom you're chasing. In our measurements, the two costs that #4383 later removed were larger.
The two fixes also interact. With #4383 in place, a plain text's font no longer recalculates on a tap, so switching the equals off today changes nothing for selection: 193 ms with it and 184 ms without, within one frame. It still matters anywhere the font does recalculate, for example a text inside a plan card whose overrides read the selection. A value that lives in snapshot state needs a real equals whether or not today's code path happens to exercise it.
To see all three costs on screen, the demo below renders a synthetic paywall with 148 texts and a downloaded font through the real SDK, on the same emulator and debug build, and taps the plans with adb shell input tap. The overlay at the top reports each tap's cost, with override resolutions shortened to "override calcs", and its spinner is driven by Compose's frame clock on the main thread, so it stops whenever the main thread is busy. On the left, #4245 and #4383 are switched off, and after the first tap, which is slower in both runs, a tap takes about 360 ms. On the right is the current SDK, at about 75 ms. The variable passes barely change between the two (296 and 288), because 144 of the 148 texts sit outside a package and still read the selected package. The overlay counts everything a real tap triggers, including checks the click handler runs before the selection changes, so its numbers aren't directly comparable with the instrumented test:

Smaller audit fixes: Work repeated more often than its inputs changed
A Compose audit of the paywall renderer, done a week before the recording arrived, found three more places where work ran more often than its inputs changed. None of them was measured against tap latency:
- Markdown parsing (#4194): the CommonMark parse in
Markdown, the leaf renderer for every text, is now wrapped inremember(text)instead of running on every recomposition. Countdown texts recompose every second, and before #4245 so did every text with a downloaded font on every tap. - Shadows (#4195): the outlines, paths,
Paint, andBlurMaskFilterthatdrawBehindbuilt on every draw are now built indrawWithCache, so each draw only clips and paints. - Window size class (#4200): each component called
currentWindowAdaptiveInfo(), which collects the window layout flow, only to read the width size class. A helper now derives it from the window size and density.
For the first two, all 279 Paparazzi screenshots were byte for byte identical before and after, which shows that nothing visible changed, not that anything got faster.
Animation in the draw phase: Dimming a button
The package tap isn't the only tap on a paywall, and the buttons had their own per frame cost. When a button's action is in progress, its content fades out and a progress indicator fades in. ButtonComponentView animated the content alpha with animateFloatAsState and passed the Float down to StackComponentView, so the whole button stack recomposed on every frame of the fade. #4201 hands the stack a provider instead, and the stack reads it inside the lambda form of graphicsLayer:
Compose runs that lambda when it updates the layer's properties, so a new alpha value changes the layer without recomposing the stack or laying it out again. The provider is null while the button is fully opaque, which keeps the common case free of an extra layer, just like Modifier.alpha with a value of 1.
The shipped code still recomposes the button itself. It decides whether to pass a provider by reading the animated value in the composable body:
Reading .value in the body subscribes the button to every frame of the animation, even though == 1f only flips twice. The stack skips, because it receives the same remembered lambda each time, but the button doesn't. The demo below isolates this read in a small standalone app built in release mode with Compose BOM 2026.09.00, and counts how many times the button's scope and the stack's scope run during one press. The third variant moves the check into derivedStateOf { animatedContentAlpha.value != 1f }:

Each counter includes the first composition, so one press cost the Float version about 60 recompositions of both scopes, the shipped provider about 60 of the button and 2 of the stack, and the derivedStateOf version 4 and 2. The real ButtonComponentView also reads the progress indicator's alpha in the body, so that read would have to move out of composition too.
Frames nobody asked for: An invisible spinner
A second report described a paywall that stopped updating after a package tap, this time in a Flutter app on an Android 10 phone (purchases-flutter#1954). The reporter also noticed that the paywall redrew continuously while nobody touched it: 307 frames in five seconds, according to dumpsys gfxinfo. Testing one component type at a time, the RevenueCat engineer on the issue found that only buttons did this.
The cause was the button's progress indicator. It was always composed, with its alpha animated to zero while idle:
An indeterminate CircularProgressIndicator runs an infinite animation, and alpha 0 doesn't pause it. Each animation frame invalidates the indicator's drawing, and each invalidation makes the window render a new frame, so an invisible spinner kept the whole paywall rendering at the display's refresh rate. In the Flutter app, the native paywall renders into a small queue of buffers that Flutter reads from. After the tap, the log shows that queue falling behind, then Skipped 36 frames!, and then the surface disconnecting, after which Flutter kept showing its last frame. The reporter suspected the constant stream of idle frames. Whether removing it alone fixed that phone wasn't confirmed, but the stream itself was real. #4442 composes the indicator only while it's visible:
On our emulator, an untouched paywall with the old behavior switched back on rendered 302 to 303 frames in five seconds across three runs. With the fix, it rendered 0. In the demo below, the "Profile HWUI rendering" developer option draws one bar per rendered frame. Ignore the bar heights, because frames on an emulator are slow to swap. What matters is that new bars keep arriving on the left. On the right, the tall strip at the right edge is left over from the first frames after launch, and nothing new arrives:

If you hide an animated component with alpha, check whether its animation is infinite. Alpha controls what the user sees, not whether frames are produced.
What reordering can and cannot do: The interaction listener
When a package is tapped, the SDK calls the app's PaywallListener.onInteraction synchronously, and it used to do so before applying the selection, so a slow listener delayed the selection. #4298 applies the selection first, which looks like it should keep the listener from delaying the frame. It doesn't, because the main thread runs one queued task at a time: the click handler, with both the selection write and the listener call inside it, is one task, and the next frame can't start until it returns. Measured from the start of the click handler to the frame that draws the new selection, on the 89 component template, with a listener that blocks for 300 ms, in two runs of ten taps:
- Listener first (before #4298): 350 to 356 ms.
- Selection first (#4298): 342 to 343 ms.
- Listener posted to the main thread: 348 to 369 ms.
- Listener deferred until after the next frame: 41 to 48 ms.
Applying the selection first saved about 10 ms, less than a frame, and posting doesn't help, because the posted task still runs before the next frame. Deferring the listener past the frame takes its cost out of the tap, though the 300 ms block then delays the frames that follow. Deferring also changes when apps receive the callback, so it's an API decision rather than a refactor. For now, keep onInteraction short and move anything slow off the main thread.
Conclusion
The habit this investigation leaves you with is a question to ask about any state that many composables observe: who reads it, and do they all need to? derivedStateOf protects you from recomposition, not from recalculation, and a calculation that reads more than it uses subscribes to more than it needs. When you fix something like this, keep a way to turn the fix back off in a measurement build, because "it fixed a defect" and "it was the cause" are different claims, and only an A/B can tell them apart.
The symptom was a short pause after a tap, and the first theories were about expensive drawing. On every paywall we measured, the real cost was invisible to tools that look at what changed on screen, because nothing changed. Composition did what it promised and skipped the components that looked the same. What made the tap slow was hundreds of components each asking, correctly, whether they needed to do anything.

