A remote-config paywall you can A/B test without shipping a build
The paywall is the single highest-leverage screen in a paid app, and for years I treated it like the one screen I couldn't touch without a week of lead time. Every headline tweak, every price test, every reorder of the feature bullets meant a new build, a new review, and a wait. That's backwards. Here's the setup I use now to change the paywall from a config push and measure whether it actually worked.
If you ship subscription apps, you already know the math: a one-point swing in trial-start rate, compounded across every install for the life of the app, dwarfs almost any feature you could build that sprint. So you want to experiment constantly. The problem is that a hard-coded paywall makes every experiment expensive. You write two variants, you gate them behind a flag, you submit, you wait two days for review, you wait another few days to collect enough data, and by the time you have an answer you've forgotten which build it shipped in. Do that ten times and a year is gone.
The shape of the fix
The fix is to stop encoding the paywall in Swift and start encoding it in data. Instead of a PaywallView with its strings, layout, and selected offer baked in, I ship a renderer — a view that takes a small Codable description and draws whatever it's told to. The description lives on the server. Change the JSON, push it, and every device that opens the paywall on its next launch draws the new one. No build, no review, no wait.
That description is deliberately small. It is not a layout engine and it is not HTML. It's a handful of fields the design can vary meaningfully without me writing new view code: a headline, a subhead, an ordered list of feature bullets, which products to show, which one is pre-selected, and the call-to-action label. If a future experiment genuinely needs a new kind of element, that's a build — but in practice 90% of what you want to test is copy and arrangement, and those are just fields.
struct PaywallConfig: Codable, Equatable {
let id: String // names the experiment variant
let headline: String
let subhead: String
let bullets: [String]
let productIDs: [String] // StoreKit product identifiers
let highlightedProductID: String
let ctaTitle: String
let footnote: String?
}
The server names products. It never sends a price.
This is the rule I'd tattoo on anyone building this: the config carries product identifiers, never prices. The string "com.app.pro.yearly" is safe to send over the wire. The string "$39.99/year" is a bug waiting to happen.
Prices belong to StoreKit, full stop. Apple already localises them — currency symbol, decimal convention, the works — and the App Store is the source of truth for what the user will actually be charged. If you put a price in your JSON, you now have two numbers that can disagree: the one in your config and the one in the payment sheet. The day they drift apart is the day you either confuse users or, worse, advertise a price you can't honour. So the renderer takes the productIDs from the config, asks StoreKit for those Product values, and reads product.displayPrice off the result. The server decides which products to show; StoreKit decides what they cost.
Config sends product IDs. StoreKit sends prices. If a productID in the config doesn't resolve to a real Product — a typo, a product not yet approved, a region where it isn't sold — drop that offer rather than rendering an empty row. A paywall missing one button still converts; a paywall showing a blank price doesn't.
Bucketing, and why it has to be stable
An experiment needs two things: a way to split users into variants, and a guarantee that a given user stays in their variant. The first is easy. The second is where home-grown setups quietly fall apart.
I lean on Firebase Remote Config with A/B Testing for the assignment, because it solves stable bucketing for you — a user lands in a variant and stays there across launches, and the console wires the experiment up to an analytics goal. But the principle is the same whichever tool you use: assignment must be deterministic per user, not per session. If a user sees variant A on Monday and variant B on Tuesday, your data is poisoned — you can no longer attribute their eventual purchase to either one, and the test means nothing.
If you ever roll your own, the assignment is a hash of a stable identifier, not a random number:
import CryptoKit
func bucket(userID: String, experiment: String, variants: Int) -> Int {
let seed = "\(experiment):\(userID)"
let digest = SHA256.hash(data: Data(seed.utf8))
// First 8 bytes of the digest as an unsigned integer.
let value = digest.prefix(8).reduce(into: UInt64(0)) { acc, byte in
acc = (acc << 8) | UInt64(byte)
}
return Int(value % UInt64(variants))
}
Because the hash is pure, the same userID and experiment always return the same bucket — no stored state, no race on first launch, and adding a new experiment never reshuffles an existing one. (Salting with the experiment name is what keeps experiments independent; reuse a bare user-ID hash across two tests and you'll correlate them by accident.)
Caching, and a config baked into the binary
A server-driven paywall has an obvious failure mode: the screen that makes you money depends on a network call. First launch on a plane, a flaky connection, a Remote Config fetch that times out — in every one of those cases the paywall still has to render something. So there are three layers, in order of preference: a fresh value fetched this session, a cached value from last time, and a fallback config compiled into the app bundle as a last resort.
The bundled fallback is the part people skip, and it's the most important one. It guarantees the paywall is never blank, never blocked on the network, and always at least as good as the last build you shipped. Fetch in the background, apply the result to the next presentation, and never make the user wait on a spinner to find out whether they can subscribe. A slightly stale paywall beats no paywall every single time.
Trust nothing: validate, then fall back
The flip side of "anyone can change the paywall from a dashboard" is that anyone can break the paywall from a dashboard. A missing comma, an empty bullets array, a highlightedProductID that isn't in the productIDs list — any of these can sail out of the console and into production. The renderer has to treat the remote config as hostile input.
So I never render a config straight from the wire. I decode it, run it through a validator, and if anything is off I discard it and fall back to the cached or bundled config. The decode itself catches malformed JSON; the validator catches well-formed but nonsensical JSON, which is the failure that actually bites you. Here's the whole load path, fallback included:
enum PaywallLoader {
/// Decode + validate a remote payload, falling back to the bundled
/// config if anything is wrong. The app is never left without a paywall.
static func load(remoteJSON: Data?) -> PaywallConfig {
if let data = remoteJSON,
let config = try? JSONDecoder().decode(PaywallConfig.self, from: data),
isValid(config) {
return config
}
return bundled // last-resort, ships in the binary
}
/// Well-formed JSON can still be nonsense. Reject it before it renders.
static func isValid(_ c: PaywallConfig) -> Bool {
guard !c.headline.isEmpty,
!c.bullets.isEmpty,
!c.productIDs.isEmpty,
c.productIDs.contains(c.highlightedProductID) else {
return false
}
return true
}
/// Compiled into the app bundle — the floor under every experiment.
static let bundled = PaywallConfig(
id: "fallback",
headline: "Unlock everything",
subhead: "One subscription, every feature.",
bullets: ["Unlimited projects", "Sync across devices", "No ads"],
productIDs: ["com.app.pro.yearly", "com.app.pro.monthly"],
highlightedProductID: "com.app.pro.yearly",
ctaTitle: "Start free trial",
footnote: "Cancel anytime."
)
}
Tag the returned config's id onto every analytics event the paywall fires — impression, product tap, purchase. That string is how you'll tell which variant a conversion belongs to, and crucially, the fallback shows up in your data as "fallback". If that bucket is bigger than a rounding error, you've got a config delivery problem to chase, not a copy problem.
Measuring lift without lying to yourself
Now the hard part, which has nothing to do with Swift. You can push a paywall variant in seconds; that speed makes it tempting to push, glance at the dashboard an hour later, declare a winner, and move on. Don't. That's the fastest way to ship changes that do nothing — or quietly hurt.
Two disciplines keep the numbers honest. First, define the metric and the duration before you start, and write them down. The metric is usually trial-start rate or paid-conversion rate, attributed by the id you're already logging. The duration runs long enough to clear your sales cycle — at least a full week so weekday and weekend traffic both land, and longer if you're measuring conversion past a trial, because a seven-day trial means today's signups don't convert until next week.
Second, and this is the one everybody violates: don't peek. Checking the experiment repeatedly and stopping the moment it crosses significance inflates your false-positive rate enormously — run enough early peeks and a coin-flip difference will eventually look "significant" by chance. Pick the sample size up front, wait for it, and read the result once. Tools like Firebase A/B Testing compute the credible interval for you; your job is to leave it alone until it's done. A 0.4-point lift on ten thousand users is noise; the same lift on a million is real money — and only the sample size you fixed in advance tells you which one you're looking at.
None of this is exotic. A small Codable struct, a renderer that draws it, StoreKit for prices, a hash for buckets, three layers of caching with a fallback in the bundle, and the discipline to read the result only once. Put those together and the paywall stops being the screen you're afraid to touch. It becomes the screen you iterate on weekly — pricing, headline, the order of the bullets — at the speed of a config push instead of a release. That shift, more than any single winning variant, is what compounds.