January 30, 2024
January 30, 2024
In this blog post we'll talk about how to avoid common concurrency pitfalls when renewing authentication tokens in React Native apps.
In apps with logins, we rely on tokens from the server to validate user identities. These tokens act as keys, allowing users to stay logged in. Since tokens have a lifespan, we periodically request new ones by sending a refresh token.
The server responds with a fresh authentication token, extending the user's login duration.
However, when the server invalidates a token (indicated by a 401 Unauthorized
message), we gracefully initiate a renewal process.
Join us in navigating the nuances of token renewal in React Native apps. 🗝️
Let's explore a simple method for handling authorized requests and authentication token renewals.
Take a moment to review the code snippet below. Can you spot potential issues?
const renewAuthenticationToken = async () => {// It renews the access token, by sending refresh token to the serverawait authService.renewAccessToken()}const makeAuthenticatedRequest = async (url: string, options: RequestInit) => {const response = await fetch(url, options)if (response.status === 401) {await renewAuthenticationToken()// Retry the request after successful token renewal using recursionreturn await makeAuthenticatedRequest(url, options)}return response}
In the snippet, we've identified a few general network handling concerns:
While these aspects contribute to robust network handling, our main focus in this blog post is to elevate the token renewal process. 🚀
Let's revisit our naive implementation and envision the following scenario: an authorized request fails with a 401 Unauthorized
message, triggering the token renewal process.
In the world of apps, it's common to send multiple requests concurrently. What happens if a previous token renewal is still in progress, and the app initiates another authorized request? In our naive implementation, it sparks another renewal process, unintentionally creating a race condition.
We've unintentionally introduced a race condition where multiple token renewal operations can be in progress simultaneously when a single operation would suffice.
Depending on the backend token renewal implementation, each consecutive renewal might invalidate the previous tokens, leading to a cascade of failed requests. This not only wastes resources but also negatively impacts performance, drains the battery, and creates a frustrating experience for both users and developers.
Stay tuned as we uncover solutions to navigate these concurrency pitfalls and refine our token renewal process. 🚧
A common misconception is assuming JavaScript is immune to race conditions because of its single-threaded nature.
Yes, JavaScript operates on a single thread, but the underlying environment (JavaScriptCore on iOS, V8 on Android, or Node.js during app development) empowers operations like network requests to run in parallel without blocking the main JS thread.
To renew tokens correctly, we must fortify ourselves against potential race conditions.
When the token renewal process is underway, we don't need to initiate another renewal request until we secure a new token. By abstaining from additional renewal requests, we allow the initial process to conclude seamlessly.
Consider this (overly) simplified implementation:
let isDuringRenewal = falseconst waitForRenewalToFinish = async (): Promise<boolean> => {// Imagine a function that resolves when `isDuringRenewal === true`}const renewAuthenticationToken = async () => {if (isDuringRenewal) {await waitForRenewalToFinish()}try {isDuringRenewal = trueawait authService.renewAccessToken()} finally {isDuringRenewal = false}}
This ensures that the token renewal is an exclusive operation.
However, we've addressed one issue, but another persists.
Even with our newfound restraint, subsequent requests might still fail with 401 Unauthorized
before the renewal process completes.
We know that after the first 401 Unauthorized
message, all subsequent authorized requests with the same outdated token are destined to fail.
So, why send them? Instead, we delay sending authorized requests until we acquire a new token from the renewal operation triggered by one of the previous requests.
const makeAuthenticatedRequest = async (url: string, options: RequestInit) => {// if token is being renewed now - wait for the new tokenawait waitForRenewalToFinish()const response = await fetch(url, options)if (response.status === 401) {await renewAuthenticationToken()return await makeAuthenticatedRequest(url, options)}return response}
Hold on! The above code is an improvement, but it's still not entirely free from the race condition.
There's a small window between the check if (isDuringRenewal)
and setting the flag isDuringRenewal = true
, where another function could potentially start the renewal process concurrently.
Stay tuned as we introduce a powerful tool, the mighty mutex, to put a complete end to these concurrency challenges. 🛡️
To ensure the integrity of our token renewal process, consider making our checks atomic Enter the formidable mutex, an object designed to synchronize parallel operations.
Let's leverage the Mutex
from the async-mutex
package for a robust solution:
import { Mutex } from 'async-mutex'const tokenRenewalMutex = new Mutex()const renewAuthenticationToken = async () => {if (tokenRenewalMutex.isLocked()) {await tokenRenewalMutex.waitForUnlock()}try {tokenRenewalMutex.acquire()await authService.renewAccessToken()} finally {tokenRenewalMutex.release()}}const makeAuthenticatedRequest = async (url: string, options: RequestInit) => {await tokenRenewalMutex.waitForUnlock()const newOptions = getOptionsWithCurrentToken(options)const response = await fetch(url, newOptions)if (response.status === 401) {await renewAuthenticationToken()return await makeAuthenticatedRequest(url, newOptions)}return response}
With the introduction of the async-mutex
library, we've fortified our solution against concurrent token renewal processes.
The Mutex
ensures a single, synchronized renewal at any given time. It not only resolves the race condition but also promotes a more robust and efficient token renewal process.
In this blog, we explored pitfalls in renewing authentication tokens in React Native apps.
The naive approach led to race conditions with multiple token renewals.
To address this, we introduced a Mutex
, ensuring a single, synchronized renewal process.
The solution prevents race conditions and enhances app efficiency.
Happy coding! 🚀
If you liked this post, why don't you subscribe for more content? If you're as old-school as we are, you can just grab the RSS feed of this blog. Or enroll to the course described below!
Alternatively, if audio's more your thing why don't you subscribe to our podcast! We're still figuring out what it's going to be, but already quite a few episodes are waiting for you to check them out.