<?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: Tochukwu Amaechina</title>
    <description>The latest articles on DEV Community by Tochukwu Amaechina (@tochi27).</description>
    <link>https://dev.to/tochi27</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3966899%2F9fb033e6-2dc7-4b72-b64e-099266bc3632.png</url>
      <title>DEV Community: Tochukwu Amaechina</title>
      <link>https://dev.to/tochi27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tochi27"/>
    <language>en</language>
    <item>
      <title>Building PurpleWallet: a full-stack fintech app with a real ledger, AI, and ₦0 hosting</title>
      <dc:creator>Tochukwu Amaechina</dc:creator>
      <pubDate>Thu, 05 Nov 2026 15:58:41 +0000</pubDate>
      <link>https://dev.to/tochi27/building-purplewallet-a-full-stack-fintech-app-with-a-real-ledger-ai-and-n0-hosting-2ec9</link>
      <guid>https://dev.to/tochi27/building-purplewallet-a-full-stack-fintech-app-with-a-real-ledger-ai-and-n0-hosting-2ec9</guid>
      <description>&lt;p&gt;&lt;strong&gt;PurpleWallet started as a backend exercise:&lt;/strong&gt; a Node.js wallet API with Prisma, Redis and a few queues. Over the past few months it grew into a complete product, with a React web app, Paystack payments, a double-entry ledger, an admin console, identity checks, AI spending insights and two layers of automated tests. It's live, and it costs nothing to run.&lt;/p&gt;

&lt;p&gt;Here are the parts I found most interesting to build, and the surprises I hit putting it online.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://walletpurple.netlify.app" rel="noopener noreferrer"&gt;walletpurple.netlify.app&lt;/a&gt;. Click &lt;strong&gt;"Use the demo account"&lt;/strong&gt; on the login page, or sign up for your own. Payments run in Paystack's test mode, so no real money moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/tochi27/Wallet_System" rel="noopener noreferrer"&gt;github.com/tochi27/Wallet_System&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Node.js, Express 5, TypeScript, Zod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;PostgreSQL with Prisma; Redis with BullMQ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web app&lt;/td&gt;
&lt;td&gt;React 19, Vite, Tailwind CSS, shadcn/ui, TanStack Query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments and AI&lt;/td&gt;
&lt;td&gt;Paystack (test mode), Google Gemini&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;Jest and Supertest, Playwright&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both apps live in one npm workspaces monorepo, so a single pull request can change the API, the web app and the browser tests together.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Moving money, exactly once
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is to credit the wallet when the user lands back from Paystack's checkout. That's wrong twice over: the redirect can be faked, and the same payment gets reported several times, by the redirect, by Paystack's webhook, and by retries of both.&lt;/p&gt;

&lt;p&gt;So PurpleWallet never trusts the redirect. Whichever signal arrives first triggers a server-side check with Paystack, and the deposit is credited inside a database transaction that first &lt;strong&gt;claims&lt;/strong&gt; it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only one caller can move the deposit out of PENDING; everyone else gets count 0&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deposit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;deposit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DepositStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PENDING&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DepositStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUCCESSFUL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;paidAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already credited by the webhook, the redirect or a retry&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webhook, the redirect and a retry can all race, and the wallet is still credited once.&lt;/p&gt;

&lt;p&gt;Withdrawals work the other way round: the wallet is debited &lt;strong&gt;first&lt;/strong&gt;, so the money can't be spent twice while the bank transfer is in flight. A failed transfer is refunded exactly once with the same claim pattern, and a job every five minutes picks up anything a webhook missed. On top of that, an &lt;code&gt;Idempotency-Key&lt;/code&gt; header and a Redis lock per wallet stop a double-tapped "Send" button from sending twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Books that always balance
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;balance&lt;/code&gt; column works until a number looks wrong and you have nothing to check it against. So PurpleWallet also keeps a &lt;strong&gt;double-entry ledger&lt;/strong&gt;. Every movement of money is a journal entry whose lines sum to zero. A ₦5,000 transfer from Ada to Bola:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Account&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wallet: Ada&lt;/td&gt;
&lt;td&gt;+5,000.00 (debit: we owe Ada less)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wallet: Bola&lt;/td&gt;
&lt;td&gt;−5,000.00 (credit: we owe Bola more)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The database enforces the rules, not just the code. A &lt;strong&gt;deferred constraint trigger&lt;/strong&gt; rejects any transaction that leaves an entry unbalanced, and &lt;strong&gt;append-only triggers&lt;/strong&gt; block edits and deletes, so mistakes are fixed with reversing entries, as an accountant would.&lt;/p&gt;

