Feature Flags in .NET MAUI 10: LaunchDarkly, ConfigCat, and Firebase Remote Config Compared (2026)

A hands-on comparison of LaunchDarkly, ConfigCat, and Firebase Remote Config in .NET MAUI 10, with setup code, offline caching patterns, kill-switch design, and CI/CD wiring.

Feature Flags in .NET MAUI 10 Compared (2026)

Updated: August 2, 2026

Feature flags in .NET MAUI 10 are runtime switches that let you turn features on or off, per user, per platform, per app version, without shipping a new build to the App Store or Google Play. The three SDKs most MAUI teams reach for in 2026 are LaunchDarkly (enterprise-grade targeting and experimentation), ConfigCat (developer-friendly and inexpensive), and Firebase Remote Config (free, tightly coupled to the Firebase stack). This guide compares all three from a mobile DevOps angle: SDK setup, offline caching, kill-switch patterns, and CI/CD hooks so a bad flag never ships a broken app.

  • LaunchDarkly wins on targeting rules, audit logs, and enterprise features, but pricing scales with MAU and can hit $10k+/year for consumer apps.
  • ConfigCat is the cheapest paid option ($0 to $99/month) with a proper .NET SDK, semantic version targeting, and a clean web console. It's the pragmatic default for small teams.
  • Firebase Remote Config is free and integrates with Analytics/A/B Testing, but the .NET MAUI story is a Plugin.Firebase wrapper, not a first-party SDK, and it fetches lazily.
  • All three need an offline cache and safe defaults baked in. A MAUI app on a plane must never freeze waiting for a flag response.
  • Wire flag names into a typed IFeatureFlags abstraction so you can swap providers, mock in tests, and delete stale flags with confidence.
  • Every feature flag needs a lifecycle: an owner, an expiry date, and a CI check that fails the build when a flag is older than 90 days and still referenced.

What are feature flags in mobile apps?

Most teams skip this part, and then a bad release forces a hotfix through App Store review because there was no way to turn off the broken feature remotely. A feature flag (also called a feature toggle) is a boolean, string, or JSON value your app reads at runtime to decide whether to show a UI element, hit a new API, or fall back to old behavior. In a web app you can ship a fix in minutes. On mobile, you can't. Apple review is unpredictable, and even after Google Play approves a release, staged rollout means only some users get it. Flags decouple deploy from release.

For .NET MAUI 10 specifically, flags earn their keep in five scenarios I run into on almost every project: killing a crash-prone code path (the "kill switch"), gating a beta feature to internal testers, running an A/B test on a new checkout flow, rolling a change out to 5% of users before the full fleet, and platform-scoping (turn a feature on for Android but not iOS while you fix a WKWebView bug). If any of those sound familiar, you need flags, not another #if DEBUG.

The mobile constraint that shapes everything is connectivity. Unlike a server that can call the flag service on every request, a MAUI app has to work on the subway, on a plane, in a warehouse basement. So: cache aggressively, ship sane defaults inside the binary, and treat the flag service as best-effort. Never a hard dependency for launch.

LaunchDarkly vs ConfigCat vs Firebase Remote Config

Here's how the three most common options stack up for a mid-size .NET MAUI team in 2026. I've shipped production apps on all three, and honestly, the differences are less about "which is better" and more about "which trade-off matches your team."

FeatureLaunchDarklyConfigCatFirebase Remote Config
Starting price~$0 (developer tier) up to $10k+/yr productionFree tier (10 flags), $99/mo ProFree (Blaze plan for advanced)
Official .NET SDKYes, LaunchDarkly.ClientSdkYes, ConfigCat.ClientNo, via Plugin.Firebase or handlers
MAUI-friendly (net9.0-ios/android)YesYesWrapper required
Offline cache built inYes (local file + memory)Yes (auto-poll + local snapshot)Yes (24h default TTL)
Targeting rulesDeep (segments, semver, JSON payloads)Good (percentage, semver, custom attrs)Basic (conditions on Analytics audiences)
Streaming updatesYes (Server-Sent Events)Auto-poll (webhook fanout)Poll-only (min 12h in prod)
A/B test / experimentationBuilt in (Experimentation add-on)Basic (percentage rollouts)Free A/B testing via Firebase
Audit log & approvalsYes (SOC 2, approvals workflow)Yes (Enterprise tier)Change history, no approvals
Data residencyUS/EU/AU regionsEU/US regionsGoogle Cloud regions

