Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

Getting Started

GitHub GitHub stars for shinyorg/shiny
Downloads NuGet downloads for Shiny.Data.Sync
Frameworks
.NET
.NET MAUI
Blazor
Operating Systems
Android
iOS
tvOS
macOS
Windows
Linux

Shiny.Data.Sync is a reliable, background-capable, bidirectional JSON record-sync engine between a mobile / desktop / web app and an HTTP backend. Where Shiny.Net.Http moves files in the background, Shiny.Data.Sync moves records: Create / Update / Delete operations queued in a local outbox and drained against a REST API, with a complementary inbox pulling deltas back from the server.

If you’ve been waiting for “EF Sync, but for mobile that actually survives suspension” — this is that.

Component Description
An outbox Queue<T>(verb, entity) writes a SyncOperation to a persistent repository. The transport drains it as soon as connectivity is available, retries transient failures with exponential backoff, sends each retry when it is due, and persists the next-attempt time so a process restart resumes the wait window mid-air.
An inbox PullNow<T>() and PullAll() fetch deltas keyed by an opaque cursor. A single call drains the full delta set (the engine re-pulls while hasMore: true), then optionally fetches tombstones, all on the same cursor stream.
A scheduled job AddDataSync auto-registers a SyncJob with the Shiny Jobs scheduler so periodic pulls keep running while the app is backgrounded. No AddJob call required.
A connectivity loop every transport listens on IConnectivity.Changed. A network transition offline → online immediately drains the outbox and runs PullAll.
One activity stream every lifecycle moment (queued, started, sent, failed, conflict, conflict-resolved, retry-scheduled, canceled, pull-started, item-received, pull-completed, pull-failed, tombstones-applied) surfaces through a single Activity event.
Platform Outbox transport Inbox transport Survives app kill
iOS / Mac Catalyst / tvOS Background NSURLSession upload task Background NSURLSession download task Yes (both directions)
Android Foreground Service + HttpClient HttpClient (in-process) Outbox yes (foreground notification visible); inbox no
Windows / Linux / macOS / base .NET HttpClient + connectivity loop HttpClient + connectivity loop No — resumes on next launch
Blazor WebAssembly HttpClient + LocalStorage HttpClient + LocalStorage No — sync runs while the tab is open

The right transport is picked automatically by AddDataSync<TDelegate> based on the target TFM. See Architecture for why each tier looks the way it does, and Platform Behavior for the details and required Info.plist / manifest bits.

Changed files
  • MyApp/