&lt;p&gt;The admin console then runs a &lt;strong&gt;three-way reconciliation&lt;/strong&gt;: for every wallet, it compares the stored balance, the sum of its transactions and the ledger. The trial balance should always read ₦0.00. It does.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Security that behaves like a bank's
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transaction PIN.&lt;/strong&gt; Transfers and withdrawals need a 4-digit PIN. Five wrong attempts lock money movement for 30 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verified email before money moves.&lt;/strong&gt; Unverified accounts can look around but get &lt;code&gt;403&lt;/code&gt; on anything that moves money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Careful password resets.&lt;/strong&gt; Links are single-use, expire in 30 minutes, and are stored only as a hash. Changing a password signs out every other session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;401&lt;/code&gt; for a wrong PIN.&lt;/strong&gt; The web app treats &lt;code&gt;401&lt;/code&gt; as "session expired", and a typo must never sign someone out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits and tiers.&lt;/strong&gt; Tier 1 allows ₦50,000 per payment, ₦200,000 a day and a ₦500,000 balance. The checks run inside the payment's own transaction and lock, so two simultaneous payments can't both slip under the limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Identity checks, handled like sensitive data
&lt;/h2&gt;

&lt;p&gt;To reach Tier 2 (₦500,000 per payment, ₦2,000,000 a day, no balance cap), users upload a photo of their ID and a selfie holding it, and staff approve or reject the request with a reason.&lt;/p&gt;

&lt;p&gt;ID photos are the most sensitive data the app holds, so they're checked by their &lt;strong&gt;contents&lt;/strong&gt;, not their file names, then &lt;strong&gt;re-encoded&lt;/strong&gt; to strip GPS and camera details, and stored &lt;strong&gt;AES-256-GCM encrypted&lt;/strong&gt; with a key that never touches the database. Only staff with a dedicated permission can open them, every opening is audit-logged, and photos from rejected requests are deleted after 30 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. An admin console with checks and balances
&lt;/h2&gt;

&lt;p&gt;Staff have four roles: &lt;strong&gt;Viewer&lt;/strong&gt;, &lt;strong&gt;Support&lt;/strong&gt;, &lt;strong&gt;Finance&lt;/strong&gt; and &lt;strong&gt;Owner&lt;/strong&gt;. Balance adjustments over ₦10,000 use &lt;strong&gt;maker-checker&lt;/strong&gt;: a second admin must approve, and both steps require re-entering a password. Every admin action lands in an append-only audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. AI that can't make up numbers
&lt;/h2&gt;

&lt;p&gt;PurpleWallet uses Google Gemini for monthly &lt;strong&gt;spending insights&lt;/strong&gt; and an &lt;strong&gt;assistant&lt;/strong&gt; you can ask about your money.&lt;/p&gt;

&lt;p&gt;The rule: &lt;strong&gt;the AI never does arithmetic.&lt;/strong&gt; My code computes every figure first, and Gemini only writes the sentences. Every ₦ amount and percentage in its text is then checked against the computed figures. If the model quotes a number the code didn't produce, the text is rejected and the app falls back to template sentences. Names are swapped for aliases like "Person A" before anything leaves the server.&lt;/p&gt;

&lt;p&gt;The assistant uses &lt;strong&gt;read-only tools&lt;/strong&gt;. It can't move money: when it suggests a transfer, it pre-fills the Send dialog, and the user still confirms with their PIN.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Tests at two levels
&lt;/h2&gt;

