Topics
Recent articles

Android & Mobile

Designing an Offline-First Event Pipeline on Android

Design an Android event pipeline that captures locally first, survives connectivity loss, retries safely, and verifies that records remain retrievable end to end.

Table of Contents11 sections
Android phone showing offline mode controls
A reliable event pipeline treats connectivity as an optimization for delivery, not a prerequisite for capture.

An app that records important events cannot treat a successful HTTP request as proof that the event was captured. Mobile connectivity disappears in elevators, tunnels, parking areas, warehouses, and ordinary weak-signal zones. If capture depends on the network, the most important record can vanish at exactly the wrong moment.

The durable pattern is local commit first, asynchronous delivery second. Persist the event and its payload on the device before reporting success to the UI. Then let a retryable background worker synchronize pending records when suitable connectivity returns. Make the server operation idempotent, track delivery state explicitly, and test retrieval from end to end rather than testing only whether an upload request returned 200.

That design turns a fragile upload flow into an offline-first event pipeline.

Define what “captured” means before choosing APIs

A reliable pipeline needs separate definitions for local capture and remote delivery.

For example:

CAPTURED_LOCAL
  = metadata committed to durable local storage
  + required payload file finalized on disk

DELIVERED_REMOTE
  = server accepted the event idempotently
  + payload is associated with the event
  + event can be retrieved through the normal read path

This distinction matters because an event can be safely captured while the device is offline. The UI can tell the user that the record is saved without pretending that synchronization has already completed.

Android’s offline-first guidance recommends a local data source for repositories that use the network and describes the local source as the canonical source higher layers read from. That principle fits event capture well: the screen observes durable local state, while synchronization updates that state later.

If you are already separating UI intent from persistence in a Compose application, the same boundary appears in bulk editing with Jetpack Compose: the UI expresses an operation, while the data layer owns how it becomes durable.

Model an event as a state machine

A boolean such as uploaded = true is usually too weak. It cannot distinguish a record waiting for connectivity from one that failed permanently or one whose metadata reached the server while its file did not.

Use explicit states:

enum class DeliveryState {
    PENDING,
    SYNCING,
    RETRYABLE_FAILURE,
    DELIVERED,
    PERMANENT_FAILURE
}

A minimal Room entity might look like this:

@Entity(tableName = "events")
data class EventEntity(
    @PrimaryKey val eventId: String,
    val occurredAt: Instant,
    val payloadPath: String,
    val payloadSha256: String,
    val deliveryState: DeliveryState,
    val attemptCount: Int,
    val lastAttemptAt: Instant?,
    val remoteVersion: String?
)

Generate eventId on the device before any network call. That identifier becomes the idempotency key shared by the local database, payload file, worker, and server.

The payload hash is useful for integrity checks. It does not replace transport security, but it gives the pipeline a deterministic way to detect a changed or corrupted local file before upload.

Commit metadata and payload in the right order

Large binary payloads such as photos, video clips, or diagnostic archives should not be stuffed into WorkManager input data. Persist them as files and enqueue only stable identifiers.

A safe capture sequence is:

1. Generate eventId.
2. Write payload to a temporary file.
3. Flush and close the file.
4. Rename or move it to its final event-owned path.
5. Calculate and store integrity metadata.
6. Insert the event row as PENDING.
7. Enqueue synchronization for eventId.
8. Report local capture success.

The exact ordering can vary with your storage design, but the invariant should not: never expose a locally captured event whose required payload is still only in memory.

If the app crashes after the file is finalized but before the database insert, a cleanup job can detect an orphan file. If it crashes after the row is inserted, the durable PENDING state gives the sync worker something to resume.

For apps that also upload user-selected photos, our Android photo upload architecture guide covers the broader separation between capture, metadata, upload orchestration, and remote storage.

Let WorkManager supervise delivery, not own the data

WorkManager is Android’s recommended API for persistent deferrable work. It can run work after constraints are satisfied and can reschedule work across process death and device reboot.

That makes it a good supervisor for pending delivery, but the queue itself should remain in your database.

val request = OneTimeWorkRequestBuilder<EventSyncWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        30,
        TimeUnit.SECONDS
    )
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "event-sync",
    ExistingWorkPolicy.KEEP,
    request
)

The worker can query a bounded batch of pending rows:

override suspend fun doWork(): Result {
    val events = eventDao.nextPending(limit = 20)

    for (event in events) {
        when (val outcome = syncOne(event)) {
            SyncOutcome.Delivered ->
                eventDao.markDelivered(event.eventId, outcome.remoteVersion)

            SyncOutcome.Retryable ->
                eventDao.markRetryableFailure(event.eventId)

            SyncOutcome.PermanentFailure ->
                eventDao.markPermanentFailure(event.eventId)
        }
    }

    return if (eventDao.hasRetryableEvents()) {
        Result.retry()
    } else {
        Result.success()
    }
}