Delegates/MyDataSyncDelegate.cs
MyDataSyncDelegate.cs
1using System;2using System.Threading.Tasks;3using Shiny.Data.Sync;4​5namespace MyApp.Delegates;6​7public class MyDataSyncDelegate : IDataSyncDelegate8{9    public Task OnSent(SyncOperation operation, string? responseBody)10    {11        // Server accepted the op. responseBody may carry a server-assigned id / ETag / etc.12        return Task.CompletedTask;13    }14​15    public Task OnError(SyncOperation operation, int statusCode, Exception ex)16    {17        // The op exhausted its retry budget. Persist to a dead-letter store, surface to the user, or re-queue.18        return Task.CompletedTask;19    }20​21    public Task OnReceived(SyncReceivedItem item)22    {23        // The engine already deserialized item.Entity via Shiny.Json.Default.24        // Apply the change to your local store; item.Verb is Create / Update / Delete.25        return Task.CompletedTask;26    }27​28    public Task<ConflictResolution> OnConflict(SyncOperation operation, string remotePayload)29        // Server already has a newer version. Decide how to resolve.30        => Task.FromResult(ConflictResolution.AcceptRemote);31}

Anything sync’d through the engine must implement ISyncEntity — a single property:

using Shiny.Data.Sync;
public record TodoItem(string Identifier, string Title, bool Completed) : ISyncEntity;

That’s it. No base classes, no attributes, no special interfaces beyond ISyncEntity. The Identifier is the stable, server-recognized id — it goes into the URL for PUT /{id} / DELETE /{id}, is used to dedupe within a batch, and identifies the entity in tombstone deletes.

All serialization runs through Shiny.Json.Default — the shared ISerializer from Shiny.Extensions.Serialization. Tag your entity in a [ShinyJsonContext]-attributed context and a source-generated module initializer wires the type into the shared chain before any code runs:

[ShinyJsonContext]
[JsonSerializable(typeof(TodoItem))]
public partial class AppJsonContext : JsonSerializerContext;

You only need one such context per app — every endpoint that registers a type reachable from it is covered automatically. Trimming, NativeAOT, and source-only iOS-AOT builds all just work. To customize global serializer options use services.ConfigureJsonSerializer(opts => ...) from Shiny.Extensions.Serialization.

Your app supplies one IDataSyncDelegate. It is the integration seam between the engine and your local store.

using Shiny.Data.Sync;
public class MyDataSyncDelegate(IMyLocalStore store) : IDataSyncDelegate
{
public Task OnSent(SyncOperation op, string? responseBody)
{
// Server accepted the op. responseBody may carry a server-assigned id, ETag, etc.
return Task.CompletedTask;
}
public Task OnError(SyncOperation op, int statusCode, Exception ex)
{
// The op exhausted its retry budget. Persist to a dead-letter store, surface to the user,
// or re-queue with a different shape. The original op is already removed from the outbox.
return Task.CompletedTask;
}
public Task OnReceived(SyncReceivedItem item)
{
// item.Entity is already deserialized via Shiny.Json.Default — strongly-typed.
// item.RawPayload is also available if you need to deserialize against a different schema.
if (item.Entity is TodoItem todo)
store.Apply(todo, item.Verb);
return Task.CompletedTask;
}
public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload)
=> Task.FromResult(ConflictResolution.AcceptRemote);
}

The four methods are the only opinionated contract the library imposes. See Conflict Resolution for the merge story.

AddDataSync<TDelegate> picks the right transport for the target TFM, registers the delegate, wires the default REST transport behind an IHttpClientFactory-named client, and auto-registers the periodic SyncJob. Endpoint registration runs inside the callback:

using Shiny;
using Shiny.Data.Sync;
using Shiny.Data.Sync.Infrastructure;
builder.Services.AddDataSync<MyDataSyncDelegate>(opts =>
{
// The simple case — defaults are sane.
opts.RegisterEndpoint<TodoItem>("https://api.example.com/todos");
// The configured case.
opts.RegisterEndpoint<Project>("https://api.example.com/projects", ep =>
{
ep.Direction = SyncDirection.Both; // or PullOnly / PushOnly
ep.Batch = true; // coalesce queued ops per round-trip
ep.UseMeteredConnection = false; // wait for WiFi
ep.MinPullInterval = TimeSpan.FromMinutes(5); // throttle scheduled pulls
ep.MaxAttempts = 8; // retry transient failures up to 8x
ep.RetryBaseDelay = TimeSpan.FromSeconds(3);
ep.DefaultConflictPolicy = ConflictPolicy.ServerWins;
ep.TombstoneUrl = "https://api.example.com/projects/deleted";
ep.SoftDeletePredicate = entity => entity is Project p && p.IsArchived;
});
});
// Optional: a single cross-cutting auth header for every sync request
builder.Services.AddSyncInterceptor<MyAuthInterceptor>();
// Centralize base address / Polly handlers / message handlers on the named client.
// RestSyncTransport.HttpClientName is the constant "Shiny.Data.Sync".
builder.Services
.AddHttpClient(RestSyncTransport.HttpClientName, c => c.BaseAddress = new Uri("https://api.example.com"))
.AddPolicyHandler(GetRetryPolicy());

For Blazor WASM use AddBlazorDataSync<TDelegate> from Shiny.Data.Sync (its net10.0 build) — it persists the outbox and cursors to LocalStorage and drives sync from an in-process HttpClient loop on connectivity events:

builder.Services.AddBlazorDataSync<MyDataSyncDelegate>(opts =>
{
opts.RegisterEndpoint<TodoItem>("/api/todos");
});
builder.Services.AddHttpClient(RestSyncTransport.HttpClientName, c =>
c.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress));

Sync only runs while the tab is open — there is no Service Worker Background Sync hook today.

Forcing the HttpClient path on Apple / Android

Section titled “Forcing the HttpClient path on Apple / Android”

If you need the cross-platform HttpClient loop on a native target (for testing, to skip NSURLSession constraints, or because your interceptor needs the request body), register explicitly:

builder.Services.AddHttpClientDataSync<MyDataSyncDelegate>(opts =>
{
opts.RegisterEndpoint<TodoItem>("https://api.example.com/todos");
});

You give up the survives-app-kill guarantee on iOS / Mac Catalyst, and on Android you lose the foreground-service notification (the queue runs in-process and pauses when the OS reclaims the process).

public class TodosService(IDataSyncManager sync)
{
public Task Create(TodoItem item) => sync.Queue(SyncVerb.Create, item);
public Task Update(TodoItem item) => sync.Queue(SyncVerb.Update, item);
public Task Delete(TodoItem item) => sync.Queue(SyncVerb.Delete, item);
// Pull-to-refresh on a single endpoint. Bypasses MinPullInterval.
public Task Refresh(CancellationToken ct = default) => sync.PullNow<TodoItem>(ct);
// Pull every Both/PullOnly endpoint. Respects MinPullInterval per endpoint.
// SyncJob calls this on the background scheduler — you rarely need to call it manually.
public Task RefreshAll(CancellationToken ct = default) => sync.PullAll(ct);
}

Queue<T> writes the operation to the persistent repository immediately and returns. On Apple it kicks off an NSURLSession upload task; on Android it starts the foreground service if it isn’t already running; elsewhere it kicks the HttpClient loop. The caller doesn’t wait for the network round-trip — that happens in the background and the result surfaces through events / the delegate.

