Single-flight token refresh

Concurrency Senior Swift 5.5+ iOS 15+ Reviewed By Alok Singh

Pick the fix

Five requests fail with 401 at once. Each calls refresh(), the server rotates refresh tokens, and four refreshes fail. Best fix?

Options
Hint 1 of 2

How many refreshes should happen?

Hint 2 of 2

What do the other callers do while one refresh runs?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A An actor stores the in-flight refresh Task; other callers await it; each request retries once

Correct. One refresh, shared by everyone, with a bounded retry.

Why not the others?
  • B Put refresh() on a serial DispatchQueue. Refreshes still happen one after another, each with a token that was already rotated.
  • C Retry each failed request up to 3 times. That creates even more refreshes and can loop.
  • D Refresh on a timer every 5 minutes. Tokens can still expire or be revoked between ticks, and the race remains.

30-second answer

Put the token in an actor that stores the in-flight refresh as a Task. The first caller starts it; every other caller awaits the same task. When it completes, each original request retries once with the new token. If the refresh itself fails, sign the user out once, not once per request.

Practise saying it · 60 s. No recording, just a timer

Explain how you'd implement single-flight token refresh.

Say your answer out loud, then check which of these points you covered:

  • Actor holds the token and in-flight Task
  • Other callers await the same Task
  • Retry each request once
  • Refresh failure logs out once

Deep dive

Many servers rotate refresh tokens, so parallel refreshes make all but one fail.

Retry each request at most once after a refresh, to avoid loops.

Clear the stored task when it finishes, with defer.

Optionally refresh shortly before expiry, but still handle 401 because tokens can be revoked.

Token store Type-checked · Swift 6.4
actor TokenStore {
    private var token: Token
    private var refreshTask: Task<Token, Error>?
    init(token: Token) { self.token = token }

    func validToken() async throws -> Token {
        if let refreshTask { return try await refreshTask.value }
        return token
    }

    func refresh() async throws -> Token {
        if let refreshTask { return try await refreshTask.value }
        let old = token
        let task = Task { try await AuthAPI.refresh(using: old.refreshToken) }
        refreshTask = task
        defer { refreshTask = nil }
        token = try await task.value
        return token
    }
}

Common mistakes

  • Refreshing once per failed request.
  • Recursively retrying with no limit.
  • Holding the old token in each request and retrying with it.

Senior take & follow-ups

Seniors mention token rotation and explain why the in-flight Task is stored inside an actor.

Follow-up questions

Think of your answer first, then open the hint.

  1. What if a 401 arrives after the refresh finished?

    Show hint

    Compare token versions; only refresh if the request used the current token.

  2. Where does the refresh token live?

    Show hint

    In the Keychain, never in UserDefaults.

Suggest a fix if something here is wrong or out of date.