&lt;p&gt;The API has &lt;strong&gt;562 Jest tests&lt;/strong&gt;, including the nasty cases: two deposits racing, a refund claimed twice, a limit hit by simultaneous payments. &lt;strong&gt;Playwright tests&lt;/strong&gt; then drive the real app in Chrome against a throwaway database, covering sign-up, sending money, wrong PINs, limits, ID checks and admin approvals. GitHub Actions runs both on every pull request, and again before each deploy.&lt;/p&gt;

&lt;p&gt;CI had its own bug. Change detection worked on pull requests, but on merge commits it decided nothing had changed, so after every merge the tests &lt;strong&gt;and the deploy&lt;/strong&gt; were skipped, while the run still showed a green tick. Pushes to &lt;code&gt;main&lt;/code&gt; now always test and deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Hosting for ₦0, and what the free tiers taught me
&lt;/h2&gt;

&lt;p&gt;The web app runs on Netlify, the API on Render, Postgres on Supabase and Redis on Redis Cloud, all on free plans. Each had a surprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A "free" deploy that wasn't.&lt;/strong&gt; Because my Render Blueprint didn't say &lt;code&gt;plan: free&lt;/code&gt;, Render used its paid default, and months later suspended the account over an unpaid invoice. Render kindly waived it, and the Blueprint now states the plan explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plans block outgoing email.&lt;/strong&gt; Render's free tier blocks SMTP, so verification emails silently timed out. The fix was a short Google Apps Script deployed in the sending Gmail account: the API posts each email to it over HTTPS with a shared secret, and the script sends it through Gmail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase exposes your tables by default.&lt;/strong&gt; Supabase emailed a critical warning: every table was reachable through its auto-generated Data API, because row-level security was off. My app never uses that API, but the door was open. A migration now enables row-level security and revokes the API roles' access, and a test fails CI if a new migration forgets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free services sleep.&lt;/strong&gt; A scheduled ping every 10 minutes keeps the API awake, using about 730 of Render's 750 free hours a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. A demo you can share safely
&lt;/h2&gt;

&lt;p&gt;The live site has a &lt;strong&gt;one-click demo account&lt;/strong&gt; with three months of realistic Nigerian spending (rent, Shoprite, Bolt rides, DSTV), so the insights and assistant have something to talk about. Since everyone shares that login, visitors can't change its password or PIN, wrong PINs never lock it, and it never receives email. There's also a sample ID card to download for trying the identity check, so nobody uploads a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the database enforce the rules that matter.&lt;/strong&gt; Triggers caught mistakes that tests alone would have missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for duplicates.&lt;/strong&gt; Webhooks, retries and double-clicks will deliver the same event twice; a conditional "claim" update solves most of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let code do the maths and AI do the words&lt;/strong&gt;, then check the words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the free-tier small print&lt;/strong&gt;, and remember that a green CI tick only means something if the jobs actually ran.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PurpleWallet is live at &lt;a href="https://walletpurple.netlify.app" rel="noopener noreferrer"&gt;walletpurple.netlify.app&lt;/a&gt;, and the code is on &lt;a href="https://github.com/tochi27/Wallet_System" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Try the demo, send some test money, or ask the assistant where your money went last month. I'd love to hear what you think.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>node</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Redis Caching Strategies for High-Performance APIs</title>
      <dc:creator>Tochukwu Amaechina</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:12:29 +0000</pubDate>
      <link>https://dev.to/tochi27/redis-caching-strategies-for-high-performance-apis-2kna</link>
      <guid>https://dev.to/tochi27/redis-caching-strategies-for-high-performance-apis-2kna</guid>
      <description>&lt;p&gt;When I first started thinking seriously about API performance, my instinct was simple:&lt;/p&gt;

&lt;p&gt;If the database is slow, optimize the database.&lt;/p&gt;

&lt;p&gt;Add indexes. Improve the query. Tune the connection pool.&lt;/p&gt;

&lt;p&gt;And sometimes, that's exactly what you should do.&lt;/p&gt;

&lt;p&gt;But eventually, you run into a different problem.&lt;/p&gt;

&lt;p&gt;The database isn't necessarily slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're just asking it the same question too many times.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Imagine an endpoint that returns product information.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/products/123
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The product changes once every few hours, but the endpoint gets thousands of requests every minute.&lt;/p&gt;