sync.PendingCountChanged += (s, count) => StatusLabel.Text = $"{count} pending";
sync.UpdateReceived += (s, result) =>
{
if (result.State == SyncOperationState.Error)
ShowToast($"Sync failed: {result.Exception?.Message}");
};
sync.PullCompleted += (s, c) =>
{
if (c.Error != null) ShowToast($"Pull failed for {c.EndpointKey}: {c.Error.Message}");
else if (c.ItemsReceived > 0) ShowToast($"{c.ItemsReceived} new {c.EndpointKey} items");
};
// Unified stream — covers every outbox + inbox lifecycle moment
sync.Activity += (s, evt) =>
Console.WriteLine($"{evt.Type} {evt.EndpointKey} items={evt.ItemCount} status={evt.StatusCode}");

Activity fires a SyncEvent for each of these moments. Type says which, and every event has EndpointKey. The other properties are set when they apply:

SyncEventType When Also set
OutboxQueued An op was written to the outbox. Operation
OutboxStarted An op is being sent. Operation
OutboxSent The server accepted the op. Operation, StatusCode
OutboxFailed The op failed for good and was handed to OnError. Operation, StatusCode, Error
OutboxConflict The server answered 409 / 412. Operation, StatusCode, RemotePayload
OutboxConflictResolved The conflict policy or a delegate decided. Operation, StatusCode, RemotePayload, Resolution
OutboxRetryScheduled A transient failure; the op will be sent again after its backoff. Operation, StatusCode, Error
OutboxCanceled The op was removed by Cancel, CancelAll or CancelAll<T>. Operation
InboxPullStarted A pull began. Cursor, Trigger
InboxItemReceived A pulled item was handed to the delegate. Item
InboxPullCompleted A pull finished. ItemCount, Cursor
InboxPullFailed A pull failed (network, parse error, non-2xx status). Error, and StatusCode / Cursor when known
TombstonesApplied A tombstone fetch dispatched deletes. ItemCount, Cursor

It’s the right hook for telemetry, status bars, and toast notifications. The typed events (PendingCountChanged / UpdateReceived / PullCompleted) are kept for fine-grained subscribers.

New event types can be added in later versions, so give a switch over SyncEventType a default branch. A switch expression that lists every value and has no default gets a compiler warning (CS8509) as soon as a new value is added.

InboxPullStarted carries Trigger, a SyncPullTrigger:

Value Meaning
Manual App code called PullAll or PullNow<T>.
Job The scheduled SyncJob ran.
Connectivity The device regained an internet connection (HttpClient engine: Android, Windows, Linux, macOS, Blazor).
sync.Activity += (s, evt) =>
{
if (evt.Type == SyncEventType.InboxPullStarted && evt.Trigger == SyncPullTrigger.Job)
Log.Info($"Background pull for {evt.EndpointKey}");
};

On Apple, a pull that goes on to fetch more pages keeps the trigger of its first page.

await sync.Cancel(operationId); // one operation
await sync.CancelAll<TodoItem>(); // every queued op for one endpoint
await sync.CancelAll(); // entire outbox (in-flight inbox pulls are left alone)

Every op that is removed raises UpdateReceived with SyncOperationState.Canceled and an OutboxCanceled activity event, whether it was canceled on its own or by CancelAll / CancelAll<T>.

On Apple these translate to NSURLSessionTask.Cancel() on the upload task with the matching TaskDescription, so an in-flight background upload also stops. An op with no upload task is removed directly.

┌─────────────────────────────────────────────────────────────┐
│ Your code │
│ ───────── │
│ Queue<T>(verb, entity) ──┐ ▲ │
│ PullNow<T>() ────────────┼───────────► │ delegate │
│ │ │ OnReceived │
│ ▼ │ OnSent │
│ IDataSyncManager │ OnError │
│ │ │ OnConflict │
│ ▼ │ │
│ Outbox (persisted) Inbox dispatch │
│ │ ▲ │
│ ▼ │ │
│ ┌──────────────────────────────┴──┐ │
│ │ ISyncTransport │ │
│ │ (default: RestSyncTransport) │ │
│ └─────────────┬───────────────────┘ │
│ │ │
└───────────────────────────┼─────────────────────────────────┘
▼
POST / PUT / DELETE / GET / GET /tombstones
to your existing HTTP API

The outbox, inbox cursors, and tombstone cursors all live in the Shiny key/value repository (SQLite on native, LocalStorage on Blazor). Operations are persisted before any network call — so a process kill mid-send leaves the operation in the queue, ready to retry. ISyncTransport is the wire-level seam: replace it for gRPC, GraphQL, or any custom protocol. See Custom Transports.

A full reference server + client live in this repo:

  • Sample.Api — minimal ASP.NET API speaking the default RestSyncTransport wire shapes for outbox, batched outbox, paginated cursor pulls, and tombstones. Backed by Shiny.DocumentDb.Sqlite for storage.
  • Sample.Maui — MAUI consumer demonstrating AddDataSync, the delegate pattern, and live Activity event observation.
claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-data-sync Skill