Skip to contentRyan Katayi
    All writing

    Notes from the work

    One token refresh, several callers: exporting a Droplyst playlist

    How Droplyst shares an in-flight token refresh across callers and why playlist export still needs evidence that each write succeeded.

    Droplyst · TypeScript · Spotify

    A captured Droplyst session can be ready to export while its Spotify access token is approaching expiry. The export needs credentials before it can create a playlist or append songs. Elsewhere, the profile screen's connection check uses the same token helper. If both callers need a refresh while one is pending, starting another request does no useful work.

    My Spotify client lets them await the same refresh promise. Once it resolves, export can continue. Tracking which songs actually reached the playlist takes a different piece of code.

    Refresh before the next request

    Every request through spotifyFetch() first calls getValidToken(). That helper checks the token cached in memory, then SecureStore, accepting it only when the recorded lifetime has more than five minutes remaining. Otherwise it requests a refresh. The check happens when work needs a token; there is no background timer.

    The margin gives the next request some room before expiry. It doesn't prevent several callers from observing the same approaching deadline. That is the useful problem behind this September 2025 Reddit discussion about concurrent refresh attempts: independent callers can each make a reasonable decision and collectively start redundant work.

    doRefresh() reads the stored Spotify refresh token, obtains the app session and calls the refresh backend. It stores the returned credentials before resolving successfully. If the response omits a replacement refresh token, it retains the existing one, consistent with OAuth's optional refresh-token replacement.

    That completed token belongs in the cache. While the request is still pending, callers need something else to share: the work that will produce it.

    Let callers await the same work

    This is the coordination code in my client:

    let refreshPromise: Promise<string | null> | null = null;
    
    function refreshToken(): Promise<string | null> {
      if (!refreshPromise) {
        refreshPromise = doRefresh().finally(() => {
          refreshPromise = null;
        });
      }
      return refreshPromise;
    }

    The first caller starts the refresh and stores its promise. Subsequent calls to refreshToken() receive that promise until it settles. There is no await between checking the slot and assigning it, so another asynchronous caller in this module instance cannot interrupt that interval.

    Here is a possible overlap, assuming both callers have decided they need a refresh:

    Step Export caller Connection-check caller Shared refresh
    1 Calls refreshToken() Starts request
    2 Awaits promise Calls refreshToken() Same request pending
    3 Still waiting Awaits same promise Stores returned credentials
    4 Continues export Reports connected Settles; slot clears

    If refresh fails, both instead receive null. The shared result includes failure. Clearing the slot in finally lets a later caller attempt refresh again.

    Jacob Chan's October 2023 article on coordinating iOS token refresh explores the same need to coordinate concurrent callers. Here, the promise carries both completion and its result, so callers don't need a separate queue of callbacks to wake them afterward.

    The guarantee is bounded: overlapping calls to this function share work within one runtime. The storage reads in getValidToken() happen before that function. A caller holding an old expiry value can arrive after the previous refresh has finished and start another one. A shared in-flight promise doesn't make every earlier expiry observation current.

    A refreshed token doesn't mean the playlist was saved

    A single Droplyst export resolves tracks sequentially, then appends the resulting URIs in sequential batches of up to 100. That size matches Spotify's documented playlist append limit. The export itself doesn't generate a burst of parallel searches.

    Each append obtains a token through the same helper. A refresh completing successfully means credentials are available for the request. spotifyFetch() still sends that request only once; it has no refresh-and-retry branch for a 401. Neither the local expiry check nor a successful refresh is an acknowledgement that songs were added.

    The batch loop waits for each response and checks res.ok. A non-success response throws and stops subsequent batches. Only after every batch succeeds does addTracksToPlaylist() return an ExportResult to the screen.

    There is a naming detail worth keeping straight: the function builds an internal result map with tracks marked added while resolving them, before sending the batches. Those entries describe planned additions at that point. The map reaches the screen only after the writes succeed, so it cannot serve as a record of individual acknowledged batches when a later write fails.

    Recover from the last acknowledged write

    Consider a hypothetical two-batch export where the first append succeeds and the second is rejected. The first batch remains in Spotify. The function throws without returning ExportResult and the screen never reaches markSpotifyExported().

    Retrying a new export creates another playlist. Retrying an update can append the successful first batch again because its tracks still look unsynced locally. Refreshing the token cannot resolve this uncertainty: the missing information concerns writes already attempted.

    I would retain the playlist ID after creation and record acknowledged batches as they complete. That would give recovery a starting point, though a lost response still needs special treatment. Spotify may have accepted an append whose response never arrived. Before replaying it, the app would need to inspect the remote playlist and reconcile what was actually written. That recovery is not implemented here.

    For now, a failed export can leave a partially written playlist. That is the next recovery case I want to handle.

    Explore Droplyst's capture and export flow.