&lt;p&gt;Without caching, every request goes straight to PostgreSQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   ↓
  API
   ↓
PostgreSQL
   ↓
Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database is doing the same work over and over again.&lt;/p&gt;

&lt;p&gt;That's where Redis comes in.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Simple Idea Behind Caching&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of asking the database every time, we store frequently requested data temporarily.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   ↓
Redis
   ↓
  HIT ─────→ Response
   │
  MISS
   ↓
Database
   ↓
Redis
   ↓
Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request pays the database cost.&lt;/p&gt;

&lt;p&gt;The next requests don't.&lt;/p&gt;

&lt;p&gt;In a Node.js API, a simple cache-aside implementation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Product not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;EX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;product&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 the basic pattern:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Check Redis → cache miss → query database → store result → return.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Simple.&lt;/p&gt;

&lt;p&gt;But this is where caching stops being just a performance optimization.&lt;/p&gt;

&lt;p&gt;Because now we have two copies of the data.&lt;/p&gt;

&lt;p&gt;And they can disagree.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Stale Data Problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose Redis contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;product:123
price: ₦45,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then someone updates the product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database
price: ₦50,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis still contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;₦45,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the cache has a five-minute TTL, users could receive the old price for another five minutes.&lt;/p&gt;

&lt;p&gt;This is the real challenge with caching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making data fast to access is easy. Keeping it correct is harder.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's why TTL alone isn't enough.&lt;/p&gt;




&lt;h1&gt;
  
  
  TTL vs Invalidation
&lt;/h1&gt;

&lt;p&gt;TTL answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How long should this cache entry exist?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Invalidation answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"When should I remove it because the underlying data changed?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;updateData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the next request becomes a cache miss and retrieves the latest value from the database.&lt;/p&gt;

&lt;p&gt;For many APIs, &lt;strong&gt;cache-aside + TTL + delete-on-write&lt;/strong&gt; is a very practical starting point.&lt;/p&gt;

&lt;p&gt;You don't need a complicated caching architecture on day one.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Then There's the Cache Stampede&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's where things get interesting.&lt;/p&gt;

&lt;p&gt;Imagine a popular cache entry expires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;product:123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the exact same time, 1,000 requests arrive.&lt;/p&gt;

&lt;p&gt;They all check Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MISS
MISS
MISS
MISS
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And suddenly all 1,000 requests hit PostgreSQL.&lt;/p&gt;

&lt;p&gt;The cache that was supposed to protect the database has just created a database traffic spike.&lt;/p&gt;

&lt;p&gt;This is commonly called a &lt;strong&gt;cache stampede&lt;/strong&gt; or &lt;strong&gt;thundering herd&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For extremely hot keys, you can prevent this by allowing only one request to rebuild the cache while the others wait.&lt;/p&gt;

&lt;p&gt;Redis can help here through distributed locking.&lt;/p&gt;

&lt;p&gt;But you wouldn't want to introduce locking everywhere.&lt;/p&gt;

&lt;p&gt;If a query takes 10ms, the complexity may not be worth it.&lt;/p&gt;

&lt;p&gt;If rebuilding the cache takes several seconds under heavy traffic, it's a different conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimize for the workload you actually have.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What Should You Cache?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is probably the most important question.&lt;/p&gt;

&lt;p&gt;I wouldn't cache something simply because Redis is available.&lt;/p&gt;

&lt;p&gt;Good candidates usually have three characteristics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frequently requested
+
Expensive to retrieve
+
Safe to be slightly stale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product details
Public profiles
Search results
Configuration
Reference data
Expensive aggregations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd be much more careful with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Account balances
Payment state
Authorization decisions
Inventory
Transaction state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Especially in financial systems.&lt;/p&gt;

&lt;p&gt;If a wallet contains ₦100,000 and Redis says ₦100,000 while the database says ₦70,000 after a transaction, using that cached value for a financial decision is dangerous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast wrong data is still wrong data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For critical state, the database should remain authoritative.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Your Cache Key Is Part of Your Architecture&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A cache key isn't just a random string.&lt;/p&gt;

&lt;p&gt;This:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a terrible cache key.&lt;/p&gt;