The short version: pick LaunchDarkly if you have a platform team, regulated data, and budget. Pick ConfigCat if you want a clean .NET-first SDK with predictable pricing. Pick Firebase Remote Config if you're already all-in on Firebase (Analytics, Crashlytics, A/B Testing) and don't want another vendor bill. The same team that reads your crash reporting comparison will feel at home.

How do you implement feature flags in .NET MAUI 10?

The cleanest pattern is: define a typed IFeatureFlags abstraction, register a provider-specific implementation in MauiProgram, and inject it into view models. That way you can swap ConfigCat for LaunchDarkly next quarter without touching a single page.

Step 1. Define the abstraction

public interface IFeatureFlags
{
    bool IsEnabled(string key, bool defaultValue = false);
    string GetString(string key, string defaultValue = "");
    T GetValue<T>(string key, T defaultValue);
    Task RefreshAsync(CancellationToken ct = default);
    event EventHandler FlagsChanged;
}

Step 2. Implement with ConfigCat (my default for small teams)

Add the NuGet package ConfigCat.Client (v9.5+ supports .NET 9/10 and the MAUI target frameworks). Then wire up the client with a local cache and a poll interval that respects mobile battery.

public sealed class ConfigCatFeatureFlags : IFeatureFlags, IDisposable
{
    private readonly IConfigCatClient _client;
    public event EventHandler FlagsChanged;

    public ConfigCatFeatureFlags(string sdkKey, string cacheDir)
    {
        _client = ConfigCatClient.Get(sdkKey, options =>
        {
            // Auto-poll every 5 minutes when app is foregrounded
            options.PollingMode = PollingModes.AutoPoll(TimeSpan.FromMinutes(5));

            // Persist last-known-good config to disk so cold starts work offline
            options.ConfigCache = new FileSystemConfigCache(cacheDir);

            // Log to your existing ILogger pipeline
            options.Logger = new ConfigCatLoggerAdapter();
        });

        _client.ConfigChanged += (_, __) => FlagsChanged?.Invoke(this, EventArgs.Empty);
    }

    public bool IsEnabled(string key, bool defaultValue = false)
        => _client.GetValue(key, defaultValue, BuildUserContext());

    public string GetString(string key, string defaultValue = "")
        => _client.GetValue(key, defaultValue, BuildUserContext());

    public T GetValue<T>(string key, T defaultValue)
        => _client.GetValue(key, defaultValue, BuildUserContext());

    public Task RefreshAsync(CancellationToken ct = default)
        => _client.ForceRefreshAsync(ct);

    private static User BuildUserContext() => new(
        identifier: Preferences.Get("user_id", Guid.NewGuid().ToString()),
        custom: new Dictionary<string, object>
        {
            ["platform"]    = DeviceInfo.Platform.ToString(),
            ["os_version"]  = DeviceInfo.VersionString,
            ["app_version"] = AppInfo.VersionString,
            ["locale"]      = CultureInfo.CurrentCulture.Name
        });

    public void Dispose() => _client?.Dispose();
}

Step 3. Register in MauiProgram.cs

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder()
        .UseMauiApp<App>()
        .ConfigureFonts(f => f.AddFont("OpenSans-Regular.ttf", "OpenSans"));

    var sdkKey  = Environments.Current == "Production"
        ? "configcat-prod-key"
        : "configcat-dev-key";
    var cacheDir = Path.Combine(FileSystem.CacheDirectory, "flags");
    Directory.CreateDirectory(cacheDir);

    builder.Services.AddSingleton<IFeatureFlags>(
        _ => new ConfigCatFeatureFlags(sdkKey, cacheDir));

    return builder.Build();
}

