Never lose a submission: offline retries with idempotency keys in Swift

A message someone believed they sent must arrive. A queue and one key per submission make that true.

· InsightsKit

The rule

A submission someone believed they sent must arrive. On a phone, that is harder than it sounds: the user taps Send in a lift, on a train, or in airplane mode, and the request never leaves the device. If your code only shows an error, what they typed is gone, and so is their goodwill.

Two tools solve this: a small local queue, and an idempotency key on every submission.

What an idempotency key does

An idempotency key is a random identifier you create once per submission and send with it. If the same key arrives twice, the server stores the submission only once. That makes retries safe: when the network fails after the server received the request but before you got the answer, a retry with the same key does not create a duplicate.

The rule is simple: the same key for every retry of the same submission, and a new key for a new submission. If you create a fresh key on each retry, every retry can become a second copy.

The pattern in Swift

This example uses the InsightsKit Swift client, but the pattern works with any API that accepts an idempotency key.

Swift
func submit(title: String, body: String?) async {
    // One key per attempt, reused across retries.
    let key = UUID().uuidString
    saveToYourQueue(title: title, body: body, idempotencyKey: key)

    do {
        try await kit.submitRequest(title: title, body: body, idempotencyKey: key)
        removeFromYourQueue(key)
    } catch let error as InsightKitError where error.isRetryable {
        // Still queued. Retry on the next launch or when the network returns.
    } catch {
        removeFromYourQueue(key)    // permanent refusal; do not retry forever
    }
}

Three details make it work:

  • Save before you send. The submission is in the queue before the first network call, so a crash or a killed app cannot lose it.
  • Store the key with the submission. The retry must read the key from the queue, never create a new one.
  • Separate temporary and permanent errors. A network failure or a server error is worth retrying. A validation error or a blocked sender is not; retrying it forever only drains the battery. The client’s isRetryable property makes that decision for you.

When to retry

Retry when the app starts, when it comes back to the foreground, and when the network returns (NWPathMonitor tells you). Wait longer between attempts after each failure, so a server that is down does not get a flood of requests when it comes back. When the server says you are rate-limited, keep what the user typed and wait for the interval it gives you.

Votes are different: apply them at once

Never make someone wait on a round trip for a vote. Update the screen immediately, send the vote in the background, and correct the count quietly on the next load if something went wrong.

Swift
// Tapping a vote: update the UI now, do not wait on the network.
myVoteIDs.insert(request.id)
Task {
    do { _ = try await kit.vote(requestID: request.id) }
    catch { /* correct it quietly on the next load */ }
}

// On launch
myVoteIDs = Set(try await kit.myVotes())

Test it in airplane mode

Before you ship, switch on airplane mode, submit an idea, force-quit the app, switch the network back on and open the app again. The idea should appear on the board exactly once. If it appears twice, a retry created a new key; if it never appears, the queue did not survive the restart.

InsightsKit gives your app a ranked feature board and a support inbox, on iOS, Android or the web. Your users need no account, and the price stays flat however many users you have. Free for your first app.

Create a free account