<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Devil</title>
    <description>The latest articles on DEV Community by Devil (@devil_21cf096c1059553286d).</description>
    <link>https://dev.to/devil_21cf096c1059553286d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3865079%2F72d2e181-86f5-40ce-a3cb-850642417765.jpg</url>
      <title>DEV Community: Devil</title>
      <link>https://dev.to/devil_21cf096c1059553286d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devil_21cf096c1059553286d"/>
    <language>en</language>
    <item>
      <title>The JWT Refresh Race Condition Nobody Talks About (And How I Fixed It)</title>
      <dc:creator>Devil</dc:creator>
      <pubDate>Tue, 07 Apr 2026 06:23:58 +0000</pubDate>
      <link>https://dev.to/devil_21cf096c1059553286d/the-jwt-refresh-race-condition-nobody-talks-about-and-how-i-fixed-it-pid</link>
      <guid>https://dev.to/devil_21cf096c1059553286d/the-jwt-refresh-race-condition-nobody-talks-about-and-how-i-fixed-it-pid</guid>
      <description>&lt;h2&gt;
  
  
  The Bug That Kept Logging Me Out
&lt;/h2&gt;

&lt;p&gt;I was building a PWA with a custom Node.js backend and Supabase auth. &lt;br&gt;
Everything worked fine — until users (me) kept getting randomly logged out &lt;br&gt;
for no obvious reason.&lt;/p&gt;

&lt;p&gt;No errors. No warnings. Just suddenly back at the login screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Actually Happening
&lt;/h2&gt;

&lt;p&gt;Most JWT auth setups use two refresh strategies:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proactive&lt;/strong&gt; — a timer fires ~1 minute before the access token expires and &lt;br&gt;
refreshes it silently in the background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reactive&lt;/strong&gt; — an Axios interceptor catches 401 errors and refreshes the token &lt;br&gt;
when a request fails.&lt;/p&gt;

&lt;p&gt;The problem: both fired at the same time.&lt;/p&gt;

&lt;p&gt;Here's the exact sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Proactive timer fires → sends refresh token to backend&lt;/li&gt;
&lt;li&gt;An API call returns 401 simultaneously → interceptor also sends the same 
refresh token&lt;/li&gt;
&lt;li&gt;Backend receives two requests with the same refresh token&lt;/li&gt;
&lt;li&gt;First one succeeds → Supabase rotates the token, old one is now dead&lt;/li&gt;
&lt;li&gt;Second one fails with 401 → interceptor hits the failure path → clears 
localStorage → redirects to /login&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;User is logged out. No error. No warning. Just gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Existing Libraries Don't Fix This
&lt;/h2&gt;

&lt;p&gt;I checked axios-auth-refresh and axios-auth-refresh-queue. Both solve &lt;br&gt;
concurrent 401s — multiple requests failing at the same time. &lt;/p&gt;

&lt;p&gt;But neither coordinates with a proactive timer. They only know about &lt;br&gt;
requests going through Axios. Your timer fires outside of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Both the proactive timer and the reactive interceptor need to share a &lt;br&gt;
single lock. If one is already refreshing, the other should join a queue &lt;br&gt;
and wait for the result — not fire a second request.&lt;/p&gt;

&lt;p&gt;I extracted this pattern into a small package:&lt;/p&gt;

&lt;h2&gt;
  
  
  axios-refresh-sync
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;axios-refresh-sync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRefreshManager&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axios-refresh-sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRefreshManager&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;axiosInstance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;refreshEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/auth/refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;getRefreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;setTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onRefreshFailed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Call after login or app init&lt;/span&gt;
&lt;span class="nx"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleRefresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Both the timer and the interceptor now coordinate under one lock.&lt;br&gt;
If one is mid-refresh, the other waits. Supabase only gets called once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works Internally
&lt;/h2&gt;

&lt;p&gt;The core is a simple shared lock module:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;acquireLock()&lt;/code&gt; — check if a refresh is already running&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;setLock(true/false)&lt;/code&gt; — claim or release the lock
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enqueue()&lt;/code&gt; — join the waiting queue if locked&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flushQueue()&lt;/code&gt; — resolve or reject everyone waiting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both proactive.js and interceptor.js import from this same lock. &lt;br&gt;
Neither can race the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Works With Any Backend
&lt;/h2&gt;

&lt;p&gt;Despite being built with Supabase in mind, the package is completely &lt;br&gt;
backend agnostic. As long as your refresh endpoint accepts a refresh token &lt;br&gt;
and returns new tokens, it works.&lt;/p&gt;

&lt;p&gt;Storage is also configurable — you bring your own get/set functions, &lt;br&gt;
so localStorage, cookies, or anything else works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://npmjs.com/package/axios-refresh-sync" rel="noopener noreferrer"&gt;axios-refresh-sync&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/mk90909876-art/axios-refresh-sync.git" rel="noopener noreferrer"&gt;mk90909876-art/axios-refresh-sync&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've ever had users mysteriously logged out and couldn't figure out why — &lt;br&gt;
this might be it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>auth</category>
      <category>npm</category>
    </item>
  </channel>
</rss>
