Why Networking Is the Backbone of Every Real .NET MAUI App
Strip away the UI, the animations, and the local storage, and what you're left with is a mobile app that talks to a server. Whether you're building a task manager, an e-commerce client, or a field-service tool, REST API consumption is the single most common operation your .NET MAUI app will perform. Get it wrong, and you'll ship an app that leaks sockets, drains batteries, and crashes the moment a user steps into an elevator.
Here's the thing — mobile networking is harder than server-side networking. You're dealing with connections that vanish mid-request, cellular-to-Wi-Fi handoffs, platform-specific TLS stacks, and devices that aggressively kill background work to save battery. The patterns that work perfectly in an ASP.NET Core backend can quietly fail on a phone sitting in someone's pocket.
This article walks you through everything you need to build production-grade REST API integration in .NET MAUI 10. We'll start with basic HttpClient usage, graduate to IHttpClientFactory and Refit, layer in resilience patterns for flaky connections, optimize with native HTTP handlers, and finish with a complete, testable architecture you can drop into your next project.
Basic REST Calls with HttpClient
Every HTTP story in .NET starts with HttpClient. Before we introduce any abstractions, let's make sure the fundamentals are solid. Here's the simplest possible GET request in a .NET MAUI context:
public async Task<List<Product>> GetProductsAsync(CancellationToken cancellationToken = default)
{
var client = new HttpClient();
client.BaseAddress = new Uri("https://api.example.com/");
var response = await client.GetAsync("v1/products", cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<List<Product>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
}
This works, but it has problems we'll address shortly. For now, notice two important details: we pass a CancellationToken so the caller can cancel the request when the user navigates away, and we call EnsureSuccessStatusCode to throw on 4xx/5xx responses rather than silently returning bad data.
Posting data follows a similar pattern. Here's a POST with a JSON body using System.Text.Json:
public async Task<Product> CreateProductAsync(Product product, CancellationToken cancellationToken = default)
{
var client = new HttpClient();
client.BaseAddress = new Uri("https://api.example.com/");
var json = JsonSerializer.Serialize(product);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("v1/products", content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
throw new ApiException($"API returned {response.StatusCode}: {error}");
}
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return (await JsonSerializer.DeserializeAsync<Product>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
cancellationToken))!;
}
Notice we check IsSuccessStatusCode manually here instead of calling EnsureSuccessStatusCode. This gives us the chance to read the error body and include it in our exception — invaluable for debugging API integration issues when something inevitably goes sideways.
The Socket Exhaustion Trap: Why You Should Never New Up HttpClient
Take another look at those examples above. Every call creates a new HttpClient(). This is the single most common networking mistake in .NET, and honestly, it's especially dangerous on mobile where resources are tighter.
Each HttpClient instance owns an underlying HttpMessageHandler, which in turn holds a socket connection pool. When you dispose the client, those sockets enter a TIME_WAIT state and linger for up to 240 seconds. Under load — say, a list view pulling images and data simultaneously — you'll exhaust available sockets and start getting SocketException errors.
// DO NOT do this — socket exhaustion waiting to happen
public async Task<string> GetDataAsync()
{
using (var client = new HttpClient()) // New handler + sockets every call
{
var response = await client.GetAsync("https://api.example.com/data");
return await response.Content.ReadAsStringAsync();
}
// Sockets enter TIME_WAIT here, not immediately released
}
The naive fix is to make HttpClient a static singleton. This solves socket exhaustion but introduces a different problem: the singleton never refreshes its DNS resolution. If the server behind your API rotates IP addresses (common with cloud load balancers), your app keeps connecting to a stale IP until the process restarts.
// Better, but DNS caching can cause stale connections
private static readonly HttpClient _client = new HttpClient
{
BaseAddress = new Uri("https://api.example.com/")
};
On mobile, this DNS problem gets worse. A user moves from Wi-Fi to cellular and back, and your singleton's cached DNS entries become meaningless. We need something smarter.
IHttpClientFactory: The Right Way to Manage HTTP Clients in .NET MAUI
IHttpClientFactory solves both problems elegantly. It pools and recycles HttpMessageHandler instances behind the scenes, reusing sockets while periodically rotating handlers so DNS changes get picked up. In .NET MAUI 10, it's fully supported through the Microsoft.Extensions.Http NuGet package.
Start by registering it in your MauiProgram.cs:
// MauiProgram.cs
using Microsoft.Extensions.Http;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Register a named HttpClient
builder.Services.AddHttpClient("ProductApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
return builder.Build();
}
}
You can then inject IHttpClientFactory and create named clients on demand. Each call to CreateClient returns a new HttpClient instance backed by a pooled handler — cheap to create, safe to let go of without disposing.
public class ProductService
{
private readonly IHttpClientFactory _httpClientFactory;
public ProductService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<List<Product>> GetProductsAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient("ProductApi");
var response = await client.GetAsync("v1/products", ct);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(ct);
return (await JsonSerializer.DeserializeAsync<List<Product>>(stream, cancellationToken: ct))!;
}
}
The Typed Client Pattern
Named clients work, but typed clients are cleaner. A typed client is a class that accepts a pre-configured HttpClient in its constructor, and the factory handles creating and configuring it for you.
// Registration in MauiProgram.cs
builder.Services.AddHttpClient<ProductService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
// The service class
public class ProductService
{
private readonly HttpClient _client;
public ProductService(HttpClient client)
{
_client = client;
}
public async Task<List<Product>> GetProductsAsync(CancellationToken ct = default)
{
var stream = await _client.GetStreamAsync("v1/products", ct);
return (await JsonSerializer.DeserializeAsync<List<Product>>(stream, cancellationToken: ct))!;
}
}
The default handler lifetime is two minutes. After that, the factory retires the handler (existing requests finish normally) and spins up a fresh one that'll pick up DNS changes. You can customize this with SetHandlerLifetime if your infrastructure needs a different cadence.
builder.Services.AddHttpClient<ProductService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
Refit: Declarative REST APIs with Zero Boilerplate
If you find yourself writing repetitive serialization and URL-building code across dozens of endpoints, Refit eliminates that boilerplate entirely. It lets you define your REST API as a C# interface, and it generates the implementation at compile time. I've used Refit on several production apps, and it's one of those libraries that genuinely makes your codebase better.
Install the NuGet package first:
dotnet add package Refit.HttpClientFactory
Now define your API as an interface. Each method maps to an HTTP verb and endpoint:
using Refit;
public interface IProductApi
{
[Get("/v1/products")]
Task<List<Product>> GetProductsAsync(CancellationToken cancellationToken = default);
[Get("/v1/products/{id}")]
Task<Product> GetProductAsync(int id, CancellationToken cancellationToken = default);
[Post("/v1/products")]
Task<Product> CreateProductAsync([Body] Product product, CancellationToken cancellationToken = default);
[Put("/v1/products/{id}")]
Task<Product> UpdateProductAsync(int id, [Body] Product product, CancellationToken cancellationToken = default);
[Delete("/v1/products/{id}")]
Task DeleteProductAsync(int id, CancellationToken cancellationToken = default);
[Get("/v1/products")]
Task<List<Product>> SearchProductsAsync([Query] string category, [Query] int page = 1,
CancellationToken cancellationToken = default);
}
Register the Refit client in MauiProgram.cs. It integrates directly with IHttpClientFactory, so you get all the handler pooling benefits automatically.
using Refit;
builder.Services.AddRefitClient<IProductApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
In your ViewModel, inject the interface and call methods as if the API were a local service. That's it — no manual serialization, no URL building, no fuss:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class ProductsViewModel : ObservableObject
{
private readonly IProductApi _productApi;
public ProductsViewModel(IProductApi productApi)
{
_productApi = productApi;
}
[ObservableProperty]
private List<Product> _products = [];
[ObservableProperty]
private bool _isLoading;
[RelayCommand]
private async Task LoadProductsAsync(CancellationToken cancellationToken)
{
try
{
IsLoading = true;
Products = await _productApi.GetProductsAsync(cancellationToken);
}
catch (ApiException ex)
{
// Refit throws ApiException for non-success status codes
await Shell.Current.DisplayAlert("Error",
$"Failed to load products: {ex.StatusCode}", "OK");
}
finally
{
IsLoading = false;
}
}
}
Dynamic Headers and Authorization
Refit makes dynamic headers straightforward. Use [Header] for per-request headers or [Headers] at the interface level for static ones:
public interface IProductApi
{
[Get("/v1/products")]
[Headers("X-Api-Version: 2")]
Task<List<Product>> GetProductsAsync(CancellationToken cancellationToken = default);
[Get("/v1/products/me")]
Task<List<Product>> GetMyProductsAsync(
[Authorize("Bearer")] string token,
CancellationToken cancellationToken = default);
[Post("/v1/products")]
Task<Product> CreateProductAsync(
[Body] Product product,
[Header("Idempotency-Key")] string idempotencyKey,
CancellationToken cancellationToken = default);
}
Refit vs Manual HttpClient: When to Use Each
| Criteria | Refit | Manual HttpClient |
|---|---|---|
| Boilerplate code | Minimal — interface only | Significant — serialization, URL building |
| Strongly typed APIs | Yes, compile-time checked | Manual, error-prone |
| Complex request logic | Harder to customize | Full control |
| Streaming large responses | Possible but awkward | Native support |
| Learning curve | Low if you know the attributes | Lowest — raw .NET |
| Testability | Excellent — mock the interface | Good — mock the handler |
My recommendation? Use Refit when your API is well-defined and follows REST conventions consistently. Fall back to manual HttpClient when you need fine-grained control over request construction, streaming, or multipart uploads with custom progress reporting.
Handling Connectivity Like a Mobile-First Developer
Desktop developers can often assume a stable connection. Mobile developers can't. .NET MAUI provides IConnectivity through MAUI Essentials, giving you real-time network status without any platform-specific code.
using Microsoft.Maui.Networking;
public class ConnectivityAwareService
{
private readonly IConnectivity _connectivity;
private readonly IProductApi _productApi;
public ConnectivityAwareService(IConnectivity connectivity, IProductApi productApi)
{
_connectivity = connectivity;
_productApi = productApi;
}
public async Task<List<Product>> GetProductsAsync(CancellationToken ct = default)
{
if (_connectivity.NetworkAccess != NetworkAccess.Internet)
{
throw new NoInternetException("No internet connection available.");
}
return await _productApi.GetProductsAsync(ct);
}
}
You can also inspect the connection type to make smarter decisions. For example, you might want to skip prefetching large images on a metered cellular connection (your users will thank you for not burning through their data plan).
var profiles = _connectivity.ConnectionProfiles;
if (profiles.Contains(ConnectionProfile.Cellular))
{
// On cellular — skip large prefetch operations
return await _productApi.GetProductsAsync(ct);
}
else
{
// On Wi-Fi — prefetch additional data
var products = await _productApi.GetProductsAsync(ct);
await PrefetchProductImagesAsync(products, ct);
return products;
}
For a more reactive approach, subscribe to connectivity changes so your UI can respond instantly when the network drops or comes back.
public partial class AppShell : Shell
{
public AppShell(IConnectivity connectivity)
{
InitializeComponent();
connectivity.ConnectivityChanged += OnConnectivityChanged;
}
private async void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
{
if (e.NetworkAccess != NetworkAccess.Internet)
{
await DisplayAlert("Offline",
"You appear to be offline. Some features may be unavailable.", "OK");
}
}
}
Using Native HTTP Handlers for Maximum Platform Performance
By default, .NET MAUI uses the managed SocketsHttpHandler for all HTTP traffic. This works fine, but each platform has a native HTTP stack that's faster, better optimized for battery life, and handles platform-specific TLS and proxy configurations automatically.
On Android, AndroidMessageHandler uses the OkHttp stack under the hood. On iOS and macOS, NSUrlSessionHandler taps into Apple's Foundation networking layer. Both support HTTP/2, automatic compression, and platform-managed certificate trust out of the box.
builder.Services.AddHttpClient<ProductService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
#if ANDROID
return new Xamarin.Android.Net.AndroidMessageHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All
};
#elif IOS || MACCATALYST
return new NSUrlSessionHandler
{
AllowAutoRedirect = true
};
#else
return new SocketsHttpHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All,
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
};
#endif
});
The performance difference is measurable. Native handlers negotiate TLS using hardware-accelerated platform APIs, handle HTTP/2 multiplexing natively, and integrate with the OS-level proxy and VPN settings your users have configured. On Android specifically, the native handler avoids the overhead of the managed SSL implementation, which can shave hundreds of milliseconds off the first request. That's noticeable.
When using Refit, the same configuration applies since Refit sits on top of IHttpClientFactory:
builder.Services.AddRefitClient<IProductApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
#if ANDROID
return new Xamarin.Android.Net.AndroidMessageHandler();
#elif IOS || MACCATALYST
return new NSUrlSessionHandler();
#else
return new SocketsHttpHandler();
#endif
});
Building Resilient API Calls with Microsoft.Extensions.Http.Resilience
A mobile device on a train, in a parking garage, or switching between Wi-Fi and cellular will experience transient failures that a server in a data center never will. Your API calls need to handle these gracefully. Microsoft.Extensions.Http.Resilience — the modern replacement for the now-deprecated Microsoft.Extensions.Http.Polly — provides composable resilience strategies built on the Polly v8 library.
Install the package:
dotnet add package Microsoft.Extensions.Http.Resilience
The Standard Resilience Handler
For most scenarios, the standard resilience handler gives you a battle-tested combination of retry, circuit breaker, and timeout policies with sensible defaults.
using Microsoft.Extensions.Http.Resilience;
builder.Services.AddHttpClient<ProductService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
})
.AddStandardResilienceHandler();
That single line adds a retry strategy (3 retries with exponential backoff), a circuit breaker (opens after repeated failures), a per-attempt timeout (10 seconds), and a total request timeout (30 seconds). For many mobile apps, this is genuinely all you need.
Custom Resilience Configuration
When the defaults don't fit your requirements, you can configure each strategy individually:
builder.Services.AddHttpClient<ProductService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
})
.AddStandardResilienceHandler(options =>
{
// Retry: 4 attempts with exponential backoff + jitter
options.Retry.MaxRetryAttempts = 4;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.Retry.BackoffType = DelayBackoffType.ExponentialWithJitter;
options.Retry.UseJitter = true;
// Circuit breaker: open after sustained failures
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(60);
options.CircuitBreaker.FailureRatioThreshold = 0.5;
options.CircuitBreaker.MinimumThroughput = 5;
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
// Timeouts
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(8);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60);
});
The jitter in the retry backoff is critical for mobile. Without it, thousands of devices that lost connectivity at the same time will all retry at the exact same instant when the connection returns, hammering your server. Jitter spreads those retries across a time window.
The circuit breaker prevents your app from wasting battery and bandwidth on an endpoint that's clearly down. When the circuit opens, requests fail immediately with no network call — giving both the server time to recover and the device battery a break.
JSON Serialization Best Practices for Mobile
How you deserialize JSON matters more on mobile than on the server. Memory is constrained, and large response payloads can cause garbage collection pauses that make your UI stutter.
Stream-Based Deserialization
Always deserialize from the response stream instead of first reading the entire response into a string. This avoids allocating a large string on the heap — and on a device with 3-4 GB of RAM shared across the entire OS, that matters.
// Bad — allocates the entire response as a string
var json = await response.Content.ReadAsStringAsync(ct);
var products = JsonSerializer.Deserialize<List<Product>>(json);
// Good — deserializes directly from the stream
var stream = await response.Content.ReadAsStreamAsync(ct);
var products = await JsonSerializer.DeserializeAsync<List<Product>>(stream, cancellationToken: ct);
Centralized Serializer Options
Create a shared JsonSerializerOptions instance. Each time you create a new one, the serializer rebuilds its metadata cache, which is surprisingly expensive.
public static class AppJsonOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
}
Source-Generated Serialization for AOT and Trimming
If you're targeting ahead-of-time compilation or aggressive trimming — both increasingly relevant for mobile startup performance — use source-generated serialization contexts. These eliminate runtime reflection entirely.
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
[JsonSerializable(typeof(ApiError))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class AppJsonContext : JsonSerializerContext
{
}
// Usage
var products = await JsonSerializer.DeserializeAsync(
stream,
AppJsonContext.Default.ListProduct,
cancellationToken: ct);
When configuring Refit with source-generated serialization, pass the serializer settings during registration:
var refitSettings = new RefitSettings
{
ContentSerializer = new SystemTextJsonContentSerializer(
new JsonSerializerOptions
{
TypeInfoResolver = AppJsonContext.Default
})
};
builder.Services.AddRefitClient<IProductApi>(refitSettings)
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
Connecting to Local Development Servers
One of the most frustrating parts of mobile API development is the localhost problem. Your API runs on https://localhost:5001, but your emulator or device can't reach localhost because that refers to the emulator or device itself, not your development machine. If you've ever spent an hour debugging this before realizing the issue, you're not alone.
Platform-Specific Base URLs
Android emulators route 10.0.2.2 to the host machine's loopback adapter. iOS simulators share the Mac's network stack, so localhost works directly. Physical devices need your machine's actual IP address on the local network.
public static class ApiConfiguration
{
public static string BaseUrl
{
get
{
#if DEBUG
#if ANDROID
return "https://10.0.2.2:5001";
#elif IOS
return "https://localhost:5001";
#else
return "https://localhost:5001";
#endif
#else
return "https://api.yourproduction.com";
#endif
}
}
}
SSL Certificate Trust Issues
Even with the right address, your development HTTPS certificate isn't trusted by Android or iOS. On Android, you'll see Javax.Net.Ssl.SSLHandshakeException. On iOS, you'll get NSURLErrorDomain errors.
For debug builds only, you can bypass certificate validation. Never ship this to production.
#if DEBUG
builder.Services.AddHttpClient<ProductService>(client =>
{
client.BaseAddress = new Uri(ApiConfiguration.BaseUrl);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
#if ANDROID
var handler = new Xamarin.Android.Net.AndroidMessageHandler();
handler.ServerCertificateCustomValidationCallback =
(message, cert, chain, errors) => true;
return handler;
#elif IOS
var handler = new NSUrlSessionHandler
{
TrustOverrideForUrl = (sender, url, trust) => true
};
return handler;
#else
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
return handler;
#endif
});
#endif
Android Cleartext Traffic
If you need to connect over plain HTTP during development (port 5000 without TLS), Android 9 and above blocks cleartext traffic by default. Create a network security configuration to allow it for your development server only:
<!-- Platforms/Android/Resources/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>
Then reference it in your AndroidManifest.xml:
<application android:networkSecurityConfig="@xml/network_security_config" ...>
Warning: Don't ship a permissive network security config to production. Use build configurations or conditional file inclusion to make sure debug-only network configurations never reach your release builds.
Putting It All Together: A Production-Ready API Service
So, let's combine everything we've covered into a complete, production-grade setup. This is what a real .NET MAUI 10 app's HTTP layer looks like when you put all the pieces together.
The Auth Token Handler
First, a DelegatingHandler that attaches the bearer token to every outgoing request. This is much cleaner than passing tokens manually to each API call.
public class AuthTokenHandler : DelegatingHandler
{
private readonly ISecureStorage _secureStorage;
public AuthTokenHandler(ISecureStorage secureStorage)
{
_secureStorage = secureStorage;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await _secureStorage.GetAsync("auth_token");
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
return await base.SendAsync(request, cancellationToken);
}
}
Full MauiProgram.cs Registration
using Microsoft.Extensions.Http.Resilience;
using Refit;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
// Register the auth handler
builder.Services.AddTransient<AuthTokenHandler>();
builder.Services.AddSingleton<ISecureStorage>(SecureStorage.Default);
builder.Services.AddSingleton<IConnectivity>(Connectivity.Current);
// Configure Refit serialization
var refitSettings = new RefitSettings
{
ContentSerializer = new SystemTextJsonContentSerializer(
new JsonSerializerOptions
{
TypeInfoResolver = AppJsonContext.Default
})
};
// Register the Refit API client with full production configuration
builder.Services
.AddRefitClient<IProductApi>(refitSettings)
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri(ApiConfiguration.BaseUrl);
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add("X-App-Version",
AppInfo.Current.VersionString);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
#if ANDROID
return new Xamarin.Android.Net.AndroidMessageHandler
{
AutomaticDecompression = DecompressionMethods.All
};
#elif IOS || MACCATALYST
return new NSUrlSessionHandler();
#else
return new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All,
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
};
#endif
})
.AddHttpMessageHandler<AuthTokenHandler>()
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.Retry.BackoffType = DelayBackoffType.ExponentialWithJitter;
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(45);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(3));
// Register services and ViewModels
builder.Services.AddSingleton<ConnectivityAwareProductService>();
builder.Services.AddTransient<ProductsViewModel>();
builder.Services.AddTransient<ProductsPage>();
return builder.Build();
}
}
The Connectivity-Aware Service
public class ConnectivityAwareProductService
{
private readonly IProductApi _api;
private readonly IConnectivity _connectivity;
public ConnectivityAwareProductService(IProductApi api, IConnectivity connectivity)
{
_api = api;
_connectivity = connectivity;
}
public async Task<List<Product>> GetProductsAsync(CancellationToken ct = default)
{
if (_connectivity.NetworkAccess != NetworkAccess.Internet)
throw new NoInternetException("No internet connection.");
return await _api.GetProductsAsync(ct);
}
public async Task<Product> CreateProductAsync(Product product, CancellationToken ct = default)
{
if (_connectivity.NetworkAccess != NetworkAccess.Internet)
throw new NoInternetException("No internet connection.");
return await _api.CreateProductAsync(product, ct);
}
}
The ViewModel
public partial class ProductsViewModel : ObservableObject
{
private readonly ConnectivityAwareProductService _productService;
public ProductsViewModel(ConnectivityAwareProductService productService)
{
_productService = productService;
}
[ObservableProperty]
private ObservableCollection<Product> _products = [];
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private bool _isEmpty;
[ObservableProperty]
private string? _errorMessage;
[RelayCommand]
private async Task LoadProductsAsync(CancellationToken cancellationToken)
{
try
{
ErrorMessage = null;
IsLoading = true;
var result = await _productService.GetProductsAsync(cancellationToken);
Products = new ObservableCollection<Product>(result);
IsEmpty = Products.Count == 0;
}
catch (NoInternetException)
{
ErrorMessage = "You're offline. Please check your connection.";
}
catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
ErrorMessage = "Your session has expired. Please sign in again.";
await Shell.Current.GoToAsync("//login");
}
catch (Exception ex)
{
ErrorMessage = "Something went wrong. Pull down to try again.";
#if DEBUG
System.Diagnostics.Debug.WriteLine($"LoadProducts failed: {ex}");
#endif
}
finally
{
IsLoading = false;
}
}
[RelayCommand]
private async Task RefreshAsync(CancellationToken cancellationToken)
{
await LoadProductsAsync(cancellationToken);
}
}
The XAML Page
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MyApp.ViewModels"
x:Class="MyApp.Views.ProductsPage"
x:DataType="vm:ProductsViewModel"
Title="Products">
<RefreshView Command="{Binding RefreshCommand}"
IsRefreshing="{Binding IsLoading}">
<Grid>
<CollectionView ItemsSource="{Binding Products}"
IsVisible="{Binding IsEmpty,
Converter={StaticResource InvertBoolConverter}}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Grid Padding="16,12" ColumnDefinitions="*,Auto">
<VerticalStackLayout>
<Label Text="{Binding Name}"
FontSize="16"
FontAttributes="Bold" />
<Label Text="{Binding Category}"
FontSize="13"
TextColor="Gray" />
</VerticalStackLayout>
<Label Grid.Column="1"
Text="{Binding Price, StringFormat='${0:F2}'}"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<VerticalStackLayout IsVisible="{Binding IsEmpty}"
VerticalOptions="Center"
HorizontalOptions="Center">
<Label Text="No products found"
FontSize="18"
HorizontalOptions="Center" />
</VerticalStackLayout>
<Frame IsVisible="{Binding ErrorMessage,
Converter={StaticResource IsNotNullConverter}}"
BackgroundColor="#FFF3CD"
Padding="16"
VerticalOptions="End"
Margin="16">
<Label Text="{Binding ErrorMessage}" TextColor="#856404" />
</Frame>
</Grid>
</RefreshView>
</ContentPage>
This architecture is fully testable. In your unit tests, you mock IProductApi and IConnectivity and test the ViewModel in complete isolation. No HTTP calls, no platform dependencies, no flakiness.
Frequently Asked Questions
Can I use IHttpClientFactory in .NET MAUI?
Yes, absolutely. IHttpClientFactory is fully supported in .NET MAUI through the Microsoft.Extensions.Http package. Since .NET MAUI uses the same generic host builder pattern as ASP.NET Core, you register clients in MauiProgram.cs exactly as you would in a web application. Named clients, typed clients, and DelegatingHandler pipelines all work identically.
How do I add authentication headers to every request?
The cleanest approach is a DelegatingHandler. Create a handler that reads the token from secure storage and attaches it to the Authorization header, as shown in the AuthTokenHandler example above. Register it with AddHttpMessageHandler<AuthTokenHandler>() in your client pipeline. This keeps authentication logic out of your service classes and ViewModels entirely. For token refresh scenarios, the handler can detect 401 responses, refresh the token, and retry the request transparently:
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await _tokenService.GetAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
token = await _tokenService.RefreshAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
Should I use Refit or raw HttpClient?
Use Refit when your API follows standard REST conventions, you want compile-time safety, and you value concise code. It excels for CRUD-heavy APIs with dozens of endpoints — defining them as interface methods is far less error-prone than writing manual serialization code for each one. Use raw HttpClient (via IHttpClientFactory) when you need fine-grained control over the request pipeline, need to stream large payloads with progress reporting, or are dealing with non-standard APIs. In practice, many apps use both: Refit for the main API and raw HttpClient for edge cases like file uploads.
Why does my .NET MAUI Android app fail with SSL errors connecting to localhost?
Two things are happening. First, localhost on an Android emulator refers to the emulator itself, not your development machine — use 10.0.2.2 instead to reach your host. Second, your ASP.NET Core development certificate isn't in the Android trust store. For debug builds, use the ServerCertificateCustomValidationCallback workaround shown earlier to bypass certificate validation. For physical devices, you'll need to either install the development certificate on the device or use a tool like ngrok to expose your local server with a valid certificate.
How do I handle offline scenarios when calling REST APIs?
A robust offline strategy has three layers. First, check connectivity before making API calls using IConnectivity and fail fast with a user-friendly message. Second, cache successful responses locally using SQLite or file-based caching so the app stays useful without a connection. Third, for write operations, implement an offline queue: save the pending request to local storage, and process the queue when connectivity returns by listening to the ConnectivityChanged event. For read-heavy apps, a stale-while-revalidate pattern works really well — show cached data immediately and refresh in the background when you're back online.
public async Task<List<Product>> GetProductsAsync(CancellationToken ct = default)
{
// Always try to return cached data first for instant UI
var cached = await _cacheService.GetAsync<List<Product>>("products");
if (_connectivity.NetworkAccess != NetworkAccess.Internet)
{
return cached ?? throw new NoInternetException("Offline and no cached data.");
}
try
{
var fresh = await _api.GetProductsAsync(ct);
await _cacheService.SetAsync("products", fresh, TimeSpan.FromMinutes(15));
return fresh;
}
catch (HttpRequestException) when (cached is not null)
{
// Network error but we have cache — return stale data
return cached;
}
}