&lt;p&gt;This is better:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user:123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in a multi-tenant system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant:456:user:123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For filtered results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;products:category:phones:page:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every parameter that changes the response needs to be represented appropriately in the key.&lt;/p&gt;

&lt;p&gt;Otherwise, you can end up returning the wrong cached response to the right request.&lt;/p&gt;

&lt;p&gt;That's a correctness bug—not a performance bug.&lt;/p&gt;




&lt;h1&gt;
  
  
  Redis Should Accelerate Your System, Not Become Your System
&lt;/h1&gt;

&lt;p&gt;One of the most important design decisions is what happens when Redis fails.&lt;/p&gt;

&lt;p&gt;For a cache-aside architecture, I'd rather have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Redis available
    ↓
Use cache

Redis unavailable
    ↓
Use database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API might become slower.&lt;/p&gt;

&lt;p&gt;But it remains functional.&lt;/p&gt;

&lt;p&gt;That's a much better failure mode than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Redis unavailable
    ↓
Entire API unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your application completely depends on Redis just to retrieve data that already exists in PostgreSQL, you've accidentally turned your cache into a critical dependency.&lt;/p&gt;




&lt;h1&gt;
  
  
  Measure the Difference
&lt;/h1&gt;

&lt;p&gt;Finally, don't add Redis and declare victory.&lt;/p&gt;

&lt;p&gt;Measure it.&lt;/p&gt;

&lt;p&gt;Before caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Average latency: 400ms
P95 latency:     800ms
DB queries/sec:  2,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Average latency: 70ms
P95 latency:     150ms
DB queries/sec:  500
Cache hit rate:  85%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have evidence.&lt;/p&gt;

&lt;p&gt;The goal isn't to make Redis busy.&lt;/p&gt;

&lt;p&gt;The goal is to make the &lt;strong&gt;entire system do less unnecessary work.&lt;/strong&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  The Bigger Lesson
&lt;/h1&gt;

&lt;p&gt;Redis isn't what makes an API fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoiding unnecessary work makes an API fast.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Redis is simply one of the tools that helps you do that.&lt;/p&gt;

&lt;p&gt;The moment you introduce a cache, you're making a trade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;More memory
+
More complexity
+
Potentially stale data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;in exchange for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lower latency
+
Less database load
+
Higher throughput
+
Better scalability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The best caching strategy isn't the one that puts the most data into Redis.&lt;/p&gt;

&lt;p&gt;It's the one that &lt;strong&gt;eliminates the most unnecessary work while keeping the system correct.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the difference between simply &lt;strong&gt;using Redis&lt;/strong&gt; and actually &lt;strong&gt;designing with Redis&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>performance</category>
      <category>redis</category>
    </item>
    <item>
      <title>How I Stopped a Race Condition from Creating Free Money in My Wallet System</title>
      <dc:creator>Tochukwu Amaechina</dc:creator>
      <pubDate>Wed, 03 Jun 2026 21:57:40 +0000</pubDate>
      <link>https://dev.to/tochi27/how-i-stopped-a-race-condition-from-creating-free-money-in-my-wallet-system-5hhc</link>
      <guid>https://dev.to/tochi27/how-i-stopped-a-race-condition-from-creating-free-money-in-my-wallet-system-5hhc</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Bug That Doesn't Show Up in Testing.&lt;/strong&gt;&lt;br&gt;
When I set out to build a production-grade wallet system, I expected the hard parts to be the database schema, the transaction logic, or the authentication layer.&lt;/p&gt;

&lt;p&gt;What I didn't expect was that the most dangerous bug in the system would have nothing to do with wrong code - it would be about timing.&lt;/p&gt;

&lt;p&gt;This is the story of a race condition that lets users spend money they don't have, why it's invisible until it hits production, and how Redis turned it into a non-issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a Wallet System Actually Does&lt;/strong&gt;&lt;br&gt;
Before we get to the bug, a quick picture of the system. A wallet holds a balance. Users can:&lt;/p&gt;

