The paywall is usually the highest leverage screen in a subscription app, and it is also the screen most tightly related to your release cycle. Reordering the packages, rewriting the headline above them, or changing which one is preselected means a ticket, a build, a store review, and then waiting for users to update. Server-driven UI breaks that coupling by moving the screen’s definition to a server and letting the app render whatever arrives. The moment you do that, though, you inherit a problem the compiler used to solve for you: the server can send your app a screen it has never heard of.
In this article, you’ll dive deep into how the RevenueCat Android SDK renders server driven paywalls as native Compose UI, comparing to the WebView screen and what it defers, the two level offerings cache and asset pre-download, the three stage pipeline from wire format to render model, how unknown input and misconfiguration get absorbed by different mechanisms, the facts only the device can resolve, and the cost of holding every platform to the same JSON.
The fundamental problem: The server can send what your binary does not know
There are broadly two ways to render a screen defined somewhere else. You can ship a runtime that resolves an open-ended instruction set at display time, or you can ship a renderer that understands a closed set of types fixed at compile time. A third option exists, shipping executable code from the server and running it against native widgets, but it trades the schema problem for a second runtime inside your APK and a longer conversation with store policy, so set it aside.
The first option has a ready-made implementation on every Android device. HTML is the instruction set, the browser engine resolves it, and WebView is the host. A paywall becomes a URL. When the design team invents a new layout, they write new markup, the server serves it, and an app that shipped two years ago renders it without ever having been taught what a carousel is.
The second option is what you get when you want native views. You define a schema, you write a renderer for each shape in it, and both live inside your binary. Consider the smallest possible version of that schema:
1sealed interface PaywallComponent {
2 data class Text(val text: String) : PaywallComponent
3 data class Image(val url: String) : PaywallComponent
4 data class Stack(val children: List<PaywallComponent>) : PaywallComponent
5}
Three shapes, a recursive tree, and a renderer that walks it. This works, and it keeps working right up until the dashboard learns to emit a fourth shape. Now the server sends this to a binary compiled against the three shape schema:
1{
2 "type": "carousel",
3 "pages": [ { "type": "text", "text": "Unlimited access" } ]
4}
Your deserializer has three options if it has to decide alone, and all of them are bad. It can throw, which fails the whole paywall and shows the user nothing on the screen your revenue depends on. It can skip the unknown node, which leaves a hole in the middle of a layout that was designed around it, so the user sees a headline, empty space, and a purchase button with no explanation of what they are buying. Or it can guess, which is worse than either.
Notice what makes this asymmetric. The problem is not rendering. Drawing a carousel in Compose is a solved problem. The problem is that a native renderer knows a closed set of types while the server can emit an open one, and the gap between the two widens every time you ship a dashboard feature, because users update their apps on their own schedule and some of them never will.
This is the actual decision behind native versus WebView paywalls, and it is not settled by a benchmark or a visual audit. Both of those follow from a prior question: where do you put the uncertainty about what the server might send? Inside a runtime you did not write and do not version, or inside a schema boundary you design deliberately?
The shortcut and what it defers: Hosting a page versus owning a tree
Before looking at how RevenueCat’s SDK answers that question, it is worth accounting for what the WebView approach actually costs, because it genuinely does solve forward compatibility.
A WebView hosted paywall makes the SDK’s job small. Its responsibilities reduce to roughly this shape:
1webView.loadUrl(paywallUrl)
2webView.addJavascriptInterface(bridge, "native")
The page owns layout, typography, animation, and state. The bridge carries a handful of messages in both directions: the page tells native that the user tapped a product, native tells the page that the purchase succeeded. Everything visual is HTML and CSS, so the design team iterates without touching Kotlin and without a schema negotiation.
What that architecture defers, rather than solves, is everything that depends on the paywall being present, fast, and native.
The paywall becomes a network resource. Before a single pixel can paint, the page has to be fetched. You can pre-warm a WebView to hide this, and well built implementations do, but pre-warming a WebView means keeping a renderer process alive on the speculative chance that the user will see a paywall. You are pre-warming a process. Pre-warming bytes on disk is a different proposition, and the next section is about what that difference buys.
Caching the page yourself is possible, and more possible than it first looks. shouldInterceptRequest lets you serve every request from storage you control, and a service worker gives you explicit eviction and validation. What you cannot do is skip the work. You now maintain a second asset pipeline, its correctness, and its invalidation logic, in a language and a runtime separate from the app hosting it.
The layout engine is not yours to pin. Android System WebView updates independently of your app, through the Play Store, on a schedule set by the user’s device, and devices without Play Services, with updates disabled, or on older Chromium builds form a version matrix nobody controls. A CSS rule that lays out correctly during QA can lay out differently on a device that updated its WebView provider last week. Compose does not remove platform variation, since text shaping still comes from StaticLayout and HarfBuzz, but the measure and layout code ships inside your APK, so the box model does not move under you. That is a much narrower moving target than an entire layout engine.
The page’s content also sits outside Compose’s reasoning. Compose can skip the AndroidView wrapper when its parameters are stable, and the hosted view does report a measured size, but Compose cannot reason about anything inside the document. The page’s content height is only known after the document lays out, which happens after Compose has already measured. So a WebView inside a Compose column either gets a height you hardcode or a height that arrives over the bridge one frame late, and nested scrolling between the page’s scroll container and a Compose parent becomes a coordination problem rather than a feature.
Finally, the seam between the page and the purchase is untyped. A tap crosses a postMessage boundary as a string, and nothing checks that identifier against your real product catalog until the user presses the button. As you’ll see later, the native path checks the same mapping, just before the paywall renders rather than on the tap.
None of this makes the WebView approach a mistake. It makes it a set of deferred payments. The rest of this article walks through what paying them up front looks like in code.
Nothing to fetch when the paywall opens: Two-level caching and asset predownload
Start with the property that is easiest to verify and hardest to retrofit. After the first successful offerings fetch, presenting the current offering’s paywall no longer depends on the network. The component tree, the fonts, and the images are already on the device.
Offerings, which carry the paywall definitions, are cached at two levels. If you examine OfferingsCache:
1internal class OfferingsCache(
2 private val deviceCache: DeviceCache,
3 private val dateProvider: DateProvider = DefaultDateProvider(),
4 private val offeringsCachedObject: InMemoryCachedObject<Offerings> = InMemoryCachedObject(
5 dateProvider = dateProvider,
6 ),
7 private val localeProvider: LocaleProvider,
8)
There is an in-memory layer, offeringsCachedObject, for the warm path within a process, and a disk layer behind deviceCache that survives process death. Writing goes to both:
1@Synchronized
2fun cacheOfferings(offerings: Offerings, offeringsResponse: JSONObject) {
3 offeringsCachedObject.cacheInstance(offerings)
4 deviceCache.cacheOfferingsResponse(offeringsResponse)
5 offeringsCachedObject.updateCacheTimestamp(dateProvider.now)
6 cachedLanguageTags = String(localeProvider.currentLocalesLanguageTags.toCharArray())
7}
The raw response JSON is persisted, not just the parsed object graph, which means the disk copy can go back through the same parsing path as a fresh network body rather than needing its own deserialization format.
Look at the last line. The cache records which language tags were active when it was populated, and staleness checks both time and locale:
1@Synchronized
2fun isOfferingsCacheStale(appInBackground: Boolean): Boolean =
3 offeringsCachedObject.lastUpdatedAt.isCacheStale(appInBackground, dateProvider) ||
4 cachedLanguageTags != localeProvider.currentLocalesLanguageTags
A user who changes their system language gets a refetch even if the cache is chronologically fresh. This is the kind of invalidation rule you only write when the cached payload is locale dependent, and it is the first hint of a theme that runs through the whole architecture: the device is a participant in rendering, not just a display surface.
The disk copy is a failure path rather than a startup path. On a cold start the in memory cache is empty, so the SDK does attempt a network fetch, and what matters is what happens when that fetch fails:
1GetOfferingsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_OFFERINGS -> {
2 val cachedOfferingsResponse = offeringsCache.cachedOfferingsResponse
3 if (cachedOfferingsResponse == null) {
4 handleErrorFetchingOfferings(backendError, onError)
5 } else {
6 warnLog { OfferingStrings.ERROR_FETCHING_OFFERINGS_USING_DISK_CACHE }
7 createAndCacheOfferings(
8 offeringsJSON = cachedOfferingsResponse,
9 loadedFromDiskCache = true,
10 ...
11 )
12 }
13}
Because the disk entry is a complete offerings response, it flows through exactly the same createAndCacheOfferings path as a network body, with loadedFromDiskCache as the only difference the rest of the pipeline sees. A user who opens the app on a plane gets last week’s paywall instead of an error screen, so after the first successful fetch no connectivity failure leaves them with no paywall.
Caching the tree is only half of it. A component tree full of remote font and image URLs still paints late if those URLs are fetched when the paywall opens. So the SDK fetches them when offerings are fetched, which is typically during SDK configuration at app launch. Looking at where OfferingsManager handles a successful offerings response:
1onSuccess = { offeringsResultData ->
2 offeringsResultData.offerings.current?.let {
3 offeringImagePreDownloader.preDownloadOfferingImages(it)
4 }
5 offeringFontPreDownloader.preDownloadOfferingFontsIfNeeded(offeringsResultData.offerings)
6 offeringsCache.cacheOfferings(offeringsResultData.offerings, offeringsJSON)
7 ...
8}
Images and fonts start downloading before the offerings are handed back to the caller. Two limits are visible in those four lines and worth stating rather than glossing: images are predownloaded for offerings.current only, so a paywall on a non-current offering does not get them, and fonts are read from the first offering that has paywall components on the assumption that all offerings share a font set. Both downloads are also asynchronous, so a user who reaches a paywall within the first second of a fresh install can still outrun them.
Fonts are where the distance between native and web is greatest. On the web, a custom font is a single @font-face declaration and the browser handles fetching, caching, validation, and fallback. Android does have downloadable fonts through FontsContractCompat, but that resolves a font name against a provider’s catalog. An arbitrary file that a customer uploaded to their own bucket has no platform support at all, and FontLoader is 235 lines of the work the browser was doing for free.
Those 235 lines buy something the section title has been promising: the font file is on disk before the paywall opens, so the first frame draws in the right typeface. Font resolution happens once, before composition, which means no font swap and no flash of invisible text. It also means the guarantee is one directional. A font that is not in the index when the paywall state is built resolves to the system font for the entire life of that presentation, so a missed predownload is not a late swap, it is a paywall rendered in the wrong typeface until the user closes it and opens it again.
The predownloader starts by filtering out fonts it does not need to fetch at all:
1private fun isBundled(info: FontInfo.Name): Boolean {
2 if (info.value.isEmpty()) return false
3 return when (info.value) {
4 in genericFonts -> true
5 else -> context.getResourceIdentifier(info.value, "font") != 0 ||
6 context.getAssetFontPath(info.value) != null
7 }
8}
The dashboard can reference a font by name, and the device checks whether that name resolves to a font already bundled in the app’s resources or assets before downloading anything. A server could only know this if every app reported its bundled font names on every build.
For fonts that do need downloading, the loader keys its cache by a hash of the URL, so a file referenced by more than one font alias is fetched once and later requests attach themselves to the in flight download as listeners. The download itself verifies integrity and commits atomically:
1val tempFile = File.createTempFile("rc_paywall_font_download_", ".$extension", cacheDir)
2urlConnectionFactory.downloadToFile(url, tempFile, description = "paywall font")
3
4val actualMd5 = md5Hex(tempFile.readBytes())
5if (!actualMd5.equals(expectedMd5, ignoreCase = true)) {
6 tempFile.delete()
7 return Result.failure(IOException("Downloaded font file is corrupt for $url"))
8}
9
10if (!tempFile.renameTo(cachedFile)) {
11 tempFile.copyTo(cachedFile, overwrite = true)
12 tempFile.delete()
13}
The file downloads to a temporary name, gets checked against an expected MD5 the server supplied alongside the URL, and only then moves into place. A truncated download never becomes a cache entry, so a partially written font file cannot poison the cache and produce garbled text on every subsequent launch.
There is one more recovery path that only shows up in production. Android can reclaim cacheDir at any time, so a cache entry can point at a file that no longer exists:
1if (cachedFontFamily != null) {
2 if (cachedFontFamily.fonts.all { it.file.exists() }) {
3 return cachedFontFamily
4 }
5 warnLog { "Cached font files missing for ${cachedFontFamily.family}, re-downloading" }
6 ...
7}
The in-memory index is checked against the filesystem before being trusted, and a family whose files have been evicted is dropped and refetched rather than handed to Compose as a set of dangling File references.
The three-stage pipeline: Tolerant, then validated, then rendered
With assets local, the remaining question is how the JSON becomes Compose UI. The SDK does this in three distinct stages, and the separation is what makes everything after it possible.
The first stage is deserialization into a data model that mirrors the wire format closely and validates almost nothing. PaywallComponentsData is the root:
1public class PaywallComponentsData(
2 @SerialName("template_name") public val templateName: String,
3 @SerialName("asset_base_url") public val assetBaseURL: URL,
4 @SerialName("components_config") public val componentsConfig: ComponentsConfig,
5 @SerialName("components_localizations")
6 public val componentsLocalizations: Map<LocaleId, Map<LocalizationKey, LocalizationData>>,
7 @SerialName("default_locale") public val defaultLocaleIdentifier: LocaleId,
8 public val revision: Int = 0,
9 @SerialName("zero_decimal_place_countries")
10 public val zeroDecimalPlaceCountries: List<String> = emptyList(),
11 ...
12)
Two things in that declaration shape everything downstream. Localizations arrive as a map keyed by every locale the paywall supports, not as a single pre-resolved language, which makes locale selection the device’s job. And revision is carried explicitly, so a client can tell one published version of a paywall from another, which is what makes experiment attribution possible later.
Inside componentsConfig is the tree, built from PaywallComponent, a sealed interface with a hand written serializer:
1@Serializable(with = PaywallComponentSerializer::class)
2public sealed interface PaywallComponent
The second stage compiles that data model into a render model. StyleFactory performs the transformation, and its constructor shows what compiling means here:
1internal class StyleFactory(
2 private val localizations: NonEmptyMap<LocaleId, LocalizationDictionary>,
3 private val colorAliases: Map<ColorAlias, ColorScheme>,
4 private val fontAliases: Map<FontAlias, FontSpec>,
5 private val variableLocalizations: NonEmptyMap<LocaleId, NonEmptyMap<VariableLocalizationKey, String>>,
6 private val offering: Offering,
7 private val stripRules: Boolean = false,
8)
Every reference in the wire format gets resolved here. A component whose background is ColorAlias("primary") gets an actual ColorScheme. A text component whose font is FontAlias("brand") gets a FontSpec. A package component naming a package identifier gets a real Package from the Offering, or an error if no such package exists. String keys become resolved strings for every supported locale. The stripRules flag is the second stage’s one concession to unknown input, and the next section is about what sets it.
The output type is deliberately minimal:
1@Immutable
2internal sealed interface ComponentStyle {
3 val visible: Boolean
4 val size: Size
5}
By the time a ComponentStyle exists there is nothing left to look up. No alias to resolve, no locale to pick, no package to find. That is what makes it safe to declare @Immutable, and it is why nothing inside composition can fail. The annotation is a promise to the Compose compiler that an instance’s properties never change after construction, which lets Compose skip re-executing a composable whose inputs are equal to last time and leave what it emitted in place. Strong skipping means Compose would already skip on instance equality for many of these, but the annotation documents the guarantee and lets the compiler treat the type as stable everywhere it appears rather than only where the same instance happens to be reused.
Validation in this stage accumulates rather than fails fast, and the reason is practical. When a dashboard user publishes a paywall referencing three colors that were deleted, a fail fast validator reports one, the user fixes it, republishes, and discovers the second. Accumulating means one report lists all three. The SDK uses its own Result type:
1internal sealed class Result<out A, out B> {
2 class Success<A>(val value: A) : Result<A, Nothing>()
3 class Error<B>(val value: B) : Result<Nothing, B>()
4}
paired with combinators that gather every error instead of stopping at the first:
1internal inline fun <A, B, G, H> zipOrAccumulate(
2 first: Result<A, NonEmptyList<H>>,
3 second: Result<B, NonEmptyList<H>>,
4 transform: (A, B) -> G,
5): Result<G, NonEmptyList<H>>
zipOrAccumulate takes two results and a function. If both succeeded it applies the function. If either failed it returns every error from both, concatenated. The error type is NonEmptyList<PaywallValidationError>, and the choice of NonEmptyList is what keeps the failure case from lying: a list type that guarantees at least one element makes “failed with zero errors” unrepresentable. PaywallValidationError has twenty one cases, including MissingColorAlias, MissingFontAlias, MissingPackage, MissingStringLocalization, and TabControlNotInTab.
The third stage is rendering, and it is almost boring by comparison. ComponentView dispatches a ComponentStyle to a composable:
1@Composable
2internal fun ComponentView(
3 style: ComponentStyle,
4 state: PaywallState.Loaded.Components,
5 onClick: suspend (PaywallAction) -> Unit,
6 modifier: Modifier = Modifier,
7 componentInteractionTracker: PaywallComponentInteractionTracker = PaywallComponentInteractionTracker { _ -> },
8) = when (style) {
9 is StackComponentStyle -> StackComponentView(...)
10 is TextComponentStyle -> TextComponentView(...)
11 is ImageComponentStyle -> ImageComponentView(...)
12 ...
13}
The remaining branches follow the same shape, and the function ends there. There is no else, and there cannot be a missing case either: a when used as an expression over a sealed type will not compile unless every subtype is covered. Adding a style without a corresponding view is a compile error rather than a runtime hole.
That is the payoff of the three-stage split, and the precise version of the claim is narrower than the grand one. Every question of what type of thing the server sent is answered before stage two ends. What is not settled at that boundary is data. A variable name, an image URL, a window width, and a selected package are all resolved during composition, which is why the misconfiguration path below exists: the types are pinned down at the boundary, the values are not.
Absorbing bad input: Forward compatibility and misconfiguration are different problems
That boundary has to actually hold, which brings us back to the carousel arriving at a binary that predates carousels. The SDK has two forward compatibility mechanisms for genuinely unknown input, and then a third, separate path for input that is well formed but misconfigured. Keeping those two problems apart matters, because they fail for different reasons and deserve different answers.
Tier one: A fallback subtree supplied by the server
The component deserializer is written by hand rather than generated, and the last branch is where forward compatibility lives:
1override fun deserialize(decoder: Decoder): PaywallComponent {
2 val jsonDecoder = decoder as? JsonDecoder
3 ?: throw SerializationException("Can only deserialize PaywallComponent from JSON, got: ${decoder::class}")
4 val json = jsonDecoder.decodeJsonElement().jsonObject
5 return when (val type = json["type"]?.jsonPrimitive?.content) {
6 "button" -> jsonDecoder.json.decodeFromJsonElement<ButtonComponent>(json)
7 "image" -> jsonDecoder.json.decodeFromJsonElement<ImageComponent>(json)
8 ...
Fifteen branches later comes the part that matters:
1 "fallback_header" -> FallbackHeaderComponent
2 else -> json["fallback"]
3 ?.let { it as? JsonObject }
4 ?.let { jsonDecoder.json.decodeFromJsonElement<PaywallComponent>(it) }
5 ?: throw SerializationException("No fallback provided for unknown type: $type")
6 }
7}
When the type is unrecognized, the deserializer looks for a fallback key on the same object and decodes that value as a PaywallComponent, recursively. So the server does not send a carousel and hope. It sends a carousel with instructions for what to render instead:
1{
2 "type": "carousel",
3 "pages": [ "..." ],
4 "fallback": {
5 "type": "stack",
6 "components": [ { "type": "text", "text_lid": "carousel_summary" } ]
7 }
8}
A binary that knows carousels renders the carousel and ignores fallback. A binary that does not renders the stack. The recursion means a fallback can itself contain a fallback, so a component added in the newest SDK can degrade through intermediate representations down to something a much older binary understands.
The design decision here is that the server owns the degradation choice rather than the client. A client cannot reasonably invent a substitute for a component type it has never seen, but the person who designed the component can specify one, and the dashboard can emit it automatically.
This does not hand an old binary a capability it was never built to render. What it guarantees is that the old binary shows a coherent, purchasable screen instead of a hole, and that a designer chose what that screen is. The hosted approach has a floor of its own here: its instruction set is only as new as the oldest WebView in your install base.
Notice also that every branch decodes from json, the already parsed JsonElement, rather than from text. A comment in the source explains why:
Decode the JsonElement directly; re-stringifying (
decodeFromString(json.toString())) is ~quadratic in tree depth, as every nested PaywallComponent would re-stringify its whole subtree.
The obvious implementation of a discriminated union deserializer reads the type field, then hands the JSON text to the right serializer. In a recursive tree, every node on the path from the root down to a leaf re-serializes that leaf, so total work becomes the node count multiplied by the tree’s depth instead of just the node count. Ten levels of nesting means the deepest text gets written out and parsed again ten times. Decoding from the parsed element does each node once.
Tier two: Unknown conditions collapse the override cascade
The second class of unknown is not a component but a condition. Components carry overrides that apply only in certain situations, which is how one definition covers phone and tablet, light and dark, selected and unselected. Think of a stack of CSS rules applied in source order: each override that matches overwrites the properties it names and leaves everything else alone. ComponentOverride pairs the conditions with the properties:
1public class ComponentOverride<T : PartialComponent>(
2 public val conditions: List<Condition>,
3 public val properties: T,
4)
Condition is nested inside ComponentOverride and has grown over time, from simple markers to parameterized rules:
1@Serializable(with = ConditionSerializer::class)
2public sealed interface Condition {
3 public val isRule: Boolean get() = false
4
5 @Serializable public object Compact : Condition
6 @Serializable public object Medium : Condition
7 @Serializable public object Expanded : Condition
8 @Serializable public object IntroOffer : Condition
9 @Serializable public object Selected : Condition
The newer ones carry operators and operands, and mark themselves with isRule:
1 @Serializable
2 public data class SelectedPackage(
3 public val operator: ArrayOperator,
4 public val packages: List<String>,
5 ) : Condition { override val isRule: Boolean get() = true }
6
7 @Serializable
8 public data class Variable(
9 public val operator: EqualityOperator,
10 public val variable: String,
11 public val value: JsonPrimitive,
12 ) : Condition { override val isRule: Boolean get() = true }
13
14 @Serializable public object Unsupported : Condition
15}
16
That isRule flag separates the original fixed set of conditions, which every SDK version has always understood, from the ones added later. The distinction is what the fallback strategy keys on.
Unknown condition types deserialize to Unsupported rather than throwing, through a reusable helper:
1internal object ConditionSerializer : SealedDeserializerWithDefault<Condition>(
2 serialName = "Condition",
3 serializerByType = mapOf(
4 "compact" to { Condition.Compact.serializer() },
5 "selected_package_condition" to { Condition.SelectedPackage.serializer() },
6 "variable_condition" to { Condition.Variable.serializer() },
7 ...
8 ),
9 defaultValue = { Condition.Unsupported },
10)
SealedDeserializerWithDefault is used at seven boundaries, six of them in the component schema, and it falls back in two situations rather than one:
1val serializer = type?.let { serializerByType[it] }
2 ?: return defaultValue(type ?: "null")
3return try {
4 jsonDecoder.json.decodeFromJsonElement(serializer(), jsonObject)
5} catch (_: Exception) {
6 defaultValue(type)
7}
An unrecognized discriminator falls back. So does a type the client recognizes whose payload fails to parse, which means a future server version can add a required field to an existing condition without breaking older clients. The unknown value half of this pattern also appears on enums through EnumDeserializerWithDefault, and on nested sealed types like PurchaseButtonComponent.Method, which carries an explicit Unknown case alongside InAppCheckout and the web checkout variants. It is not applied to every sealed type in the schema, though: PaywallComponent itself throws when no fallback is present, and types like Dimension and ColorInfo are plain polymorphic with no default.
Now, what should a client do when it finds an Unsupported condition? Rendering the override anyway is wrong, because the condition it depended on was never evaluated. Ignoring only that override is also wrong, and the reason takes a moment to see. Say one override paints a dark background on compact screens, and a second one, carrying a rule, switches the text to light when the annual package is selected. Drop the second and the text keeps its base dark colour on the dark background the first one painted, which nobody ever previewed. Overrides are written as a stack, and half a stack is not a smaller design, it is a different one.
So the effect escalates from the override to the whole tree. Any Unsupported condition anywhere sets a single flag, and that flag discards every override carrying a rule across the entire paywall:
1internal fun <T : PartialComponent, P : PresentedPartial<P>> List<ComponentOverride<T>>.toPresentedOverrides(
2 stripRules: Boolean = false,
3 transform: (T) -> Result<P, NonEmptyList<PaywallValidationError>>,
4): Result<List<PresentedOverride<P>>, PaywallValidationError> {
5 val overridesToProcess = if (stripRules) {
6 this.filter { override ->
7 override.conditions.none { it.isRule || it is ComponentOverride.Condition.Unsupported }
8 }
9 } else {
10 this
11 }
12 ...
What survives is the base set of conditions, so the paywall renders as the design without conditional refinements. The trade off is explicit: a coarser but coherent paywall instead of a precisely targeted but internally inconsistent one. The cost is a real deployment coupling. The flag is computed per component tree, so on the day the dashboard starts emitting a new condition type, every client that has not yet updated loses conditional refinement across the whole of any paywall that uses it, not just the component that carried the new condition.
Once conditions are resolvable, the cascade itself is a fold:
1internal fun <T : PresentedPartial<T>> List<PresentedOverride<T>>.buildPresentedPartial(
2 windowSize: ScreenCondition,
3 offerEligibility: OfferEligibility,
4 state: ComponentViewState,
5 conditionContext: ConditionContext = ConditionContext(null, emptyMap()),
6): T? {
7 var partial: T? = null
8 for (override in this) {
9 if (override.shouldApply(windowSize, offerEligibility, state, conditionContext)) {
10 partial = partial.combineOrReplace(override.properties)
11 }
12 }
13 return partial
14}
Overrides apply in declaration order, and combineOrReplace merges each matching one over the accumulated result, replacing outright only when there is nothing accumulated yet. Precedence is an explicit list order rather than a scoring algorithm, which makes it deterministic and testable.
The separate path: Misconfiguration exits the components pipeline
The two mechanisms above handle input the client does not recognize. A different failure is input that is perfectly well formed and simply wrong: a color alias that references a deleted color, a package identifier that is not in the offering, a tabs component with no tabs. That is not a forward compatibility problem, it is a misconfiguration problem, and it gets a different answer.
validatedPaywall is where all of it converges:
1internal fun Offering.validatedPaywall(
2 currentColorScheme: ColorScheme,
3 resourceProvider: ResourceProvider,
4): PaywallValidationResult =
5 validatePaywallComponentsDataOrNull(resourceProvider)?.let { result ->
6 when (result) {
7 is RcResult.Success -> result.value
8 is RcResult.Error -> fallbackPaywall(currentColorScheme, resourceProvider, errors = result.value)
9 }
10 } ?: paywall?.validate(currentColorScheme, resourceProvider)
11 ?: fallbackPaywall(currentColorScheme, resourceProvider, error = PaywallValidationError.MissingPaywall)
Four outcomes in one expression. A component tree that validates is used. A component tree that fails validation goes to fallbackPaywall. An offering with no component tree falls through to the older PaywallData paywall it may have configured. An offering with neither also goes to fallbackPaywall.
The result type is what makes the exit clean:
1internal sealed interface PaywallValidationResult {
2 val errors: NonEmptyList<PaywallValidationError>?
3
4 data class Legacy(
5 val displayablePaywall: PaywallData,
6 val template: PaywallTemplate,
7 override val errors: NonEmptyList<PaywallValidationError>? = null,
8 ) : PaywallValidationResult
and on the components branch, a comment stating the policy directly:
1 data class Components(...) : PaywallValidationResult {
2 // If a Components Paywall has an error, it will be reflected as a Legacy type so we can use the Legacy
3 // fallback.
4 override val errors: NonEmptyList<PaywallValidationError>? = null
5 }
Components structurally cannot carry errors. A components paywall either compiled cleanly or it is no longer a components paywall, and there is no third state for the renderer to interpret.
The name fallbackPaywall suggests something more configured than it is. It builds PaywallData.createDefault(availablePackages, ...) against PaywallData.defaultTemplate, and because that result carries errors, what actually draws is DefaultPaywallView, which reads only the package list off it. The generated screen is nicer than it sounds: it pulls the app’s name and icon from the platform and derives two prominent colors from the icon, so it lands approximately on brand with no configuration at all. It lists real products at real prices with a working purchase button, and the diagnostic explaining what went wrong is a debug build only overlay, so a production user sees a plain paywall rather than an error message aimed at a developer.
The shape across all three mechanisms comes down to this. Tiers one and two are forward compatibility, absorbing input from a newer server than the binary expects, and both stay inside the components renderer. The misconfiguration path is robustness against a badly configured dashboard, and it leaves the components renderer entirely. What they share is that none of them reaches ComponentView as an unknown type, which is why stage three needs no else and no defensive branch.
What the node tree gives you: Semantics and a typed purchase path
Everything so far has been about surviving the schema boundary. Two things that become possible on the other side of it are hard to get any other way.
The first is accessibility. Because components render as Compose nodes, they emit semantics that TalkBack consumes directly, and the SDK can shape that tree deliberately. StackComponentView splits a clickable stack into three parts. A wrapper Box carries the caller’s modifier and owns both the click gesture and the semantics. Inside it, the stack draws its content without a shape clip so that children which intentionally overflow, like offset badges, stay visible, and a sibling supplies the shape clipped ripple. Putting the click and the semantics together on the wrapper merges them into a single node alongside whatever testTag or Role the caller supplied, rather than scattering them into separate nodes a screen reader has to announce one at a time.
A WebView is not inaccessible, and pretending otherwise would be easy to refute. ARIA gives explicit control over roles, labels, and grouping. What it gives you is a second accessibility contract to get right, one that does not compose with the native tree around it: focus order across the AndroidView boundary, mergeDescendants on a Compose parent, and tag based UI tests all stop at the edge of the document. The native tree also means the paywall is testable with SemanticsNodeInteraction, the same tooling as the rest of the app, instead of needing a browser automation layer.
The second is the purchase path. The purchase button is not a generic button posting a string across a bridge, it is a typed component with a typed action:
1@SerialName("purchase_button")
2public class PurchaseButtonComponent(
3 public val stack: StackComponent,
4 public val action: Action? = null,
5 public val method: Method? = null,
6 public val name: String? = null,
7) : PaywallComponent {
8 public enum class Action {
9 IN_APP_CHECKOUT,
10 WEB_CHECKOUT,
11 WEB_PRODUCT_SELECTION,
12 }
By the time this reaches the renderer it has been compiled into a style holding a resolved Package, which came from the Offering, which came from Google Play. A dashboard that references a package not in the offering produces MissingPackage in stage two and exits to the fallback path before a user can tap anything. This is not compile time safety on the product identifier, since the identifier is data. It is the same check a bridge would eventually do, moved from the button press to before the paywall renders.
What only the device can know: Where the division of labor falls
There is a common misreading of server driven UI, which is that the server draws the screen and the client displays it. That is not what is happening here, and the difference is why the on device work is not incidental.
The dashboard owns design intent. The device owns facts nobody can know at publish time. The schema is the contract between them, and conditions are how intent gets expressed in terms of facts the server does not have.
Start with theme, because it shows the pattern in four lines. Colors arrive from the server as a pair rather than a value:
1public class ColorScheme(
2 public val light: ColorInfo,
3 public val dark: ColorInfo? = null,
4)
and resolution happens inside composition:
1internal val ColorStyles.forCurrentTheme: ColorStyle
2 @Composable
3 get() = if (isSystemInDarkTheme()) dark ?: light else light
Reading isSystemInDarkTheme() inside a composable registers a recomposition dependency on the configuration, so a theme change updates the paywall in place, provided the host activity declares android:configChanges="uiMode" and is not recreated outright. Locale works the same way and is held as Compose state:
1private var localeId by mutableStateOf(initialLocaleList.toLocaleId())
2
3val locale by derivedStateOf { localeId.toComposeLocale() }
Every text component reading locale recomposes with strings from a different entry in the localizations map that was already downloaded, because the wire format shipped all of them.
Screen size comes from the standard adaptive API rather than a guess about device class:
1internal enum class ScreenCondition {
2 COMPACT, MEDIUM, EXPANDED;
3
4 companion object {
5 fun from(sizeClass: WindowWidthSizeClass) =
6 when (sizeClass) {
7 WindowWidthSizeClass.COMPACT -> COMPACT
8 WindowWidthSizeClass.MEDIUM -> MEDIUM
9 WindowWidthSizeClass.EXPANDED -> EXPANDED
10 else -> {
11 Logger.d("Unexpected WindowWidthSizeClass: '$sizeClass'. Falling back to COMPACT.")
12 COMPACT
13 }
14 }
15 }
16}
That else is instructive after a section about exhaustive when expressions. WindowWidthSizeClass is a plain class with a private constructor and companion object constants rather than a sealed type or an enum, so there is no exhaustiveness to lean on and the branch is mandatory. Note where it falls back to, and note that it logs. Because the value comes from the current window rather than the physical display, a foldable that unfolds or an app entering split screen moves between conditions live.
The case that no server can resolve at all is offer eligibility. A dashboard author wants a headline reading “Start your free trial” for users who qualify and “Subscribe” for users who already used theirs. Eligibility is a property of this user’s purchase history on this store, so the device resolves it by reading the shape of what Google Play returned:
1internal val Package.introOfferEligibility: OfferEligibility
2 get() {
3 val phaseCount = (product.defaultOption?.pricingPhases?.size ?: 0) - 1
4
5 return when (phaseCount) {
6 1 -> OfferEligibility.IntroOfferSingle
7 2 -> OfferEligibility.IntroOfferMultiple
8 else -> OfferEligibility.Ineligible
9 }
10 }
The number of pricing phases beyond the base phase tells you how many discounted phases this user can actually receive, because Google Play only includes phases they are eligible for. The else treats an offer with three or more discounted phases as ineligible rather than inventing a category for it, which is a real limit in an otherwise total function. Promotional offers layer on top with the same logic and fall back to intro eligibility when no promo applies, and the result becomes the offerEligibility argument to buildPresentedPartial, which is what makes an IntroOffer condition evaluable at all.
Currency formatting is the subtlest of these, and a good illustration of why the device has to decide. A user’s language and their storefront country are independent, so a Korean speaker can have a US storefront. Format a derived per month price in the wrong locale and the separator or symbol placement disagrees with the price string the store returned, which users notice immediately. The lookup tries three things in order: a locale that already pairs the device’s language with the storefront’s country, then any locale for that storefront, then one built by stapling the storefront’s region onto the device’s language.
1val currencyLocale by derivedStateOf {
2 if (storefrontCountryCode.isNullOrBlank()) {
3 locale
4 } else {
5 val deviceLanguageCode = locale.language.lowercase()
6
7 val javaLocale = availableStorefrontCountryLocalesByLanguage[deviceLanguageCode]
8 ?: availableStorefrontCountryLocalesByLanguage.values.firstOrNull()
9 ?: Locale.Builder()
10 .setLocale(locale.toJavaLocale())
11 .setRegion(storefrontCountryCode.uppercase())
12 .build()
13
14 javaLocale.toComposeLocale()
15 }
16}
availableStorefrontCountryLocalesByLanguage is built once by scanning Locale.getAvailableLocales() for every locale whose country matches the storefront. Text renders in the device language, prices format according to the storefront country, and zeroDecimalPlaceCountries from the wire format suppresses decimals in currencies that do not use them.
Localization covers more than text. The payload is a three case hierarchy:
1public sealed interface LocalizationData {
2 public value class Text(public val value: String) : LocalizationData
3 public value class Image(public val value: ThemeImageUrls) : LocalizationData
4 public value class Video(public val value: ThemeVideoUrls) : LocalizationData
5}
A localization key can resolve to a string or to a set of image URLs, which means a Korean user and a US user can see entirely different hero imagery from one paywall definition, chosen on the device with the assets already predownloaded. The Video case is declared and read by the renderer, but the deserializer only attempts Text then Image, so it is not currently reachable from the wire. That is what a schema running slightly ahead of its parser looks like from the inside, and it is the same coordination problem the platform section returns to.
Finally, UiConfig adds a token layer above individual paywalls:
1public class UiConfig(
2 public val app: AppConfig = AppConfig(),
3 public val localizations: Map<LocaleId, Map<VariableLocalizationKey, String>> = emptyMap(),
4 @SerialName("variable_config") public val variableConfig: VariableConfig = VariableConfig(),
5 @SerialName("custom_variables") public val customVariables: Map<String, CustomVariableDefinition> = emptyMap(),
6) {
7 public class AppConfig(
8 public val colors: Map<ColorAlias, ColorScheme> = emptyMap(),
9 public val fonts: Map<FontAlias, FontsConfig> = emptyMap(),
10 )
Components reference ColorAlias and FontAlias rather than literal values, so changing a brand color once updates every paywall referencing it without touching a component. FontsConfig, whose declaration is not in that excerpt, holds a single field named android. The name is the interesting part: a schema serving only Android would not need to key the field by platform at all. The wire format carries a font entry per platform and each SDK reads its own key, which is the first place in the schema where cross-platform parity shows up as a design constraint rather than an implementation detail.
The localizations map on UiConfig is separate from a paywall’s own copy and holds translations for variable rendering. When a component writes {{ product.period }}, the word “month” has to appear in the reader’s language even though the dashboard author only wrote English. Those translations ship with the config and get applied on the device.
So the division of labor is specific. The dashboard decides what the paywall means. The device decides what is true right now. Three of those facts a hosted page can also read for itself: navigator.language, prefers-color-scheme, and a media query cover locale, theme, and width without any bridge. Theme comes with a wrinkle, since what a WebView reports to prefers-color-scheme follows the host theme’s android:isLightTheme rather than the system setting directly. The other three are the ones a browser cannot see on its own. What this user’s purchase history makes them eligible for, which currency conventions this storefront uses, and which fonts already shipped inside this binary all have to arrive as bridge messages the page then re-renders against, and until they arrive the page is displaying a guess.
The platform behind the JSON: What the SDK does not show you
Reading through the SDK, it is easy to conclude the hard part is the renderer. It is not. The renderer is the visible half of a system whose expensive half never ships to a device.
Start with the editor. A dashboard where a product manager rearranges a paywall has to emit a component tree valid against the schema, with live preview, which means a browser side implementation of the same layout semantics the native renderers implement. Visual editors for general purpose layout are a solved product category. An editor for a schema you invented is not one of them. It is a third renderer, and it has to stay in parity with the other two.

Then the schema. PaywallComponentsData carries a revision, and a good number of its sealed types have default cases, because the schema is a contract between one server and every SDK version ever installed. SealedDeserializerWithDefault covers seven of them, and others like ButtonComponent.Action and ButtonComponent.Destination reach the same result through hand written serializers that map an unrecognized value to Unknown.
Adding a component type is not a code change, it is a protocol change. The type has to be designed, added to the schema, implemented in each platform renderer, given a sensible fallback representation for older clients, taught to the editor, and covered by fixtures. The fallback mechanism from tier one only works because someone designs the fallback at the same time as the component.
Then parity, where the cost is highest and least visible, because it is not a feature you finish. The same JSON has to produce the same paywall on Android, on iOS, and through every hybrid framework in between, across codebases with different layout systems. Compose and SwiftUI do not agree by default about how a stack distributes remaining space, how a shadow interacts with a clipped shape, or what a percentage size resolves to inside a scroll container. Getting them to agree is deliberate work, and keeping them agreeing is permanent work.
The Android repository shows two pieces of the machinery. A corpus of paywall templates lives in a shared upstream repository, wired in as a submodule.
Those resources feed the debug source set, where TemplatePreviews renders them through a preview parameter provider so a change to the renderer can be reviewed against the same definitions every platform consumes. Separately, ui/revenuecatui-testing publishes PaywallFixtures, PaywallFixtureView, PaywallFixtureViewOptions, and PaywallFixturesTestRule, which let an app developer snapshot test their own dashboard configured paywalls against recorded fixtures. Parity is not maintained by discipline alone; there is a shared corpus and there is tooling that renders it.
On top of rendering, an experimentation platform needs to know what happened. The SDK carries PaywallEvent, PaywallStoredEvent, PaywallPresentedCache, PaywallPostReceiptData, and PaywallComponentInteractionTracker, which threads through most component views. Attribution is the reason: to know whether variant B beat variant A, a purchase has to be tied back to the exact paywall revision on screen when it happened, surviving process death, backgrounding, and a store callback that arrives later.
Add it up and the shape is clear. The JSON is the easy part. A visual editor, a versioned schema with a designed degradation story, a delivery and caching tier, a renderer per platform held to parity by a shared corpus, and an experiment assignment and attribution pipeline are the actual system. Parity is also not a cost you pay once: every component type you add adds work on every platform you support, indefinitely.
This is the honest argument for not building it yourself. Not that the rendering is hard, though the degradation design took real thought, but that the rendering is the part you would actually finish. RevenueCat provides the dashboard, the schema, the delivery, the renderers, the experiment engine, and the attribution, which is why a product manager can reorder packages, change which one is preselected, swap a hero image for one locale, or restyle a paywall and have it take effect on installed apps without a release. That capability is not a feature of the SDK. It is a property of the whole platform, and the SDK is the part that happens to run on the device.
The bill, honestly: Release gates, schema ceilings, and parity
Native server-driven UI is the more expensive architecture, and there are four places the cost lands.
The first is that new capabilities are gated on releases. When a component type is added, apps cannot render it until they ship an SDK version that knows it, and the fallback mechanism exists precisely because that transition takes months across a real install base. A hosted paywall has no such gate. That is a genuine advantage, and the fallback subtree does not erase it, it only guarantees the old binary shows something coherent in the meantime. The gap is not a fee that purchases anything either. It is the same architectural decision seen from the other side: a render model living inside your binary is what makes the offline path, the theme and locale response, and the pre-render validation possible, and it is what makes new components wait for a release.
The second is a ceiling on expression, and it is the one a competitor leads with. A designer can only produce effects the schema models. If the schema has no notion of a particular animation curve or a blend mode, no amount of dashboard work produces it, and the request becomes a protocol change with a multiplatform release behind it. A hosted page has no such ceiling, because its instruction set is the whole of CSS.
The third is the deployment coupling described in tier two. One unknown condition type strips every override that carries a rule across the whole of the affected paywall, which means the day a new condition ships, clients that have not updated lose conditional refinement throughout that paywall rather than only where the new condition appeared.
Conclusion
The next time you evaluate a server driven UI system, the question to ask is not how it renders. It is where the system puts the uncertainty about what the server might send, and who pays when that uncertainty resolves badly. A hosted document pushes it into a runtime you do not version, and the bill arrives as a blank screen on a bad network, a layout that shifted when the WebView provider updated, or a product identifier that did not match anything until a user pressed the button. A typed component tree pushes it into a schema boundary you design, and the bill arrives as release coordination, a ceiling on expression, and renderer parity, paid by the team rather than the user.
What makes the second approach hold up is that the boundary is a single, narrow place, and that every way past it has a named floor. Past that boundary, ComponentView is a when with no else, and the compiler will not let the renderer be incomplete. Every architecture has a point where it stops guessing. It is worth choosing where yours is.