Step 4. Consume from a view model

public partial class CheckoutViewModel : ObservableObject
{
    private readonly IFeatureFlags _flags;

    public CheckoutViewModel(IFeatureFlags flags)
    {
        _flags = flags;
        _flags.FlagsChanged += (_, __) => OnPropertyChanged(nameof(ShowApplePayButton));
    }

    public bool ShowApplePayButton =>
        DeviceInfo.Platform == DevicePlatform.iOS &&
        _flags.IsEnabled("checkout_apple_pay_v2", defaultValue: false);
}

Offline caching and safe defaults

Honestly, this is the section most feature-flag tutorials skip, and it's the one that'll bite you the hardest on a mobile app. When your app boots on a phone with no signal, three things need to be true: (1) the SDK returns instantly with a value, (2) that value is either the last-known-good from disk cache or a safe default compiled into the binary, and (3) no code path awaits the flag service on the critical launch path.

All three SDKs cache to memory. Only some cache to disk out of the box, and none of them ship with defaults that match your product. Here's the pattern I use.

Bundle a defaults file in the app package

Add a flag-defaults.json under Resources/Raw/ with every flag your app reads, mapped to the value that guarantees a working app if the network is dead forever:

{
  "checkout_apple_pay_v2": false,
  "new_onboarding_flow":    false,
  "search_use_typesense":   false,
  "killswitch_video_upload": false,
  "min_supported_version":  "3.0.0"
}

Then in your feature-flags implementation, load that file at construction and use it as the terminal fallback when both the SDK cache and the network are unavailable. LaunchDarkly and ConfigCat both accept a defaultValue per call, so pipe the bundled JSON through that argument.

Fetch on the background thread, never block launch

protected override async void OnStart()
{
    base.OnStart();
    // Fire and forget: the SDK already has the last-known-good on disk
    _ = Task.Run(async () =>
    {
        try { await _flags.RefreshAsync(); }
        catch (Exception ex) { _logger.LogWarning(ex, "Flag refresh failed"); }
    });
}

Kill switches vs experiments

These are two different tools that happen to share the same SDK. Conflating them is how you end up with 400 stale flags and a runbook nobody trusts.

A kill switch is a boolean, defaults to on (feature enabled), and exists so an operator can flip it to off in an outage. Its whole purpose is to be flipped from a Slack alert at 3 a.m. It should live for the lifetime of the feature, and the code path it gates should always be present. When the video-upload endpoint is on fire, you flip killswitch_video_upload to false and the button disappears in the next 5-minute poll cycle.

An experiment flag is a percentage rollout or A/B test. It defaults to the control (usually off), targets a subset of users, and has a defined end date after which the winner is picked and the flag is deleted. LaunchDarkly's Experimentation add-on and Firebase's built-in A/B Testing both handle the statistics. ConfigCat leaves that to you.

The DevOps rule: name them differently. Prefix kill switches with killswitch_, experiments with experiment_, and release rollouts with rollout_. When you grep the code six months later you'll know which ones you can safely delete and which are load-bearing. Same discipline that saved my team when we did the Fastlane rollout automation. Naming is half of DevOps.

Managing feature flag lifecycle and tech debt

Every flag is a branch in your code. Twenty flags is twenty branches. A hundred flags is unmaintainable. Google's engineering blog and Martin Fowler's canonical Feature Toggles article both hammer this point: flags need an expiry date and an owner. Here's the checklist I paste into every project's contributing guide.

  1. Every new flag gets a Jira ticket in the "Flag Cleanup" epic at creation time, with a due date 90 days out. If nobody's willing to schedule the cleanup, they don't get the flag.
  2. The flag name includes an owner suffix: checkout_apple_pay_v2__team_payments. When it's stale, we know who to Slack.
  3. A CI job enumerates all flag references and cross-checks against the SDK dashboard. Flags older than 90 days that are still referenced fail the PR check with a link to the cleanup ticket.
  4. Delete the flag in the SDK dashboard first, then remove the code in the next release. Doing it in the other order means an old app version could hit the SDK, get the default, and behave surprisingly.
  5. Never nest flags. An if (a) if (b) combo has four states, and nobody tests all four. Refactor to a single flag or split into separate features.

