<?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: GeekyAnts</title>
    <description>The latest articles on DEV Community by GeekyAnts (geekyants).</description>
    <link>https://dev.to/geekyants</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%2Forganization%2Fprofile_image%2F12611%2F12ba258b-c1e8-4802-9a86-9447eacb706a.png</url>
      <title>DEV Community: GeekyAnts</title>
      <link>https://dev.to/geekyants</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/geekyants"/>
    <language>en</language>
    <item>
      <title>How We Engineered a Three-Path Reward Processing System for a Real-Time Sports Platform</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 13 Nov 2026 10:29:04 +0000</pubDate>
      <link>https://dev.to/geekyants/how-we-engineered-a-three-path-reward-processing-system-for-a-real-time-sports-platform-1bmi</link>
      <guid>https://dev.to/geekyants/how-we-engineered-a-three-path-reward-processing-system-for-a-real-time-sports-platform-1bmi</guid>
      <description>&lt;p&gt;How one score event triggers 20+ reward evaluations across sync, async, and scheduled paths&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. The Problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A player steps into one of our Core Soccer Tech pods, plays a 90-second game, and scores 847 points. The kiosk needs to respond instantly: show the score, trigger a celebration animation if they hit a personal high, and display any XP (experience points) earned.&lt;/p&gt;

&lt;p&gt;But behind that single score, the system has to answer over 20 questions. Did they beat their high score? Did they unlock a new game mode? Did that score push them past a reward threshold? How many of today's seven daily missions did that game advance? What about the eight weekly missions running in parallel? And the eight seasonal missions that span the entire quarter?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All of this from one POST request.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The naive approach is to evaluate everything synchronously inside the score submission endpoint. The kiosk waits, the database holds a long transaction, and every player in the facility feels the lag. At 75+ touches per 90-second game and multiple pods running simultaneously, that is not a theoretical concern. It is a production one.&lt;/p&gt;

&lt;p&gt;We needed to split the work. &lt;strong&gt;Some rewards have to be synchronous because the kiosk needs the data immediately for animations.&lt;/strong&gt; Some can be deferred by a few seconds. Some only make sense at the end of a week or quarter.&lt;/p&gt;

&lt;p&gt;The result is a &lt;strong&gt;three-path reward processing engine&lt;/strong&gt;: synchronous rewards evaluated inside the score transaction, asynchronous processing via a BullMQ worker for missions and achievements, and cron-driven distribution for leaderboard rewards.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. Architecture Overview&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The backend is a &lt;a href="https://geekyants.com/hire-nest-js-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;NestJS&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; 11 application on Node.js 24. It talks to PostgreSQL through the Knex query builder (via nestjs-knex); there is no ORM (Object-Relational Mapper), so every query in this article is either a Knex builder call or raw SQL. Schema changes, unique constraints, and database triggers all live in Knex migrations. Background jobs use BullMQ 5 on Redis, and scheduled jobs use @nestjs/schedule.&lt;/p&gt;