&lt;p&gt;Credit - add funds&lt;br&gt;
Debit - spend funds&lt;br&gt;
Transfer - move funds to another user&lt;br&gt;
Reverse - undo a transaction&lt;br&gt;
Every operation reads the current balance, validates it, and writes a new transaction record. Simple enough. The problem hides in that gap between reading and writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Race Condition&lt;/strong&gt;&lt;br&gt;
Here's the scenario. A user has ₦5,000 in their wallet. They're on a slow connection, so they tap "Pay" twice. Two HTTP requests hit the server within milliseconds of each other. Both are trying to debit ₦3,000.&lt;/p&gt;

&lt;p&gt;Without any concurrency protection, this is what happens:&lt;/p&gt;

&lt;p&gt;Request A → reads balance: ₦5,000&lt;br&gt;
Request B → reads balance: ₦5,000&lt;/p&gt;

&lt;p&gt;Request A → ₦5,000 - ₦3,000 = ₦2,000 ✓ writes transaction&lt;br&gt;
Request B → ₦5,000 - ₦3,000 = ₦2,000 ✓ writes transaction&lt;br&gt;
Both requests read ₦5,000. Both pass the balance check. Both write a successful debit. The user just spent ₦6,000 from a ₦5,000 wallet - and the system called both transactions successful.&lt;/p&gt;

&lt;p&gt;This isn't a theoretical edge case. Under any real concurrent load, this happens. And the worst part: it never shows up in testing, because your test runner doesn't fire two real HTTP requests at the same millisecond.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a Database Transaction Doesn't Save You&lt;/strong&gt;&lt;br&gt;
The instinct here is to reach for a database transaction. Wrap the whole thing in BEGIN / COMMIT and let Postgres sort it out.&lt;/p&gt;

&lt;p&gt;The problem is that the race happens before the database transaction begins. Both requests read the balance from the database (or worse, from a cache), both pass the validation check in application code, and then both open their database transactions. The damage is already done.&lt;/p&gt;

&lt;p&gt;You need a lock at the application layer, before the read even happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix: Distributed Locking with Redis&lt;/strong&gt;&lt;br&gt;
The solution is to ensure that no two operations on the same wallet can run at the same time. Before any balance operation begins, it must claim exclusive access. If another operation already holds the claim, it retries until the lock is free, or gives up after a timeout.&lt;/p&gt;