Wiring flags into your CI/CD pipeline

Feature flags don't replace CI/CD, they extend it. The pipeline still builds, signs, and ships the binary; flags just decide what code runs. Three integration points matter for a MAUI team.

1. Inject SDK keys as build-time secrets. Do not commit the production SDK key. In GitHub Actions or Azure DevOps, expose it as a masked secret and template it into appsettings.Production.json during the build step. My GitHub Actions pipeline for MAUI already has the secret-injection dance, so plug the flag SDK key in the same way.

2. Add a smoke-test flag. Create one flag called ci_smoke_check that your UI test suite reads at boot. If the value doesn't match what the test expects, fail the pipeline. This is how you catch broken SDK keys, wrong environment routing, and network egress issues before the app hits TestFlight.

3. Gate risky rollouts behind an approval step. Both LaunchDarkly and ConfigCat expose REST APIs for toggling flags. Wrap them in a workflow job that requires a manual approval before enabling a flag in production. Same reviewer pattern as a production deploy, because that's what it is.

# .github/workflows/enable-flag.yml
name: Enable production flag
on: workflow_dispatch
jobs:
  enable:
    runs-on: ubuntu-latest
    environment: production   # requires reviewer approval
    steps:
      - name: Enable checkout_apple_pay_v2
        run: |
          curl -X PATCH \
            -H "Authorization: configcat-auth $CONFIGCAT_MGMT_KEY" \
            -H "Content-Type: application/json" \
            -d '[{"op":"replace","path":"/value","value":true}]' \
            https://api.configcat.com/v1/settings/12345/value
        env:
          CONFIGCAT_MGMT_KEY: ${{ secrets.CONFIGCAT_MGMT_KEY }}

Once those three are in place, you have the same operational surface a mature web team has: deploy the binary through CI, release the feature through the flag console, roll back with a toggle instead of a hotfix.

Frequently Asked Questions

Are feature flags free for .NET MAUI apps?

Firebase Remote Config is fully free at any scale. ConfigCat has a permanent free tier capped at 10 flags. LaunchDarkly's free tier is for individual developers only, so production usage requires a paid contract that typically starts around $500/month.

What's the difference between feature flags and A/B testing?

Feature flags are the mechanism (a runtime switch). A/B testing is one use case where you split users into groups and measure a metric. All A/B tests use flags, but most flags are not A/B tests. They're kill switches, gradual rollouts, or environment toggles.

How do you handle feature flags when the phone is offline?

Cache the last-known-good flag values to disk, bundle a JSON defaults file in the app package, and never await the flag service on the launch path. All three SDKs cache to memory automatically, but only LaunchDarkly and ConfigCat persist to disk without extra configuration.

Can I use Firebase Remote Config in .NET MAUI without Plugin.Firebase?

Yes, but it's more work. You write MAUI Handlers that call the native Firebase Remote Config SDK on each platform. Plugin.Firebase.RemoteConfig wraps that boilerplate and is the pragmatic choice unless you already maintain custom native bindings.

How often should a MAUI app poll for flag updates?

Every 5 to 15 minutes when the app is in the foreground, plus once on app resume. More frequent polling drains battery and hits SDK rate limits; less frequent polling delays kill-switch response. Never poll from a background task, use the SDK's built-in polling mode instead.

Sofia Rodriguez
About the Author Sofia Rodriguez

Mobile DevOps engineer focused on the unglamorous stuff: build pipelines, signing, store releases, and the tooling that keeps teams shipping.