&lt;p&gt;The reward engine sits between score ingestion and four output tables. Every reward, regardless of its source, writes to one or more of these: player_transaction (the universal ledger of XP and CC, the platform's credit currency), player_metadata (running totals maintained by database triggers), player_achievement (one-time milestones), and player_notification (kiosk alerts).&lt;/p&gt;

&lt;p&gt;Three ingestion paths, including classic and weekly pattern games, OnePod batch sessions, and battle rooms, all converge into a single addScore() method. From there, processing splits into three paths based on urgency:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F10%252Fa2f077c3-89a6-4b3c-acdc-1966c47d3ef2.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F10%252Fa2f077c3-89a6-4b3c-acdc-1966c47d3ef2.png%26w%3D3840%26q%3D75" alt="Reward processing architecture with synchronous score rewards, BullMQ background missions, and scheduled leaderboard payouts." width="1920" height="3280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sync Path (green):&lt;/strong&gt; Runs inside the database transaction. Returns rewards in the HTTP response so the kiosk can show celebration animations immediately. Handles single game rewards, high score detection, and game mode unlocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async Path (purple):&lt;/strong&gt; Score events are enqueued to a BullMQ queue after the transaction commits. A background worker evaluates achievements, badges, daily missions, weekly missions, seasonal missions, and level-ups with a concurrency cap of 5 parallel jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled Path (red):&lt;/strong&gt; Cron jobs run on fixed schedules in the America/Denver timezone. Every Monday: the weekly pattern rotates at 12:00 AM, weekly leaderboard rewards are distributed at 12:05 AM, and the "top 10 in your age group" seasonal mission is updated at 12:10 AM. Battle season rewards run at 12:05 AM on the first day of each quarter. These jobs rank players and distribute CC, XP, and badges based on leaderboard position.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. The Unified Entry Point&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;All three ingestion paths converge into addScore(). This is a deliberate architectural choice: a single method handles score creation and sync reward evaluation inside one database transaction, then dispatches the async event after that transaction commits.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Why a Single Entry Point?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Classic games, OnePod batch sessions (2–4 players ranked together), and battle rooms (deferred scoring until all players finish) have very different ingestion patterns. But once a score is created, the reward logic is identical. By funneling everything through addScore(), we guarantee that every score, regardless of how it was submitted, gets the same reward treatment.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Transaction Boundary&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The transaction boundary is the most important design decision in this system. Inside the transaction, we run eight operations in a fixed order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the previous high score for this player and game mode (weekly pattern scores excluded).&lt;/li&gt;
&lt;li&gt;Increment player.game_count and read the new value back.&lt;/li&gt;
&lt;li&gt;Insert the score into soccer_game_score.&lt;/li&gt;
&lt;li&gt;Insert the game-specific record (one_pod_score or weekly_pattern_score) if the game style needs one.&lt;/li&gt;
&lt;li&gt;Insert a NewGameUnlock achievement if the new game count crosses an unlock threshold.&lt;/li&gt;
&lt;li&gt;Evaluate single game rewards (section 4) and, if any qualify, record the XP and write one player_transaction row.&lt;/li&gt;
&lt;li&gt;Insert a PersonalHighScore achievement if the score beats the previous high.&lt;/li&gt;
&lt;li&gt;Log today's play date into player_daily_play_log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The transaction callback returns, Knex issues COMMIT, and only then is the &lt;strong&gt;ScoreEventPayload enqueued to BullMQ&lt;/strong&gt;. Enqueueing is deliberately outside the transaction. &lt;strong&gt;If the enqueue fails, the score is saved but async rewards are lost&lt;/strong&gt;, and because the enqueue call is not caught, the kiosk receives a 500 for a score that was in fact persisted. We accepted this tradeoff to keep the transaction short and avoid coupling score persistence to Redis availability.&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="n"&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="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;trx&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="n"&gt;prevHighScore&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;getPrevHighScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;game_mode_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;game_count&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&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;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'game_count'&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'game_count'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;]&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;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'soccer_game_score'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;game_mode_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;game_mode_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;game_style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;game_style&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="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 3&lt;/span&gt;
  &lt;span class="c1"&gt;// Steps 4 to 8 run here, all on `trx`&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prevHighScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;newGameCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;game_count&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;// COMMIT has happened. Now, and only now, hand the event to the worker.&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scoreEventsQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addScoreEventJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;score_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;game_mode_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;game_mode_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;game_style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;game_style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;prev_high_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prevHighScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;new_game_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;newGameCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdScore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;season&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;Battle rooms and OnePod sessions insert several scores in one outer transaction, so they pass their own trx into addScore(). Battle completion also passes skipEnqueue = true, collects one payload per player, and enqueues them all after the outer commit.&lt;/p&gt;

&lt;p&gt;The ScoreEventPayload The payload sent to the async worker carries everything needed to evaluate all downstream rewards without additional database lookups at the entry point:&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;game_mode_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;game_style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_high_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_game_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;prev_high_score is included because the worker needs it for score level achievement checks. Without it, the worker would need to query historical scores, adding latency and database load to every async job.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. The Sync Path: Immediate Rewards&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;These rewards are evaluated inside the addScore() transaction and returned in the HTTP response. The kiosk uses them to display immediate feedback: XP earned animations, high score celebrations, and game unlock screens.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Single Game Rewards&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Four reward types are evaluated per score, and all can stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reward&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;play_any_game&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always qualifies&lt;/td&gt;
&lt;td&gt;Base XP for playing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;score_x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;score &amp;gt;= floor(third_best_score * threshold_pct / 100)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Needs at least 3 scores in the mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;score_y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same formula, higher &lt;code&gt;threshold_pct&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Harder tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new_high_score&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;score &amp;gt; prev_high_score AND games_in_mode &amp;gt;= min_high_score_games&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;min_high_score_games&lt;/code&gt; comes from global config, default 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each row is configured in single_game_reward_config (threshold_pct, xp_reward, animation, and a game_style exclusion list). Weekly pattern games skip this block entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best-score formula is worth noting:&lt;/strong&gt; instead of comparing against the player's top score, we compare against their &lt;strong&gt;third-best&lt;/strong&gt; score in that game mode. This prevents a single lucky outlier from permanently raising the bar; a player has to post three strong games before the threshold moves.&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;// Top 3 in this mode, read after the new score was inserted (so it is included)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;topScores&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;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'soccer_game_score'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'score'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;game_mode_id&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="nf"&gt;whereNot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'game_style'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'weekly_pattern'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'score'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'desc'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;thirdBestScore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;topScores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;topScores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="c1"&gt;// score_x / score_y&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thirdBestScore&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threshold_pct&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thirdBestScore&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threshold_pct&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;qualifies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// new_high_score&lt;/span&gt;
&lt;span class="n"&gt;qualifies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gamesInMode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minHighScoreGames&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;prevHighScore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a player has fewer than 3 scores in a mode, thirdBestScore is null and score_x and score_y are not awarded at all. Only play_any_game fires on those first two games. There is one side effect of including the new score in the top-3 query: on exactly the third game in a mode, the new score can be the third best itself, so score_x and score_y automatically qualify for that game.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;High Score Detection and Game Unlocks&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If the new score beats the player's previous high score (and the game style is not weekly_pattern), a PersonalHighScore achievement is inserted. This check has no game-count gate, so a player on their third game gets the celebration animation but not the new_high_score XP. Similarly, when game_count crosses an unlock threshold, a NewGameUnlock achievement is created with metadata indicating which game mode (CoreOG, Mirror, Xpert, Clutch, or Mirror_X) was unlocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;5. The Async Path: BullMQ Worker&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;After the transaction commits, a ScoreEventPayload is enqueued to a Redis-backed BullMQ queue named scoreEvents. A worker picks it up and processes the steps below sequentially.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F10%252Fa2f077c3-d99a-4b0d-a0a3-e08fade2ad2e.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F10%252Fa2f077c3-d99a-4b0d-a0a3-e08fade2ad2e.png%26w%3D3840%26q%3D75" alt="Score event flow from kiosk submission through synchronous rewards, Redis queuing, BullMQ processing, and independent scheduled jobs." width="1920" height="2280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Worker Configuration&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The worker runs in the same &lt;a href="https://geekyants.com/hire-nodejs-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Node.js&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; process as the HTTP server with a concurrency cap of 5 parallel jobs. Successful jobs are removed immediately; the last 100 failed jobs are retained for debugging. This is a pragmatic choice: a separate worker process would add deployment complexity for a system that handles scoring bursts measured in dozens per minute, not thousands.&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="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'scoreEvents'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redisConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ScoreEventPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'scoreEvents'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDeadlockRetry&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processScoreEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redisConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&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;// called by addScore() after COMMIT&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'processScore'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;removeOnComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;removeOnFail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;That is the whole configuration. We do not set attempts or backoff, so BullMQ's defaults apply: a job that throws is moved straight to the failed set with no automatic retry. The only retry in this path is application-level. withDeadlockRetry() reruns the job up to three times when &lt;a href="https://geekyants.com/hire-postgresql-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;PostgreSQL &lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; reports a deadlock (error code 40P01), waiting 50 * attempt + random(0..50) milliseconds between tries. Everything else fails once.&lt;/p&gt;

&lt;p&gt;Stalled jobs follow BullMQ defaults too: a job lock lasts 30 seconds and is renewed every 15; if the process dies or blocks the event loop long enough for the lock to expire, the stalled checker (every 30 seconds) puts the job back in the wait list once (maxStalledCount: 1). A second stall causes the job to fail. After a final failure the error is logged with the player_id and score_id, the job sits in the failed set until it is pushed out by the 100-job cap, and nothing replaces it. There is no dead-letter queue and no alerting on the failed set today.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Achievement Evaluation (Steps 1–2)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Step 1 checks whether the new score crossed a score-level tier. These configurable thresholds are defined per game mode and can award XP, credits, and badges. Step 2 checks career total points milestones, using a database trigger-maintained counter on player_metadata. Both use an achievementExists(score_id, type) idempotency check before inserting, preventing duplicates if the same event is ever processed twice. A points-badge evaluation runs between step 2 and the missions.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Mission Processing (Steps 3–5)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;This is where the bulk of the work happens. Steps 3 through 5 evaluate daily, weekly, and seasonal missions respectively. They run sequentially within a single job, not in parallel. This was a deliberate choice: sequential execution means a database error in daily missions prevents weekly and seasonal from running for that event, but it also means we never have two mission services competing for database connections from the same job. A level-up check runs last, after the seasonal missions.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;6. The Mission System: Three Time Horizons&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The mission system operates across three time horizons, each with fundamentally different state management patterns. Missions unlock on a player's fifth game overall: games 1 to 4 are skipped, game 5 creates the mission rows, and processing starts from game 6.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Daily Missions&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Seven mission types reset every day. On a player's first score each day, getOrCreateDailyMissions() lazily initializes all seven missions with fresh progress counters. Goals are personalized; the TotalPoints mission, for example, sets its goal to the player's third-best Core OG score multiplied by a configurable factor (minimum 1), ensuring the target scales with skill level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ScoreYStreak mission is the most interesting pattern:&lt;/strong&gt; it requires the player to score above a threshold N times in a row. One miss resets progress to zero, so each consecutive successful score increases the cost of breaking the streak. The backend tracks this with a simple conditional: if score &amp;gt;= threshold, increment progress; else, reset to zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Weekly Missions&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Eight mission types reset every Monday. The week anchor is computed in the database as date_trunc('week', (current_timestamp AT TIME ZONE 'America/Denver')::date). The most complex is TopNScore, where N is locked when the week's missions are created, based on the player's total game count: experienced players must score in their top 5, newer players in their top 10. This graduated difficulty prevents both sandbagging and frustration.&lt;/p&gt;

&lt;p&gt;Weekly missions also track consecutive play days using an append-only player_daily_play_log table. Each day a player plays, a row is inserted with INSERT ... ON CONFLICT (player_id, play_date) DO NOTHING (Knex .onConflict([...]).ignore()) for concurrent access safety. The streak count is computed from this log at evaluation time.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Seasonal Missions&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Eight missions spanning an entire quarter, with 16 to 200 threshold milestones each. Unlike daily and weekly missions that track per-score progress, seasonal missions use aggregate counters stored in player_season_data. Each score event increments these counters with a single UPDATE, and the mission service compares current values against threshold tables.&lt;/p&gt;

&lt;p&gt;The idempotency mechanism here is elegant: each player's mission row stores a last_completed_threshold_id. When processing, the service iterates through thresholds in sort order, skipping anything at or below this ID, and stopping at the first uncrossed threshold. The ID update and reward insert happen in the same database transaction, so if the transaction rolls back, neither persists.&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;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// sort_order ascending&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_completed_threshold_id&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_completed_threshold_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threshold_value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="n"&gt;newThresholds&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="n"&gt;t&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="n"&gt;newThresholds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&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;continue&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player_seasonal_mission'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;last_completed_threshold_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newThresholds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;levelUpService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recordXp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totalXp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;XpSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SeasonalMission&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player_transaction'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transaction_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'add'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reward_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'seasonal_mission'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xp_earned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;totalXp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;free_live_credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;totalCc&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;h3&gt;
  
  
  &lt;strong&gt;Rotating Game Modes&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Both daily and weekly missions include a "play the rotating game mode" mission type. Daily rotation is deterministic: day of year (Denver local date) modulo 4 maps to Mirror, Xpert, Clutch, or Mirror_X. Weekly rotation is derived from the week's start date and stored in a weekly_mission_rotation table with ON CONFLICT (week_start_date) DO NOTHING for concurrent initialization. This keeps missions fresh without requiring manual configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;7. The Scheduled Path: Cron Jobs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Four cron jobs handle scheduled game-pattern updates and time-bounded reward processing. All run in the America/Denver timezone to match the facilities' operational hours. The scheduler is @nestjs/schedule (which wraps the cron npm package), and every job passes the same constant:&lt;/p&gt;

&lt;p&gt;export const CURR_APP_TIMEZONE = 'America/Denver'&lt;/p&gt;

&lt;p&gt;@Cron('0 0 * * 1', { timeZone: CURR_APP_TIMEZONE }) // Monday 12:00 AM: rotate weekly pattern @Cron('5 0 * * 1', { timeZone: CURR_APP_TIMEZONE }) // Monday 12:05 AM: weekly leaderboard rewards @Cron('10 0 * * 1', { timeZone: CURR_APP_TIMEZONE }) // Monday 12:10 AM: top 10 age group mission @Cron('5 0 1 1,4,7,10 *', { timeZone: CURR_APP_TIMEZONE }) // Jan/Apr/Jul/Oct 1, 12:05 AM: battle season rewards&lt;/p&gt;

&lt;p&gt;Daylight Saving Time (DST) is handled by the cron library, which evaluates the expression in the named zone, so "12:05 AM Denver" stays 12:05 AM local across the March and November changes. Daily mission dates, the weekly anchor, and the play log use the same zone inside PostgreSQL (current_timestamp AT TIME ZONE 'America/Denver'); the database session itself runs in UTC. Game-mode rotation and the battle season calendar resolve the same zone in Node.js.&lt;/p&gt;

&lt;p&gt;Each job is wrapped in a small withRetry() helper that allows three attempts with a fixed five-minute wait between them. A job that fails all three attempts logs the error and waits for its next scheduled run.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Weekly Leaderboard Rewards – Monday 12:05 AM&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Every Monday, the system ranks the previous week's weekly pattern scores. Players are split into age group leaderboards (grouped by age bracket and gender) plus an overall leaderboard. Ranks are matched against a leaderboard_reward_config table that maps rank ranges to CC, XP, and badge rewards. A unique index on (player_id, leaderboard_type, season, week_start_date) prevents duplicate awards.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Top 10 Age-Group Update – Monday 12:10 AM&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Five minutes after weekly rewards, a second cron processes the seasonal mission that tracks how many weeks a player placed in their age group's top 10. It reads from the player_leaderboard_reward table that the 12:05 AM job just populated.&lt;/p&gt;

&lt;p&gt;The five-minute gap is the only dependency mechanism. The 12:10 AM job does not check whether the 12:05 AM job finished; it simply queries last week's top-10 rows and processes whoever is there. If the first job is still running, or is in its 5-minute retry wait, the second job sees a partial or empty result and finishes early. Two properties make this acceptable rather than dangerous. First, the job recomputes each player's count from player_leaderboard_reward as an absolute value rather than an increment and skips the player when the stored value already matches. This allows the job to be rerun manually without double-awarding. Second, the first job's retry schedule (12:10 and 12:15) means the rows usually exist within minutes. Affected players are picked up the next time they finish in the top 10, when the recount includes the missed week. We accept that a player can see this mission update one week late.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Battle Season Rewards – Quarterly&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;On the first day of each quarter, battle season rewards process. Two leaderboards are computed: total battle points (sum of points from battle royale and OnePod ranked games) and battle efficiency (points-to-expected ratio, preventing pure volume from dominating). Both leaderboards use the same check-then-insert and unique index pattern for idempotency, with the season start date in week_start_date.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;8. Idempotency and Known Risks&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;No distributed system is perfectly reliable. Here is an honest assessment of where our idempotency holds and where it has gaps.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Where It Holds&lt;/strong&gt;
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;System&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Status&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Achievements&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;achievementExists(score_id, type)&lt;/code&gt; before every insert&lt;/td&gt;
&lt;td&gt;Fully idempotent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seasonal thresholds&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;last_completed_threshold_id&lt;/code&gt; bookmark, updated in the reward transaction&lt;/td&gt;
&lt;td&gt;Safe via transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaderboard rewards&lt;/td&gt;
&lt;td&gt;Check-then-insert inside a transaction, unique index as backstop&lt;/td&gt;
&lt;td&gt;Fully idempotent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Level-up rewards&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;player_level_reward_history&lt;/code&gt; unique on &lt;code&gt;(player_id, level_config_id, season)&lt;/code&gt; with &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fully idempotent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The achievement check is a single indexed lookup:&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;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;achievementExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;achievement_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AchievementType&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="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player_achievement'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;achievement_type&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="nf"&gt;first&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leaderboard rewards use the unique index from the migration plus an explicit check, because the check lets us skip the CC, XP and notification writes cleanly instead of catching a constraint error:&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;// migration&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'player_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'leaderboard_type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'season'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'week_start_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="n"&gt;indexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'uq_player_leaderboard_reward'&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="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;trx&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="n"&gt;existing&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;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player_leaderboard_reward'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leaderboard_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;week_start_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="nf"&gt;first&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="n"&gt;existing&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;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player_leaderboard_reward'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leaderboard_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;week_start_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cc_awarded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cc_reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xp_awarded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xp_reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;badge_config_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;badge_config_id&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="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="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cc_reward&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'player_transaction'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transaction_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'add'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;free_live_credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cc_reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;free_virtual_credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xp_earned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xp_reward&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="kc"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;levelUpService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recordXp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;player_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;season&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xp_reward&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;XpSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WeeklyPattern&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wherever the article says a row is inserted with conflict protection, the actual statement is PostgreSQL's INSERT ... ON CONFLICT (...) DO NOTHING, written in Knex as .onConflict([...]).ignore(). Reward history rows use .onConflict([...]).merge([...]), which becomes ON CONFLICT ... DO UPDATE SET.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Where It Has Gaps&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Daily and weekly missions track progress with increment-based counters, and the seasonal player_season_data counters (games played, total points, days played) are incremented the same way. If a score event is processed twice, those counters get double-incremented. The completion rewards are protected by a separate reward history table and the threshold bookmark, but the progress inflation can cause missions to complete earlier than intended.&lt;/p&gt;

&lt;p&gt;With no BullMQ retries configured, a second run of the same event can come from only two places: the in-process deadlock retry, which re-runs processScoreEvent() from the top after a partial write (the function is not one transaction), and BullMQ's stalled-job recovery, which re-queues a job once if its lock expired mid-run.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Known Risks We Accept&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pool exhaustion:&lt;/strong&gt; The HTTP server and BullMQ workers share a single database connection pool. Under high scoring load, 5 concurrent workers multiplied by multiple queries per job plus HTTP traffic can compete for connections. This has not been a production issue yet, but it could become a scaling constraint if scoring volume increases significantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sequential mission processing:&lt;/strong&gt; Steps 3 to 5 in the worker run sequentially. A failure in daily missions prevents weekly and seasonal from running for that score event. We chose this over parallel execution to avoid connection pool pressure, but it means one service's bug can cascade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-process cron:&lt;/strong&gt; The cron jobs have no distributed lock. The API runs as one Node.js process today; running two instances would fire every cron job in both, and the second run would fail on the unique index rather than double-award.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Building Reward Systems That Can Handle Different Processing Needs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A reward system does not need to process every event in the same way. In this case, separating immediate, asynchronous, and scheduled work keeps player-facing responses fast while moving heavier evaluations and time-based rewards to paths better suited to them. The approach has been running in production across multiple Core Soccer Tech facilities, handling over 38 million scored points from 7,000+ players. The architecture also shows why transaction boundaries, idempotency, queue behavior, failure handling, and known system constraints need to be considered together when designing production systems. These are the same concerns that shape &lt;a href="https://geekyants.com/engineering/backend" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;backend engineering&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; for applications that need to support growing workloads without making every workflow part of one synchronous request.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>node</category>
      <category>postgressql</category>
      <category>techtalks</category>
    </item>
    <item>
      <title>Scaling Down Before Scaling Up: Why Bigger Servers Don’t Fix Bad Architecture</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 13 Nov 2026 09:27:58 +0000</pubDate>
      <link>https://dev.to/geekyants/scaling-down-before-scaling-up-why-bigger-servers-dont-fix-bad-architecture-38n6</link>
      <guid>https://dev.to/geekyants/scaling-down-before-scaling-up-why-bigger-servers-dont-fix-bad-architecture-38n6</guid>
      <description>&lt;p&gt;This blog explores how inefficient backend architecture can cause performance issues even under low traffic, covering practical ways to reduce database load, API latency, and resource usage before scaling infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why Scaling Infrastructure Wasn’t the Answer&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When an application becomes slow, the first instinct is often predictable: add more memory, upgrade the server, increase database capacity, or scale vertically.&lt;/p&gt;

&lt;p&gt;Sometimes, that works until it doesn’t.&lt;/p&gt;

&lt;p&gt;We experienced this firsthand when an application that had been running smoothly on a 64 GB server started crashing after being moved to a basic 2 GB instance.&lt;/p&gt;

&lt;p&gt;What made the situation unusual was the workload. There were only 4–5 active users, a limited dataset, and no major traffic spikes. Yet the application experienced database connection spikes, high API latency, frequent request timeouts, and unstable performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This wasn’t a scaling problem. It was an architecture problem.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Problem: When a 64 GB Server Hid the Real Issue&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What we observed&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The infrastructure changed, but the workload did not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo5fz2racube9yz8gezo9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo5fz2racube9yz8gezo9.png" alt=" " width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our initial assumption was that the smaller server lacked sufficient resources. But the connection behavior and latency suggested something deeper.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database connections were spiking despite low traffic.&lt;/li&gt;
&lt;li&gt;API latency remained high.&lt;/li&gt;
&lt;li&gt;Requests frequently timed out.&lt;/li&gt;
&lt;li&gt;Connection pooling stabilized the number of connections but did not resolve the latency issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Architecture Before Refactoring&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The application used Hasura, &lt;a href="https://geekyants.com/hire-nodejs-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Node.js services&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;, PostgreSQL, and multiple &lt;a href="https://geekyants.com/blog/streamlining-devops-with-ansible" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Dockerized services&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;. The biggest issue was in the request path for Hasura Actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The problematic request lifecycle&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The Node.js service was calling back through Hasura instead of using a direct database path where appropriate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6v9lzgm01k5t3z42ojep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6v9lzgm01k5t3z42ojep.png" alt=" " width="799" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This created a circular dependency between Hasura and Node.js. A single request could therefore involve multiple network hops, &lt;a href="https://geekyants.com/hire-graphql-api-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;GraphQL&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; execution layers, serialization and parsing, and additional database connection usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hasura → Node.js → Hasura → PostgreSQL was doing more work than the request actually required.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why Connection Pooling Wasn’t the Final Fix&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Symptom vs. root cause&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7o2dq95edp1sthjr8rn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd7o2dq95edp1sthjr8rn.png" alt=" " width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PgPool/PgBouncer helped control the number of active database connections, but it did not reduce the amount of work being performed per request.&lt;/p&gt;

&lt;p&gt;That distinction mattered. We had solved connection management, not query efficiency or architectural overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection pooling controls concurrency; it does not automatically make inefficient work efficient.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Breaking the Loop: Rethinking the Data Access Layer&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The simplified architecture&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For straightforward database operations, remove unnecessary internal round trips.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Where Node.js remains valuable&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxzj3gbxd3y7w2kyvkrr8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxzj3gbxd3y7w2kyvkrr8.png" alt=" " width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We reduced unnecessary Hasura Actions and removed circular Hasura → Node.js → Hasura flows where direct database access was more appropriate.&lt;/p&gt;

&lt;p&gt;Hasura continued to handle client-facing GraphQL access, CRUD, filtering, and aggregations. Node.js remained responsible for business logic that genuinely required an application service, authentication flows, external integrations, and asynchronous workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal was not to remove Hasura or Node.js. It was to give every layer a clear responsibility.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Cost of Over-Abstraction&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;GraphQL, &lt;a href="https://geekyants.com/blog/microservices-architecture-from-theory-to-practice" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;microservices&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;, Hasura Actions, and Node.js each solve real problems. The issue occurs when every request is forced through every abstraction.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More network hops&lt;/li&gt;
&lt;li&gt;More serialization/deserialization&lt;/li&gt;
&lt;li&gt;Higher latency&lt;/li&gt;
&lt;li&gt;Duplicate responsibilities&lt;/li&gt;
&lt;li&gt;More database connection churn&lt;/li&gt;
&lt;li&gt;Harder debugging and observability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Abstraction should reduce complexity—not increase the number of steps required to complete a request.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Database Optimization: Reducing Work at the Source&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. Eliminate Redundant Queries&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Redundant request pattern&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54fpqgtzg6b6ovrl2tbm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F54fpqgtzg6b6ovrl2tbm.png" alt=" " width="799" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Optimized request pattern&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3ce1d-8a89-4e83-9b79-d642f8c28106.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3ce1d-8a89-4e83-9b79-d642f8c28106.png%26w%3D3840%26q%3D75" alt="One structured database query returns only the data required by the API" width="1920" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We consolidated repeated reads into fewer, better-structured queries. This reduced query count, database pressure, network traffic, and response time.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. Solve N+1 Query Patterns&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;N+1 pattern&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47153-d444-410c-aa54-df6dcf10364f.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47153-d444-410c-aa54-df6dcf10364f.png%26w%3D3840%26q%3D75" alt="N+1 query pattern fetching parents first and then querying each child separately" width="1920" height="680"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Optimized pattern&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3ce88-f00d-4863-8ec0-397b379a77c2.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3ce88-f00d-4863-8ec0-397b379a77c2.png%26w%3D3840%26q%3D75" alt="JOIN, CTE, or database function returns parents and related data in one result" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of repeatedly fetching related entities, we used joins, CTEs, and &lt;a href="https://geekyants.com/hire-postgresql-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;PostgreSQL&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; functions where appropriate.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. Indexing Strategy&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Indexing decision flow&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c471a6-9098-4c02-97ca-03a1eae67bea.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c471a6-9098-4c02-97ca-03a1eae67bea.png%26w%3D3840%26q%3D75" alt="Indexing workflow from real query patterns through EXPLAIN ANALYZE and index validation" width="1920" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We added indexes based on actual access patterns rather than indexing fields indiscriminately. Foreign keys, join columns, filtering fields, sorting fields, and pagination patterns were the primary candidates.&lt;/p&gt;

&lt;p&gt;The goal was predictable query performance. Every index also carries storage and write-maintenance costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4. Schema Design and Constraints&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use clear and consistent naming conventions.&lt;/li&gt;
&lt;li&gt;Define foreign-key relationships explicitly.&lt;/li&gt;
&lt;li&gt;Use NOT NULL, UNIQUE, and CHECK constraints where the data model requires them.&lt;/li&gt;
&lt;li&gt;Keep relationships normalized where appropriate.&lt;/li&gt;
&lt;li&gt;Review RBAC policies for both correctness and query efficiency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Database constraints document the data model and enforce important data rules at the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;PostgreSQL Functions and Triggers: Put the Right Work in the Database&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;PostgreSQL Functions&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;When is a PostgreSQL function useful&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c471f1-769e-4045-8b22-96c8c674f536.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c471f1-769e-4045-8b22-96c8c674f536.png%26w%3D3840%26q%3D75" alt="PostgreSQL function combines joins, filters, and aggregation into a pre-shaped API result" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Functions are useful for complex database-oriented fetching, reusable calculations, aggregations, and returning data in a form that reduces application-layer processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Database Triggers&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Trigger use case&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47222-eb20-42a6-bb0b-aa964a6bdea1.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47222-eb20-42a6-bb0b-aa964a6bdea1.png%26w%3D3840%26q%3D75" alt="Database trigger updates a counter, derived value, or audit record during a transaction" width="1920" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Triggers are appropriate for deterministic database responsibilities such as maintaining counters, derived values, timestamps, audit records, or data-integrity behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use triggers for deterministic database responsibilities—not as a hidden replacement for the entire application layer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Database Events vs. External Workflows&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Keep external work out of the critical transaction&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47c36-9082-4d5b-9c86-b6a86d3a354d.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47c36-9082-4d5b-9c86-b6a86d3a354d.png%26w%3D3840%26q%3D75" alt="Committed database change queues asynchronous email, notification, or external API work" width="1920" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Email delivery, push notifications, and other external calls should be handled asynchronously when the user-facing database transaction does not need to wait for the external system.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Views and Materialized Views&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Normal Views&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Reusable read model&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47c61-c90d-415d-9045-913b71adb8e9.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47c61-c90d-415d-9045-913b71adb8e9.png%26w%3D3840%26q%3D75" alt="Database view provides a reusable read model over underlying tables" width="1920" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Normal views are useful when you want to encapsulate reusable joins and filtering logic while still reading the current underlying data.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Materialized Views&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Precomputed analytics path&lt;/strong&gt;\
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47c8d-604d-49d2-8cf4-ae4f400cc3ed.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47c8d-604d-49d2-8cf4-ae4f400cc3ed.png%26w%3D3840%26q%3D75" alt="Materialized view precomputes heavy joins and aggregations for an analytics API" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Materialized views are useful when expensive aggregations do not need to be recalculated for every request.&lt;/p&gt;

&lt;p&gt;For appropriate workloads, REFRESH MATERIALIZED VIEW CONCURRENTLY can reduce disruption during refreshes. A suitable unique index is required for concurrent refreshes.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Safe refresh strategy&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47cb2-8dde-4018-b50a-d89683f6e30c.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47cb2-8dde-4018-b50a-d89683f6e30c.png%26w%3D3840%26q%3D75" alt="Scheduled concurrent materialized-view refresh keeps analytics reads available" width="1920" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Optimizing Analytics APIs&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Avoid repeated runtime calculations&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47cdf-1236-4181-8f62-69a738c48b23.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47cdf-1236-4181-8f62-69a738c48b23.png%26w%3D3840%26q%3D75" alt="Analytics API reads precomputed summaries and optionally uses Redis caching" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Analytics endpoints often perform expensive joins and aggregations over transactional data. We reduced this work by pre-aggregating data, using materialized views, caching suitable results, and minimizing on-the-fly calculations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If an expensive value does not need second-by-second freshness, don’t calculate it from scratch on every request.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;API Optimization Principles&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Pagination and Filtering&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Efficient list API&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47d15-92ff-41b4-93b0-4a3f6d39bd53.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47d15-92ff-41b4-93b0-4a3f6d39bd53.png%26w%3D3840%26q%3D75" alt="Efficient list API applies database filters, cursor sorting, limits, and a minimal payload" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pagination and filtering should happen as close to the database as possible. Cursor-based pagination can be useful for large or frequently changing datasets.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Reduce Over-Fetching&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Minimal response path&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47d4f-b841-4c83-a55c-fa9f9b542f12.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47d4f-b841-4c83-a55c-fa9f9b542f12.png%26w%3D3840%26q%3D75" alt="API selects required fields and serializes a minimal response payload" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Avoid SELECT * and fields that the client does not need. Smaller payloads reduce database work, serialization, network transfer, and client-side processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Computed Fields&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Avoid repeated runtime computation&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47dda-4913-4a30-bd6d-26febf207075.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47dda-4913-4a30-bd6d-26febf207075.png%26w%3D3840%26q%3D75" alt="API reuses a precomputed or cached value instead of calculating it for every request" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expensive computed fields can introduce latency when evaluated for every record. Where appropriate, use precomputed values, summary tables, materialized views, or caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;GraphQL: Powerful, but Give It Guardrails&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Avoid unnecessarily deep nested queries.&lt;/li&gt;
&lt;li&gt;Control expensive computed fields.&lt;/li&gt;
&lt;li&gt;Watch for N+1 patterns.&lt;/li&gt;
&lt;li&gt;Limit excessive Hasura Actions.&lt;/li&gt;
&lt;li&gt;Avoid using GraphQL as an internal service-to-service communication protocol when a more direct mechanism is appropriate.&lt;/li&gt;
&lt;li&gt;Return only the data required by the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Healthy boundary&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3d129-a023-465b-9bca-d44ff0bf5592.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3d129-a023-465b-9bca-d44ff0bf5592.png%26w%3D3840%26q%3D75" alt="Healthy GraphQL boundary connects the frontend through Hasura to controlled database access" width="1920" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Service responsibility&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47f43-2e1f-4966-bed0-be82c28ae761.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47f43-2e1f-4966-bed0-be82c28ae761.png%26w%3D3840%26q%3D75" alt="Node.js handles auth, external APIs, async work, and PostgreSQL access where appropriate" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Redis: Cache What Doesn’t Need to Be Recomputed&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Read-through cache&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3ef6f-7a93-4f2a-8f92-71b8670a5a34.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3ef6f-7a93-4f2a-8f92-71b8670a5a34.png%26w%3D3840%26q%3D75" alt="Redis returns cached results on a hit and stores PostgreSQL results after a miss" width="1920" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Redis is useful for read-heavy endpoints, configuration data, analytics results, frequently requested reference data, and suitable computed counters.&lt;/p&gt;

&lt;p&gt;Every cache should have a clear TTL, invalidation, refresh, and consistency strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Effective caching targets data that benefits from reuse and has a defined consistency strategy.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Batch Processing: Email and Notifications&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Synchronous anti-pattern&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47fca-47ac-4d00-ab4d-99aa4d19ca89.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c47fca-47ac-4d00-ab4d-99aa4d19ca89.png%26w%3D3840%26q%3D75" alt="Request waits for record creation, email, and notification delivery before responding" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Asynchronous pattern&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c480cd-924a-4425-a759-7acce2552c5e.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c480cd-924a-4425-a759-7acce2552c5e.png%26w%3D3840%26q%3D75" alt="Request creates a record, queues a background job, and returns immediately" width="1920" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Background processing&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c4811a-3537-4b13-81cb-3e347ab2aaf3.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c4811a-3537-4b13-81cb-3e347ab2aaf3.png%26w%3D3840%26q%3D75" alt="Worker batches queued email and push jobs with retries for failed items" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This keeps external service latency out of the user-facing request and allows retries, batching, and controlled failure handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Data Lifecycle Management&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Scheduled cleanup&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c4814f-c7a5-429c-9e13-6df580ffa55b.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c4814f-c7a5-429c-9e13-6df580ffa55b.png%26w%3D3840%26q%3D75" alt="Scheduled cleanup batches stale data for deletion or archiving and monitors the result" width="1920" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expired sessions, temporary records, old notifications, logs, stale tokens, and other disposable data should have an explicit lifecycle.&lt;/p&gt;

&lt;p&gt;Large cleanup operations should be batched and controlled so that maintenance itself does not become a resource spike.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Observability: Measure Before You Optimize&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Performance investigation loop&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c48199-003f-4262-8a64-0e223d0e8e82.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c48199-003f-4262-8a64-0e223d0e8e82.png%26w%3D3840%26q%3D75" alt="Performance investigation moves from monitoring and query inspection to optimization and remeasurement" width="1920" height="760"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query logging to identify slow SQL statements&lt;/li&gt;
&lt;li&gt;pg_stat_activity to inspect active database sessions&lt;/li&gt;
&lt;li&gt;EXPLAIN ANALYZE to inspect query execution plans&lt;/li&gt;
&lt;li&gt;API timing to separate application, database, external-service, and network time&lt;/li&gt;
&lt;li&gt;GraphQL resolver timing to identify expensive fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Without observability, you can spend weeks optimizing the wrong layer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reducing Backend Downtime&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Reliability loop&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3efbc-f2df-4c62-be8c-f6c6d399cb69.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3efbc-f2df-4c62-be8c-f6c6d399cb69.png%26w%3D3840%26q%3D75" alt="Reliability loop uses health checks, monitoring, graceful degradation, retries, and recovery" width="1920" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Performance and availability are connected. Health checks, monitoring, structured logs, graceful degradation, controlled retries, and automated recovery help prevent resource pressure from becoming prolonged downtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries should recover from failures without amplifying them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Do We Really Need That Microservice?&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Microservice decision&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3efe6-1a05-4c58-b48b-153a6eb02058.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3efe6-1a05-4c58-b48b-153a6eb02058.png%26w%3D3840%26q%3D75" alt="Microservice decision checks independent scaling, deployment, and boundaries before splitting a service" width="1920" height="1410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every service adds container memory, network communication, database connections, deployment complexity, monitoring, and operational overhead.&lt;/p&gt;

&lt;p&gt;If multiple services are tightly coupled, communicate constantly, share the same database, and always scale together, consolidation can produce a simpler and more efficient architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Anti-Patterns We Discovered&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hasura → Node.js → Hasura circular API calls&lt;/li&gt;
&lt;li&gt;Using GraphQL as a universal internal communication layer&lt;/li&gt;
&lt;li&gt;Over-splitting logic across microservices&lt;/li&gt;
&lt;li&gt;Distributing one business flow across too many layers&lt;/li&gt;
&lt;li&gt;Treating PostgreSQL as pure storage&lt;/li&gt;
&lt;li&gt;Using infrastructure capacity to hide inefficient request paths&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Each decision looked manageable in isolation. Together, they created system-wide overhead.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When 2 GB Became an Advantage&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What the smaller environment exposed&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3f0b9-d410-448e-a7ee-088026ad9ca3.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3f0b9-d410-448e-a7ee-088026ad9ca3.png%26w%3D3840%26q%3D75" alt="A 2 GB constraint exposes the cost of database connections, queries, network hops, and architecture" width="1920" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 2 GB environment didn’t create all of these problems. It exposed them. The constrained environment forced us to question every connection, query, network hop, container, and runtime calculation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 2 GB environment didn’t create all of these problems. It exposed them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What Changed After the Refactor?&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Outcome&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3f0ea-6d32-4211-8863-0e5002ad2673.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3f0ea-6d32-4211-8863-0e5002ad2673.png%26w%3D3840%26q%3D75" alt="Simpler architecture reduces database calls, connection churn, API latency, and resource use" width="1920" height="1240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More controlled database connections&lt;/li&gt;
&lt;li&gt;Lower API latency&lt;/li&gt;
&lt;li&gt;Fewer unnecessary database calls&lt;/li&gt;
&lt;li&gt;Reduced network hops&lt;/li&gt;
&lt;li&gt;Less application-layer processing&lt;/li&gt;
&lt;li&gt;More efficient analytics queries&lt;/li&gt;
&lt;li&gt;Better separation of responsibilities&lt;/li&gt;
&lt;li&gt;Reduced container overhead&lt;/li&gt;
&lt;li&gt;More asynchronous background processing&lt;/li&gt;
&lt;li&gt;Lower infrastructure requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Bigger Lesson: Scale Down Before You Scale Up&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When an application becomes slow, the instinct is to scale. But adding infrastructure to an inefficient architecture can increase its cost without addressing the underlying problem.&lt;/p&gt;

&lt;p&gt;If every request makes unnecessary network calls, adding more servers does not eliminate those calls. If every API performs expensive queries, increasing RAM does not make those queries logically simpler. If every service opens unnecessary database connections, increasing database capacity does not fix the request flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The optimization mindset&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3f128-0a63-441c-b67c-0e54664b7097.png%26w%3D3840%26q%3D75" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgeekyants.com%2F_next%2Fimage%3Furl%3Dhttps%253A%252F%252Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%252Fmedia%252F2026%252F09%252Fa2c3f128-0a63-441c-b67c-0e54664b7097.png%26w%3D3840%26q%3D75" alt="Optimization loop observes bottlenecks, removes waste, tunes the API and database, then scales" width="1920" height="1240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In our case, the answer was to simplify the request path, remove circular Hasura–Node.js communication, reduce unnecessary Actions, move appropriate operations closer to PostgreSQL, optimize queries and indexes, address N+1 patterns, use pagination and database-level filtering, precompute analytics, cache read-heavy workloads, move external work to background processing, and reduce unnecessary services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 64 GB server made the system comfortable. The 2 GB server made it honest.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before scaling infrastructure, reduce unnecessary work across the architecture, request path, and database. Then scale based on measured demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Build for Scale at the Architecture Level&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Scaling infrastructure works best when the architecture is ready to support it. Clear service boundaries, optimized database access, efficient request paths, caching, and asynchronous processing can reduce resource pressure before additional capacity enters the equation. If your application is facing performance bottlenecks or rising infrastructure demands, explore how &lt;a href="https://geekyants.com/engineering/backend" rel="noopener noreferrer"&gt;&lt;strong&gt;backend engineering services&lt;/strong&gt;&lt;/a&gt; can help strengthen the system before the next stage of scale.&lt;/p&gt;

</description>
      <category>postgressql</category>
      <category>graphql</category>
      <category>node</category>
      <category>techtalks</category>
    </item>
    <item>
      <title>Building Local LLMs Using Dart FFI And llama.cpp: Beyond Wrapper Packages</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 13 Nov 2026 07:35:00 +0000</pubDate>
      <link>https://dev.to/geekyants/building-local-llms-using-dart-ffi-and-llamacpp-beyond-wrapper-packages-f93</link>
      <guid>https://dev.to/geekyants/building-local-llms-using-dart-ffi-and-llamacpp-beyond-wrapper-packages-f93</guid>
      <description>&lt;p&gt;Build local LLMs in Flutter with Dart FFI and llama.cpp, and see how native bridges, GGUF models, memory management, and token streaming enable private, on-device AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When I first explored running local LLMs inside Flutter, I had a lot of questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why are people using C++ libraries for AI?&lt;/li&gt;
&lt;li&gt;Why not just write everything in Dart?&lt;/li&gt;
&lt;li&gt;What exactly is llama.cpp?&lt;/li&gt;
&lt;li&gt;What are .so, .dll, and .dylib files?&lt;/li&gt;
&lt;li&gt;Why does Flutter suddenly need native code? If you're coming from a Flutter-only background, all of this initially feels very unfamiliar. Most Flutter developers rarely touch:&lt;/li&gt;
&lt;li&gt;native memory&lt;/li&gt;
&lt;li&gt;C/C++&lt;/li&gt;
&lt;li&gt;shared libraries&lt;/li&gt;
&lt;li&gt;compilers&lt;/li&gt;
&lt;li&gt;ABI architectures&lt;/li&gt;
&lt;li&gt;or systems-level programming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But once you understand the architecture, things become surprisingly logical. This article documents my journey building a proof-of-concept meeting summarizer with local LLM inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Disclaimer
&lt;/h2&gt;

&lt;p&gt;This article is not intended to convince anyone to replace existing Flutter local AI packages.&lt;/p&gt;

&lt;p&gt;In fact, if your goal is simply to integrate local AI into an application, a well-maintained package may be the fastest way to ship.&lt;/p&gt;

&lt;p&gt;My goal here was different.&lt;/p&gt;

&lt;p&gt;I wanted to understand what happens beneath those abstractions, how Flutter communicates with native AI runtimes, and what it takes to build that bridge myself.&lt;/p&gt;

&lt;p&gt;That curiosity ultimately led me down the Dart FFI and llama.cpp rabbit hole.&lt;/p&gt;

&lt;p&gt;POC support scope: The working LLM path in this repository currently targets Android arm64-v8a. The platform descriptions below explain how FFI libraries generally work; the other native llama bridge integrations like iOS or desktop OS’s are not implemented in this POC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Statement
&lt;/h2&gt;

&lt;p&gt;Before diving into implementation details, let's start with a simple question.&lt;/p&gt;

&lt;p&gt;Why go through all the complexity of Flutter FFI and llama.cpp when we could simply host a GGUF model on a Python FastAPI server and consume it like any normal API?&lt;/p&gt;

&lt;p&gt;That's exactly what I wondered initially.&lt;/p&gt;

&lt;p&gt;The answer is that local AI solves a completely different problem.&lt;/p&gt;

&lt;p&gt;Server-based AI depends on internet connectivity, introduces latency, incurs infrastructure costs, and sends user data to external servers&lt;/p&gt;

&lt;p&gt;Local AI via llama.cpp runs the model directly on the device itself, enabling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Offline LLM inference after the model has been downloaded&lt;/li&gt;
&lt;li&gt;Better privacy because prompts and model output do not need to leave the device&lt;/li&gt;
&lt;li&gt;Lower latency (no network round trips)&lt;/li&gt;
&lt;li&gt;No backend costs (computation happens on user's device)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For applications like meeting summarization, note taking, personal assistants, or enterprise environments with strict privacy requirements, these benefits can be extremely valuable.&lt;/p&gt;

&lt;p&gt;That said, local AI isn't always the right choice.&lt;/p&gt;

&lt;p&gt;If your application requires very large models, centralized company knowledge, or continuous model updates, a &lt;a href="https://geekyants.com/blog/the-self-healing-cloud-a-strategic-blueprint-for-autonomous-operations-with-agentic" rel="noopener noreferrer"&gt;cloud-based architecture&lt;/a&gt; may still make more sense.&lt;/p&gt;

&lt;p&gt;The important thing is understanding the trade-offs and choosing the right approach for your use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Are AI Libraries Usually Written in C++?
&lt;/h2&gt;

&lt;p&gt;As Flutter developers, we're used to solving everything in Dart. So why do most AI runtimes seem to be written in C++, Rust, CUDA, or other low-level technologies?&lt;/p&gt;

&lt;p&gt;The answer is performance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/ai/large-language-model-development-services" rel="noopener noreferrer"&gt;Large Language Models&lt;/a&gt; spend most of their time performing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Matrix multiplications&lt;/li&gt;
&lt;li&gt;Tensor operations&lt;/li&gt;
&lt;li&gt;Memory-intensive computations&lt;/li&gt;
&lt;li&gt;CPU and GPU optimizations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Languages like Dart are excellent for &lt;a href="https://geekyants.com/service/hire-mobile-app-development-services" rel="noopener noreferrer"&gt;application development&lt;/a&gt;, but they're not designed for squeezing every bit of performance from modern hardware.&lt;/p&gt;

&lt;p&gt;C++ gives developers direct control over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Memory management&lt;/li&gt;
&lt;li&gt;CPU instructions&lt;/li&gt;
&lt;li&gt;Threading&lt;/li&gt;
&lt;li&gt;SIMD optimizations&lt;/li&gt;
&lt;li&gt;GPU integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why projects such as TensorFlow, PyTorch, ONNX Runtime, and llama.cpp all rely heavily on native code under the hood. Even many Python AI frameworks are actually thin wrappers around C++ implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is llama.cpp?
&lt;/h2&gt;

&lt;p&gt;At a high level, llama.cpp is an inference engine for running Large Language Models locally. Think of it as an AI inference engine optimized for local hardware.&lt;/p&gt;

&lt;p&gt;It takes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model weights (the trained parameters from the LLM, in GGUF format)&lt;/li&gt;
&lt;li&gt;Tokens (numerical representations of text)&lt;/li&gt;
&lt;li&gt;Prompts (user input)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And performs all the mathematical operations required to generate text.&lt;/p&gt;

&lt;p&gt;Some reasons llama.cpp has become so popular include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs directly on CPUs&lt;/li&gt;
&lt;li&gt;No Python dependency&lt;/li&gt;
&lt;li&gt;Optimized for mobile and embedded devices&lt;/li&gt;
&lt;li&gt;Supports quantized models&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/solution/universal-and-cross-platform-app-development-services" rel="noopener noreferrer"&gt;Cross-platform support&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding GGUF Models
&lt;/h2&gt;

&lt;p&gt;When working with llama.cpp, you'll frequently come across GGUF files.&lt;/p&gt;

&lt;p&gt;GGUF (GGML Universal File) is a model file format designed to store model tensors and metadata for efficient loading by GGML-based inference engines.&lt;/p&gt;

&lt;p&gt;GGUF can store a model’s weights in different levels of precision. Quantization is the process of making those weights smaller by reducing their precision. So, GGUF can store either the original high-precision weights or quantized versions like Q4.&lt;/p&gt;

&lt;p&gt;For example, Q4 quantization can reduce a 1B model from roughly 2 GB to roughly 800 MB on disk while maintaining acceptable quality i.e., small enough to be practical on some phones.&lt;/p&gt;

&lt;p&gt;Without formats like GGUF, running local LLMs on mobile devices would be far less practical.&lt;/p&gt;

&lt;p&gt;One thing I learned quickly is that choosing the right model is often more important than choosing the right framework.&lt;/p&gt;

&lt;p&gt;A smaller, well-quantized model that runs smoothly on target devices is usually more valuable than a larger model that struggles with memory and performance constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Existing Flutter Packages for Local AI
&lt;/h2&gt;

&lt;p&gt;Before building my own integration, I explored some of the existing Flutter packages available for running local models. Some examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;flutter_gemma&lt;/li&gt;
&lt;li&gt;fllama&lt;/li&gt;
&lt;li&gt;llama_cpp_dart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These alternatives can be a great starting point if your goal is simply to get local AI working quickly. So why didn't I just use one of them?&lt;/p&gt;

&lt;p&gt;For this project, my goal wasn't only to run a model locally. I wanted to understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How models are loaded&lt;/li&gt;
&lt;li&gt;How memory is managed&lt;/li&gt;
&lt;li&gt;How Dart communicates with native code&lt;/li&gt;
&lt;li&gt;How token streaming works&lt;/li&gt;
&lt;li&gt;How worker isolates fit into the picture&lt;/li&gt;
&lt;li&gt;What actually happens inside the FFI layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building the bridge myself gave me a much deeper understanding of the architecture and helped me appreciate the amount of work these packages abstract away.&lt;/p&gt;

&lt;p&gt;If your objective is shipping a feature quickly, evaluating existing packages first is absolutely a sensible approach.&lt;/p&gt;

&lt;p&gt;If your objective is learning how local AI actually works inside Flutter, building a small FFI bridge yourself is an incredibly valuable exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flutter + llama.cpp Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl469xkdve34bbug0dvhz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl469xkdve34bbug0dvhz.png" alt=" " width="795" height="76"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each layer has a specific responsibility.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flutter - Handles UI, state management, and user interactions.&lt;/li&gt;
&lt;li&gt;Provider / Services - Contains application logic and coordinates model operations.&lt;/li&gt;
&lt;li&gt;Worker Isolate - Keeps heavy inference work away from the UI thread.&lt;/li&gt;
&lt;li&gt;Dart FFI - Acts as the bridge between Dart and native code.&lt;/li&gt;
&lt;li&gt;Native Bridge - Exposes a stable C API that Flutter can call.&lt;/li&gt;
&lt;li&gt;Llama.cpp - Loads models and performs inference.&lt;/li&gt;
&lt;li&gt;GGUF Model - Contains the trained weights that generate responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Understanding Native Libraries
&lt;/h2&gt;

&lt;p&gt;One thing that confused me initially was seeing files like:&lt;/p&gt;

&lt;p&gt;libllama_bridge.so&lt;/p&gt;

&lt;p&gt;llama_bride.dll&lt;/p&gt;

&lt;p&gt;libllama_bridge.dylib&lt;/p&gt;

&lt;p&gt;When we used to install games on Windows, many saw files like: game.dll, audio.dll, etc.&lt;/p&gt;

&lt;p&gt;Those .dll files are compiled native libraries. They contain optimized code for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rendering&lt;/li&gt;
&lt;li&gt;Audio processing&lt;/li&gt;
&lt;li&gt;Physics simulation&lt;/li&gt;
&lt;li&gt;Networking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main game executable loads them and uses their functionality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform-Specific Names
&lt;/h3&gt;

&lt;p&gt;Flutter integrations using llama.cpp can follow the same pattern:&lt;/p&gt;

&lt;p&gt;Instead of physics.dll, a bridge might be packaged as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Android/Linux: libllama_bridge.so (Shared Object)&lt;/li&gt;
&lt;li&gt;macOS/iOS: Symbols linked directly into the process or a .dylib where platform rules allows it&lt;/li&gt;
&lt;li&gt;Windows: llama_bridge.dll&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The native library does the heavy work, and Flutter communicates with it via FFI.&lt;/p&gt;

&lt;p&gt;Please note that in our POC we have only covered Android arm64 build configs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Flutter Talks to C++: Dart FFI
&lt;/h2&gt;

&lt;p&gt;This is where Dart FFI comes in.&lt;/p&gt;

&lt;p&gt;FFI stands for: Foreign Function Interface&lt;/p&gt;

&lt;p&gt;Which means: "Allow Dart to call functions written in another language." At the Dart-to-native boundary, FFI can call native C APIs without:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flutter Platform channels&lt;/li&gt;
&lt;li&gt;Platform-channel message serialization or&lt;/li&gt;
&lt;li&gt;Message passing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;[Official &lt;a href="https://dart.dev/interop/c-interop" rel="noopener noreferrer"&gt;Dart FFI Documentation&lt;/a&gt;](&lt;a href="https://dart.dev/interop/c-interop" rel="noopener noreferrer"&gt;https://dart.dev/interop/c-interop&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  FFI vs Platform Channels: Why Not Platform Channels?
&lt;/h2&gt;

&lt;p&gt;Flutter already provides Platform Channels, so why introduce FFI?&lt;/p&gt;

&lt;p&gt;The answer comes down to performance.&lt;/p&gt;

&lt;p&gt;AI workloads involve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frequent native calls&lt;/li&gt;
&lt;li&gt;Large amounts of memory&lt;/li&gt;
&lt;li&gt;Continuous token generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;FFI allows Dart to call native functions directly without serialization overhead. A simple FFI binding looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5j6mtf4v74vanmew9rm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5j6mtf4v74vanmew9rm.png" alt=" " width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;DynamicLibrary.open() only loads the library. lookupFunction() resolves an exported C symbol and converts it to a callable Dart function. Only then can Dart invoke the native bridge. The repository collects these typed lookups in _BridgeBindings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Concepts That Finally Made Everything Click
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Opaque Pointers
&lt;/h3&gt;

&lt;p&gt;One concept that initially felt strange was opaque pointers.&lt;/p&gt;

&lt;p&gt;Instead of exposing the actual C++ model object to Dart, native code simply returns a handle.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fge9cjb1zebl6hpcwm64s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fge9cjb1zebl6hpcwm64s.png" alt=" " width="799" height="233"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Dart doesn't know what's inside the session.&lt;/p&gt;

&lt;p&gt;It simply passes the handle back whenever it wants to load a model, generate tokens, or destroy the session.&lt;/p&gt;

&lt;p&gt;This keeps the object layout and memory management on the native side. It also creates an explicit ownership contract: every successful bridgeSessionCreate() must eventually be paired with bridgeSessionDestroy(), and pointers returned by allocating native functions must be released with the matching native free function.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F5fae473f8f0a02655347989e%2Fbdf91707-b8d1-46f4-8c1a-7bdefe955018.png%2520align%3D%2522center%2522" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F5fae473f8f0a02655347989e%2Fbdf91707-b8d1-46f4-8c1a-7bdefe955018.png%2520align%3D%2522center%2522" alt="Opaque pointer code screenshot" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming Callbacks
&lt;/h3&gt;

&lt;p&gt;One of the coolest parts of the implementation was token streaming.&lt;/p&gt;

&lt;p&gt;Instead of waiting for the entire response, llama.cpp generates tokens one by one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frvzrj6encq396e3l1j7j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frvzrj6encq396e3l1j7j.png" alt=" " width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every generated token flows back into Flutter through this callback. Pointer.fromFunction() converts a Dart callback into something the native C code can call. The userData pointer tells the callback which token stream to send the generated tokens to, so the tokens can appear one by one in the typing effect.&lt;/p&gt;

&lt;p&gt;One important limitation is that the callback must run on the same Dart isolate thread. In this POC, that works because generation and the callback happen on the same worker thread. If the native library calls the callback from a different thread, we should use NativeCallable.listener instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worker Isolates
&lt;/h3&gt;

&lt;p&gt;AI inference is computationally expensive.&lt;/p&gt;

&lt;p&gt;Running inference on Flutter's main Isolate would quickly make the UI feel sluggish or frozen. To avoid that, I moved all native interactions into a dedicated Worker Isolate. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9c56kiwunwo0kjv3vtg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9c56kiwunwo0kjv3vtg.png" alt=" " width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The UI remains responsive while the model generates tokens in the background.&lt;/p&gt;

&lt;p&gt;The Isolate lets the main Dart thread and the worker communicate by sending messages. The main isolate sends a command to the worker, the worker runs the blocking native/FFI work, and then sends back token, completion, or error messages. FFI talks directly to the native code, avoiding platform-channel overhead, while the Isolate keeps the heavy work away from the UI thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The POC I Built - Meeting Summarizer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;

&lt;p&gt;The proof of concept I built follows a fairly simple flow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frgtvec5fyzo94kavqbln.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frgtvec5fyzo94kavqbln.png" alt=" " width="799" height="73"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The user records a conversation.&lt;/p&gt;

&lt;p&gt;The app has two transcription options: recorded audio uses local Whisper, while live speech uses the device’s speech recognition. It prefers offline recognition but falls back to online recognition if offline isn’t supported.&lt;/p&gt;

&lt;p&gt;That transcript is then passed to the local LLM through the FFI bridge.&lt;/p&gt;

&lt;p&gt;As the model generates a summary, tokens are streamed back to Flutter and displayed in real time.&lt;/p&gt;

&lt;p&gt;The LLM inference process happens locally on the device.&lt;/p&gt;

&lt;p&gt;No server required for LLMs.&lt;/p&gt;

&lt;p&gt;Preview of what I build&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges I Faced
&lt;/h2&gt;

&lt;p&gt;While the final architecture looks clean, getting there wasn't always smooth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model Downloading and Distribution
&lt;/h3&gt;

&lt;p&gt;One thing that surprised me initially was that llama.cpp doesn't ship models - you download GGUF files, store them, and verify checksums. Model management quickly becomes a product concern rather than just an engineering concern.&lt;/p&gt;

&lt;p&gt;This was probably one of the first moments where I realized local AI introduces a completely different set of challenges compared to calling a hosted API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory Consumption
&lt;/h3&gt;

&lt;p&gt;The Llama 3.2 1B Q4_K_M model file used in this POC is roughly 800 MB. Total runtime memory is higher and varies with context size, KV cache, runtime buffers, backend allocations, and device. Bigger isn’t better on a phone. Choosing a model becomes a balancing act between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quality&lt;/li&gt;
&lt;li&gt;Speed&lt;/li&gt;
&lt;li&gt;RAM usage&lt;/li&gt;
&lt;li&gt;Storage size&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  FFI Debugging Can Be Painful
&lt;/h3&gt;

&lt;p&gt;A normal Dart error usually gives a helpful stack trace.&lt;/p&gt;

&lt;p&gt;A native crash often gives something like:&lt;/p&gt;

&lt;p&gt;Segmentation Fault&lt;/p&gt;

&lt;p&gt;And that's it.&lt;/p&gt;

&lt;p&gt;Most of my debugging time was spent tracking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invalid pointers&lt;/li&gt;
&lt;li&gt;Memory ownership issues&lt;/li&gt;
&lt;li&gt;Double frees&lt;/li&gt;
&lt;li&gt;Native lifecycle bugs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;FFI is powerful, but it also removes some of the safety nets Flutter developers are used to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Device Variability
&lt;/h3&gt;

&lt;p&gt;Fast on one phone, slow on another. Testing across multiple devices becomes extremely important when working with local AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local AI Is Not Always Better
&lt;/h3&gt;

&lt;p&gt;This was perhaps the biggest lesson.&lt;/p&gt;

&lt;p&gt;It's easy to get excited about running models completely offline.&lt;/p&gt;

&lt;p&gt;But local AI isn't a universal replacement for cloud AI.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/engineering/backend/cloud-platform-engineering-services" rel="noopener noreferrer"&gt;Cloud-based solutions&lt;/a&gt; still have advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Access to larger models&lt;/li&gt;
&lt;li&gt;Easier updates&lt;/li&gt;
&lt;li&gt;Centralized knowledge&lt;/li&gt;
&lt;li&gt;Less device storage usage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best solution depends entirely on the problem you're trying to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Would I Actually Use This?
&lt;/h2&gt;

&lt;p&gt;One question I kept asking myself throughout the project was: "This is cool, but would I use it in a real application?"&lt;/p&gt;

&lt;p&gt;I think the answer depends on the use case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Great Candidates
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Meeting summarization&lt;/li&gt;
&lt;li&gt;Personal journals&lt;/li&gt;
&lt;li&gt;Offline assistants&lt;/li&gt;
&lt;li&gt;Enterprise privacy-focused applications&lt;/li&gt;
&lt;li&gt;Travel apps with limited connectivity&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Probably Better on the Cloud
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Large-scale document analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Complex &lt;a href="https://geekyants.com/ai/rag-development-services" rel="noopener noreferrer"&gt;RAG systems&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Company-wide knowledge assistants&lt;/li&gt;
&lt;li&gt;Very large models requiring significant compute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Local AI is powerful, but it isn't a universal replacement for cloud-hosted AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Repository
&lt;/h2&gt;

&lt;p&gt;The complete source code for this proof of concept is available on GitHub:&lt;/p&gt;

&lt;p&gt;GitHub Repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/anannyapm/meeting_summarizer_llamacpp_ffi" rel="noopener noreferrer"&gt;GitHub Link to Project&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flutter application code&lt;/li&gt;
&lt;li&gt;Dart FFI bindings&lt;/li&gt;
&lt;li&gt;Native C/C++ bridge implementation&lt;/li&gt;
&lt;li&gt;Worker isolate architecture&lt;/li&gt;
&lt;li&gt;Token streaming implementation&lt;/li&gt;
&lt;li&gt;Model loading lifecycle management&lt;/li&gt;
&lt;li&gt;Meeting summarization proof of concept&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're interested in exploring the implementation details beyond what is covered in this article, feel free to check out the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Learnings From This Project
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Memory Management Matters
&lt;/h3&gt;

&lt;p&gt;Dart uses garbage collection; native C/C++ memory must be released manually to avoid leaks and crashes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolates Are Essential
&lt;/h3&gt;

&lt;p&gt;Inference can take several seconds depending on the model and device. Running everything on the UI isolate is not a realistic option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming Creates Better UX
&lt;/h3&gt;

&lt;p&gt;Showing tokens as they're generated makes the application feel much faster and more responsive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Native Boundaries Should Be Simple
&lt;/h3&gt;

&lt;p&gt;Keeping a small, stable API between Dart and C++ significantly reduces debugging complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't Fight Existing Ecosystems
&lt;/h3&gt;

&lt;p&gt;Building an inference engine from scratch would be an enormous undertaking.&lt;/p&gt;

&lt;p&gt;Using llama.cpp allowed me to focus on application development rather than reinventing years of optimization work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Before starting this project, Flutter FFI and local AI felt intimidating.&lt;/p&gt;

&lt;p&gt;There were many unfamiliar concepts, from native libraries and memory management to callbacks and worker isolates.&lt;/p&gt;

&lt;p&gt;But once I understood the architecture, it became clear that each layer has a very specific purpose.&lt;/p&gt;

&lt;p&gt;The stack I ended up with looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flutter for UI&lt;/li&gt;
&lt;li&gt;Dart for application logic&lt;/li&gt;
&lt;li&gt;FFI for interoperability&lt;/li&gt;
&lt;li&gt;C++ for performance-critical operations&lt;/li&gt;
&lt;li&gt;llama.cpp for inference&lt;/li&gt;
&lt;li&gt;GGUF models for local execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a working POC on android arm64: speech -&amp;gt; transcript -&amp;gt; streamed local summary, with LLM inference that runs directly on the device without depending on cloud infrastructure.&lt;/p&gt;

&lt;p&gt;The learning curve is definitely steeper than consuming a REST API, but the capabilities you unlock - private, offline, low-latency AI running entirely on a user's device - make the journey worthwhile.&lt;/p&gt;

&lt;p&gt;Building this POC showed how much Flutter can do when it reaches beyond Dart into native code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/anannyapm/meeting_summarizer_llamacpp_ffi" rel="noopener noreferrer"&gt;GitHub Link to Project&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Dart FFI Documentation&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;llama.cpp Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ggml-org/ggml/blob/master/docs/gguf.md" rel="noopener noreferrer"&gt;GGUF Model Format&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.flutter.dev/platform-integration" rel="noopener noreferrer"&gt;Flutter Native Integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pub.dev/packages/provider" rel="noopener noreferrer"&gt;Provider Package&lt;/a&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
    </item>
    <item>
      <title>Automation Testing With Playwright Using JavaScript</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 06 Nov 2026 05:10:44 +0000</pubDate>
      <link>https://dev.to/geekyants/automation-testing-with-playwright-using-javascript-f79</link>
      <guid>https://dev.to/geekyants/automation-testing-with-playwright-using-javascript-f79</guid>
      <description>&lt;p&gt;Automation testing is a critical part of &lt;a href="https://geekyants.com/service/enterprise-software-development-services" rel="noopener noreferrer"&gt;software development&lt;/a&gt; that helps ensure the quality and reliability of your application. Playwright is a robust end-to-end testing framework that enables you to automate browser actions and interactions. This comprehensive guide will walk you through performing &lt;a href="https://geekyants.com/blog/how-to-use-ai-in-qa-software-testing--a-guide-with-live-openai-demo" rel="noopener noreferrer"&gt;automation testing&lt;/a&gt; using Playwright in a &lt;a href="https://geekyants.com/hire-next-js-developers" rel="noopener noreferrer"&gt;Next.js application&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Playwright is a JavaScript library that provides a high-level API to automate browsers like Chromium, Firefox, and WebKit. It allows you to write browser automation scripts that simulate user interactions, navigation, form submissions, and more. Playwright's unique architecture provides a fast and reliable way to test web applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up a Javascript Project
&lt;/h2&gt;

&lt;p&gt;Before we start writing tests, let's set up a Next.js project:&lt;/p&gt;

&lt;p&gt;Create a new Javascript project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;my-js-project
&lt;span class="nb"&gt;cd &lt;/span&gt;my-js-project
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will create a new directory called my-js-project, navigate into it, and initialize a new Node.js project with default settings.&lt;/p&gt;

&lt;p&gt;The -y flag in the npm init -y command is a shorthand for "yes". It automatically answers "yes" to all the prompts that npm init would normally ask you, thereby accepting all the default values for the project setup.&lt;/p&gt;

&lt;p&gt;Install the Playwright dependencies:&lt;br&gt;
&lt;/p&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;playwright
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Writing Automation Tests with Playwright
&lt;/h2&gt;

&lt;p&gt;Let's create a simple automation test for a javascript application. We want to test the navigation from the home page to a blog post page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Next.js Blog Automation Test&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="o"&gt;=&amp;gt;&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;browser&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;page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;beforeAll&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="nx"&gt;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;beforeEach&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="nx"&gt;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;afterAll&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;should navigate to a blog post&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href="/blog"]&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1&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;pageTitle&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog Post Title&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let us see the components of the above code in a bit more detail.&lt;/p&gt;

&lt;p&gt;When using testing frameworks like Jest, Mocha, Jasmine, or Playwright, the describe block is a fundamental organizational structure that helps group related test cases together. It provides a way to define a logical container for a set of tests with a common context or functionality. The describe block is primarily used to improve your test suite's organization, readability, and maintainability.&lt;/p&gt;

&lt;p&gt;The beforeEach function is a part of testing frameworks like Jest and is used to set up a consistent and controlled environment before running each individual test case within a describe block. This is particularly helpful in maintaining a clean state and avoiding interference between test cases.&lt;/p&gt;

&lt;p&gt;In testing, it's important to create a controlled environment for each test case to ensure that they run independently and reliably. Sometimes, tests require the same setup steps before execution. Instead of repeating the setup code in every test case, you can use beforeEach to define those common setup actions.&lt;/p&gt;

&lt;p&gt;In automated testing, especially when using testing frameworks like Jest, you often encounter lifecycle functions that help you set up and tear down certain conditions before and after tests are run. One important lifecycle function is beforeAll.&lt;/p&gt;

&lt;p&gt;The beforeAll function executes a block of code before all the tests within a test suite (also known as a test file or describe block) are run. This block of code typically sets up any common resources or configurations required for all the tests in that suite.&lt;/p&gt;

&lt;p&gt;The afterAll function is a part of testing frameworks like Jest, and it is used to perform cleanup tasks or teardown operations after all the test cases within a describe block have been executed. It's the counterpart of the beforeAll function, which is used for setup operations before the test cases.&lt;/p&gt;

&lt;p&gt;In testing, there might be scenarios where you need to perform cleanup tasks or reset the environment after all the test cases within a particular test suite have run. afterAll allows you to define these cleanup actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enhancing API Testing with Playwright
&lt;/h2&gt;

&lt;p&gt;Did you know that Playwright, commonly recognized for its powerful browser automation capabilities, can also be utilized for API testing? This versatile tool excels in web application testing and offers robust features to automate and validate API interactions. By leveraging Playwright for API testing, you can streamline your &lt;a href="https://geekyants.com/service/hire-quality-assurance-developers" rel="noopener noreferrer"&gt;testing processes&lt;/a&gt; and ensure your APIs function as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing Your First API Test
&lt;/h2&gt;

&lt;p&gt;Let's start with a basic example. We'll test a simple REST API that returns user information.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Start with creating a new file called apiTest.spec.js.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Import Playwright:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Write a Basic Test:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a test that fetches user data from a hypothetical API endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET user data&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;request&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;response&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;request&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/users/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;userData&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userData&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="nf"&gt;toBe&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Leanne Graham&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Breaking Down the Test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;test: Defines a test case.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;request.get: Sends a GET request to the specified URL.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;expect: assertions to check if the response meets our expectations.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;response.status(): Checks the HTTP status code of the response.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;response.json(): Parses the response body as JSON.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Handling Different HTTP Methods
&lt;/h2&gt;

&lt;p&gt;Playwright supports various HTTP methods. Here are examples of POST, PUT, and DELETE requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;POST Request:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST create new user&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;request&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;response&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/users&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="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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;John Doe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;johndoe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;johndoe@example.com&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&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;newUser&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;John Doe&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PUT Request:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT update user data&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;request&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;response&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/users/1&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="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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jane Doe&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;updatedUser&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updatedUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jane Doe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;});&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DELETE Request:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE user&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;request&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;response&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/users/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;h2&gt;
  
  
  Handling Authentication
&lt;/h2&gt;

&lt;p&gt;Many APIs require authentication. Here’s how to handle it in Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET user data with authentication&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;request&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;response&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;request&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/protected-resource&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer YOUR_ACCESS_TOKEN`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;h2&gt;
  
  
  Using Fixtures for Setup and Teardown
&lt;/h2&gt;

&lt;p&gt;Fixtures help set up and tear down resources required for your tests. Here's an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&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 tests with setup and teardown&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="o"&gt;=&amp;gt;&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;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beforeAll&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="nx"&gt;request&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;response&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/users&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="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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test User&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&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="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;afterAll&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="nx"&gt;request&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://jsonplaceholder.typicode.com/users/&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="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET created user data&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;request&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;response&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;request&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="s2"&gt;`https://jsonplaceholder.typicode.com/users/&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;h2&gt;
  
  
  Running Your Tests
&lt;/h2&gt;

&lt;p&gt;You can run your Playwright tests using the Playwright Test Runner. Add a script to your package.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"playwright test"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, run your tests with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Generating Test Depictions
&lt;/h2&gt;

&lt;p&gt;Playwright Test generates reports by default. You can view the test results in the console, but you might want a more detailed report. Playwright provides a built-in HTML report. To enable it, add a playwright.config.js file in your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;reporter&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;list&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html&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="na"&gt;outputFolder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright-report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run your tests again.&lt;/p&gt;

&lt;p&gt;After completing the tests, you'll find the HTML report in the playwright-report directory. To open it, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm playwright show-report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command opens the HTML report in your default browser, providing a detailed view of your test results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best Practices for Automation Testing
&lt;/h3&gt;

&lt;p&gt;Here are some best practices to follow when writing automation tests using Playwright in a Next.js application:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Isolation: Each test should be independent and not depend on the state of other tests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use Data Attributes: Use data attributes in your components to make it easier to select elements in your tests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Mock API Calls: Use mocking or stubbing to avoid making actual API requests during tests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Assertions: Use meaningful assertions to verify the expected behavior of your application.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Page Objects: Create page objects or helper functions to encapsulate the interactions with specific parts of your application.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Continuous Integration with Playwright
&lt;/h3&gt;

&lt;p&gt;Integrate your Playwright tests into your continuous integration (CI) pipeline. This ensures that your tests are automatically executed whenever you push code changes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Choose a CI platform like GitHub Actions, Jenkins, or Travis CI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure the CI pipeline to run the Playwright tests using Jest.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Playwright vs. Selenium: A Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Playwright&lt;/th&gt;
&lt;th&gt;Selenium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language Support&lt;/td&gt;
&lt;td&gt;Javascript/TypeScript, Python, C#, Java&lt;/td&gt;
&lt;td&gt;Java, C#, Python, Ruby, JavaScript, Kotlin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser Support&lt;/td&gt;
&lt;td&gt;Chromium (Chrome, Edge), WebKit (Safari), Firefox&lt;/td&gt;
&lt;td&gt;Chrome, Firefox, Safari, Edge, IE, Opera&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Installation and Setup&lt;/td&gt;
&lt;td&gt;Single package installation (includes browser binaries)&lt;/td&gt;
&lt;td&gt;Separate installations for WebDriver binaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed and Performance&lt;/td&gt;
&lt;td&gt;Generally faster (uses DevTools Protocol)&lt;/td&gt;
&lt;td&gt;Slower (uses WebDriver protocol)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallel Testing&lt;/td&gt;
&lt;td&gt;Built-in support for parallel testing&lt;/td&gt;
&lt;td&gt;Requires additional configuration/tools (e.g., Selenium Grid)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APIs and Features&lt;/td&gt;
&lt;td&gt;Modern API, auto-wait, network interception, mobile emulation&lt;/td&gt;
&lt;td&gt;Mature API, similar features but more configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community and Support&lt;/td&gt;
&lt;td&gt;Growing community, backed by Microsoft&lt;/td&gt;
&lt;td&gt;Established community, extensive documentation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-Browser Testing&lt;/td&gt;
&lt;td&gt;Consistent results with the same API for all browsers&lt;/td&gt;
&lt;td&gt;It is possible but may have inconsistencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test Reporting&lt;/td&gt;
&lt;td&gt;Integrated reporting tools&lt;/td&gt;
&lt;td&gt;Requires additional tools/libraries (e.g., TestNG, JUnit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Script Debugging&lt;/td&gt;
&lt;td&gt;Built-in tools for debugging (e.g., Playwright Inspector)&lt;/td&gt;
&lt;td&gt;Debugging through language-specific IDE/tools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Automation testing using Playwright in a Next.js application can help you catch bugs and ensure the quality of your software. Following the steps in this guide, you've learned how to set up a Next.js project, write automation tests, and integrate them into your CI pipeline. Remember to continuously update and expand your test suite as your application evolves.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>javascript</category>
      <category>testing</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Automate the Obvious, Review the Rest: Designing AI-Powered Content Moderation</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Thu, 29 Oct 2026 03:33:21 +0000</pubDate>
      <link>https://dev.to/geekyants/automate-the-obvious-review-the-rest-designing-ai-powered-content-moderation-2aoi</link>
      <guid>https://dev.to/geekyants/automate-the-obvious-review-the-rest-designing-ai-powered-content-moderation-2aoi</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A social product does not fail only when the database is slow. It also fails when the feed becomes a place users no longer trust.&lt;/p&gt;

&lt;p&gt;We needed a &lt;a href="https://geekyants.com/blog/image-moderation-using-nodejs-amazon-rekognition-and-sqs" rel="noopener noreferrer"&gt;moderation system&lt;/a&gt; that could inspect captions, comments, ads, stories, images, and videos as soon as they were created without delaying uploads or posts for human review or automating every borderline case.&lt;/p&gt;

&lt;p&gt;The design we shipped is deliberately split. Text goes through &lt;a href="https://geekyants.com/hire-nest-js-developers" rel="noopener noreferrer"&gt;NestJS&lt;/a&gt;, Hasura event triggers, BullMQ, and the OpenAI Moderation API. Images and videos go through S3, SNS, SQS, AWS Lambda, and Amazon Rekognition. Both pipelines write the same status model, store the AI evidence, and leave gray-area content for the admin panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moderation is not a feature bolted onto the feed. It is a gate the feed has to pass through.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Traditional Moderation Doesn't Scale
&lt;/h2&gt;

&lt;p&gt;As content volume grows, moderation becomes less about identifying whether a single piece of content is safe and more about deciding where each piece should go.&lt;/p&gt;

&lt;p&gt;Sending every piece of content to a human does not scale, while automatically approving or rejecting everything based on a single model score introduces its own risks.&lt;/p&gt;

&lt;p&gt;The system therefore treats moderation as a triage pipeline: clear cases are automated, ambiguous cases are escalated, and the feed only exposes content that has reached an approved state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Content Moderation Is Required
&lt;/h2&gt;

&lt;p&gt;User-generated content is the product. It is also a risk. Without a consistent review path, unsafe or policy-breaking material can reach other users in seconds.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Protect people on the platform from explicit, violent, hateful, or otherwise harmful material.&lt;/li&gt;
&lt;li&gt;Keep posts, ads, and stories out of public feeds until they are safe to show.&lt;/li&gt;
&lt;li&gt;Meet App Store, Play Store, and trust-and-safety expectations for UGC products.&lt;/li&gt;
&lt;li&gt;Create an audit trail: what the model saw, what score it produced, and who made the final call.&lt;/li&gt;
&lt;li&gt;Notify the owner when content is rejected, instead of failing silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Manual review of every caption and every frame does not scale.A fully automated approve-or-reject model also creates risk: models are confident on extremes and uncertain in the middle. The system is built around that fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the System Does
&lt;/h2&gt;

&lt;p&gt;Every piece of moderated content starts as pending. Background work then produces a decision: approved, under_review, or rejected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7816amicfzgq52563mz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm7816amicfzgq52563mz.jpg" alt=" " width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Approved content can appear in feeds and listings. Rejected content stays out of the public product. Under-review content stays out of the public product until a moderator confirms or overrides the AI decision. That is the contract.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqyz2qiymp42q89jozaf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqyz2qiymp42q89jozaf.jpg" alt=" " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture: Two Pipelines, One Status Model
&lt;/h2&gt;

&lt;p&gt;We did not force text and media through the same worker. Text is small, frequent, and already in &lt;a href="https://geekyants.com/hire-postgresql-developers" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; at the moment it is written. Media is large, arrives in S3, and is expensive to inspect. Each path uses the infrastructure that fits it.&lt;/p&gt;

&lt;p&gt;Shared data model content_moderation_status on the source row: pending, approved, under_review, rejected. A content row as the generic moderation record. moderation_results for provider, risk_score, labels, ai_decision, and final_decision. reviewed_by on the result row when a human takes the last step. Feeds that list posts, ads, stories, or media filter on content_moderation_status = approved. The existing status columns (active, trash, uploaded, and so on) still mean what they always meant. Moderation is a separate lifecycle on purpose.&lt;/p&gt;

&lt;p&gt;Do not overload “active” to mean “safe.” Safety needs its own column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text Moderation: Hasura, BullMQ, and OpenAI
&lt;/h2&gt;

&lt;p&gt;When a user writes text, the row is saved first. The HTTP request does not wait for OpenAI. Hasura fires an event; NestJS creates the content record and enqueues a job; a worker calls the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text request path
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Insert / update row
↓
Hasura event trigger
↓
POST /v1/event/content-moderation
↓
Create content row
↓
BullMQ job MODERATE_TEXT
↓
OpenAI /v1/moderations
↓
Save result + update status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The worker
&lt;/h3&gt;

&lt;p&gt;The content-moderation processor runs with limited concurrency and a rate limit so OpenAI traffic stays controlled. Empty text is approved without a call.&lt;/p&gt;

&lt;p&gt;Failed jobs go to a dead-letter queue. Hasura retries the webhook (three attempts, ten-second interval, sixty-second timeout) if Nest is briefly unavailable.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Text Scores Are Calculated
&lt;/h3&gt;

&lt;p&gt;OpenAI returns a flag plus a category_scores map, each value between 0 and 1. We do not treat the boolean flag as the decision. We convert the highest category score into a 0–100 risk score and classify from that number.&lt;/p&gt;

&lt;h4&gt;
  
  
  Text scoring
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenAI category_scores
→
Take the maximum score
→
riskScore = round(max × 100)
→
Compare to thresholds
→
approved / under_review / rejected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Risk below 40 → approved. The content can enter the feed.&lt;/li&gt;
&lt;li&gt;Risk from 40 up to (but not including) 75 → under_review. A human must decide.&lt;/li&gt;
&lt;li&gt;Risk 75 or above → rejected. The content stays hidden and the owner is notified.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only categories that meet or exceed the approved threshold are stored as labels, as percentages. The admin later sees the violated categories, not a wall of near-zero scores. Thresholds are configurable through MODERATION_APPROVED_THRESHOLD and MODERATION_REJECTED_THRESHOLD; the production defaults are 40 and 75.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4l2ql4g0zqulsdnragua.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4l2ql4g0zqulsdnragua.jpg" alt=" " width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How We Calibrated the Thresholds
&lt;/h3&gt;

&lt;p&gt;Scores of 40, 75, or 90 only become production policy after you measure what those cutoffs do to false positives, false negatives, and human workload. The value of these thresholds comes from the calibration loop that produced and maintains them.&lt;/p&gt;

&lt;h4&gt;
  
  
  Starting points, not final answers
&lt;/h4&gt;

&lt;p&gt;We began with provider semantics and product risk posture, then validated against labelled samples rather than shipping intuition.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text (OpenAI): start near the middle of the score distribution. Auto-approve only when the max category score is clearly low; auto-reject only when it is clearly high; leave a wide under_review band for the first weeks.&lt;/li&gt;
&lt;li&gt;Images/video (Rekognition): start stricter on auto-approve than text (review at 75%, reject at 90%), because a false approval in a feed is costlier than an extra item in the admin queue.&lt;/li&gt;
&lt;li&gt;Environment variables keep cutoffs changeable without rewriting the decision engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Labelled test set and threshold sweeps
&lt;/h4&gt;

&lt;p&gt;Before locking production defaults, we built a small labelled set covering three buckets: clearly safe, borderline / ambiguous, and clearly violating. For each item we stored the model’s raw scores, the would-be decision at candidate cutoffs, and a human gold label.&lt;/p&gt;

&lt;h4&gt;
  
  
  Calibration experiment
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collect labelled sample (safe / borderline / violate)
↓
Run OpenAI or Rekognition scores
↓
Sweep candidate thresholds
↓
Measure auto-approve FP, auto-reject FN, review volume
↓
Pick cutoffs that protect the feed without drowning admin
↓
Re-check against human final_decision in production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  What “good” looked like for us
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Near-zero false auto-approves on the labelled violate set (missed harm is the expensive error).&lt;/li&gt;
&lt;li&gt;Bounded false auto-rejects: overturns in admin or appeals should stay rare; if many auto-rejects become approved, raise the reject line or widen review.&lt;/li&gt;
&lt;li&gt;A review band that is large enough to catch ambiguity but small enough that queue age stays within SLA.&lt;/li&gt;
&lt;li&gt;We treat the AI-human disagreement rate on under_review items as a first-class metric rather than noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Text settled at 40 / 75 after sweeps showed that scores below ~40 almost never carried human-confirmed violations in our sample, while scores at or above ~75 almost always did. The middle band produced the majority of human overrides, which is exactly where we want people in the loop.&lt;/p&gt;

&lt;p&gt;Media settled at 75 / 90 based on the same calibration process but with a different cost curve: Rekognition confidences in the mid-70s were often ambiguous on a single frame, so we refuse to auto-approve there. Sustained or high-confidence hits (including the video frame policy) justify auto-reject without waiting for a moderator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thresholds are hypotheses. Production disagreement rates are the experiment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We revisit cutoffs when operational signals move: rising appeals after auto-reject, reports against content that was auto-approved, or a review queue that ages past SLA. Calibration is continuous, not a one-time spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image and Video Moderation: S3, Lambda, SQS, Rekognition
&lt;/h2&gt;

&lt;p&gt;The media never waits in the Nest process for Rekognition. The user uploads to S3 under users/uploads/. An ObjectCreated notification is published to SNS, and the media path continues asynchronously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not two Lambdas on one SQS queue
&lt;/h3&gt;

&lt;p&gt;SQS is a competing-consumer queue: each message is delivered to exactly one consumer. If two Lambda event-source mappings share a single queue and each “filters” by message shape, a message can land on the wrong function. That function may skip and still succeed—deleting the message—or fail and retry forever. Either way, delivery semantics are not safe for mixed payload types.&lt;/p&gt;

&lt;p&gt;We therefore do not rely on two independently filtering consumers on one queue. The media pipeline is a two-stage design: an ingest queue owned by the scorer Lambda, and a results queue owned by the worker Lambda. SNS fans events into the correct stage; it is not used as a substitute for competing consumers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frs1vx98c3tmjslwget4z.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frs1vx98c3tmjslwget4z.jpg" alt=" " width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the first Lambda does
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Images (.jpg, .jpeg, .png, .bmp, .webp): DetectModerationLabels and RecognizeCelebrities, then evaluate thresholds and send a result message to SQS.&lt;/li&gt;
&lt;li&gt;Videos (.mp4, .mov): StartContentModeration with an SNS notification channel. Rekognition works asynchronously; the worker finishes the job later.&lt;/li&gt;
&lt;li&gt;Files matching a thumbnail filename or path pattern are still labeled because ad thumbnails can be the image users see first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the worker Lambda does
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Look up the media row by S3 key, retrying three times with a three-second delay so the upload API can finish inserting the row.&lt;/li&gt;
&lt;li&gt;Inserts content and moderation_results.&lt;/li&gt;
&lt;li&gt;Updates media.content_moderation_status.&lt;/li&gt;
&lt;li&gt;Propagates the worst status to the parent post, ad, or story.&lt;/li&gt;
&lt;li&gt;For videos, pages through GetContentModeration until every label is collected, then applies the frame policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SQS is the buffer. Lambda is the computer. Rekognition is the eyes. PostgreSQL is the record.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How Image Scores Are Calculated
&lt;/h3&gt;

&lt;p&gt;Rekognition returns a list of moderation labels, each with a confidence from 0 to 100. We keep every label name mapped to a rounded confidence, then take the maximum confidence as the risk score.&lt;/p&gt;

&lt;h4&gt;
  
  
  Image scoring
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DetectModerationLabels
↓
Build label → confidence map
↓
riskScore = round(max confidence)
↓
Apply 75 / 90 thresholds
↓
Optional celebrity override
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Confidence below 75% → approved.&lt;/li&gt;
&lt;li&gt;Confidence from 75% up to 90% → under_review.&lt;/li&gt;
&lt;li&gt;Confidence 90% or above → rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The defaults are MODERATION_REVIEW_THRESHOLD = 75 and MODERATION_REJECT_THRESHOLD = 90. They use a stricter review boundary than the text moderation thresholds because a false approval on a photo is more damaging in a feed than an unnecessary review.&lt;/p&gt;

&lt;p&gt;The policy also calls out hard categories Rekognition can surface—explicit material, sexual activity, violence, hate symbols, drugs and tobacco, visually disturbing content. High confidence in those labels is what the 90% reject line is designed to catch without a human delay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Video: Async Jobs and Frame Policy
&lt;/h3&gt;

&lt;p&gt;A video cannot be scored from a single still. Rekognition runs a content-moderation job across the file, publishes completion to SNS, and that notification arrives on the same SQS queue the image path already uses.&lt;/p&gt;

&lt;h4&gt;
  
  
  Video path
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S3 video object
↓
StartContentModeration
↓
Rekognition job
↓
SNS success notification
↓
Worker fetches all labels (paginated)
↓
Frame policy + thresholds
↓
Write status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each label is tied to a timestamp. We still compute riskScore as the maximum confidence, but the decision also depends on how often and how long the violation appears.&lt;/p&gt;

&lt;h4&gt;
  
  
  Frame policy
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;A frame is violating if its label confidence is at least the review threshold (75%).&lt;/li&gt;
&lt;li&gt;Five or more violating frames, or a cluster lasting three seconds or more, meets the frame policy.&lt;/li&gt;
&lt;li&gt;If any label reaches the rejection threshold (90%+), the video is rejected.&lt;/li&gt;
&lt;li&gt;If labels are only in the review band and the frame policy is met, the video is also rejected—repeated mid-confidence hits are treated as a pattern, not a coincidence.&lt;/li&gt;
&lt;li&gt;If labels are only in the review band and the frame policy is not met, the video goes to under_review.&lt;/li&gt;
&lt;li&gt;No qualifying labels → approved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One risky frame is a question. Five frames or three seconds is an answer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Parent Aggregation and Feed Visibility
&lt;/h2&gt;

&lt;p&gt;A post, ad, or story can have several media items plus text. The parent must not appear “approved” while one of its images is still pending or rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worst-status rollup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;→ Media A approved
→ Media B under_review
→ Caption approved
→ Parent = under_review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Statuses are ordered pending &amp;lt; approved &amp;lt; under_review &amp;lt; rejected. The database function apply_aggregate_moderation_status applies that order to user_post, hey_ads, and users_story. If any child is rejected, the parent is rejected.&lt;/p&gt;

&lt;p&gt;List APIs for the product surface follow one rule: show content only when content_moderation_status is approved. Under-review and rejected items are not a client-side filter. They never qualify for the public listing query.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When Content Is Rejected
&lt;/h2&gt;

&lt;p&gt;When content_moderation_status becomes rejected, a Hasura event on the content table calls POST /v1/event/content-rejected-notify. That queues an email through SendGrid and an in-app/push notification through Firebase, including the content type and rejection reason. The user is told the content did not pass review. They are not left guessing why it vanished.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rejection side effects
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status → rejected
↓
Hasura content_rejected_email
↓
Nest notify webhook
↓
Email + push
↓
Still hidden from feed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Last Step: Admin Panel Review
&lt;/h2&gt;

&lt;p&gt;Automation is the first reviewer, not the last. Anything in under_review—text, image, or video—is a case a person has to close. That is the admin panel’s job.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2v65df2xpk6o0lufw5l.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn2v65df2xpk6o0lufw5l.jpg" alt=" " width="800" height="264"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Human review path
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywrsrny1vg4kugmhymwg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywrsrny1vg4kugmhymwg.jpg" alt=" " width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the moderator needs on screen
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A queue, not a scavenger hunt: one list of under_review (and optionally pending) items across posts, comments, ads, stories, and media.&lt;/li&gt;
&lt;li&gt;The source itself: caption text, image, or playable video.&lt;/li&gt;
&lt;li&gt;The machine evidence: provider (Openai or Rekognition), risk score, labels, celebrity names when present, video frame summary when present.&lt;/li&gt;
&lt;li&gt;Actions: approve, reject. Optionally escalate, assign, or add an internal note.&lt;/li&gt;
&lt;li&gt;Filters by content type, date, and score so a small team can work the riskiest items first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On approval, both content_moderation_status and final_decision are set to approve. On rejection, the status becomes rejected, the existing rejection notification path fires, and the item still never reaches the feed. The AI decision remains on the row as ai_decision so we can measure how often humans agree with the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;under_review is not a soft approve. It is a closed door until a person opens it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Production Feedback Loop
&lt;/h2&gt;

&lt;p&gt;Shipping thresholds and queues is not the end of moderation work. The system has to be operated. We treat the following signals as the minimum set for running AI moderation in production—and as the inputs that decide when to retune cutoffs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg6i6myejwu154p6eybwr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg6i6myejwu154p6eybwr.jpg" alt=" " width="799" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Operational signals
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Moderation latency: time from enqueue (Hasura event or S3 ObjectCreated) to written decision, tracked as p50/p95 separately for OpenAI text and Rekognition image/video.&lt;/li&gt;
&lt;li&gt;Review-queue age and SLA: count and age of under_review items; alert when the oldest item or median wait exceeds the team’s SLA.&lt;/li&gt;
&lt;li&gt;Provider failures: OpenAI and Rekognition error rates, throttles, empty results, and dead-letter / retry volume on BullMQ and Lambda.&lt;/li&gt;
&lt;li&gt;AI–human disagreement rate: share of rows where ai_decision ≠ final_decision after admin review, broken down by provider and content type.&lt;/li&gt;
&lt;li&gt;False positives and appeals: auto-reject later overturned to approve; user appeals or support tickets after rejection notifications.&lt;/li&gt;
&lt;li&gt;False negatives and reports: user reports or admin discoveries on content that was auto-approved—these are the most expensive misses.&lt;/li&gt;
&lt;li&gt;Threshold drift: weekly or biweekly review of the above rates;if disagreement rates, appeals, or queue age move outside defined bands if disagreement, appeals, or queue age move outside bands, re-run the labelled sweep and adjust env thresholds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Operate → measure → retune
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ship with calibrated defaults
↓
Watch latency, queue age, provider health
↓
Compare ai_decision to final_decision
↓
Track appeals and reports
↓
Detect drift
↓
Re-sweep thresholds or widen review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing ai_decision alongside final_decision and reviewed_by is what makes this loop possible. Without that pair, you can run a model, but you cannot prove whether the model and the product still agree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A moderation system without operational signals is a deployment, not a practice.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deployment Pipeline
&lt;/h2&gt;

&lt;p&gt;The media Lambdas are not updated by hand in the console as a normal workflow. Each function has a GitHub Actions workflow on the develop branch, scoped to its own directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda CI/CD
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Push to develop
↓
OIDC to AWS
↓
npm ci --omit=dev
↓
Zip the function
↓
Create or update Lambda
↓
Wait + verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;deploy-image-moderation.yml watches lambda/image-moderation/**.&lt;/li&gt;
&lt;li&gt;deploy-image-moderation-worker.yml watches lambda/image-moderation-worker/**.&lt;/li&gt;
&lt;li&gt;Runtime is &lt;a href="https://geekyants.com/hire-nodejs-developers" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; 22. The scorer Lambda is sized for a short Rekognition call (about 60 seconds, 256 MB). The worker is sized for video pagination and database work (about 5 minutes, 512 MB).&lt;/li&gt;
&lt;li&gt;Secrets stay in GitHub Secrets: queue URL, database connection, and deployment role. Thresholds and regions live in deploy config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Text moderation ships with the NestJS service: the Hasura trigger metadata, the webhook, the BullMQ processor, and the OpenAI client. Changing a threshold is a configuration change, not a rewrite of the decision engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases the Pipeline Had to Absorb
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Race with inserts: the worker retries media lookup because S3 can notify Lambda before Nest commits the media row.&lt;/li&gt;
&lt;li&gt;Ad thumbnails: if the object is a thumbnail rather than a media row, the worker can still attach the result to hey_ads.&lt;/li&gt;
&lt;li&gt;Unsupported types: unknown extensions are skipped rather than auto-approved by accident.&lt;/li&gt;
&lt;li&gt;Idempotency: if media is already moderated, a later result may only escalate to a worse status, never quietly downgrade it.&lt;/li&gt;
&lt;li&gt;Aggregation after the fact: parent status is recomputed from children so mixed media cannot hide a reject.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What This Changed in the Product
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Outcome
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload / post returns immediately
↓
Background AI inspects content
↓
Clear cases resolve themselves
↓
Gray cases wait for admin
↓
Feed only shows approved
↓
Rejected owners are notified
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Text, image, and video share one status vocabulary.&lt;/li&gt;
&lt;li&gt;OpenAI and Rekognition are isolated behind queues, so model latency is not user latency.&lt;/li&gt;
&lt;li&gt;Scores are explicit numbers with published thresholds, not hidden boolean flags.&lt;/li&gt;
&lt;li&gt;The admin panel is the last reviewer for under_review, not an optional extra screen.&lt;/li&gt;
&lt;li&gt;Evidence is stored: labels, scores, provider, and the human override.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important change was not simply adding another moderation API. It was moving moderation from an isolated check into a product lifecycle. Content can return from the upload or post request without waiting for model inference, while remaining outside public listings until the moderation lifecycle resolves. Clear cases move automatically, uncertain cases reach the admin queue, and the final decision is recorded alongside the machine evidence.&lt;/p&gt;

&lt;p&gt;This also creates a feedback loop: the AI decision remains stored alongside the human final decision, allowing the team to measure where automated decisions and human decisions agree or differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson: Automate the Extremes, Staff the Middle
&lt;/h2&gt;

&lt;p&gt;A moderation system that only auto-rejects the obvious still leaks. A system that auto-approves anything the model did not scream about still leaks. The useful design is a three-way split.&lt;/p&gt;

&lt;h3&gt;
  
  
  The moderation mindset
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ingest without blocking the user
↓
Score with the right model for the medium
↓
Approve the clearly safe
↓
Reject the clearly unsafe
↓
Queue the rest for admin
↓
Keep the feed on approved only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We used OpenAI for language because it already classifies the categories we care about in text. We used Rekognition for pixels because it already understands moderation labels, video timestamps, and celebrity faces. We used Lambda and SQS because media events are bursty and should not live inside the API process. We used Hasura and BullMQ because text events are already database events. We used an admin queue because a score of 62 is not a moral fact. It is a request for a person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal of AI moderation is not to remove humans from the loop. It is to make sure humans spend their time where human judgment matters most.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So before a caption, a photo, or a video is allowed to represent the product, it has to survive the pipeline: pending, scored, decided, and—if the model is unsure—reviewed. That is how content moderation works here: as a path every piece of user content has to follow before it hits the feed, rather than as a slogan in a policy document.&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s Next? Engineer AI Moderation for Production
&lt;/h2&gt;

&lt;p&gt;The bigger lesson goes beyond content moderation. &lt;a href="https://geekyants.com/ai-powered-product-engineering/prototype-to-production" rel="noopener noreferrer"&gt;Production AI features&lt;/a&gt; need more than an accurate model. They need clear decision boundaries, asynchronous processing, human review paths, auditability, monitoring, and a way to improve as real-world behavior changes.&lt;/p&gt;

&lt;p&gt;The same principle applies whether AI is moderating content, evaluating documents, prioritizing cases, or assisting users inside a product. Automate decisions where confidence is high, route ambiguous cases to people, and retain enough evidence to understand why each decision was made.&lt;/p&gt;

&lt;p&gt;That is what turns an AI capability into a system a product team can operate at scale.&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://geekyants.com/" rel="noopener noreferrer"&gt;GeekyAnts&lt;/a&gt;, our &lt;a href="https://geekyants.com/ai" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Engineering&lt;/strong&gt;&lt;/a&gt; teams design and build production-ready AI systems around real product workflows, combining model integration with scalable architecture, observability, deployment, and human-in-the-loop controls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building an AI-powered feature that needs to move from model API to production system?&lt;/strong&gt; &lt;a href="https://geekyants.com/hire" rel="noopener noreferrer"&gt;&lt;strong&gt;Talk to our AI engineering team&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nestjs</category>
      <category>prototypetoproduction</category>
    </item>
    <item>
      <title>Mobile App Deep Linking With React Native, NestJS and AWS</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:11:40 +0000</pubDate>
      <link>https://dev.to/geekyants/mobile-app-deep-linking-with-react-native-nestjs-and-aws-2o47</link>
      <guid>https://dev.to/geekyants/mobile-app-deep-linking-with-react-native-nestjs-and-aws-2o47</guid>
      <description>&lt;p&gt;Build a self-hosted deep linking and referral system for React Native without a third-party SDK. This guide covers Android App Links, iOS Universal Links, an S3 and CloudFront setup, and a NestJS backend that routes users to the right app store.&lt;/p&gt;

&lt;p&gt;Third-party attribution services like Branch, AppsFlyer, and Firebase Dynamic Links offer turn-key deep linking out of the box. However, they frequently bring vendor lock-in, recurring SaaS subscription overhead, privacy and data residency compliance challenges, and sudden deprecations (such as the sunsetting of Firebase Dynamic Links).&lt;/p&gt;

&lt;p&gt;Firebase Dynamic Links is the clearest example: Google &lt;a href="https://firebase.google.com/support/dynamic-links-faq" rel="noopener noreferrer"&gt;shut the service down on August 25, 2025&lt;/a&gt;, and every page.link URL stopped working on that date.&lt;/p&gt;

&lt;p&gt;This guide walks through building a fully self-hosted, vendor-agnostic deep linking and referral system using native OS domain verification, &lt;a href="https://geekyants.com/aws-consulting-services" rel="noopener noreferrer"&gt;AWS infrastructure&lt;/a&gt; (S3, CloudFront, Application Load Balancer), a &lt;a href="https://geekyants.com/hire-nest-js-developers" rel="noopener noreferrer"&gt;NestJS backend&lt;/a&gt;, and a React Native mobile client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Deep Linking?
&lt;/h2&gt;

&lt;p&gt;Deep linking routes a user directly to a specific screen or action within a &lt;a href="https://geekyants.com/service/hire-mobile-app-development-services" rel="noopener noreferrer"&gt;mobile app&lt;/a&gt; when clicking a standard HTTPS URL, bypassing the default home screen.&lt;/p&gt;

&lt;p&gt;Tap a normal link on your phone and your browser opens the matching webpage. A deep link works the same way, except it opens a specific screen inside an app instead.&lt;/p&gt;

&lt;p&gt;Example: an invite link&lt;/p&gt;

&lt;p&gt;A friend sends you a link to join an app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal link: takes you to a generic webpage or app store listing&lt;/li&gt;
&lt;li&gt;Deep link: if the app is already installed, it skips the browser completely and drops you straight into the exact screen you need, like a sign-up form with your friend's invite code already filled in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why this matters&lt;/p&gt;

&lt;p&gt;Without deep linking, joining through a link looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tap the link&lt;/li&gt;
&lt;li&gt;Land on a generic webpage&lt;/li&gt;
&lt;li&gt;Manually open the app&lt;/li&gt;
&lt;li&gt;Find the right screen&lt;/li&gt;
&lt;li&gt;Type in the code by hand&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With deep linking, all five steps collapse into one tap.&lt;/p&gt;

&lt;p&gt;The hard part: app not installed&lt;/p&gt;

&lt;p&gt;If the app isn't on the phone yet, the link still needs to do something useful. Usually that means sending the person to the App Store or Play Store, then remembering their original intent (like that invite code) once they install and open the app for the first time.&lt;/p&gt;

&lt;p&gt;This is the harder problem the rest of the guide solves, and it's usually why teams reach for paid third-party tools instead of building it themselves.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxfhe84sw75gif0oh1ly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxfhe84sw75gif0oh1ly.png" alt="React Native referral deep link routing from WhatsApp, web, or email to a prefilled referral screen" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Types of Mobile Links
&lt;/h3&gt;

&lt;p&gt;Mobile platforms support more than one kind of link. This guide uses verified HTTPS links because they are the only type that the operating system ties to a domain you own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Link type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;How the OS trusts it&lt;/th&gt;
&lt;th&gt;Limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Custom URI scheme&lt;/td&gt;
&lt;td&gt;&lt;code&gt;myapp://join?code=ABC123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No verification. Any app can register the same scheme&lt;/td&gt;
&lt;td&gt;Fails with an error when the app is not installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.android.com/training/app-links/verify-android-applinks" rel="noopener noreferrer"&gt;Android App Links&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=ABC123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android checks assetlinks.json on your domain through the Digital Asset Links API&lt;/td&gt;
&lt;td&gt;Opens in the browser if verification fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.apple.com/documentation/xcode/supporting-associated-domains" rel="noopener noreferrer"&gt;iOS Universal Links&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=ABC123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS checks apple-app-site-association on your domain&lt;/td&gt;
&lt;td&gt;Opens in Safari if the app is missing or the file cannot be fetched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;App Links and Universal Links share one URL for both platforms. The same link also works as a normal web page when the app is not installed, which is what makes the store redirect in this guide possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Use Cases
&lt;/h3&gt;

&lt;p&gt;The rest of this guide builds the referral onboarding case end to end. The same setup supports the other three cases once you add their paths to the manifests and the app router.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Example URL&lt;/th&gt;
&lt;th&gt;Expected Target Screen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Referral Onboarding&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=ABC123&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pre-filled Registration Screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product Sharing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://store.app.com/products/8930&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Specific Product Details Page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push Notifications&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://app.com/notifications/102&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Direct Chat / Message Context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketing Campaigns&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://app.com/promos/summer2026&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Discount / Landing Screen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Direct vs. Deferred Deep Linking
&lt;/h2&gt;

&lt;p&gt;Understanding operating system boundaries is essential before designing the architecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhk28edy4cpsy3602kixj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhk28edy4cpsy3602kixj.png" alt="Direct and deferred mobile deep linking flows for installed and uninstalled React Native apps" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Direct Deep Linking
&lt;/h3&gt;

&lt;p&gt;The mobile application is already installed. The mobile operating system (Android or iOS) intercepts the HTTP request, matches the domain signature against its installed applications, and directly opens the app without touching the web browser or backend web servers.&lt;/p&gt;

&lt;p&gt;The OS only intercepts links that a user taps. On iOS, a Universal Link &lt;a href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-PLATFORM/MASTG-KNOW-0080/" rel="noopener noreferrer"&gt;typed or pasted into the Safari address bar opens the web page&lt;/a&gt; instead of the app. A tap on a link to the same domain inside Safari also stays in the browser. Share links from messages, email, notes or other apps to get the expected behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deferred Deep Linking
&lt;/h3&gt;

&lt;p&gt;The application is not installed. The browser handles the URL, sending the request to your backend. The user is redirected to the Google Play Store or Apple App Store. Because native Android App Links and iOS Universal Links do not natively pass context across an installation boundary, the original URL parameters are lost upon install.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Who handles the link&lt;/th&gt;
&lt;th&gt;Does the referral code survive?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App installed&lt;/td&gt;
&lt;td&gt;Android or iOS opens the app directly&lt;/td&gt;
&lt;td&gt;Yes. The app reads it from the URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App not installed, Android&lt;/td&gt;
&lt;td&gt;Browser, then CloudFront, then NestJS, then Play Store&lt;/td&gt;
&lt;td&gt;Yes, through the Play Install Referrer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App not installed, iOS&lt;/td&gt;
&lt;td&gt;Browser, then CloudFront, then NestJS, then App Store&lt;/td&gt;
&lt;td&gt;No. The user enters it in the app during a grace period&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Architecture Distinction: Without third-party SDKs, this architecture provides store routing with server-side/manual attribution (via Play Store Install Referrer on Android and manual user/web account grace periods on iOS) rather than seamless, native OS-level deferred deep linking.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Deep Linking Architecture Works
&lt;/h2&gt;

&lt;p&gt;One CloudFront distribution sits in front of two origins. Static verification files come from a private S3 bucket, and every dynamic request on the domain goes to the NestJS service behind the Application Load Balancer. This keeps the files that Android and iOS read separate from the redirect logic, on a single domain and a single HTTPS certificate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fur7gyaxoasdwr75d4dna.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fur7gyaxoasdwr75d4dna.png" alt="AWS deep linking architecture using CloudFront, S3 verification files, ALB, NestJS, and React Native" width="800" height="617"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Component Responsibilities
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AWS / Stack Component&lt;/th&gt;
&lt;th&gt;Primary Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Route 53&lt;/td&gt;
&lt;td&gt;Manages DNS A records (Alias to CloudFront).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon CloudFront&lt;/td&gt;
&lt;td&gt;CDN handling HTTPS termination, edge routing, and origin caching.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon S3&lt;/td&gt;
&lt;td&gt;Private origin hosting static .well-known OS verification manifests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS ALB&lt;/td&gt;
&lt;td&gt;Routes dynamic requests (/join, API routes) to the NestJS cluster.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS API&lt;/td&gt;
&lt;td&gt;Evaluates User-Agent headers, processes referral logic, and executes store redirects.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React Native App&lt;/td&gt;
&lt;td&gt;Handles incoming deep link events via native Linking listeners.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A request to /.well-known/* is served from S3. Every other path goes to the ALB and then to NestJS, which decides where to send the visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting the Verification Files on Amazon S3
&lt;/h2&gt;

&lt;p&gt;This creates the storage for the two files that Android and iOS download to verify your domain. The bucket stays private, and CloudFront is the only service allowed to read it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create a Private Amazon S3 Bucket
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open the Amazon S3 Console and click Create bucket.&lt;/li&gt;
&lt;li&gt;Name the bucket (e.g., app-domain-verification-prod).&lt;/li&gt;
&lt;li&gt;Keep Block all public access enabled.&lt;/li&gt;
&lt;li&gt;Set Object Ownership to Bucket owner enforced.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S3 Bucket Structure
└── .well-known/
    ├── assetlinks.json
    └── apple-app-site-association
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Generate OS Verification Manifests
&lt;/h3&gt;

&lt;p&gt;Create a local directory named .well-known containing the two required manifest files. Each file lists the apps that are allowed to open links on your domain.&lt;/p&gt;

&lt;h4&gt;
  
  
  Android Verification (.well-known/assetlinks.json)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"relation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"delegate_permission/common.handle_all_urls"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"android_app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"package_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.company.app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sha256_cert_fingerprints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"your-sha-fingerprint-here"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pro Tip: Retrieve your SHA-256 fingerprint for production builds via Google Play Console under App Signing &amp;gt; App signing key certificate. The same page in Play Console also shows a ready-made &lt;a href="https://developer.android.com/training/app-links/verify-android-applinks" rel="noopener noreferrer"&gt;Digital Asset Links JSON snippet&lt;/a&gt; for your app. The sha256_cert_fingerprints field is an array, so you can add the upload key fingerprint as a second entry if testers install builds outside the Play Store.&lt;/p&gt;

&lt;h4&gt;
  
  
  iOS Verification (.well-known/apple-app-site-association)
&lt;/h4&gt;

&lt;p&gt;Note: Do not append .json to the file name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"applinks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"appIDs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"9JA6382026.com.company.app"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"components"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/join*"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value in appIDs is your Apple Team ID followed by the app's bundle identifier. The components array controls which paths open the app. Here only paths that start with /join are claimed, so every other page on the domain keeps opening in the browser.&lt;/p&gt;

&lt;p&gt;Apple sets three hosting rules for this file. It must be served over https:// with a valid certificate, it must return directly with &lt;a href="https://developer.apple.com/documentation/xcode/supporting-associated-domains" rel="noopener noreferrer"&gt;no redirects&lt;/a&gt;, and it must sit in the .well-known directory. Since iOS 14, devices do not fetch the file from your server. They fetch it from an Apple-managed CDN, which requests your file within 24 hours. Expect a delay before changes reach devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Upload Object &amp;amp; Set MIME Types
&lt;/h3&gt;

&lt;p&gt;Upload the .well-known folder to your S3 bucket. Ensure the HTTP Content-Type metadata for both files is explicitly configured as application/json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; .well-known/ s3://app-domain-verification-prod/.well-known/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--recursive&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--content-type&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloudFront caches these files at the edge. When you change either file later, create an invalidation so the new version is served right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudfront create-invalidation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--distribution-id&lt;/span&gt; &amp;lt;your-distribution-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--paths&lt;/span&gt; &lt;span class="s2"&gt;"/.well-known/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuring CloudFront and Route 53
&lt;/h2&gt;

&lt;p&gt;One CloudFront distribution serves both origins. Static verification files come from S3, and every other request on the domain goes to the NestJS service behind the ALB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Configure Origins &amp;amp; Origin Access Control (OAC)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to CloudFront &amp;gt; Distributions &amp;gt; Create Distribution.&lt;/li&gt;
&lt;li&gt;Origin 1 (S3): Select your S3 bucket endpoint.

&lt;ul&gt;
&lt;li&gt;Under Origin Access, select Origin Access Control (OAC) and create an OAC control setting.&lt;/li&gt;
&lt;li&gt;Apply the provided JSON policy to your S3 bucket policy to allow CloudFront access while keeping the bucket completely private.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Origin 2 (ALB): Add your Application Load Balancer domain (e.g., nestjs-alb-12345.ap-south-1.elb.amazonaws.com) using HTTPS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Important: When CloudFront connects to an origin over HTTPS, the origin's certificate &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html" rel="noopener noreferrer"&gt;must include a domain name that matches the Origin Domain&lt;/a&gt;. If it does not match, CloudFront returns 502 Bad Gateway. You cannot issue a certificate for the default elb.amazonaws.com name. Create a DNS record such as origin-api.app.com that points to the ALB, attach an ACM certificate for that name to the ALB's HTTPS listener, and enter origin-api.app.com as the Origin Domain.&lt;/p&gt;

&lt;p&gt;This certificate is created in the ALB's own Region.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Configure Path-Based Cache Behaviors
&lt;/h3&gt;

&lt;p&gt;Set up path routing rules to separate static verification from dynamic application requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Priority 1 (/.well-known/*): Target: S3 Origin. Caching Enabled (Managed-CachingOptimized).

Priority 2 (/join or Default *): Target: ALB Origin.
Cache Policy: Managed-CachingDisabled (TTL = 0). Dynamic redirects must never be cached by CloudFront edge nodes.
Origin Request Policy: Must forward Query Strings (code) and Headers (User-Agent, or CloudFront Device Detection headers: CloudFront-Is-Mobile-Viewer, CloudFront-Is-IOS-Viewer, CloudFront-Is-Android-Viewer).

Note: By default, CloudFront strips query parameters and overwrites the viewer User-Agent with Amazon CloudFront. Forwarding these headers/queries is required for NestJS platform detection.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fioz8derymj0v3yg7ertk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fioz8derymj0v3yg7ertk.png" alt="CloudFront routing for verification files in S3 and referral requests through ALB and NestJS" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: SSL Certificate &amp;amp; DNS Alias
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Request an AWS Certificate Manager (ACM) public certificate for invite.app.com.

&lt;ul&gt;
&lt;li&gt;Crucial Rule: The certificate must be created in the us-east-1 (N. Virginia) region to attach to CloudFront.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;In CloudFront Settings, add invite.app.com under Alternate domain name (CNAME) and select the ACM certificate.&lt;/li&gt;
&lt;li&gt;In Route 53, create an A Record for invite.app.com, choose Alias, and point it to the CloudFront distribution target (dXXXXXX.cloudfront.net).&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 4: Protect ALB Origin (Direct Access Prevention)
&lt;/h3&gt;

&lt;p&gt;To prevent users or malicious traffic from bypassing CloudFront and hitting the ALB directly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In CloudFront Origin Settings for the ALB, add a custom HTTP header: X-Origin-Verify: &lt;code&gt;&amp;lt;your-secret-token&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In your ALB Listener rules (or NestJS middleware), block any incoming request that does not contain this exact X-Origin-Verify header value with a 403 Forbidden.&lt;/li&gt;
&lt;li&gt;Restrict the ALB security group so it only accepts traffic from the AWS-managed prefix list com.amazonaws.global.cloudfront.origin-facing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prefix list and the header protect against different things. The prefix list blocks traffic that does not come from CloudFront. The header blocks traffic from other CloudFront distributions that could also reach your ALB. AWS recommends &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html" rel="noopener noreferrer"&gt;using both together&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are building a new stack, &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html" rel="noopener noreferrer"&gt;CloudFront VPC origins&lt;/a&gt; are another option. They let CloudFront reach an ALB in a private subnet, so the ALB has no public endpoint at all. AWS &lt;a href="https://aws.amazon.com/about-aws/whats-new/2024/11/amazon-cloudfront-vpc-origins/" rel="noopener noreferrer"&gt;announced VPC origins in November 2024&lt;/a&gt; at no additional cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying Infrastructure Deployment
&lt;/h2&gt;

&lt;p&gt;Run standard curl checks to verify headers and response bodies before proceeding with client-side integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Verify Android Verification Asset&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://invite.app.com/.well-known/assetlinks.json

&lt;span class="c"&gt;# Expected Output:&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/2 200&lt;/span&gt;
&lt;span class="c"&gt;# content-type: application/json&lt;/span&gt;
&lt;span class="c"&gt;# x-cache: Hit from cloudfront&lt;/span&gt;

&lt;span class="c"&gt;# 2. Verify iOS Verification Asset&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://invite.app.com/.well-known/apple-app-site-association

&lt;span class="c"&gt;# Expected Output:&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/2 200&lt;/span&gt;
&lt;span class="c"&gt;# content-type: application/json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request after an upload or invalidation returns x-cache: Miss from cloudfront. Run the command again to see the cached Hit response. A 301 or 302 status on either file is a failure, since both platforms reject redirects.&lt;/p&gt;

&lt;p&gt;Two more checks confirm what the platforms themselves see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3. Ask Google's Digital Asset Links API to read your Android file&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://invite.app.com&amp;amp;relation=delegate_permission/common.handle_all_urls"&lt;/span&gt;

&lt;span class="c"&gt;# 4. Check the copy of your iOS file held on Apple's CDN&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://app-site-association.cdn-apple.com/a/v1/invite.app.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check 3 returns your package name and fingerprint when Google can parse the file. Check 4 returns the file once Apple's CDN has fetched it, which can take up to 24 hours after the first upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring Android App Links and iOS Universal Links
&lt;/h2&gt;

&lt;p&gt;The server files say which app may open your links. The app must also declare which domain it handles. Both sides have to match before Android or iOS will open the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Android Configuration (AndroidManifest.xml)
&lt;/h3&gt;

&lt;p&gt;Add the auto-verify intent filter inside your &lt;code&gt;&amp;lt;activity&amp;gt;&lt;/code&gt; block (typically MainActivity):&lt;/p&gt;

&lt;p&gt;XML&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;activity&lt;/span&gt;
  &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".MainActivity"&lt;/span&gt;
  &lt;span class="na"&gt;android:launchMode=&lt;/span&gt;&lt;span class="s"&gt;"singleTask"&lt;/span&gt;
  &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;intent-filter&lt;/span&gt; &lt;span class="na"&gt;android:autoVerify=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.action.VIEW"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;category&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.category.DEFAULT"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;category&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.category.BROWSABLE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="nt"&gt;&amp;lt;data&lt;/span&gt;
          &lt;span class="na"&gt;android:scheme=&lt;/span&gt;&lt;span class="s"&gt;"https"&lt;/span&gt;
          &lt;span class="na"&gt;android:host=&lt;/span&gt;&lt;span class="s"&gt;"invite.app.com"&lt;/span&gt;
          &lt;span class="na"&gt;android:pathPrefix=&lt;/span&gt;&lt;span class="s"&gt;"/join"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/activity&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three attributes matter most here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;android:autoVerify="true" tells Android to check assetlinks.json on install. On &lt;a href="https://developer.android.com/training/app-links/verify-applinks" rel="noopener noreferrer"&gt;Android 6.0 and higher&lt;/a&gt;, the system verifies the hosts in your intent filters automatically when the app is installed.&lt;/li&gt;
&lt;li&gt;android:launchMode="singleTask" reuses the running app instance. A link tapped while the app is open then reaches the existing Linking listener instead of starting a second copy of the screen.&lt;/li&gt;
&lt;li&gt;android:pathPrefix="/join" limits the app to referral paths, which matches the /join* rule in the iOS file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  iOS Configuration (Xcode Capability)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open your iOS workspace in Xcode.&lt;/li&gt;
&lt;li&gt;Under Target &amp;gt; Signing &amp;amp; Capabilities, click + Capability and select Associated Domains.&lt;/li&gt;
&lt;li&gt;Add the domain entry using the applinks: prefix:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;applinks:invite.app.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handling Deep Links in React Native
&lt;/h2&gt;

&lt;p&gt;The app can receive a link in two states. In a cold start, the link launches a closed app. In a warm launch, the link arrives while the app runs in the background. React Native's &lt;a href="https://reactnative.dev/docs/linking" rel="noopener noreferrer"&gt;Linking API&lt;/a&gt; covers both: getInitialURL() for cold starts and the url event for warm launches.&lt;/p&gt;

&lt;p&gt;Handle cold-start launches (getInitialURL) and background warm launches (addEventListener):&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/DeepLinkHandler.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;useEffect&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;react&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Linking&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;react-native&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;AsyncStorage&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;@react-native-async-storage/async-storage&lt;/span&gt;&lt;span class="dl"&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;PENDING_REFERRAL_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@pending_referral_code&lt;/span&gt;&lt;span class="dl"&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;function&lt;/span&gt; &lt;span class="nf"&gt;parseReferralCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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="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="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;url&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="k"&gt;try&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;parsedUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;parsedUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invite.app.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;parsedUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/join&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;return&lt;/span&gt; &lt;span class="nx"&gt;parsedUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to parse deep link URL:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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="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;function&lt;/span&gt; &lt;span class="nf"&gt;useDeepLinkHandler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&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;// 1. Handle background/running app deep links&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;url&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseReferralCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;code&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;AsyncStorage&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="nx"&gt;PENDING_REFERRAL_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Trigger navigation or dispatch action to store&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Handle cold-start deep links&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkInitialUrl&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;initialUrl&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;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInitialURL&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseReferralCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialUrl&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;code&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;AsyncStorage&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="nx"&gt;PENDING_REFERRAL_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&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="nf"&gt;checkInitialUrl&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return &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;subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&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="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;''While iOS requires server-side or grace-period attribution, Android allows us to extract the original referral code automatically from the Play Store install metadata upon first launch.''&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Handling Android Deferred Links (Play Store Install Referrer)
&lt;/h2&gt;

&lt;p&gt;On Android, when a user installs the app via a Play Store link containing &amp;amp;referrer=..., the referral code is preserved in the Play Store metadata. Use react-native-play-install-referrer to fetch it on first launch:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Platform&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;react-native&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;AsyncStorage&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;@react-native-async-storage/async-storage&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;PlayInstallReferrer&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;react-native-play-install-referrer&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PENDING_REFERRAL_KEY&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;./DeepLinkHandler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkAndroidInstallReferrer&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;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&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="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isFirstLaunch&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;AsyncStorage&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;@first_launch_done&lt;/span&gt;&lt;span class="dl"&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;isFirstLaunch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;PlayInstallReferrer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstallReferrer&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="nx"&gt;installReferrerInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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="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;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;installReferrerInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;installReferrer&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;installReferrerInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;installReferrer&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_content&lt;/span&gt;&lt;span class="dl"&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;code&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;AsyncStorage&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="nx"&gt;PENDING_REFERRAL_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&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;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&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;@first_launch_done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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="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;Google keeps the referrer data for 90 days, and it does not change unless the app is reinstalled. Google also advises calling the API only once, during the first run after install. The @first_launch_done flag handles this. The flag is now written inside the callback, so a launch that closes before the Play Store responds will try again on the next start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the NestJS Redirect Backend
&lt;/h2&gt;

&lt;p&gt;When users without the app click the deep link, CloudFront routes the request to your NestJS server behind the ALB. The backend detects the platform and redirects the user to the appropriate App Store.&lt;/p&gt;

&lt;h3&gt;
  
  
  NestJS Controller (referral.controller.ts)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;Controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Headers&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;@nestjs/common&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Response&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;express&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="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;join&lt;/span&gt;&lt;span class="dl"&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;class&lt;/span&gt; &lt;span class="nc"&gt;ReferralController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;PLAY_STORE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://play.google.com/store/apps/details?id=com.company.app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;APP_STORE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://apps.apple.com/app/id1234567890&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;WEB_FALLBACK_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.com/download&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="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;handleReferralRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;code&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudfront-is-android-viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;isAndroidCloudFront&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudfront-is-ios-viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;isIosCloudFront&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="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;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{3,16}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid or missing referral code.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Check CloudFront Device Headers first, fallback to User-Agent&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isAndroid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isAndroidCloudFront&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sr"&gt;/Android/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userAgent&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;isIOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isIosCloudFront&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sr"&gt;/iPhone|iPad|iPod/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userAgent&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;isAndroid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Appends Play Store install referrer parameter&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;302&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PLAY_STORE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;referrer=utm_source%3Dreferral%26utm_content%3D&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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="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;isIOS&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_STORE_URL&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;302&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEB_FALLBACK_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?code=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Production Controls: Abuse &amp;amp; Replay Prevention
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Rate Limiting: Protect the /join endpoint using @nestjs/throttler (e.g., max 10 requests per minute per IP) to prevent referral code enumeration attacks.&lt;/li&gt;
&lt;li&gt;Referral Claim Limits: Enforce database constraints limiting one claimed referral code per user account and unique device ID.&lt;/li&gt;
&lt;li&gt;Fraud Checks: Flag accounts claiming codes where the referrer and referee share the same IP address or payment method.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fallback Strategies for Uninstalled Users
&lt;/h2&gt;

&lt;p&gt;Because iOS Universal Links and standard App Links do not preserve URL query params across fresh store downloads without a vendor SDK, choose one of these production-ready custom fallbacks:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhz8hom5s4z6oj5tuhpln.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhz8hom5s4z6oj5tuhpln.png" alt="iOS deferred deep linking fallbacks using a seven-day referral grace period and web onboarding" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: 7-Day In-App Grace Period
&lt;/h3&gt;

&lt;p&gt;Allow newly registered users to manually claim or enter a referral code within 7 days of account creation.&lt;/p&gt;

&lt;h4&gt;
  
  
  Claims Service (referral-claim.service.ts)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;Injectable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ConflictException&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;@nestjs/common&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PrismaService&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;../prisma/prisma.service&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="nd"&gt;Injectable&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;class&lt;/span&gt; &lt;span class="nc"&gt;ReferralClaimService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&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;PrismaService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;claimReferral&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;code&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&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;user&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;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;user&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;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User not found.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. Enforce 7-Day Expiration Policy&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gracePeriodMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;isEligible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&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="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;gracePeriodMs&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;isEligible&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;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The 7-day grace period for applying a referral code has expired.&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;// 2. Validate Referral Code Existence &amp;amp; Ownership&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;referralCodeOwner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&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;referralCode&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;code&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;referralCodeOwner&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;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid referral code.&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referralCodeOwner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt; &lt;span class="o"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadRequestException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Users cannot claim their own referral code.&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;// 3. Atomically Record Claim&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&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="nx"&gt;tx&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;existingClaim&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;referralClaim&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="nx"&gt;existingClaim&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;ConflictException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A referral code has already been applied to this account.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claim&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;referralClaim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;referralCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;referrerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;referralCodeOwner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&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;// Credit rewards to both parties...&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;claim&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Primary Cause&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App opens in browser instead of app&lt;/td&gt;
&lt;td&gt;Fingerprint mismatch or missing domain configuration.&lt;/td&gt;
&lt;td&gt;Check assetlinks.json SHA-256 fingerprint. On iOS, verify applinks: is present in Xcode Capabilities.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront returns 403 Forbidden&lt;/td&gt;
&lt;td&gt;S3 Bucket Policy is blocking CloudFront OAC.&lt;/td&gt;
&lt;td&gt;Update S3 bucket policy with proper AWS:SourceArn matching CloudFront distribution ARN.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/join request hits wrong origin&lt;/td&gt;
&lt;td&gt;Priority order misconfiguration in CloudFront Cache Behaviors.&lt;/td&gt;
&lt;td&gt;Ensure /.well-known/* behavior is listed above Default (*) behavior in CloudFront console.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Universal Link fails on iOS Simulator&lt;/td&gt;
&lt;td&gt;Scheme/domain not registered in Simulator environment or bad test invocation.&lt;/td&gt;
&lt;td&gt;Test Universal Links on iOS Simulator via CLI using xcrun simctl openurl booted "&lt;code&gt;https://invite.app.com/join?code=ABC123&lt;/code&gt;". Ensure Associated Domains capability is added to the Xcode scheme.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Production Test Matrix
&lt;/h2&gt;

&lt;p&gt;The Play Store only records the referrer when the install starts from the redirected link. Test the Android path with the full /join redirect instead of opening the store listing directly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;App Status&lt;/th&gt;
&lt;th&gt;Test Trigger URL&lt;/th&gt;
&lt;th&gt;Expected Behavior&lt;/th&gt;
&lt;th&gt;Verification Step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Installed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=TEST1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intercepted by OS; opens app directly to onboarding screen.&lt;/td&gt;
&lt;td&gt;App opens without browser redirect; code=TEST1 present in app state.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Not Installed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=TEST1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CloudFront routes to NestJS; 302 redirects to Play Store with &amp;amp;referrer=....&lt;/td&gt;
&lt;td&gt;Inspect Network tab; confirm Play Store URL contains referrer query parameter.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;Installed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=TEST1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intercepted by iOS Universal Links; opens app directly.&lt;/td&gt;
&lt;td&gt;App opens directly from Safari/Notes/Messages without opening a web page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;Not Installed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://invite.app.com/join?code=TEST1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CloudFront routes to NestJS; 302 redirects to Apple App Store.&lt;/td&gt;
&lt;td&gt;User is redirected to App Store; code applied post-install via 7-day grace period fallback.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Self-Hosted vs Third-Party SDK
&lt;/h2&gt;

&lt;p&gt;The table below compares this self-hosted setup with a third-party SDK. Use it to decide whether the setup covers your attribution needs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Custom AWS Architecture&lt;/th&gt;
&lt;th&gt;Third-Party SDK (Branch / AppsFlyer)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct Deep Linking (App Installed)&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero Vendor Subscription Cost&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;High SaaS Tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Privacy &amp;amp; GDPR Compliance&lt;/td&gt;
&lt;td&gt;100% Data Ownership&lt;/td&gt;
&lt;td&gt;Third-party Tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero External SDK Overhead&lt;/td&gt;
&lt;td&gt;Native Modules Only&lt;/td&gt;
&lt;td&gt;Large SDK Footprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automatic Install Fingerprinting&lt;/td&gt;
&lt;td&gt;Requires Web Fallback / Grace Period&lt;/td&gt;
&lt;td&gt;Probabilistic / Deterministic Engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The self-hosted setup suits teams that need branded links, referral onboarding and full control of user data. A third-party SDK is the better fit when marketing needs automatic install attribution on iOS across paid ad networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Need Help Building This?
&lt;/h2&gt;

&lt;p&gt;Setting up deep linking infrastructure from scratch takes real engineering time: AWS configuration, platform-specific verification files, backend routing logic, and ongoing maintenance as app store requirements shift.&lt;/p&gt;

&lt;p&gt;If your team needs hands-on support, GeekyAnts' &lt;a href="https://geekyants.com/engineering/mobile-engineering" rel="noopener noreferrer"&gt;Mobile Engineering services&lt;/a&gt; cover &lt;a href="https://geekyants.com/react-native-app-development-services" rel="noopener noreferrer"&gt;React Native architecture&lt;/a&gt;, backend integration, and infrastructure setup like the one in this guide. Our engineers are active contributors to the React Native ecosystem and have shipped 300+ React Native apps for global enterprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This setup gives you deep links that run on your own domain and your own AWS account. Android and iOS verify the domain through two static files on S3, CloudFront splits those files from the dynamic /join route, and NestJS sends each visitor without the app to the correct store. The React Native app reads the code on every launch path, the Play Install Referrer carries it through Android installs, and the grace period, web fallback or invite matching recover it on iOS.&lt;/p&gt;

&lt;p&gt;With the checklist complete and the test matrix passing, the same infrastructure can carry product, notification and campaign links by adding their paths to the manifests and the app router.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>nestjs</category>
      <category>aws</category>
      <category>mobiledev</category>
    </item>
    <item>
      <title>Build an MCP-Powered QA Agent with Playwright and TypeScript</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:10:54 +0000</pubDate>
      <link>https://dev.to/geekyants/build-an-mcp-powered-qa-agent-with-playwright-and-typescript-20jm</link>
      <guid>https://dev.to/geekyants/build-an-mcp-powered-qa-agent-with-playwright-and-typescript-20jm</guid>
      <description>&lt;p&gt;Build an MCP QA agent that runs Playwright tests, validates APIs, and analyzes failures through a controlled, auditable tool layer with human approval.&lt;/p&gt;

&lt;p&gt;Connecting AI to Playwright, APIs, test evidence, and CI/CD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abstract
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/engineering/quality-assurance/automation-testing-services" rel="noopener noreferrer"&gt;AI-assisted software testing&lt;/a&gt; is moving beyond code generation toward agents that can inspect applications, execute tests, analyze failures, and interact with engineering tools. Model Context Protocol (MCP) provides a standardized interface for &lt;a href="https://geekyants.com/ai/ai-development-services" rel="noopener noreferrer"&gt;AI applications&lt;/a&gt; to discover and invoke tools exposed by external systems. The current MCP TypeScript SDK v2 exposes tools, resources, and prompts.&lt;/p&gt;

&lt;p&gt;This article presents a code-first QA architecture using TypeScript, Playwright, Zod, structured outputs, permission boundaries, evidence collection, and human approval. The objective is controlled access to deterministic &lt;a href="https://geekyants.com/engineering/quality-assurance" rel="noopener noreferrer"&gt;QA&lt;/a&gt; capabilities rather than unrestricted autonomy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A modern QA workflow spans browser automation, &lt;a href="https://geekyants.com/engineering/quality-assurance/api-testing" rel="noopener noreferrer"&gt;API validation&lt;/a&gt;, logs, traces, test reports, test data, source control, and CI/CD. An AI model can reason across these systems, but reasoning alone does not give it safe or structured access to them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/blog/mcp-in-action-a-developers-take-on-smarter-service-coordination" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; provides a standardized protocol boundary between an AI host and external tools and data. The engineering challenge is therefore not simply connecting MCP to Playwright; it is designing a QA tool layer with explicit capabilities, validated inputs, controlled environments, observable outputs, and safe failure behavior.&lt;/p&gt;

&lt;p&gt;This article focuses on a practical architecture: an MCP server exposes selected QA operations while &lt;a href="https://geekyants.com/blog/from-manual-testing-to-ai-assisted-automation-with-playwright-agents" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt; and API clients remain the deterministic execution layer. The AI can request an operation, but the server owns validation and authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5jl1eondsatr0of2zz3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5jl1eondsatr0of2zz3.png" alt="The AI host connects through MCP to a QA server, branching into Playwright, API, and evidence components that feed QA results." width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The MCP TypeScript SDK supports servers exposing tools, resources, and prompts. Tools are appropriate for actions and side effects; resources are appropriate for read-oriented data such as reports or configuration.&lt;/p&gt;

&lt;p&gt;The SDK also supports stdio and Streamable HTTP transports. See the official MCP SDK documentation for the current v2 implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qa-mcp-agent/
├── src/
│   ├── server.ts
│   ├── tools/
│   │   ├── playwright.ts
│   │   ├── api.ts
│   │   └── evidence.ts
│   ├── security/
│   │   └── permissions.ts
│   └── utils/
│       └── sanitize.ts
├── tests/
│   └── checkout.spec.ts
├── artifacts/
├── package.json
└── tsconfig.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating the MCP Server
&lt;/h2&gt;

&lt;p&gt;For a current TypeScript implementation, use the v2 server package and Zod for input validation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @modelcontextprotocol/server zod @playwright/test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;McpServer&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;@modelcontextprotocol/server&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qa-mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&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 current v2 SDK documents @modelcontextprotocol/server as the server package and uses Zod schemas for typed validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expose a Controlled Playwright Tool
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;z&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;zod/v4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;run_playwright_test&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Run Playwright Test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Execute an approved Playwright test file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;testFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;outputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;testFile&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="nf"&gt;validateTestFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFile&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="nf"&gt;runPlaywrightTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFile&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&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;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;output&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;MCP tool schemas validate arguments before the handler executes, and structured outputs can be validated against an output schema. Clients can discover tools with listTools and invoke them with callTool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Playwright Deterministic
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;execFile&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;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runPlaywrightTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFile&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;output&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;&amp;gt;&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;execFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;npx&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;testFile&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stderr&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="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;output&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="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stderr&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="p"&gt;}&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 server should not pass arbitrary shell strings from the model into an operating-system shell. Use an allowlist and validated arguments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permission Boundaries
&lt;/h3&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;ALLOWED_TESTS&lt;/span&gt; &lt;span class="o"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tests/login.spec.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tests/checkout.spec.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tests/cart.spec.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateTestFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFile&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="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;ALLOWED_TESTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testFile&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="s2"&gt;`Test file is not approved: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;testFile&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="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 security boundary belongs in deterministic application code, not in the model prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Testing as Another Tool
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validate_api&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Validate API&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Send an HTTP request and validate the response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;expectedStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&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;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedStatus&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;method&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;output&lt;/span&gt; &lt;span class="o"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedStatus&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&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;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;output&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;In production, URL allowlists, authentication handling, request-body validation, rate limits, and environment restrictions should be added before exposing mutation-capable API tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Resources for Test Evidence
&lt;/h3&gt;

&lt;p&gt;MCP resources are a better fit for read-oriented evidence than action tools. A QA server can expose resources such as test-results://latest or trace://checkout.&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="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;latest-results&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test-results://latest&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Latest Playwright Results&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;uri&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;results&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;readLatestResults&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text&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;results&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Failure Analysis
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;analyze_test_failure&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Analyze Test Failure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;testName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;consoleErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
      &lt;span class="na"&gt;networkFailures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;outputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APPLICATION&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TEST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NETWORK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENVIRONMENT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UNKNOWN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;]),&lt;/span&gt;
      &lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;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;analyzeFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&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;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&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;Separate observed evidence from AI-generated hypotheses. The AI can classify a failure and recommend an investigation path, but the underlying evidence should remain inspectable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The UNKNOWN State
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Diagnosis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APPLICATION&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TEST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NETWORK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ENVIRONMENT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UNKNOWN&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;analysisInstruction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
Analyze only the supplied evidence.
Do not invent missing information.
If evidence is insufficient, return UNKNOWN.
Separate observed evidence from hypotheses.
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Allowing UNKNOWN is an important reliability control. It prevents incomplete logs from being converted into false certainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  A QA Workflow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fivjp1w21ok9ggocz29l2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fivjp1w21ok9ggocz29l2.png" alt="AI QA agent runs Playwright tests, reads results, validates APIs, and analyzes failures before human validation or a CI decision." width="800" height="817"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Human Approval for High-Risk Operations
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RiskLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOW&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MEDIUM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HIGH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CRITICAL&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;TOOL_RISK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RiskLevel&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;read_test_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOW&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;run_playwright_test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOW&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;validate_api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOW&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;create_test_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MEDIUM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;modify_test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HIGH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;delete_test_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CRITICAL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requiresApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RiskLevel&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;risk&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HIGH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CRITICAL&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI can request an action, but the policy layer determines whether it is permitted. Human approval is an explicit control point rather than a suggestion embedded in a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditability
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;timestamp&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="nl"&gt;agent&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="nl"&gt;tool&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="nl"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;environment&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="nl"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ALLOWED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BLOCKED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APPROVAL_REQUIRED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;approvedBy&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool invocation should produce an auditable event so teams can determine which agent called which tool, against which environment, and with what authorization decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting the Client
&lt;/h2&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;}&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;run_playwright_test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;testFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tests/checkout.spec.ts&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP client API supports discovery and invocation of tools and access to resources. Playwright also provides an MCP server for browser automation, which can be used when the use case is direct browser interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the MCP Server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blocks unapproved test&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;runPlaywrightTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tests/../../secret.spec.ts&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="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Test file is not approved&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;requires approval for data deletion&lt;/span&gt;&lt;span class="dl"&gt;"&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;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete_test_data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BLOCKED&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP server itself is production software. Its schemas, authorization rules, environment restrictions, error handling, and tool contracts should therefore be covered by automated tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation: Avoid Unsupported Benchmark Claims
&lt;/h2&gt;

&lt;p&gt;A strong implementation should be evaluated rather than supported by invented numbers. Compare conventional QA with MCP-assisted QA using the same scenarios and report the actual environment and measurement method.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Execution time&lt;/td&gt;
&lt;td&gt;Repeated wall-clock runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagnosis time&lt;/td&gt;
&lt;td&gt;Failure to actionable hypothesis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correct classification&lt;/td&gt;
&lt;td&gt;Correct diagnoses / total failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human interventions&lt;/td&gt;
&lt;td&gt;Manual approvals or interventions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocked unsafe actions&lt;/td&gt;
&lt;td&gt;Policy violations prevented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool-call failures&lt;/td&gt;
&lt;td&gt;Invalid or failed calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;LLM/tool/API cost per workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If numerical performance results are reported, include the actual MCP/Node/Playwright versions, machine or CI environment, workload, number of runs, warm-up method, and measurement definition. Do not present illustrative figures as measured evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fjviij9474anah4dab6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fjviij9474anah4dab6.png" alt="QA MCP server with tools, resources, prompts, and policy controls, showing separate read-only evidence and write-enabled tool paths." width="800" height="817"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;More tools increase capability but also increase the action space the model must select from. More autonomy can reduce manual effort but increases the impact of incorrect decisions. More evidence can improve diagnosis but can increase data exposure and model cost. More guardrails improve safety but can introduce friction for unusual workflows.&lt;/p&gt;

&lt;p&gt;The engineering objective is therefore not maximum autonomy. It is useful autonomy within an explicitly defined risk boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;MCP gives &lt;a href="https://geekyants.com/service/hire-quality-assurance-developers" rel="noopener noreferrer"&gt;QA engineers&lt;/a&gt; a standardized way to expose testing capabilities to &lt;a href="https://geekyants.com/engineering/ai-engineering" rel="noopener noreferrer"&gt;AI systems&lt;/a&gt;, but the protocol itself does not define a safe QA architecture. That responsibility belongs to the implementation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxos1xeku75j8ycpfike.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxos1xeku75j8ycpfike.png" alt="AI planning flows through MCP and authorization controls to QA tool execution, evidence collection, and human-reviewed analysis." width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A production-oriented QA MCP server should expose narrow, validated capabilities; keep browser and API execution deterministic; separate tools from read-only evidence; enforce environment and permission boundaries outside the model; require human approval for high-impact actions; and record enough evidence to make agent behavior auditable.&lt;/p&gt;

&lt;p&gt;The important shift is from “AI controls the test environment” to “AI operates through a controlled QA interface.” That distinction provides the foundation for building AI-assisted testing systems that remain useful, observable, and governable as their autonomy increases.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/hire" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd51zsf2qmayfeanf9c9d.png" alt=" " width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;MCP TypeScript SDK v2 documentation; MCP client tool/resource APIs; MCP resources and tools documentation; Playwright MCP documentation. These are the current primary technical references used for the implementation.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>playwright</category>
      <category>typescript</category>
      <category>testing</category>
    </item>
    <item>
      <title>Railway Environment Variable Incident: What Laravel &amp; Cloud Application Teams Should Do Now</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:10:02 +0000</pubDate>
      <link>https://dev.to/geekyants/railway-environment-variable-incident-what-laravel-cloud-application-teams-should-do-now-4cbe</link>
      <guid>https://dev.to/geekyants/railway-environment-variable-incident-what-laravel-cloud-application-teams-should-do-now-4cbe</guid>
      <description>&lt;p&gt;On October 21, 2026, Railway disclosed that an unauthorized third party may have accessed the systems that store its customers' environment variables. That exposes a problem that rarely comes up when engineering teams discuss cloud security: &lt;strong&gt;what happens when the infrastructure you depend on to recover from an incident is itself affected by that incident?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your team deploys on a platform like Railway, you have probably already been asked the same question internally: &lt;em&gt;what do we do now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post is a practical answer. It's written for Laravel and cloud application teams, and it deliberately avoids alarm. Incidents like this are a reasonable moment to take a breather to check your own setup, and most of what follows is good hygiene you would want in place for your team anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Exactly Happened?
&lt;/h2&gt;

&lt;p&gt;The facts below come from Railway's own disclosure and from press coverage published on the first day after it. Details are still developing, so treat this as a snapshot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Railway said it detected unusual activity on October 21 in the systems that store customer environment variables.&lt;/li&gt;
&lt;li&gt;Its initial assessment was that an unauthorized party may have accessed encrypted variables and, for some of them, obtained the decrypted contents. Reporting describes the affected values as including API keys, database credentials and private registry credentials.&lt;/li&gt;
&lt;li&gt;Railway paused deployments while it investigated. &lt;a href="https://thedefiant.io/news/security/railway-urges-all-users-to-rotate-secrets-after-security-incident" rel="noopener noreferrer"&gt;The Defiant reported&lt;/a&gt; that this left customers unable to push updated applications carrying replacement credentials. Enterprise builds were restored late Wednesday, while customers on other plans were still waiting to redeploy.&lt;/li&gt;
&lt;li&gt;Railway's early analysis suggested the impact may be limited to a subset of users, but it still recommended that every customer rotate every stored secret and review service logs for unusual activity. It has not published a detailed scope.&lt;/li&gt;
&lt;li&gt;Railway described the incident as part of a series of attacks on platform companies aimed at compromising AI inference providers, and said it had notified upstream providers so they could proactively revoke potentially exposed keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One claim is still being examined as this blog was written. &lt;a href="https://thedefiant.io/news/security/developer-links-370-000-theft-to-railway-secrets-exposure" rel="noopener noreferrer"&gt;A developer has alleged&lt;/a&gt; that roughly $370,000 was stolen through secrets stored on Railway. On-chain analysts have tracked funds moving from a suspected attacker's wallet, but the link between that wallet and the Railway incident has not been confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Environment-Variable Exposure Matters?
&lt;/h2&gt;

&lt;p&gt;Environment variables are the standard way to keep configuration out of code. In practice, they're also where applications keep the keys to everything else: database passwords, payment and email credentials, cloud storage keys, signing secrets and third-party API tokens.&lt;/p&gt;

&lt;p&gt;That makes them an unusually high-value target. An attacker who reads a single set of variables doesn't need to find a vulnerability in your application. They already hold credentials that your application is trusted to use, and those credentials often work from anywhere on the internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets Are Becoming the New Security Perimeter
&lt;/h3&gt;

&lt;p&gt;There is also a structural point here, and it's the main lesson we'd draw from this incident. &lt;strong&gt;Secrets are the new perimeter.&lt;/strong&gt; Attackers increasingly go after the layer that stores and manages credentials, rather than the services those credentials unlock. It's often the softer target, and it can be just as valuable if the stolen keys grant access to paid AI accounts, databases or cloud resources. Your application can be well-written and fully patched, and its security still depends on every platform that holds a copy of its secrets.&lt;/p&gt;

&lt;p&gt;The practical conclusion is not "stop using managed platforms." They are vital in removing a great deal of operational burden for growing teams, and most teams are safer for using them. The lesson here is to design in a manner that &lt;strong&gt;a single vendor's bad day is survivable&lt;/strong&gt;, because the credentials it holds are scoped, short-lived and quick to replace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Credentials To Review And Rotate Post the Railway Security Incident?
&lt;/h2&gt;

&lt;p&gt;Railway's guidance was to rotate everything stored as an environment variable, and that's the right default for anyone affected. The harder part is knowing what "everything" includes. For a typical &lt;a href="https://techblog.geekyants.com/performance-improvement-in-laravel-app" rel="noopener noreferrer"&gt;Laravel application&lt;/a&gt;, work through this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application key (APP_KEY).&lt;/strong&gt; This encrypts sessions, cookies and any data you encrypt with Laravel's encrypter. Rotating it will invalidate sessions and make previously encrypted data unreadable unless you plan for it. Recent Laravel versions support graceful rotation through APP_PREVIOUS_KEYS, which lets old values keep decrypting while new ones are written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database and cache credentials.&lt;/strong&gt; DB_PASSWORD, any read-replica credentials, and REDIS_PASSWORD. Also check whether the database is reachable from the public internet; if it is, a leaked password is directly usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud and storage keys.&lt;/strong&gt; &lt;a href="https://geekyants.com/aws-consulting-services" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;, GCP or S3-compatible access keys, plus any service-account files stored as variables. Railway noted that it would handle resets for some platform-managed items, such as storage buckets and GitHub connections, but you should confirm that rather than assume it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party service tokens.&lt;/strong&gt; &lt;a href="https://geekyants.com/blog/integrating-payment-systems-in-laravel-paypal-and-stripe" rel="noopener noreferrer"&gt;Payment gateways (Stripe, Razorpay and similar)&lt;/a&gt;, email and SMS providers, push and broadcasting services (Pusher, Ably), search and analytics keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI and LLM provider keys.&lt;/strong&gt; &lt;a href="https://geekyants.com/blog/how-to-create-an-ai-app-using-openais-api-in-5-steps" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;, Anthropic and similar keys deserve priority. They're easy to monetize, they can run up large bills quickly, and Railway has said AI inference access appears to be the attackers' target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth and signing material.&lt;/strong&gt; OAuth client secrets, Passport or Sanctum-related keys, JWT signing secrets, and webhook signing secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry and deployment credentials.&lt;/strong&gt; Private container registry tokens, package registry tokens (for example private Composer repositories), deploy keys and Git provider tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallet and signing keys.&lt;/strong&gt; If any private key that can move money was ever stored as a variable, treat it as the most urgent item and move funds to a new key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A sensible order is: anything that can spend money or move data first, then anything that grants access to customer data, then everything else. After rotating, check the logs of each connected service for activity you don't recognize in the window around the disclosure, and keep the old credentials revoked rather than just replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secret-Management Best Practices to Consider
&lt;/h2&gt;

&lt;p&gt;Incident response is the expensive way to learn about secrets. These practices make the next one cheaper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep a secrets inventory.&lt;/strong&gt; You can't rotate what you can't list. For each secret, record what it unlocks, where it's stored, who owns it and when it was last rotated. Most teams are surprised by how many forgotten keys turn up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a dedicated secrets manager for high-value secrets.&lt;/strong&gt; Tools such as HashiCorp Vault, AWS Secrets Manager or Doppler give you access control, audit logs and rotation workflows that a plain variable store doesn't. For the most sensitive material, such as signing keys, use a KMS or HSM so the raw key never lands in your application's environment at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer short-lived and dynamic credentials.&lt;/strong&gt; A database password that expires in an hour is a much smaller liability than one that has been valid for three years. Where your stack supports it, issue credentials on demand rather than storing them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apply least privilege everywhere.&lt;/strong&gt; Use separate keys per service and per environment. Give database users only the permissions the application needs. Put spend caps and usage limits on &lt;a href="https://geekyants.com/ai" rel="noopener noreferrer"&gt;AI&lt;/a&gt; and payment keys, so that a leaked key has a ceiling on the damage it can do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make a stolen secret less useful.&lt;/strong&gt; Put databases and caches behind private networking or IP allowlists, so a leaked connection string is not enough on its own. Where providers support it, restrict API keys by IP or referrer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep secrets out of code, images and logs.&lt;/strong&gt; Check your Git history, build logs, container layers and error reporters. In Laravel, remember that debug pages and verbose exception output can echo environment values if APP_DEBUG is left on in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practice rotation before you need it.&lt;/strong&gt; Treat rotation as a routine, scripted task. If it only happens during an incident, it will be slow, manual and error-prone.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD and deployment security
&lt;/h2&gt;

&lt;p&gt;The Railway incident shows a point that is easy to miss: &lt;strong&gt;rotation is a deployment problem and not just a missed item on a security checklist.&lt;/strong&gt; Telling teams to rotate secrets is only useful if they can then ship the new values. When deployments were paused, rotating a key and getting that key into a running application became two separate problems.&lt;/p&gt;

&lt;p&gt;A few things make a real difference here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep a path to redeploy that doesn't depend on a single provider.&lt;/strong&gt; That doesn't require multi-cloud complexity. It can be as simple as a documented, tested way to stand up your application elsewhere, with infrastructure defined as code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope &lt;a href="https://geekyants.com/devops-services" rel="noopener noreferrer"&gt;CI/CD&lt;/a&gt; secrets tightly.&lt;/strong&gt; Pipeline variables should be limited per environment, protected so they are only available on trusted branches, and masked in logs. Avoid one broad token that can touch every project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer short-lived identity over stored keys in pipelines.&lt;/strong&gt; OIDC-based federation to your cloud provider lets a pipeline obtain temporary credentials without a long-lived key sitting in a variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate secrets by environment.&lt;/strong&gt; Production credentials should not be present in staging or preview environments, where controls are usually looser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin and review what your pipeline runs.&lt;/strong&gt; Lock third-party actions and dependencies, and review changes to workflow files like code, since a modified pipeline is a direct route to your secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make rotation a one-command or one-pipeline operation.&lt;/strong&gt; The goal is that rotating a credential and redeploying with it takes minutes, not an afternoon of coordination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep audit trails.&lt;/strong&gt; Know who changed a secret or a deployment setting and when.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How To Think About Production Security in Light of Security Incidents?
&lt;/h2&gt;

&lt;p&gt;It's tempting to respond to an incident like this in one of two ways: switch vendors, or bring everything in-house. Neither is usually the answer. Switching vendors moves the risk. Running your own infrastructure after a platform breach can make things worse, because you become the next potential breach, often with a smaller security team than the platform you left. A calmer approach is to ask a different set of questions about every system that touches production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a credential is exposed, the real concern is how much access that single key was allowed to have. Rotating it is the immediate fix. But if the same key can expose sensitive data or disrupt several services, that points to an architectural problem we should have addressed much earlier.&lt;/p&gt;

&lt;p&gt;— Saurabh Sahu, Chief Technology Officer (CTO)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Assume any single platform can have a bad day.&lt;/strong&gt; The question is not whether your vendors are perfect but what happens to you when one of them isn't. If the answer is "we rotate a few scoped credentials and carry on," you're in good shape. If it's "everything we own is exposed," then the design itself needs a harder assessment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shrink the blast radius by design.&lt;/strong&gt; Scoped keys, private networking, spend limits and separated environments are not exotic. Together they turn a potential catastrophe into a contained, boring incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat detection as part of the system.&lt;/strong&gt; Alerts on unusual API usage, billing spikes and unfamiliar logins mean you may notice misuse before any vendor's email arrives. In some reports of this incident, teams had already rolled their keys before the notification reached them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do vendor due diligence that matches what you store.&lt;/strong&gt; What a platform holds should determine how much scrutiny it gets. Ask where secrets are encrypted, who can decrypt them, what the incident communication process looks like, and how quickly you can recover if the platform is unavailable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep your crown jewels elsewhere.&lt;/strong&gt; Anything that can move large sums of money, sign on your behalf or unlock all customer data should live in a purpose-built secrets system, and ideally not within a general-purpose variable store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rehearse.&lt;/strong&gt; Run a short exercise: pick a key, assume it leaked, and time how long it takes to revoke, replace and redeploy. The gaps you find in an hour of practice are the ones that would otherwise appear during a real incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Approached It Across 60+ Client Projects
&lt;/h2&gt;

&lt;p&gt;The Railway disclosure wasn't the only upstream problem our teams were tracking this week. A critical PHP vulnerability, a Node.js remote-code-execution issue and a certificate authority key problem were all on the list too. None of it was code we had written, but all of it was ours to handle, because our clients' production systems depended on it.&lt;/p&gt;

&lt;p&gt;Here is what that looked like in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;We found out what runs where.&lt;/strong&gt; The hard part wasn't patching. It was answering "which projects are affected?" quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We put everything in one tracker.&lt;/strong&gt; Every project, its stack, its version and whether it's safe. Our DevOps team owns the sheet and makes sure every upgrade is closed, not just started.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We called the highest-risk client first.&lt;/strong&gt; They heard it from us before they read about it somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We resisted the wrong lesson.&lt;/strong&gt; Moving to self-managed infrastructure after a platform breach would not have made our clients safer. It would have swapped one dependency for another risk.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;You can't stop PHP, Node or a vendor from failing. The only number you own is time-to-safe: how fast you know, how fast you patch, and how fast the client hears from you.&lt;/p&gt;

&lt;p&gt;— Saurabh Sahu, Chief Technology Officer (CTO)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the idea behind everything in this post. Rotating a secret, patching a runtime and telling a client are all the same measurement: how quickly you go from "something happened" to "we know where we stand."&lt;/p&gt;

&lt;p&gt;So here is a question worth asking your own team: &lt;strong&gt;how long would it take to answer "what version of Node (or PHP) is every production service on?"&lt;/strong&gt; If the answer is more than an hour, fix that before you fix anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Geekyants Can Help
&lt;/h2&gt;

&lt;p&gt;Most teams are familiar with an ideal security stance. The difficulty is finding the time to do it while also shipping features. This is where we typically work with clients:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Production security reviews.&lt;/strong&gt; We audit how secrets, access and deployments are handled across your application and infrastructure, and give you a prioritized list of fixes rather than a long report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud architecture and modernization.&lt;/strong&gt; We help teams move from single-vendor, static-credential setups to architectures with secrets management, private networking, infrastructure as code and portable deployments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevOps and CI/CD hardening.&lt;/strong&gt; We build pipelines where rotation and redeployment are routine, and where credentials are short-lived and scoped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel engineering.&lt;/strong&gt; If your application is built on Laravel and you need senior help with configuration, key rotation, queue and cache setup or a broader hardening pass, you can &lt;a href="https://geekyants.com/hire-laravel-developers" rel="noopener noreferrer"&gt;hire Laravel developers from GeekyAnts&lt;/a&gt; who work on production systems every day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're working through your own rotation right now, or you'd like a second pair of eyes on your setup once the dust settles, we're happy to talk it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist for this week
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Rotate every secret stored on the affected platform, starting with anything that can spend money or access customer data.&lt;/li&gt;
&lt;li&gt;Check the logs and billing of each connected service for unfamiliar activity.&lt;/li&gt;
&lt;li&gt;Confirm your database and cache are not reachable from the open internet.&lt;/li&gt;
&lt;li&gt;Build a secrets inventory with an owner and a rotation date for each entry.&lt;/li&gt;
&lt;li&gt;Move your highest-value secrets into a dedicated secrets manager or KMS.&lt;/li&gt;
&lt;li&gt;Add spend caps and usage alerts to AI and payment keys.&lt;/li&gt;
&lt;li&gt;Test that you can redeploy quickly with new credentials, and write the steps down.&lt;/li&gt;
&lt;li&gt;Keep one tracker of every project, its stack, its versions and its status, so you can answer "what runs where?" in under an hour.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>laravel</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Building a Real-Time Photo Sync Video Experience Using 100ms SDK and Reactive State Management</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:08:14 +0000</pubDate>
      <link>https://dev.to/geekyants/building-a-real-time-photo-sync-video-experience-using-100ms-sdk-and-reactive-state-management-4lf4</link>
      <guid>https://dev.to/geekyants/building-a-real-time-photo-sync-video-experience-using-100ms-sdk-and-reactive-state-management-4lf4</guid>
      <description>&lt;p&gt;This blog explains how to build a real-time video experience with synchronized photo sharing, chat, and reactive state management using the 100ms SDK.&lt;/p&gt;

&lt;p&gt;Building &lt;a href="https://geekyants.com/blog/designing-a-real-time-ai-pipeline-for-human-like-video-conversations" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;real-time video experiences&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; traditionally required deep expertise in WebRTC, signaling infrastructure, media synchronization, and browser-specific handling. With modern SDKs like 100ms, teams can now focus more on product experience and less on low-level media infrastructure.&lt;/p&gt;

&lt;p&gt;In this article, we will walk through how to build a real-time video calling experience with synchronized photo sharing and chat, powered by the 100ms JavaScript SDK, a reactive state architecture, and a &lt;a href="https://geekyants.com/engineering/web-engineering-services" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;modular frontend design&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We will cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Core concepts of 100ms architecture&lt;/li&gt;
&lt;li&gt;Pre-join (green room) experience&lt;/li&gt;
&lt;li&gt;Joining and managing a live video room&lt;/li&gt;
&lt;li&gt;Real-time photo synchronization across participants&lt;/li&gt;
&lt;li&gt;Chat integration using broadcast messaging&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://geekyants.com/blog/react-query-as-a-state-manager-in-nextjs-do-you-still-need-redux-or-zustand" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;State management&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; strategies for responsive UI&lt;/li&gt;
&lt;li&gt;Synchronization correctness, conflict resolution, and reconnect handling&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;High-Level Architecture&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Before diving into the implementation, it’s worth understanding how the system components interact.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgi56c5i1froob7skbrcp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgi56c5i1froob7skbrcp.png" alt=" " width="800" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the backend, when a meetup is created, the server calls the 100ms Rooms API to provision a room and generate authentication tokens for invited participants. Each participant receives a token tied to the same room ID, ensuring everyone joins the same session.&lt;/p&gt;

&lt;p&gt;On the frontend, a single HMSReactiveStore instance acts as the centralized source of truth for media state, participants, tracks, and messages. Both the Green Room and the Live Call screens subscribe to slices of this store rather than maintaining disconnected local state.\&lt;br&gt;
The SDK exposes three key primitives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store&lt;/strong&gt; → Reactive room state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions&lt;/strong&gt; → Methods for operations such as join, mute, or messaging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notifications&lt;/strong&gt; → Event listeners for peer updates and room events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This architecture provides a predictable data flow for real-time applications and keeps media state, participant state, and UI subscriptions centralized. The synchronized-photo feature still requires explicit conflict-resolution and lifecycle handling, which we address below.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;Why a Pre-Join (Green Room) Matters&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A pre-join experience is critical for user confidence and system readiness.\&lt;br&gt;
100ms provides a &lt;strong&gt;preview API&lt;/strong&gt; that allows users to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Test camera and microphone&lt;/li&gt;
&lt;li&gt;Set initial mute/unmute states&lt;/li&gt;
&lt;li&gt;Validate network readiness and authentication before joining&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;Key Implementation Flow&lt;/strong&gt;
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Fetch authentication token from backend&lt;/li&gt;
&lt;li&gt;Call preview() with user config&lt;/li&gt;
&lt;li&gt;Attach local video track to UI&lt;/li&gt;
&lt;li&gt;Allow user to toggle mic and camera
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
 &lt;span class="na"&gt;authToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;token from backend&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="na"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once preview is successful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local tracks are created&lt;/li&gt;
&lt;li&gt;Permissions are verified&lt;/li&gt;
&lt;li&gt;Settings persist into the join phase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This reduces friction during room entry.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;Setting Up the 100ms SDK&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The 100ms JavaScript SDK ships through @100mslive/hms-video-store.\&lt;br&gt;
Everything flows from a single HMSReactiveStore instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Initialize once — treated as a module-level singleton&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;HMSReactiveStore&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;@100mslive/hms-video-store&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;hmsManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HMSReactiveStore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;hmsManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;triggerOnSubscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// fire callbacks immediately with current state&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hmsStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmsManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStore&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;hmsActions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmsManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActions&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;hmsNotifications&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmsManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getNotifications&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling triggerOnSubscribe() is important because it ensures subscribers immediately receive the current SDK state instead of waiting for the next state update.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reading State with Selectors&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The store exposes state through memoized selectors rather than raw state objects. This enables efficient reactive subscriptions.&lt;br&gt;
&lt;/p&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;selectPeers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selectIsLocalVideoEnabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selectIsLocalAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selectLocalVideoTrackID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selectLocalPeer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selectTrackByID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="nx"&gt;selectPeerByID&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;@100mslive/hms-video-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One-time read&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isVideoOn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectIsLocalVideoEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Reactive subscription — returns an unsubscribe function&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;enabled&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;// runs whenever local video enabled/disabled&lt;/span&gt;
  &lt;span class="nf"&gt;updateVideoButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;selectIsLocalVideoEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subscribing to fine-grained selectors helps avoid unnecessary re-renders. In our implementation, this was useful for participant and track updates because changes could be scoped to the affected selector rather than causing broad UI updates.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;The Green Room: Pre-Join Experience&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;The Green Room acts as a staging area before users join the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Attaching Local Video&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once preview mode starts, we subscribe to the local video track and attach it to a &amp;lt;video&amp;gt; element.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wire local video into a &amp;lt;video&amp;gt; element&lt;/span&gt;
&lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&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="nx"&gt;trackId&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;videoElement&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;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;);&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;selectLocalVideoTrackID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user disables their camera, the video track disappears and the UI reflects the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Toggling Camera and Microphone&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Media controls during preview work the same way as they do during a live call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Toggle camera&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectIsLocalVideoEnabled&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;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalVideoEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Toggle mic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectIsLocalAudioEnabled&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;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the UI subscribes to the store, button states remain synchronized.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Transitioning to the Live Call&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When the user clicks "Join", we stop the preview session first by calling hmsActions.leave(). This releases the preview peer from the 100ms session and prevents a ghost local peer from remaining alongside the new peer after the user joins. After leave completes, we navigate to the Live Call route.&lt;/p&gt;

&lt;p&gt;The camera/microphone preferences chosen in the Green Room are persisted to a Pinia store (with localStorage persistence) so the Live Call can restore them on mount without asking the user again.\&lt;br&gt;
To join a room, 100ms requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auth token&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Username&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The join flow is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;authToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;savedToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&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;// Restore preferences from Green Room&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;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalVideoEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preferredCameraOn&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preferredMicOn&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;After joining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The SDK emits events such as ON_JOIN, ON_PEER_UPDATE, and ON_TRACK_UPDATE&lt;/li&gt;
&lt;li&gt;Reactive store subscriptions keep the UI aligned with the resulting SDK state&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Rendering the Peer Strip&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;All connected peers are available via the selectPeers selector. We subscribe to it and re-render the participant strip whenever anyone joins or leaves. The local peer is sorted to the front; remote peers are sorted by join time.&lt;/p&gt;

&lt;p&gt;Each peer tile has a &amp;lt;video&amp;gt; element whose DOM reference is stored in a Map keyed by peer ID. When a peer's video track changes (e.g. they turn their camera on), we call attachVideo to bind the new track. When a peer leaves, we call detachVideo to clean up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Subscribe to the peer list — fires on every join/leave&lt;/span&gt;
&lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;peers&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="nf"&gt;renderParticipants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;selectPeers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Attach video for a specific peer&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attachPeerVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoEl&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;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoTrack&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;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoTrack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoEl&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;This provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UI updates when participants join or leave&lt;/li&gt;
&lt;li&gt;Consistent track state across components&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The store acts as a single source of truth for SDK-backed room state, while application-specific state such as the current synchronized photo is handled separately with explicit synchronization rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Showing Live Audio and Video Status&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For remote peers, track enabled/disabled state is read from the store using selectTrackByID. For the local peer, we use selectIsLocalVideoEnabled and selectIsLocalAudioEnabled directly. This allows the mic icon next to each participant tile to update in real time as participants toggle their devices, without polling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachVideo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important considerations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle track changes dynamically&lt;/li&gt;
&lt;li&gt;Detach video when track is removed&lt;/li&gt;
&lt;li&gt;Retry attaching in case of async delays&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Media toggling is handled via actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalVideoEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalAudioEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State is synced using selectors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;selectIsLocalVideoEnabled&lt;/li&gt;
&lt;li&gt;selectIsLocalAudioEnabled&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Persisting Preferences&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Camera and microphone preferences are stored locally and reused:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;During preview&lt;/li&gt;
&lt;li&gt;During join&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates a consistent user experience across sessions.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Real-Time Chat via Broadcast Messages&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Rather than spinning up a separate WebSocket or a third-party chat service, we used 100ms's built-in messaging primitives. hmsActions.sendBroadcastMessage(text) delivers a message to every peer in the room. The SDK stores all messages in HMSStore and exposes them via the selectHMSMessages selector.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Send a chat message&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBroadcastMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// React to new messages&lt;/span&gt;
&lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;messages&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;// Filter to 'chat' type only — other types are used internally (e.g. photo sync)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chatMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;renderMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatMessages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;selectHMSMessages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important design decision: messages are typed. The default type for a regular chat message is 'chat'. We use a custom type 'PHOTO_SYNC' for the carousel sync messages described in the next section. The chat panel filters to type === 'chat' so photo-sync events never appear as chat bubbles.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Unread Message Badge&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We track how many chat messages were visible the last time the panel was open (lastReadCount). When a new message arrives while the panel is closed and the sender isn't the local peer, we set an unread flag that drives a red dot on the chat button. When the panel opens, we reset the count.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Synced Image Carousel Across All Peers&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;The synchronized carousel required a separate state-management approach. We wanted participants to converge on the same photo when someone navigated the carousel. We built the synchronization on top of 100ms's broadcast message API, with no additional signaling infrastructure.&lt;/p&gt;

&lt;p&gt;An important distinction is that these messages are navigation events, not durable shared state. Broadcast messaging works for transient actions such as ‘move to photo 5,’ but it should not serve as the canonical state for late joiners. If the application needs the current photo to survive missed messages or reconnects, the 100ms Session Store is a better fit for a canonical session-level state.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Defining the Sync Message Shape&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We define a typed interface for the payload so that both the sender and all receivers agree on the structure:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PhotoSyncMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigate_photo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;photoId&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="c1"&gt;// Primary resolution: unique ID of the photo&lt;/span&gt;
  &lt;span class="nl"&gt;navigatorId&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="c1"&gt;// which peer triggered the change&lt;/span&gt;
  &lt;span class="nl"&gt;timestamp&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="c1"&gt;// Diagnostic metadata (clocks may drift, do not use for ordering)&lt;/span&gt;
  &lt;span class="nl"&gt;sequenceId&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="c1"&gt;// Monotonic counter to reject stale messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Note on Synchronization Correctness&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;To prevent concurrent navigation conflicts (e.g., two users changing photos simultaneously) and distributed clock drift issues, this architecture enforces an authoritative navigator model. The presenter acts as the authoritative navigator. Navigation initiated by other participants is ignored by receivers, preventing simultaneous navigation from producing conflicting shared state. Each accepted navigation event also carries a monotonic sequence ID, allowing receivers to reject stale events.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Sending a Navigation Event&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Whenever the local user taps a thumbnail or presses a carousel arrow, we broadcast a PHOTO_SYNC message alongside updating local state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUTHORITATIVE_ROLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;presenter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Module-level sequence tracker &lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;localSequenceId&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handlePhotoSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Only the authoritative navigator can initiate synced navigation &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;currentUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roleName&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;AUTHORITATIVE_ROLE&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="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;localSequenceId&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCurrentPhotoId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&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="c1"&gt;// local update&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendBroadcastMessage&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigate_photo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;photoId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;photo&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;navigatorId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentUser&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;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;sequenceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;localSequenceId&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PHOTO_SYNC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;         &lt;span class="c1"&gt;// custom type keeps it separate from chat&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;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionStore&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical_photo_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;photo&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; 
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to update canonical session state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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;h2&gt;
  
  
  &lt;strong&gt;Receiving and Applying Navigation Events&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;On the receiver side, we hook into hmsNotifications.onNotification(), filter to NEW_MESSAGE events with our custom type, parse and validate the payload, and resolve the target photo by its stable ID before updating local state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Track the latest applied sync sequence for the room&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastSyncSequence&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="c1"&gt;// Define which role is allowed to navigate to prevent simultaneous conflicts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUTHORITATIVE_ROLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;presenter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 

&lt;span class="nx"&gt;hmsNotifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onNotification&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;notification&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NEW_MESSAGE&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="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;payload&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="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; 
  &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigate_photo&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="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// --- 1. AUTHORITATIVE NAVIGATOR CHECK ---&lt;/span&gt;

&lt;span class="c1"&gt;// Restrict synchronization to an authoritative peer role.&lt;/span&gt;
&lt;span class="c1"&gt;// This prevents multiple participants from independently&lt;/span&gt;
&lt;span class="c1"&gt;// updating the shared navigation state.&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;senderPeer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hmsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;selectPeerByID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;navigatorId&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;senderPeer&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;roleName&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;AUTHORITATIVE_ROLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rejected navigation event from non-authoritative peer&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="p"&gt;;&lt;/span&gt; 
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// --- 2. DETERMINISTIC SEQUENCE ORDERING ---&lt;/span&gt;
  &lt;span class="c1"&gt;// Reject out-of-order/stale messages from the authoritative peer&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequenceId&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;lastSyncSequence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rejected stale navigation sequence&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="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// --- 3. STABLE ID RESOLUTION (No Index Fallback) ---&lt;/span&gt;
  &lt;span class="c1"&gt;// Match strictly by stable photoId. If lists diverge and ID is unknown, &lt;/span&gt;
  &lt;span class="c1"&gt;// do not guess using index. Ignore event and rely on Session Store recovery.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetPhoto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;photos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photoId&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;targetPhoto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Photo ID not found in local state, ignoring event.&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="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Apply state and update tracker&lt;/span&gt;
  &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCurrentPhotoId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetPhoto&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="nx"&gt;lastSyncSequence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequenceId&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;h2&gt;
  
  
  &lt;strong&gt;Syncing New Joiners&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A participant who joins mid-session could see the carousel start at index 0 while the rest of the room has moved to another photo. For late joiners, the implementation relies on the 100ms Session Store instead of allowing multiple peers to broadcast competing states. When a user joins, they simply read canonical_photo_id from the Session Store to land on the correct image.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Persisting Media Preferences Between Screens&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The user selects their camera and mic state in the Green Room. These preferences need to persist through the route change to the Live Call screen and a page refresh.&lt;/p&gt;

&lt;p&gt;We store preferredCameraOn and preferredMicOn in a Pinia store configured with selective localStorage persistence. Only these two fields are persisted; sensitive data like the HMS auth token is kept in memory only and is re-fetched on every session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pinia store — persist only media prefs&lt;/span&gt;
&lt;span class="nx"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-meetup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;storage&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="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preferredCameraOn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preferredMicOn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the Live Call screen mount, we read these two values back and call setLocalVideoEnabled / setLocalAudioEnabled immediately after join() resolves. This means the peer publishes with the camera and microphone state configured in the Green Room.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Live Session Teardown&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Proper session teardown prevents stale participant and media state.&lt;/p&gt;

&lt;p&gt;If a participant disconnects without completing session teardown, other users may see stale or ‘ghost’ participants for a short period.&lt;/p&gt;

&lt;p&gt;We handle cleanup in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explicit leave button&lt;/li&gt;
&lt;li&gt;Browser unload events&lt;/li&gt;
&lt;li&gt;Component unmount lifecycle hooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For browser lifecycle handling, do not rely on unload alone. Modern browsers may skip or limit unload processing, so lifecycle handling should account for pagehide, beforeunload, and freeze where appropriate. These events should be treated as best-effort lifecycle signals rather than a guarantee that an awaited asynchronous leave() operation will finish before the page disappears.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;explicitTeardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;unsubscribers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unsub&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;unsub&lt;/span&gt;&lt;span class="p"&gt;?.());&lt;/span&gt;
  &lt;span class="c1"&gt;// For explicit leave button, we safely await&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;leave&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// For browser lifecycle (closing tab, backgrounding, freezing)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setupBrowserLifecycleTeardown&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;bestEffortCleanup&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;unsubscribers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unsub&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;unsub&lt;/span&gt;&lt;span class="p"&gt;?.());&lt;/span&gt;
    &lt;span class="c1"&gt;// Do not await leave() during lifecycle events; it's a best-effort fire-and-forget&lt;/span&gt;
    &lt;span class="nx"&gt;hmsActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;leave&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// Modern alternatives to the deprecated 'unload' event&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pagehide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestEffortCleanup&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeunload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestEffortCleanup&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;freeze&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestEffortCleanup&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;For an explicit leave action or route transition, the async leave() call can be awaited normally. For browser termination events, cleanup should be best-effort and should not assume asynchronous work will always complete. The application should also remove subscriptions and detach UI references during component teardown, while relying on the SDK/browser lifecycle for connection termination.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Performance and UX Considerations&lt;/strong&gt;
&lt;/h1&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. Avoid Duplicate State&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use the reactive store instead of local component state wherever possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. Lazy Rendering&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Attach video tracks only when elements are mounted.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. Optimistic UI Updates&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Update the UI when the user changes a photo, then synchronize the change.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4. Graceful Error Handling&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Token failures&lt;/li&gt;
&lt;li&gt;Permission issues&lt;/li&gt;
&lt;li&gt;Network interruptions&lt;/li&gt;
&lt;li&gt;Reconnects and late joiners&lt;/li&gt;
&lt;li&gt;Unknown or stale PHOTO_SYNC messages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For synchronized navigation, network interruption is especially important: a participant may miss transient broadcast messages. The design should recover from canonical session-level state when the product requires convergence after reconnect.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Synchronization Validation and Failure Cases&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Before describing a real-time synchronization flow as production-ready, it is important to validate failure modes rather than relying only on the happy path. Useful acceptance checks include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rapid navigation:&lt;/strong&gt; repeatedly move forward/backward and verify that older messages do not overwrite the latest accepted sequence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simultaneous navigation:&lt;/strong&gt; have two participants navigate at the same time and verify that the defined authority/conflict-resolution rule produces one deterministic final state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late join:&lt;/strong&gt; join after the room has already moved through several photos and verify that the participant converges to the canonical current photo rather than starting at index 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect:&lt;/strong&gt; interrupt a participant's network connection, restore it, and verify that the participant recovers the current session state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divergent photo ordering:&lt;/strong&gt; use stable photoId matching rather than assuming every participant's array has identical ordering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser lifecycle:&lt;/strong&gt; leave through the explicit button, route away, background/freeze the page, and close the tab to verify that subscriptions and media references are cleaned up as far as the browser lifecycle permits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks make the synchronization guarantees explicit. When behavior has not been measured under a defined participant count or network condition, describe the implementation as a maintainable foundation rather than making an absolute scalability or production-readiness claim.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Extensibility Opportunities&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;This architecture can support additional capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Screen sharing&lt;/li&gt;
&lt;li&gt;Role-based permissions&lt;/li&gt;
&lt;li&gt;&lt;a href="https://geekyants.com/blog/building-production-grade-video-thumbnail-scrubbing-in-the-browser-hls-frame-extraction-caching-and-performance-trade-offs" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Recording and streaming&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Collaborative annotations&lt;/li&gt;
&lt;li&gt;Session-level shared state using the Session Store&lt;/li&gt;
&lt;li&gt;Deterministic conflict resolution for collaborative controls&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Key Takeaways&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Building this feature reinforced several architectural patterns for real-time applications:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. Centralized reactive state simplifies synchronization&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Maintaining a single HMSReactiveStore instance prevents UI desynchronization and keeps media state consistent across screens.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. Preview mode improves pre-join UX&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The Green Room gives users confidence and control before entering the live session.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. Broadcast messaging can support multiple event types&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We used the same messaging layer for chat and photo-navigation events by namespacing message types. For durable shared state, such as the current photo for late joiners, session-level state is a better model.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4. Fine-grained subscriptions improve performance&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Track-level subscriptions prevent unnecessary participant list re-renders.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;5. Clean teardown logic matters&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Explicit transitions should await leave(), while browser lifecycle cleanup must be treated as best-effort and designed around modern lifecycle events rather than assuming unload will complete asynchronous work.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Conclusion&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Real-time collaboration is no longer limited to basic video calls.&lt;/p&gt;

&lt;p&gt;Combining 100ms’s reactive SDK architecture with a structured frontend state model supports interactive shared experiences without requiring a separate synchronization layer for every feature.&lt;/p&gt;

&lt;p&gt;The synchronized-media feature also highlights an important engineering lesson: real-time UX requires more than broadcasting an event. Correctness depends on defining the source of truth, resolving concurrent updates, rejecting stale messages, handling late joiners and reconnects, and managing cleanup across browser lifecycle states.&lt;/p&gt;

&lt;p&gt;With those considerations in place, the patterns described here provide a maintainable foundation for collaborative experiences involving shared media, live messaging, and reactive participant management.\&lt;br&gt;
As real-time applications expand beyond traditional conferencing, reactive synchronization, centralized media state, and event-driven collaboration provide a foundation for shared digital experiences.&lt;/p&gt;

&lt;p&gt;For teams building real-time video and interactive media products, GeekyAnts provides &lt;a href="https://geekyants.com/industry-expertise/media-entertainment" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Media &amp;amp; Entertainment software development services&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; for developing and scaling digital media experiences.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop Automating Everything: A Balanced Quality Engineering Approach to Testing</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:55:20 +0000</pubDate>
      <link>https://dev.to/geekyants/stop-automating-everything-a-balanced-quality-engineering-approach-to-testing-1h76</link>
      <guid>https://dev.to/geekyants/stop-automating-everything-a-balanced-quality-engineering-approach-to-testing-1h76</guid>
      <description>&lt;p&gt;Balanced quality engineering places automation, API testing, exploratory work, and AI where each gives the most value, so teams ship faster without trading away user-perceived quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Key Takeaways&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Automate by Risk&lt;/strong&gt;: Focus automation on &lt;strong&gt;business-critical, high-risk workflows&lt;/strong&gt; rather than raw test-case count, since more tests do not mean better quality.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cheapest Effective Layer&lt;/strong&gt;: Validate business logic through &lt;a href="https://geekyants.com/engineering/quality-assurance/api-testing" rel="noopener noreferrer"&gt;&lt;strong&gt;API tests&lt;/strong&gt;&lt;/a&gt; where possible and reserve &lt;strong&gt;UI automation&lt;/strong&gt; for the user journeys that matter most.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep Exploratory Testing&lt;/strong&gt;: Human testers surface &lt;strong&gt;unexpected behaviour&lt;/strong&gt;, &lt;strong&gt;usability gaps&lt;/strong&gt;, and &lt;strong&gt;edge cases&lt;/strong&gt; that predefined tests never anticipate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Measure Quality, Not Volume&lt;/strong&gt;: Track &lt;strong&gt;defect detection&lt;/strong&gt;, &lt;strong&gt;leakage&lt;/strong&gt;, &lt;strong&gt;flakiness&lt;/strong&gt;, &lt;strong&gt;maintenance effort&lt;/strong&gt;, and &lt;strong&gt;investigation time&lt;/strong&gt; instead of counting automated tests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://geekyants.com/ai-accelerator" rel="noopener noreferrer"&gt;&lt;strong&gt;AI as an Accelerator&lt;/strong&gt;&lt;/a&gt;: Let AI assist with &lt;strong&gt;test generation&lt;/strong&gt;, &lt;strong&gt;failure analysis&lt;/strong&gt;, and &lt;strong&gt;maintenance&lt;/strong&gt; while engineers stay responsible for &lt;strong&gt;quality decisions&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why Passing QA Tests Can Still Ship Poor Experiences&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Software teams are expected to release faster while maintaining reliable digital experiences. Automation has become a critical part of achieving this goal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A typical automated regression workflow looks like:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8qbg1lla1qoiucyb199.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8qbg1lla1qoiucyb199.png" alt=" " width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This provides fast and repeatable feedback, but passing tests do not necessarily mean that users are experiencing a high-quality product.&lt;/p&gt;

&lt;p&gt;For example, an automated checkout test may verify that an order is successfully created while missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;confusing error messages,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;poor mobile usability,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;inaccessible controls,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;unclear navigation,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;unexpected user workflows.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates an important distinction:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical correctness and user-perceived quality are related, but they are not the same thing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Moving From Test Automation to Quality Engineering&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Traditional testing often follows:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctpj4si8yluy9p2ytme2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fctpj4si8yluy9p2ytme2.png" alt=" " width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://geekyants.com/service/hire-quality-assurance-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;Quality Engineering&lt;/strong&gt;&lt;/a&gt; approach distributes quality throughout the lifecycle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fudcxyopiajexqwb3traa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fudcxyopiajexqwb3traa.png" alt=" " width="800" height="633"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The goal is not simply to find more bugs. It is to provide fast, reliable quality feedback at the appropriate layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What a Balanced Testing Model Looks Like&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A balanced QE strategy can combine five capabilities:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6968goizfjhtx93r1ptn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6968goizfjhtx93r1ptn.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each layer has a different purpose.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Testing approach&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Primary purpose&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Unit testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Validate isolated logic&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;API testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Validate services and business rules&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;UI automation&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Validate critical user journeys&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Exploratory testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Discover unexpected behavior&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;UX testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Validate the user experience&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;AI assistance&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Accelerate testing activities&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Deciding What to Automate&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Not every scenario should become a &lt;a href="https://geekyants.com/engineering/quality-assurance/automation-testing-services" rel="noopener noreferrer"&gt;&lt;strong&gt;UI automation test&lt;/strong&gt;&lt;/a&gt;. Strong automation candidates are generally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;repeatable,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;deterministic,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;frequently executed,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;business-critical,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;expensive to execute manually.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Login&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Checkout&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Payment&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Order creation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Permission validation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;API contracts&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful decision process is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6pfkc9jizt1maiejvx2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6pfkc9jizt1maiejvx2.png" alt=" " width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The objective should not be 100% automation.&lt;/p&gt;

&lt;p&gt;It should be meaningful risk coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Validate Business Rules Through APIs, Not the UI&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;One common mistake is validating every business rule through the UI.&lt;/p&gt;

&lt;p&gt;Suppose an application exposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The business logic can be validated directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const response = await request.post("/api/orders", {
  data: {
    productId: 123,
    quantity: 2
  }
});

expect(response.status()).toBe(201);

const body = await response.json();

expect(body.quantity).toBe(2);
expect(body.status).toBe("created");
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI test can then focus on whether a user can successfully complete the workflow. This creates a useful separation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F967nvv5ruglqco7x1fb8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F967nvv5ruglqco7x1fb8.png" alt=" " width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This can reduce unnecessary UI automation and make regression suites faster and easier to maintain.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What Human Testers Catch That Automation Misses&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Automation executes what engineers explicitly define.&lt;/p&gt;

&lt;p&gt;Humans can investigate what was not anticipated.&lt;/p&gt;

&lt;p&gt;For example, an exploratory checkout charter might include:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explore checkout with focus on:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;invalid inputs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;browser back button&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;session expiry&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;duplicate submission&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;slow network&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;mobile viewport&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;unexpected navigation&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A tester might discover:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Field&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Observation&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Action&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Submit payment twice quickly.&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Expected&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;One order.&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Actual&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Two orders created.&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Risk&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Duplicate order/payment.&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A standard happy-path automation test might never discover this scenario. This is why &lt;a href="https://geekyants.com/engineering/quality-assurance/manual-testing-services" rel="noopener noreferrer"&gt;&lt;strong&gt;exploratory testing&lt;/strong&gt;&lt;/a&gt; should not disappear when automation increases.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why a Green Suite Isn't Proof of a Good Experience&lt;/strong&gt;
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;await page.getByRole("button", {
  name: "Place Order"
}).click();

await expect(
  page.getByText("Order confirmed")
).toBeVisible();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test proves that the order can be placed.&lt;/p&gt;

&lt;p&gt;It does not necessarily prove that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;the button is easy to find,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the error messages are understandable,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the workflow is accessible,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the mobile experience is usable,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the customer understands the payment status.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where human-centered testing complements automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Metrics That Show Whether the Strategy Works&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A balanced QE strategy should be measurable.&lt;/p&gt;

&lt;p&gt;Instead of measuring only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Number of automated tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;track metrics such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Execution time&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Defects detected&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Defect leakage&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Flaky tests&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Maintenance effort&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Failure investigation time&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Exploratory findings&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical experiment can use a fixed set of application workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Registration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Login&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Product Search&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cart&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Checkout&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Payment&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Order Confirmation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Invoice Download&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then evaluate them using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;UI Automation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;API Testing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Exploratory Testing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;UX-focused Testing&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The experiment should record the same measures for each testing approach. Until real results are available, it is better to define the measurement plan rather than show placeholder values.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Metric&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;What to record&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Execution time&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Time required to complete the selected workflow or test set&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Unique defects detected&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Distinct defects found by each testing approach&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Maintenance effort&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Time spent updating or repairing tests as the application changes&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Investigation time&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Time required to understand and classify a failed test or observed issue&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Defect leakage&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Defects that escaped earlier test layers and were found later&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Flaky-test rate&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Intermittent automated failures that do not consistently reproduce&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Exploratory findings&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Unexpected behaviors, usability problems, or risk scenarios discovered through human investigation&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Charts Worth Publishing Once You Have Real Data&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once the experiment has produced real measurements, two graphs can communicate the results clearly without making the article visually heavy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Execution Time by Testing Approach&lt;/strong&gt; — compare the measured execution time for UI automation, API testing, exploratory testing, and UX-focused testing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unique Defects Detected by Testing Approach&lt;/strong&gt; — compare the number of distinct defects found by each approach, ideally with defect categories called out where useful.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not publish these charts with illustrative or fabricated values. Generate them only from the completed experiment and label the units, sample size, and test conditions so readers can interpret the results correctly.&lt;/p&gt;

&lt;p&gt;These graphs should reinforce an important point:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Different testing approaches detect different categories of risk.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Keeping Automation Reliable Enough to Trust&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Automation becomes less valuable when teams stop trusting test results.&lt;/p&gt;

&lt;p&gt;A test that behaves like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run 1 → PASS
Run 2 → PASS
Run 3 → FAIL
Run 4 → PASS
Run 5 → PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is potentially flaky.&lt;/p&gt;

&lt;p&gt;Avoid solving this by adding arbitrary waits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;await page.waitForTimeout(5000);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, synchronize with application state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;await expect(
  page.locator('[data-testid="order-confirmation"]')
).toBeVisible();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait for conditions, not time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Automation should be treated as production-quality software, with code review, maintainability, monitoring, and refactoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Where AI Helps — and Where It Shouldn't Decide&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;AI can extend the QE workflow without replacing deterministic testing.&lt;/p&gt;

&lt;p&gt;A practical architecture is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fam0iv899njf26rp8jb2c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fam0iv899njf26rp8jb2c.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For example, AI can analyze:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://geekyants.com/blog/how-ai-ml-are-transforming-quality-assurance-in-software-testing-with-playwright-examples" rel="noopener noreferrer"&gt;&lt;strong&gt;Playwright Trace&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Screenshot&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DOM&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Console Logs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Network Errors&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Test History&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and suggest a failure category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Test defect&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Application defect&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Environment issue&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Data issue&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Network issue&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, the engineer should validate the result.&lt;/p&gt;

&lt;p&gt;A useful operating model is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl7k5jdihsf933ispr71o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl7k5jdihsf933ispr71o.png" alt=" " width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This keeps AI as an accelerator rather than an uncontrolled decision-maker.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Fitting the Approach Into Your CI/CD Pipeline&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The balanced approach fits naturally into a modern CI/CD pipeline:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7jwcq6orqmixy6tdao3o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7jwcq6orqmixy6tdao3o.png" alt=" " width="800" height="650"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Different test layers provide feedback at different speeds, allowing teams to avoid using expensive UI tests for problems that can be detected earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Trade-Offs Between Testing Approaches&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There is no single testing technique that covers every quality dimension.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Approach&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Strength&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Limitation&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;UI Automation&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;End-to-end regression&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Maintenance cost&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;API Testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Fast business validation&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Doesn't validate UX&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Exploratory&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Finds unexpected issues&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Less repeatable&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;UX Testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;User experience&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Requires human effort&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;AI Testing&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Faster analysis/generation&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Requires validation&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The right strategy combines these capabilities according to product risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A Reference Architecture You Can Adapt&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The complete model can be summarized as:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2xhz9rc8n7nfezsortf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2xhz9rc8n7nfezsortf.png" alt=" " width="800" height="733"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This model does not attempt to eliminate human testing.&lt;/p&gt;

&lt;p&gt;Instead, it places automation, APIs, AI, and human reasoning where each provides the most value.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Conclusion: Getting the Balance Right&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Modern Quality Engineering should not be a competition between manual testing and automation.&lt;/p&gt;

&lt;p&gt;Automation provides speed, repeatability, and scalable regression coverage. API testing provides efficient validation of business behavior. Exploratory and human-centered testing provide contextual reasoning and help identify problems that deterministic automation may miss. AI can further accelerate these activities by assisting with generation, analysis, and maintenance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6u897n0iu5sjoh1g6fc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6u897n0iu5sjoh1g6fc.png" alt=" " width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most effective Quality Engineering strategy is one that uses technology to accelerate testing without losing sight of the person ultimately using the software.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal is not maximum automation. The goal is maximum meaningful quality feedback for the engineering effort invested.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>From Rolling Deployments to Zero-Downtime Releases</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Wed, 07 Oct 2026 09:17:22 +0000</pubDate>
      <link>https://dev.to/geekyants/from-rolling-deployments-to-zero-downtime-releases-k55</link>
      <guid>https://dev.to/geekyants/from-rolling-deployments-to-zero-downtime-releases-k55</guid>
      <description>&lt;p&gt;Executive Summary&lt;br&gt;
In &lt;a href="https://geekyants.com/industry/fintech-app-development-services" rel="noopener noreferrer"&gt;digital banking&lt;/a&gt;, deployment availability is a business requirement as much as a technical one. A deployment that interrupts access to online banking can affect customers attempting to log in, view balances, initiate transfers, or perform other critical operations.&lt;/p&gt;

&lt;p&gt;Our earlier rolling deployment model introduced problems around version coexistence. Every new frontend build generated build-specific resources such as runtime.1234.js and main.1234.js. During a rolling restart, old and new pods could coexist. A request for a resource from the new build could reach an older pod where that resource did not exist, producing a 404 response.&lt;/p&gt;

&lt;p&gt;Static resources were cached at the CDN for about four minutes. We needed to consider application availability and version consistency across the application shell, static resources, CDN, and backend pods.&lt;/p&gt;

&lt;p&gt;We moved to a &lt;a href="https://geekyants.com/engineering/devops/infrastructure-deployment-automation" rel="noopener noreferrer"&gt;Blue-Green deployment strategy&lt;/a&gt;. The new version is deployed into a separate environment, its pods start and pass readiness checks, and the environment is validated before customer traffic is switched. The existing environment continues serving production traffic until the cutover.&lt;/p&gt;

&lt;p&gt;In our environment, the observed ALB traffic transition from Blue to Green was about 40–180 ms. The key benefit is not faster application startup. Pod creation, image pulling, startup, and readiness occur outside the customer-facing cutover window. The previous environment also remains available so that traffic can be routed back to it if a rollback is required.&lt;/p&gt;

&lt;p&gt;A race condition remained during the ALB transition under high request volumes. A request for a build-specific static resource could reach the previous environment and receive a 404 response. We addressed this with a targeted NGINX retry for the affected static resources, with a delay of about one second before the retry. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why We Needed a Different Deployment Strategy
1.1 The Rolling Deployment Model
A rolling deployment replaces application instances in stages. This avoids replacing every instance at once, but it also means different application versions can coexist during the deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfi77xxm905wazjq67jq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfi77xxm905wazjq67jq.jpg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;1.2 Build-Specific Static Resources&lt;br&gt;
Every application build generates resources associated with that build. For example:&lt;/p&gt;

&lt;p&gt;Build 100:&lt;br&gt;
runtime.100.js&lt;br&gt;
main.100.js&lt;br&gt;
styles.100.css&lt;/p&gt;

&lt;p&gt;Build 101:&lt;br&gt;
runtime.101.js&lt;br&gt;
main.101.js&lt;br&gt;
styles.101.css&lt;/p&gt;

&lt;p&gt;The browser receives an application shell that references a particular build's resources. If a request for runtime.101.js reaches a Build 100 pod, the resource does not exist there, and the server can return a 404 response.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5tu5ww0suaxps639n0y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5tu5ww0suaxps639n0y.jpg" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pod itself could be healthy. The failure was caused by a version mismatch between the resource requested by the client and the application instance handling the request.&lt;/p&gt;

&lt;p&gt;1.3 CDN Caching Added Another Timing Dimension&lt;br&gt;
Static resources were cached at the CDN for about four minutes. The application origin could move to a new version while cached resources from the previous version remained available at the edge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kqslrxakk8cphsda3vv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kqslrxakk8cphsda3vv.jpg" alt=" " width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deployment correctness had to account for three independent timing domains: application and pod readiness, ALB traffic convergence, and CDN cache lifetime.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Blue-Green Deployment Architecture
Blue-Green deployment introduced two complete application environments. Blue is the active version, and Green is the new version.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsz6btqq06tq9diupfw32.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsz6btqq06tq9diupfw32.jpg" alt=" " width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The principle is simple: deploy and validate Green before allowing it to receive production traffic. This separates deployment preparation from production traffic cutover.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What Happens During a Blue-Green Deployment
3.1 Build and Image Creation
The CI/CD pipeline creates an immutable image for the release. The frontend resources generated by that build are part of the same release artifact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70lj155zhmj21yjdva1l.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70lj155zhmj21yjdva1l.jpg" alt=" " width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This preserves a clear relationship between the application version and the resources it serves.&lt;/p&gt;

&lt;p&gt;3.2 Creating the Green Pods&lt;br&gt;
The new release is deployed to Green without terminating the active Blue environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fks32weg5k9mul9f4gmdj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fks32weg5k9mul9f4gmdj.jpg" alt=" " width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Green pods go through image pull, container startup, application initialization, and readiness checks before they become eligible to receive traffic.&lt;/p&gt;

&lt;p&gt;3.3 Pod Readiness&lt;br&gt;
Readiness is a key mechanism in this architecture. &lt;a href="https://geekyants.com/engineering/devops/kubernetes-containerization-services" rel="noopener noreferrer"&gt;Kubernetes&lt;/a&gt; distinguishes a running container from a container that is ready to accept traffic. A failed readiness probe causes the pod to be treated as not ready for normal service traffic. A startup probe can be used when an application needs more initialization time before liveness or readiness checks begin.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz3h5kidyck7u46ek98ps.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz3h5kidyck7u46ek98ps.jpg" alt=" " width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a banking application, readiness should represent the minimum state in which the application can process production requests without creating service risk. Depending on the implementation, this can include application initialization, configuration loading, dependency availability, and other required startup conditions.&lt;/p&gt;

&lt;p&gt;3.4 Green Validation&lt;br&gt;
Once the Green pods are Ready, the new environment can be validated before customer traffic is moved.&lt;/p&gt;

&lt;p&gt;Confirm that the pods and application are healthy.&lt;br&gt;
Run API &lt;a href="https://geekyants.com/service/hire-quality-assurance-developers" rel="noopener noreferrer"&gt;smoke tests&lt;/a&gt; against the new environment.&lt;br&gt;
Confirm that the required static resources are available.&lt;br&gt;
Check connectivity to critical dependencies.&lt;br&gt;
Test business-critical flows.&lt;br&gt;
Review application and infrastructure metrics.&lt;br&gt;
This allows the team to validate Green while Blue continues serving production traffic.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How the ALB Traffic Switch Works
The Application Load Balancer (ALB) provides the boundary for switching production traffic between Blue and Green. An ALB listener evaluates routing rules and forwards traffic to target groups. Target groups contain registered targets and can have their own health checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faqg5nht69berba4bp160.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faqg5nht69berba4bp160.jpg" alt=" " width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before the cutover, Blue is the active target group and Green is prepared without receiving normal production traffic. Once Green has passed the deployment gates, the routing configuration changes so that production requests are directed to Green.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Critical Difference: Deployment Time vs Customer-Impact Time
A key distinction in Blue-Green deployment is the difference between deployment time and customer-impact time. The new pods still need to pull the image, start the container, initialize the application, and pass readiness checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What changes is where that work occurs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fle45h9s4sb6mdn5euomh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fle45h9s4sb6mdn5euomh.jpg" alt=" " width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Blue continues serving customers while Green is being prepared. This removes the time-consuming preparation work from the customer-facing cutover path.&lt;/p&gt;

&lt;p&gt;The customer-facing transition is limited to the routing window rather than the full duration required to create and warm the new pods.&lt;/p&gt;

&lt;p&gt;The improvement should not be described as “the deployment became X% faster.” The deployment preparation time still exists, but it does not need to translate into customer downtime.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rollback: Turning Recovery Into a Traffic Switch
Blue-Green changes the rollback mechanism.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Normal state:&lt;/p&gt;

&lt;p&gt;BLUE  = Build 100&lt;br&gt;
GREEN = Build 101&lt;br&gt;
Traffic = 100% Green&lt;br&gt;
If monitoring identifies a production problem after the cutover, Blue can remain available as the previous known-good version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1n070keblg11p57vl64.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1n070keblg11p57vl64.jpg" alt=" " width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of rebuilding and redeploying the previous image, the deployment can route traffic back to the running Blue environment.&lt;/p&gt;

&lt;p&gt;This changes rollback from a deployment operation into a traffic-routing operation, which can reduce mean time to recovery (MTTR).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Production Challenge After Blue-Green
7.1 The ALB Transition Window
Blue-Green solved the major version-coexistence problem, but production testing exposed a smaller race condition during the traffic switch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In our environment, the observed ALB transition from Blue to Green was about 40–180 ms.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
Blue  = 100%&lt;br&gt;
Green =   0%&lt;/p&gt;

&lt;p&gt;Transition:&lt;br&gt;
Blue  = 100% -&amp;gt; 0%&lt;br&gt;
Green =   0% -&amp;gt; 100%&lt;/p&gt;

&lt;p&gt;After:&lt;br&gt;
Blue  =   0%&lt;br&gt;
Green = 100%&lt;br&gt;
Under high request volume, requests can arrive during this short interval. A request that arrives during the transition can encounter the previous routing state.&lt;/p&gt;

&lt;p&gt;7.2 Static Resource 404 During the Transition&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2mnpiem2dl2q8wiypxy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2mnpiem2dl2q8wiypxy.jpg" alt=" " width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This was not a persistent application failure. It was a transient race condition caused by the interaction between build-specific resource names and traffic convergence.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;NGINX Retry as the Last-Mile Resilience Layer
To address this transient condition, a targeted retry mechanism was introduced at NGINX for the affected static resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawj8seizzl3cgd9u1ix8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fawj8seizzl3cgd9u1ix8.jpg" alt=" " width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The one-second delay was longer than the observed ALB transition window. This gave the traffic transition time to complete before the retry.&lt;/p&gt;

&lt;p&gt;This mechanism was scoped to static-resource requests. A generic retry policy should not be applied to state-changing banking operations such as fund transfers without idempotency and transaction-safety guarantees.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CDN and Resource Versioning
Build-specific resource names remain part of the design. Instead of overwriting a stable path such as /main.js, each build produces a unique resource path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;/main.100.js&lt;br&gt;
/main.101.js&lt;br&gt;
/main.102.js&lt;br&gt;
This allows multiple versions of a resource to coexist in the CDN. The application shell should reference the resource set belonging to its build.&lt;/p&gt;

&lt;p&gt;The CDN cache lifetime of about four minutes is treated as a separate concern from the ALB transition. Build-specific resources provide coexistence, while the NGINX retry addresses the short traffic-convergence race.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Zero-Downtime as an End-to-End Property
The implementation showed that zero-downtime (ZDT) is not a property of Kubernetes or the load balancer alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Client&lt;br&gt;
  |&lt;br&gt;
CDN&lt;br&gt;
  |&lt;br&gt;
ALB&lt;br&gt;
  |&lt;br&gt;
NGINX&lt;br&gt;
  |&lt;br&gt;
Kubernetes&lt;br&gt;
  |&lt;br&gt;
Pod readiness&lt;br&gt;
  |&lt;br&gt;
Application&lt;br&gt;
  |&lt;br&gt;
Dependencies / data layer&lt;/p&gt;

&lt;p&gt;A system can have healthy pods and still return customer-visible errors if resource versioning, caching, or routing transitions are not handled correctly.&lt;/p&gt;

&lt;p&gt;Our final approach combines several mechanisms:&lt;/p&gt;

&lt;p&gt;Blue-Green deployment&lt;br&gt;
        +&lt;br&gt;
Pod readiness&lt;br&gt;
        +&lt;br&gt;
Build-specific static resources&lt;br&gt;
        +&lt;br&gt;
CDN caching strategy&lt;br&gt;
        +&lt;br&gt;
ALB traffic cutover&lt;br&gt;
        +&lt;br&gt;
NGINX static-resource retry&lt;br&gt;
        +&lt;br&gt;
Fast rollback&lt;br&gt;
        =&lt;br&gt;
Zero-Downtime Release Architecture&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Business and Operational Impact
Before the move toward ZDT, deployments were scheduled during low-traffic periods such as night-time maintenance windows. This was a risk-management decision. If a deployment could cause service interruption, a period with fewer customers using the platform reduced the number of users exposed to that interruption.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before:&lt;br&gt;
Release Ready&lt;br&gt;
    |&lt;br&gt;
    v&lt;br&gt;
Wait for Night&lt;br&gt;
    |&lt;br&gt;
    v&lt;br&gt;
Maintenance Window&lt;br&gt;
    |&lt;br&gt;
    v&lt;br&gt;
Deployment&lt;/p&gt;

&lt;p&gt;Blue-Green changed the release model.&lt;/p&gt;

&lt;p&gt;After:&lt;br&gt;
Release Ready&lt;br&gt;
    |&lt;br&gt;
    v&lt;br&gt;
Deploy Green&lt;br&gt;
    |&lt;br&gt;
    v&lt;br&gt;
Validate&lt;br&gt;
    |&lt;br&gt;
    v&lt;br&gt;
Switch Traffic&lt;/p&gt;

&lt;p&gt;Because Blue remains available while Green is prepared, a new deployment does not require the application to be taken offline. Subject to change-management, monitoring, and business controls, teams can perform deployments during normal operating hours, including the morning, instead of restricting them to night-time maintenance windows to avoid deployment downtime.&lt;/p&gt;

&lt;p&gt;This changes the operational model: application availability no longer dictates the deployment window.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deployment Sequence&lt;/li&gt;
&lt;li&gt;Developer merges release
      |&lt;/li&gt;
&lt;li&gt;CI/CD builds application
      |&lt;/li&gt;
&lt;li&gt;Build-specific static resources generated
      |&lt;/li&gt;
&lt;li&gt;Immutable container image created
      |&lt;/li&gt;
&lt;li&gt;Image pushed to registry
      |&lt;/li&gt;
&lt;li&gt;Green deployment created
      |&lt;/li&gt;
&lt;li&gt;Green pods start
      |&lt;/li&gt;
&lt;li&gt;Startup / initialization
      |&lt;/li&gt;
&lt;li&gt;Readiness probes pass
      |&lt;/li&gt;
&lt;li&gt;Green validation / smoke tests
      |&lt;/li&gt;
&lt;li&gt;Green target group confirmed healthy
      |&lt;/li&gt;
&lt;li&gt;ALB routing switched
      |&lt;/li&gt;
&lt;li&gt;Traffic moves Blue -&amp;gt; Green
      |&lt;/li&gt;
&lt;li&gt;Monitor application + business metrics
      |&lt;/li&gt;
&lt;li&gt;Keep Blue available for rollback
      |&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Decommission Blue after confidence window&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Key Engineering Lessons&lt;br&gt;
13.1 Blue-Green Requires Coordinated Deployment Controls&lt;br&gt;
Blue-Green depends on the interaction between deployment orchestration, readiness, traffic management, resource versioning, CDN behavior, observability, and rollback. Two application environments alone do not address the dependencies across the release path.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;13.2 Move Preparation Work Outside the Cutover Path&lt;br&gt;
Image pulling, pod creation, startup, and readiness can take minutes. Blue-Green allows the previous version to continue serving traffic while the new environment completes this work.&lt;/p&gt;

&lt;p&gt;13.3 Rollback Should Be Designed Up Front&lt;br&gt;
Keeping the previous environment available allows the system to route traffic back to a known-good version without rebuilding and redeploying that version.&lt;/p&gt;

&lt;p&gt;13.4 Short Transition Windows Can Expose Race Conditions&lt;br&gt;
A short routing transition can still expose a race condition when request volume is high. Deployment design must account for requests that arrive while traffic moves between environments.&lt;/p&gt;

&lt;p&gt;13.5 Static Resources Are Part of the Release Architecture&lt;br&gt;
Frontend resources form part of the production deployment architecture. Their versioning, CDN lifetime, and relationship with the application shell must be considered as part of the release design.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Final Architecture&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2vru39ubuumf2jcye9ld.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2vru39ubuumf2jcye9ld.jpg" alt=" " width="800" height="984"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Conclusion
The move from rolling deployments to Blue-Green deployment addressed the version mismatch that could occur when build-specific frontend resources reached pods running an older build. Blue-Green separates release preparation from traffic cutover by allowing the new environment to complete startup, readiness checks, and validation while the existing environment continues serving customers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The implementation showed that zero-downtime deployment depends on controls across the release path. Resource versioning, CDN caching, traffic switching, readiness checks, retry behavior, and rollback design must work together to protect customer requests during a release.&lt;/p&gt;

&lt;p&gt;Keeping the previous environment available also allows recovery through a traffic switch instead of another deployment. This release model reduces the need to schedule deployments around maintenance windows while preserving a path back to the previous version.&lt;/p&gt;

&lt;p&gt;Building Release Architectures for Production Reliability&lt;br&gt;
Zero-downtime deployment depends on more than switching traffic between two environments. Pod readiness, static-resource versioning, caching, traffic routing, &lt;a href="https://geekyants.com/engineering/devops" rel="noopener noreferrer"&gt;monitoring, and rollback&lt;/a&gt; must work together to protect customer-facing applications during a release. For teams building or modernizing applications with these production requirements, GeekyAnts provides &lt;a href="https://geekyants.com/service/custom-software-development-services" rel="noopener noreferrer"&gt;custom software development services&lt;/a&gt; covering architecture, CI/CD, deployment, and post-launch support.&lt;/p&gt;

&lt;p&gt;References&lt;br&gt;
Kubernetes: Liveness, Readiness, and Startup Probes&lt;br&gt;
&lt;a href="https://kubernetes.io/docs/concepts/workloads/pods/probes/" rel="noopener noreferrer"&gt;https://kubernetes.io/docs/concepts/workloads/pods/probes/&lt;/a&gt;&lt;br&gt;
AWS Elastic Load Balancing: Application Load Balancer&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html&lt;/a&gt;&lt;br&gt;
AWS Elastic Load Balancing: Target Groups&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html&lt;/a&gt;&lt;br&gt;
AWS Elastic Load Balancing: Listener Rules&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/en_en/elasticloadbalancing/latest/application/listener-rules.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/en_en/elasticloadbalancing/latest/application/listener-rules.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>banking</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>The Deprecation Notice Nobody Reads</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:49:51 +0000</pubDate>
      <link>https://dev.to/geekyants/the-deprecation-notice-nobody-reads-1i96</link>
      <guid>https://dev.to/geekyants/the-deprecation-notice-nobody-reads-1i96</guid>
      <description>&lt;p&gt;This blog explains why API deprecation notices often fail and how staged retirement, telemetry, brownouts, and scheduled enforcement can make API migrations more controlled and predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Every API team eventually writes the same message&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;"This endpoint will be removed on [date]. Please migrate before then." It goes in a changelog, a blog post, maybe an email. Traffic to the deprecated endpoint can show little movement until the day it is switched off, at which point support queues fill up with people who swear they never saw the notice.&lt;/p&gt;

&lt;p&gt;They may not have seen it. A warning with no consequence attached is easy for a busy team to defer. The problem with sunset timelines is not communication. Most contain nothing that creates a cost for delay until the exact day they stop being a warning and become an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The flat line before the cliff&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The pattern shows up across &lt;a href="https://geekyants.com/hire-graphql-api-developers" rel="noopener noreferrer"&gt;&lt;strong&gt;API providers&lt;/strong&gt;&lt;/a&gt;: deprecation is announced, documentation is updated, and usage of the old endpoint barely dips. One cited account of a typical deprecation window reported that six months after announcing a sunset date, the old version was still carrying 40% of total traffic, with a team left asking why nobody had migrated.&lt;/p&gt;

&lt;p&gt;A deprecated endpoint that still returns 200 OK carries no urgency. Migration work competes with every other roadmap item, and "the thing that still technically works" reliably loses that competition until it stops working at all. A blog post is not an effective notice if ignoring it carries no immediate consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Two timelines, two outcomes&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Timeline&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Without a strategy&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;With a strategy&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Month 0&lt;/td&gt;
&lt;td&gt;Endpoint removed, no warning&lt;/td&gt;
&lt;td&gt;Deprecation announced; Deprecation/Sunset headers added; migration guide published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Month 3&lt;/td&gt;
&lt;td&gt;47 integrations already broken&lt;/td&gt;
&lt;td&gt;Migration reminders sent to consumers still calling the endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Month 6&lt;/td&gt;
&lt;td&gt;Support queue flooded&lt;/td&gt;
&lt;td&gt;First announced brownout window, a scheduled, published maintenance-style window during which the endpoint is intentionally degraded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Month 9&lt;/td&gt;
&lt;td&gt;Emergency rollback underway&lt;/td&gt;
&lt;td&gt;Quota tightened for remaining consumers; brownout windows lengthened per the published schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Month 12&lt;/td&gt;
&lt;td&gt;Trust damaged, repeat incidents&lt;/td&gt;
&lt;td&gt;Endpoint retired, returns 410 Gone; near-zero breakage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What changes structurally is that, by month 6, the endpoint behaves differently for consumers who have not migrated. That change is published on a schedule before it takes effect rather than discovered by consumers in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why longer notice periods don't fix this alone&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Some major API providers use deprecation or version-support windows measured in years, giving customers time to include migration work in planning cycles.&lt;/p&gt;

&lt;p&gt;But two years solves a planning problem, not an urgent problem. It does nothing to make migration feel pressing in month three, nine, or eighteen because nothing about the API has changed yet. The calendar moved; the consequence didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Staged, announced retirement&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The fix isn't a louder notice, and it isn't quiet degradation either. It's a retirement plan the API enforces on a schedule the consumer knows about. Every stage is disclosed in the changelog and response headers before it takes effect rather than discovered in production.&lt;/p&gt;

&lt;p&gt;A staged retirement can follow five stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Headers-only window. Deprecation and Sunset headers present on every response. No behavioral change. This window should be long enough to cover at least one full planning cycle for your consumers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Warnings surfaced in-band. A deprecation notice is added to response bodies, or a Link header points to the migration guide, so tooling rather than memory catches it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Announced brownout windows. Published, scheduled windows (e.g., "the deprecated endpoint will return elevated latency and occasional 429s every Tuesday 2–4pm UTC starting date") during which the endpoint behaves like it will after full retirement. These are calendared and documented before they start, exactly like a maintenance window.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Progressively tighter quotas for remaining consumers, identified by telemetry, not by chance.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hard retirement. 410 Gone, with the migration link still present.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At every stage, a consumer hitting the new behavior can look at a public schedule and confirm exactly which stage is in effect and when the next one begins. Nothing about it is a surprise, and nothing about it is silent.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What changes for internal versus public APIs&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For an internal API with a handful of known consuming teams, the friction curve can be steep and communication can be direct. A Slack message plus a dashboard showing which service is still calling the old path can help get a fix shipped within a sprint. The consuming teams are known, and a sharper cutoff is more practical because the organization also controls the fallback.&lt;/p&gt;

&lt;p&gt;Public and partner-facing APIs are the harder case. The consumer may be unknown until they show up in usage telemetry, may not follow the changelog, and may be running an integration nobody at their organization has touched since it was built. That is where headers, published schedules, and graduated enforcement provide value. They turn an anonymous, unreachable consumer's client code into something that reports its own deprecation status back to whoever looks at the logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Comparing three retirement strategies&lt;/strong&gt;
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Communication-only&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Hard sunset&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Staged enforcement (brownouts)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Changelog/email notice, no behavior change until removal day&lt;/td&gt;
&lt;td&gt;Fixed date, endpoint simply stops working&lt;/td&gt;
&lt;td&gt;Published schedule of increasing, announced friction before final removal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best fit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low-risk internal APIs, few known consumers&lt;/td&gt;
&lt;td&gt;Emergency/security-driven deprecations&lt;/td&gt;
&lt;td&gt;Public and partner APIs with unknown or numerous consumers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retry-storm risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low until cutover, spikes hard at deadline&lt;/td&gt;
&lt;td&gt;High; everyone fails at once&lt;/td&gt;
&lt;td&gt;Distributed across scheduled windows; retry behavior can be observed before it's catastrophic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SLA / contractual exposure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rarely satisfies partner deprecation clauses alone&lt;/td&gt;
&lt;td&gt;Highest exposure if the date arrives early&lt;/td&gt;
&lt;td&gt;Lower; each stage documented and pausable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rollback strategy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trivial (nothing changed yet)&lt;/td&gt;
&lt;td&gt;Difficult; re-enabling under load is its own incident&lt;/td&gt;
&lt;td&gt;Straightforward; each stage is a &lt;a href="https://geekyants.com/blog/feature-flags-as-technical-debt-the-cleanup-nobody-schedules" rel="noopener noreferrer"&gt;&lt;strong&gt;feature flag&lt;/strong&gt;&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Operational cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low upfront, high at cutover&lt;/td&gt;
&lt;td&gt;Low upfront, very high at cutover&lt;/td&gt;
&lt;td&gt;Higher upfront, spread more evenly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;When it fails&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Consumers defer migration&lt;/td&gt;
&lt;td&gt;Consumers who missed the notice get zero warning&lt;/td&gt;
&lt;td&gt;Poorly tuned severity or cadence can trigger retries without Retry-After and backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No single strategy is correct in isolation. The choice depends on how well you know your consumers, what your contracts require, and how much operational machinery you're willing to build before you start the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Standards-correct signals&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;RFC 9745 defines Deprecation as a Structured Fields Date, not an HTTP-date string. That means the value should look like @1735689600 (an @-prefixed Unix timestamp), not the output of toUTCString(). Sunset (RFC 8594) does use an HTTP-date. Mixing the two formats can break client tooling. A malformed value may fail to parse as expected, which can disrupt automated deprecation tracking.&lt;/p&gt;

&lt;p&gt;Production-oriented middleware, with per-consumer rollout, a rollback switch, and telemetry hooks built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Formats a JS Date/timestamp as an RFC 9745 Structured Field Date: "@" function toStructuredFieldDate(msTimestamp) { return @${Math.floor(msTimestamp / 1000)}; }

function deprecationMiddleware({ deprecationDate, sunsetDate, migrationLink, stages, // ordered array of { startsAt, mode, config } isEnforced, // (consumerId) =&amp;gt; bool — per-consumer rollout / allowlist telemetry, // { recordHit(stage, consumerId), recordOutcome(stage, consumerId, result) } killSwitch, // () =&amp;gt; bool — global rollback }) { return function (req, res, next) { const now = Date.now(); const consumerId = req.consumerId; 

res.setHeader('Deprecation', toStructuredFieldDate(deprecationDate));
res.setHeader('Sunset', new Date(sunsetDate).toUTCString());
res.setHeader('Link', `&amp;lt;${migrationLink}&amp;gt;; rel="deprecation"`);

telemetry.recordHit('headers', consumerId);

if (killSwitch()) {
  return next(); // incident-response rollback: headers stay, no enforcement
}

const activeStage = stages
  .filter((s) =&amp;gt; now &amp;gt;= s.startsAt)
  .sort((a, b) =&amp;gt; b.startsAt - a.startsAt)[0];

if (!activeStage || !isEnforced(consumerId)) {
  return next();
}

switch (activeStage.mode) {
  case 'retired': {
    telemetry.recordOutcome('retired', consumerId, '410');
    return res.status(410).json({
      error: 'gone',
      message: 'This endpoint was retired. See the Link header for the migration guide.',
    });
  }
  case 'brownout': {
    const { rejectRatio, retryAfterSeconds } = activeStage.config;
    if (Math.random() &amp;lt; rejectRatio) {
      telemetry.recordOutcome('brownout', consumerId, '429');
      res.setHeader('Retry-After', String(retryAfterSeconds));
      return res.status(429).json({
        error: 'deprecated_brownout',
        message: `Scheduled brownout in effect per the published retirement schedule. See ${migrationLink}.`,
      });
    }
    telemetry.recordOutcome('brownout', consumerId, 'pass');
    return next();
  }
  case 'quota': {
    req.deprecationQuotaTag = activeStage.config.quotaKey;
    return next();
  }
  default:
    return next();
   }
 }; 
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stages here are explicit, scheduled, published data rather than a continuous random function tied to elapsed time. Enforcement is per-consumer and gated by an allowlist so a rollout can ramp in stages instead of switching on for every consumer at the same time. A kill switch supports incident response, and every enforcement decision is recorded to telemetry so it can feed the decision loop below.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Telemetry as the backbone&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;"Track usage per consumer" isn't a strategy on its own. The strategy is the decision loop that telemetry feeds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Identify every consumer still hitting the deprecated path, from API keys/auth identity, not just aggregate request counts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Segment by risk and traffic. A partner integration processing payments and a dormant internal script hitting the endpoint twice a day need different handling and different owners.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Notify owners, not just via the changelog. The segmented list makes targeted outreach possible.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Measure migration after each stage, before deciding to advance. The gate for moving to the next stage should be a measured drop in remaining traffic plus confirmed outreach, not just a date on a calendar.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enforce the next stage only for consumers who haven't moved. Scope enforcement to the remaining, already-notified cohort rather than applying it to everyone the moment a date arrives.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This loop is also what tells you when to pause: if a critical, contractually-protected partner is still on the old path with an active migration in progress, the gate holds them back from the next enforcement stage while the rest of the cohort proceeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Testing the strategy before shipping it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before rolling out a staged retirement for a public or partner API, &lt;a href="https://geekyants.com/engineering/quality-assurance/api-testing" rel="noopener noreferrer"&gt;&lt;strong&gt;test the full sequence&lt;/strong&gt;&lt;/a&gt; of headers, warnings, one brownout window, quota tightening, and retirement against a low-traffic internal endpoint with a small, known set of consumers. Compress the sequence into a shorter window than a real public deprecation would use.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What to Capture at Each Stage&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Request volume to the deprecated path, before and after each stage transition&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Number of distinct consumers still active, and how that count changes after outreach vs. after the first brownout&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Retry behavior during the brownout window, including whether clients back off per Retry-After or retry at a rate that begins to resemble an attack&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Support/Slack messages generated per stage&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Time from "notified" to "migrated" per consumer, segmented by whether they moved after notice alone or only after a brownout&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test report should show the share of consumers who migrated after headers and direct outreach, the share who required a brownout window, the number of integrations requiring one-on-one follow-up, and any unexpected brownout behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What actually breaks&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Staged retirements do not fail cleanly, so teams need to plan for the ways they can fail.&lt;/p&gt;

&lt;p&gt;A brownout window can trigger a retry loop that amplifies load instead of shedding it if clients do not respect Retry-After. That is why the header has to be present on every throttled response, not just the first.&lt;/p&gt;

&lt;p&gt;A batch job can fail without drawing attention if it does not check for 429 at all. The problem may surface when someone notices missing data downstream.&lt;/p&gt;

&lt;p&gt;A dormant integration with no listed owner can remain invisible until an enforcement stage breaks it. That is why the consumer registry needs an ownership field checked before enforcement begins, not after.&lt;/p&gt;

&lt;p&gt;None of these are arguments against staged enforcement. They are arguments for building the telemetry and allowlist machinery described above before turning any stage on. If something behaves in an unexpected way, it should show up in a dashboard instead of a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why this matters now&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;API environments now include &lt;a href="https://geekyants.com/engineering/backend/enterprise-backend-development" rel="noopener noreferrer"&gt;&lt;strong&gt;internal microservice contracts&lt;/strong&gt;&lt;/a&gt;, partner integrations, and a growing layer of &lt;a href="https://geekyants.com/blog/ai-agents-the-next-frontier-in-intelligent-automation" rel="noopener noreferrer"&gt;&lt;strong&gt;AI agents&lt;/strong&gt;&lt;/a&gt; and tools that can call APIs without a person reading a changelog. A deprecation strategy that depends on a person noticing an email doesn't scale to that world. One built into the protocol through headers, a published schedule, and telemetry-gated enforcement does.&lt;/p&gt;

&lt;p&gt;Sunset timelines don't fail because nobody announced them. They fail because, for most of the window, nothing about using the deprecated path changed. The fix isn't writing a better warning, and it isn't making things worse without notice until the deadline. It's a retirement plan the API keeps, announced one stage at a time. By the retirement date, consumers should know it is coming because they have seen it on the schedule, not because they experienced the change without warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Build APIs for Change, Not Just Launch&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;API reliability extends beyond keeping an endpoint available. Teams also need a plan for how APIs evolve, how consumers migrate, and how older versions are retired without creating avoidable production failures. Staged retirement, telemetry, and controlled enforcement make deprecation part of the API lifecycle rather than a last-minute cutoff. Explore how GeekyAnts approaches scalable systems and API architecture through its &lt;a href="https://geekyants.com/engineering/backend" rel="noopener noreferrer"&gt;&lt;strong&gt;Backend Engineering&lt;/strong&gt;&lt;/a&gt; capabilities.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>programming</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