The worker should be restartable. If the process dies after the server accepts an event but before Room records DELIVERED, the next run must be able to send the same eventId again without creating a duplicate.

Make the server endpoint idempotent

Retries are only safe when repeating the same logical operation has a stable result.

One practical contract is:

PUT /events/{eventId}
Idempotency-Key: {eventId}

The server can enforce uniqueness on eventId. A repeated request then returns the already-created event or safely replaces the same resource according to the API contract.

For multi-part delivery, avoid ambiguous states. If metadata and binary data use separate endpoints, the server should expose enough state for the client to determine which parts already exist. Another option is a server-side upload session tied to the same event identifier.

Do not solve duplicate delivery by disabling retries. Mobile networks fail in the middle of requests, and the client cannot always know whether the server committed the operation before the connection disappeared.

Distinguish retryable from permanent failures

A queue that retries every failure forever is not reliable. It is merely persistent.

Classify outcomes:

Failure Typical action
No connectivity or timeout Retry
HTTP 429 Retry with server guidance/backoff
HTTP 5xx Retry with bounded backoff
Authentication expired Refresh credentials, then retry if valid
Payload file missing Permanent failure plus local alert/telemetry
Validation rejected by server Permanent failure until data is corrected
Integrity hash mismatch Stop delivery and investigate local payload

Keep attempt metadata in Room so diagnostics do not disappear when WorkManager prunes its own history.

For large user-initiated transfers, also check Android’s current background transfer guidance rather than assuming WorkManager is always the correct primitive. Android documents WorkManager as appropriate for many deferrable background transfers, while user-initiated transfers with visible progress can require a different job type.

Reconcile instead of trusting one side

Offline-first systems eventually encounter partial state:

local says PENDING
server already has event

local says DELIVERED
server record was removed or is not retrievable

metadata exists remotely
payload upload is incomplete

A reconciliation pass can query remote state by eventId and repair local delivery status. This is especially valuable after app upgrades, worker bugs, or backend incidents.

The local database remains the app-facing source of truth, but “source of truth” does not mean “never compare it with the server.” It means higher layers read a consistent local model while the repository owns synchronization and reconciliation.

That same separation between local state and network behavior is useful when designing Android connectivity checks with NetworkCapabilities. Connectivity should influence when work is attempted, not decide whether locally durable work exists.

Test retrieval, not just upload

The strongest reliability test starts before capture and ends after retrieval.

For each predefined event:

trigger event
  -> verify local row
  -> verify payload exists and hash matches
  -> interrupt connectivity at a planned point
  -> restore connectivity
  -> wait for synchronization
  -> retrieve event through production read path
  -> retrieve payload
  -> verify identity and integrity

Include failure injection:

Measure the outcome that matters: captured and retrievable events divided by triggered test events. A transport success rate can look healthy while records are still missing from the actual product read path.

Keep observability tied to event identity

Logs become much more useful when every stage includes the same stable identifier:

event_id
local_capture_time
sync_attempt
worker_run_id
http_status
remote_version
delivered_time
retrieval_verified_time

Avoid logging sensitive payload content. Operational telemetry should answer where an event stopped moving without copying the event itself into logs.

A simple diagnostic query should be able to answer:

SELECT deliveryState, COUNT(*)
FROM events
GROUP BY deliveryState;

Then add age buckets for pending records. Ten pending events created thirty seconds ago are different from ten that have been stuck for three days.

The architecture in one flow

A durable implementation can stay small:

Capture
  -> finalize payload on disk
  -> insert PENDING row in Room
  -> UI observes local success
  -> enqueue unique sync work

Sync
  -> query pending batch
  -> verify payload integrity
  -> send idempotently
  -> classify response
  -> update local state

Recovery
  -> WorkManager retries eligible failures
  -> reconciliation checks ambiguous state
  -> diagnostics expose stuck records

Verification
  -> retrieve through normal product path
  -> verify event identity and payload integrity

The key design choice is not Room or WorkManager by themselves. It is the boundary between capture and delivery.

When capture commits locally first, network loss becomes a synchronization problem instead of a data-loss event. When delivery is idempotent and observable, retries become safe. And when tests verify end-to-end retrieval, reliability is measured where users actually experience it.

Continue Exploring

You Might Also Like

View all articles
Android App Updates: Play vs Managed Devices
10 min read

Android App Updates: Play vs Managed Devices

Choose the right Android update path for Play-distributed apps, enterprise-managed fleets, and true OS OTA updates without mixing three different mechanisms.