An Android app queues actions while offline. After the process is killed and restarted, some actions sync twice and the user sees duplicate orders. How do you design the fix?
The queue records that work was sent but cannot know whether the server received it, so any crash between sending and recording the acknowledgement replays the action. At-least-once delivery is the only thing a mobile client can guarantee, so the fix is to make the operation idempotent with a client-generated key persisted alongside the queued action, not to try harder to deliver exactly once.
What the interviewer is scoring
- Whether the candidate recognises exactly-once delivery as unachievable and reframes to at-least-once plus idempotency
- That the idempotency key is generated on the client and persisted with the action before any send attempt
- Does the answer identify the specific window - between sending and durably recording the acknowledgement
- Whether the queue is stored in a durable database rather than in memory or SharedPreferences
- That WorkManager's own at-least-once guarantee is understood, so its retries are part of the problem
- Whether ordering and dependency between queued actions is considered, not only duplication
- Does the answer say how long the server must remember keys, and what happens after that
Answer
Short answer
There is a window between the request leaving the device and the acknowledgement being durably written to the queue. If the process dies inside it, the action is still marked pending and gets replayed on restart — even though the server already applied it. No amount of care closes that window, because the client cannot distinguish "the server never received it" from "the server received it and the response was lost." The fix is to accept at-least-once delivery and make the operation idempotent.
Why exactly-once is not available
This is the framing that matters, and it is what an interviewer is listening for. Delivering exactly once would require the send and the local record of success to be a single atomic operation spanning a device and a server. They are not, and cannot be, in one transaction.
So every attempt has three outcomes, and the client can only distinguish two of them:
1. Request never reached the server -> safe to retry
2. Server applied it, response returned -> do not retry
3. Server applied it, response LOST -> looks identical to (1)
Case 3 is the bug. The client sees a timeout, a socket error, or nothing at all because the process was killed, and its only correct move is to retry — which is why the duplicate appears. Trying to engineer this away leads to increasingly elaborate schemes that all fail on the same case. Naming it and moving on to idempotency is the senior answer.
Note that WorkManager itself guarantees at-least-once execution. Its retry behaviour is not a bug you can configure away; it is the same guarantee, and it is part of why the duplicate happens.
The design
Generate the key on the client, before the first send attempt, and persist it with the action.
@Entity(tableName = "pending_actions")
data class PendingAction(
@PrimaryKey val idempotencyKey: String = UUID.randomUUID().toString(),
val type: String,
val payloadJson: String,
val createdAt: Long,
val state: State = State.PENDING, // PENDING -> IN_FLIGHT -> CONFIRMED
)
The key must be created at enqueue time, not at send time. If it is generated when the request is built, a retry after process death produces a different key and the server has no way to recognise the replay — which is the same bug with extra steps. It must also be persisted in the same durable write that enqueues the action, so a crash immediately afterwards still finds it.
It has to be a client key rather than a server-assigned id for the obvious reason: the device is offline when the action is created, so there is no server to ask.
Send it and let the server deduplicate:
val response = api.createOrder(
idempotencyKey = action.idempotencyKey, // Idempotency-Key header
body = action.payloadJson,
)
dao.markConfirmed(action.idempotencyKey) // safe to lose; replay is harmless now
The server stores the key with the result of the first successful application. A second request with the same key returns the original response without re-executing. Now a replay is not merely tolerable — it returns the same order id, so the client converges on correct state rather than needing to detect anything.
The property this buys is that markConfirmed no longer has to be reliable. Losing it costs one redundant request, not a duplicate order, which is exactly the right place for the weakness to sit.
What the server owes you
Two details worth raising, because they are where this design leaks.
Retention. The server must remember keys for longer than the client might plausibly retry. A device offline for a week and then restored from backup will replay actions well past a 24-hour window. If the key has expired, the request is treated as new and the duplicate reappears — so retention has to be set against realistic client behaviour, and 24 hours is usually too short for mobile.
Response semantics. The server should return the original response for a repeated key, not a conflict error, so the client can complete normally. And a 500 deserves a specific answer: retry with the same key. A different key would be a new request, and the whole point is that you do not know whether the first one applied.
Ordering, which duplication hides
Deduplication alone does not make the queue correct. If the user creates an order offline, edits it, then reconnects, the edit is meaningless if it arrives first or if the create failed. Independent parallel retries will reorder them.
The queue therefore needs sequential draining per logical entity — one worker chain, beginUniqueWork with ExistingWorkPolicy.APPEND, or an explicit dependency between rows — and a rule for what happens when an action permanently fails: does the queue halt for that entity, or discard the dependents? Answering that shows the candidate is designing a queue rather than a retry.
Testing something you cannot reproduce
Process death is awkward to trigger reliably, so test the invariant rather than the event. Drive the queue through its states directly and assert that replaying any state transition twice produces one server-side effect. adb shell am kill and the "Don't keep activities" developer option cover the manual pass, and a fake API that records every key it receives lets you assert in CI that a forced replay sends the same key rather than a new one — which is the property the whole design rests on.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Where exactly does the key get generated, and why not on the server?
- The user creates an order offline, then edits it, then goes online. What must your queue guarantee?
- How long should the server retain idempotency keys, and what breaks when it forgets?
- What happens if the response was a 500 - do you retry with the same key or a new one?
- How would you test this without being able to reproduce process death reliably?
Related questions
- Your services talk through events and one consumer has been down for an hour. What has it missed, and how does it catch up?hardAlso on idempotency6 min
- The monthly bill run dies two thirds of the way through and the cycle closes tomorrow. What do you do?hardAlso on idempotency5 min
- How would you design a data pipeline you can safely re-run?hardAlso on idempotency6 min
- How do you design the tools an agent calls?hardAlso on idempotency5 min