&lt;p&gt;This is a distributed lock, and Redis is the right tool for it. Here's the implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/utils/lock.utils.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&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="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;redisClient&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../services/redis.service&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCK_TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCK_RETRY_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCK_RETRY_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RELEASE_SCRIPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
  else
    return 0
  end
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acquireLock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redisClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LOCK_TTL_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withLock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&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;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`wallet:lock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCK_RETRY_COUNT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;acquireLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LOCK_RETRY_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not acquire lock — resource is busy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redisClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RELEASE_SCRIPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's break down each decision.&lt;/p&gt;

&lt;p&gt;SET key token PX 10000 NX&lt;br&gt;
This is a single atomic Redis command that does three things at once:&lt;/p&gt;

&lt;p&gt;NX — only set the key if it does not already exist&lt;br&gt;
PX 10000 — expire the key after 10 seconds&lt;br&gt;
The value is a unique token (UUID), not just 1 or "locked"&lt;br&gt;
The atomicity of SET NX is the core guarantee. There is no gap between "check if key exists" and "set the key" — it's one operation. Two requests racing to acquire the same lock cannot both succeed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry logic&lt;/strong&gt;&lt;br&gt;
Rather than immediately rejecting a request when the lock is held, the implementation retries up to 50 times with a 100ms delay between each attempt - giving it up to 5 seconds to acquire the lock before giving up. This handles brief overlaps gracefully without dropping legitimate requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TTL&lt;/strong&gt;&lt;br&gt;
The 10-second expiry is a safety net. If the application crashes mid-operation and fails to release the lock, it will expire automatically. Without this, one crash would lock that user's wallet forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Lua script on release&lt;/strong&gt;&lt;br&gt;
This is where most implementations get it wrong. A naive release looks like this:&lt;/p&gt;

&lt;p&gt;await redisClient.del(key); // ❌ dangerous&lt;br&gt;
Here's why that's broken: imagine a slow operation acquires the lock, but takes longer than 10 seconds. The TTL expires. A second operation acquires the lock. Now the first operation finishes and calls del - it just released the second operation's lock, not its own. The guarantee is broken.&lt;/p&gt;

&lt;p&gt;The Lua script fixes this by checking the token before deleting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"DEL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Lua scripts execute atomically in Redis, the check-and-delete is a single uninterruptible operation. You can only release a lock you actually own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The withLock wrapper&lt;/strong&gt;&lt;br&gt;
Rather than exposing raw acquireLock / releaseLock functions and trusting every caller to use them correctly, the implementation wraps the entire pattern in a higher-order function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/services/wallet.service.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debitWallet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Transaction&lt;/span&gt;&lt;span class="o"&gt;&amp;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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wallet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wallet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;wallet&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;wallet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Insufficient balance&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// create transaction, update balance...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The finally block inside withLock ensures the lock is always released, even if the operation throws. A lock that leaks on error is as bad as no lock at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preventing Deadlocks on Transfers&lt;/strong&gt;&lt;br&gt;
A transfer touches two wallets - sender and receiver. If two transfers run simultaneously in opposite directions (A→B and B→A), they can deadlock: each holds one lock and waits for the other indefinitely.&lt;/p&gt;

&lt;p&gt;The fix is to always acquire locks in the same order, regardless of who is sending to whom. We sort the user IDs before acquiring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withMultiLock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;userIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;fn&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;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sortedIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sortedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`wallet:lock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCK_RETRY_COUNT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;acquireLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LOCK_RETRY_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not acquire lock — resource is busy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;releaseLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// In transfer service:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withMultiLock&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;senderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;async &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="c1"&gt;// both wallets locked — safe to proceed&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If A→B and B→A both arrive at the same time, both will sort to [A, B] and try to acquire A's lock first. One wins, the other waits. No deadlock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Full System&lt;/strong&gt;&lt;br&gt;
The distributed lock is one layer in a larger stack. The complete wallet engine includes:&lt;/p&gt;

&lt;p&gt;Idempotency - Idempotency-Key header on credit and debit prevents duplicate transactions on network retries, backed by a 24-hour Redis TTL&lt;br&gt;
Webhook delivery with retry - BullMQ queue, 4 attempts, exponential backoff (2s base), per-attempt audit trail&lt;br&gt;
Balance caching - 60-second Redis TTL, invalidated on every write&lt;br&gt;
Token blacklisting - SHA-256 hashed JWTs stored in Redis with matching expiry, making logout actually secure&lt;br&gt;
Cursor-based pagination - transaction history that scales&lt;br&gt;
Full test suite - 103 tests, all mocked, no database required to run.&lt;br&gt;
Built with TypeScript, Express, Prisma, PostgreSQL, and Redis (ioredis). Deployed on Render with a GitHub Actions CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;br&gt;
Race conditions in financial systems are not exotic. They're the default outcome when you don't explicitly prevent them. Most wallet tutorials never mention it. Most wallet implementations in production have it.&lt;/p&gt;

&lt;p&gt;The fix isn't complicated - but you have to know to look for it.&lt;/p&gt;

&lt;p&gt;If you're building anything that touches money, check your balance reads. If there's no lock between the read and the write, the race condition is already there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open for Collaboration&lt;/strong&gt;&lt;br&gt;
This project is open source and actively welcoming contributors. Whether you want to add a feature, improve test coverage, or spot something that could be done better - PRs are open.&lt;/p&gt;

&lt;p&gt;Check out the CONTRIBUTING.md for how to get set up and where to send your changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/tochi27/Wallet_System.git" rel="noopener noreferrer"&gt;https://github.com/tochi27/Wallet_System.git&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live API + Swagger:&lt;/strong&gt; &lt;a href="https://wallet-system-api-16cv.onrender.com/api-docs" rel="noopener noreferrer"&gt;https://wallet-system-api-16cv.onrender.com/api-docs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>redis</category>
      <category>typescript</category>
      <category>node</category>
      <category>backenddevelopment</category>
    </item>
  </channel>
</rss>
