← Coding round practice

Offline Notes App (CRUD with Sync Queue)

HardRoom · SyncEvery write local-first

The problem

Build a notes app: create, edit and delete notes, synced with a server.

Requirements:

  • Everything works fully offline; changes sync when the network returns.
  • No lost edits if the app is killed mid-sync.
  • Explain your conflict policy when the same note was edited on two devices.
Stuck? Open one hint

Give every note a client id and a sync status. The server id arrives later — it cannot be the primary key.

Show the worked solution

This is the offline-first design round. Build the answer around three ideas.

1. Local id is the identity. The note gets a UUID generated on the device; serverId is nullable and filled in after the first successful sync. If you key on the server id, creating a note offline is impossible.

2. Every row carries a status. pendingCreate, pendingUpdate, pendingDelete, synced. Deletes are soft until confirmed, so a failed sync does not resurrect or lose the note. The UI hides soft-deleted rows.

3. A sync worker drains the queue. WorkManager with a network constraint, picking up everything not synced, in order. Requests carry the client id, so a retry after a dropped response is idempotent rather than creating duplicates.

For conflicts, state a policy and its cost out loud: last-write-wins on updatedAt is simple but can silently lose an edit; keeping both versions and letting the user choose is safer but needs UI. Picking one and naming the trade-off is the answer — not having a policy is the failure.

KotlinEvery write local-first
@Entity(tableName = "note")
data class NoteEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),  // client id
    val serverId: String? = null,
    val title: String,
    val body: String,
    val updatedAt: Long,
    val status: SyncStatus = SyncStatus.PENDING_CREATE,
    val isDeleted: Boolean = false,
)

enum class SyncStatus { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }

// writes are always local first
suspend fun save(note: NoteEntity) {
    dao.upsert(
        note.copy(
            updatedAt = System.currentTimeMillis(),
            status = if (note.serverId == null) SyncStatus.PENDING_CREATE else SyncStatus.PENDING_UPDATE,
        )
    )
    SyncWorker.enqueue(context)            // drains later, with a network constraint
}

class SyncWorker(ctx: Context, p: WorkerParameters) : CoroutineWorker(ctx, p) {
    override suspend fun doWork(): Result {
        val pending = dao.pending()
        for (note in pending) {
            runCatching {
                when (note.status) {
                    SyncStatus.PENDING_DELETE -> { api.delete(note.serverId!!); dao.hardDelete(note.id) }
                    else -> {
                        val saved = api.save(note.toDto())   // client id sent -> idempotent retry
                        dao.upsert(note.copy(serverId = saved.id, status = SyncStatus.SYNCED))
                    }
                }
            }.onFailure { return Result.retry() }
        }
        return Result.success()
    }
}