<?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 India Pvt Ltd</title>
    <description>The latest articles on DEV Community by GeekyAnts India Pvt Ltd (@geekyants-inc).</description>
    <link>https://dev.to/geekyants-inc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3805440%2Fb1d94ce3-8033-4b59-b443-beb091ccb36b.jpg</url>
      <title>DEV Community: GeekyAnts India Pvt Ltd</title>
      <link>https://dev.to/geekyants-inc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/geekyants-inc"/>
    <language>en</language>
    <item>
      <title>The Hidden Complexity Behind GitOps’ “Push to Deploy”</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 13 Nov 2026 11:39:57 +0000</pubDate>
      <link>https://dev.to/geekyants-inc/the-hidden-complexity-behind-gitops-push-to-deploy-19l1</link>
      <guid>https://dev.to/geekyants-inc/the-hidden-complexity-behind-gitops-push-to-deploy-19l1</guid>
      <description>&lt;p&gt;A GitOps platform that provisioned isolated customer environments straight from Git, and the four problems that surfaced once it was running: dependencies that weren’t ready when resources existed, configuration changes that broke existing tenants, successful deploys that hid broken integrations, and resource limits that blocked work the cluster could handle. The piece documents each fix, the ~110-second provisioning run, and is careful about which numbers are measured observations and which are calculations.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What Building a GitOps Platform Actually Taught Us About Reliable Infrastructure&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Getting an application into &lt;a href="https://geekyants.com/engineering/devops/kubernetes-containerization-services" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Kubernetes&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; was the easy part. Making sure its database was ready, its configuration pointed to the right environment, its secrets existed, and its deployment could recover from failure took considerably more work.&lt;/p&gt;

&lt;p&gt;We spent roughly &lt;strong&gt;two weeks and 194 commits&lt;/strong&gt; building a GitOps platform that provisioned isolated customer evaluation environments directly from Git. Adding a client configuration could trigger the creation of an entire environment without an engineer manually applying Kubernetes resources.&lt;/p&gt;

&lt;p&gt;The provisioning flow worked. Operating it exposed a different set of problems: migrations ran too early, applications started before their dependencies were reachable, configuration changes broke existing environments, and deployments could get stuck waiting for the very changes needed to fix them.&lt;/p&gt;

&lt;p&gt;These weren't problems with GitOps itself. They exposed the assumptions underneath our automation.&lt;/p&gt;

&lt;p&gt;This is what building the platform taught us about making infrastructure provisioning reliable, and what our production observations and resource analysis revealed about its limitations.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;We built a GitOps platform in roughly &lt;strong&gt;two weeks&lt;/strong&gt;, using ApplicationSets, Argo CD, and Helm, to provision isolated customer evaluation environments.&lt;/li&gt;
&lt;li&gt;A documented cold-provisioning run reached &lt;code&gt;Synced+Healthy&lt;/code&gt; in about &lt;strong&gt;110 seconds&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Production incidents exposed a Redis/NATS startup race that left readiness checks returning &lt;strong&gt;HTTP 503 for more than three minutes&lt;/strong&gt; across two client environments.&lt;/li&gt;
&lt;li&gt;We added a TCP-connectivity gate across &lt;strong&gt;23 Deployments&lt;/strong&gt;, along with explicit deployment ordering and changes to migration recovery.&lt;/li&gt;
&lt;li&gt;A historical CPU observation reported &lt;strong&gt;39m used against 1,230m reserved&lt;/strong&gt;, though the original record carries an unresolved inconsistency.&lt;/li&gt;
&lt;li&gt;Recalculating from the current Helm chart and live node capacity gives a projected &lt;strong&gt;~10.2 CPU-bound or ~10.9 memory-bound environments per node&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central lesson: declaring the desired state is only part of reliable GitOps. Dependencies, configuration compatibility, resource allocation, and recovery behavior matter just as much.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Platform’s Numbers: Build, Failures, Capacity&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Six figures frame the build and what operating it revealed. Each one is a historical observation or a calculation, not a repeated benchmark, and the sections that follow explain where each came from and what it does and does not prove.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Metric&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initial platform build&lt;/td&gt;
&lt;td&gt;~2 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering activity&lt;/td&gt;
&lt;td&gt;~194 commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold provisioning&lt;/td&gt;
&lt;td&gt;~110 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readiness failures&lt;/td&gt;
&lt;td&gt;HTTP 503 for over 3 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployments using the dependency-readiness gate&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projected capacity&lt;/td&gt;
&lt;td&gt;~10 CPU-bound or ~11 memory-bound environments per node&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The provisioning time and CPU figures are historical observations, not repeated benchmark results. The capacity figures are calculations based on current resource requests and node allocatable capacity, not results from a concurrent load test.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Original Provisioning Design&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The requirement was straightforward:&lt;/strong&gt; adding a client to Git should provision an isolated evaluation environment without an engineer manually creating Kubernetes resources.&lt;/p&gt;

&lt;p&gt;Each environment needed its own application stack, supporting services, configuration, secrets, and HTTPS. Some prospects retained their environments for weeks and occasionally used real data, making isolation and persistence as important as provisioning speed.&lt;/p&gt;

&lt;p&gt;We built the workflow around three components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ApplicationSets&lt;/strong&gt; generated Argo CD Applications from each client’s configuration, so one config entry became one environment definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm&lt;/strong&gt; rendered the actual Kubernetes resources from that definition, turning templates into concrete manifests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Argo CD&lt;/strong&gt; reconciled the cluster toward the desired state declared in Git, applying whatever the manifests described.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The provisioning flow looked 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%2F6iueri83yk1i05ee2m36.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%2F6iueri83yk1i05ee2m36.png" alt=" " width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This removed manual provisioning, but it didn't guarantee that dependencies were ready, configuration changes were compatible with existing tenants, or a running application was functionally correct.&lt;/p&gt;

&lt;p&gt;Those gaps became apparent as we operated the platform. They fall into four areas, and the rest of this article works through them in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. Ordering the Resources Was Not the Same as Them Being Ready&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Getting the resources to come up in the right order turned out to be easier than getting each one to actually be usable when the next one needed it. Two separate failures made that distinction clear, and a third showed that recovery had the same problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Database Migrations Ran Before the Database was Ready&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Our initial database migration used a pre-install hook that assumed the database would already be available. Sometimes the database was still starting when the migration tried to connect. The resource existed, but that did not mean it was accepting connections.&lt;/p&gt;

&lt;p&gt;We replaced the implicit, hook-based ordering with explicit Argo CD sync waves, which run groups of resources in a numbered sequence rather than all at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Dependencies
&lt;/span&gt;&lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt;

&lt;span class="c1"&gt;# Migrations and initialization
&lt;/span&gt;&lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;

&lt;span class="c1"&gt;# Application workloads
&lt;/span&gt;&lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This established the intended sequence: dependencies first, migrations and initialization next, and application workloads afterward.&lt;/p&gt;

&lt;p&gt;However, sync waves control resource ordering; they don't independently guarantee that a database is ready to accept connections. Argo CD's progression also depends on resource health assessment and hook behavior.&lt;/p&gt;

&lt;p&gt;A database can exist and still be unavailable. Resource creation and dependency readiness are different conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Redis and NATS Introduced Another Startup Race&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We encountered a more persistent version of this problem in the knowledge-service and parsing-service.&lt;/p&gt;

&lt;p&gt;Our production incident notes document two client environments where these services encountered &lt;code&gt;ECONNREFUSED&lt;/code&gt; while connecting to Redis and NATS. Their readiness endpoints continued returning HTTP 503 for more than three minutes.&lt;/p&gt;

&lt;p&gt;The immediate workaround was to delete the affected pod manually.&lt;/p&gt;

&lt;p&gt;The important detail was that sync waves had already placed Redis and NATS before the application workloads. Ordering alone had not prevented the race.&lt;/p&gt;

&lt;p&gt;The problem exposed two separate concerns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application could start before its dependencies accepted connections.&lt;/li&gt;
&lt;li&gt;The application's recovery behavior did not restore readiness as expected after the failed connections.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We introduced a &lt;code&gt;wait-for-redis-nats&lt;/code&gt; init container across 23 Deployments. It waits for TCP connectivity before allowing the application container to start.&lt;/p&gt;

&lt;p&gt;The distinction matters: &lt;strong&gt;sync waves control ordering; startup guards check connectivity.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Neither replaces functional validation. An open TCP port doesn't guarantee that a database can execute queries or that a broker can process messages. The init-container gate addresses startup timing, while application-level recovery must handle dependencies becoming unavailable after startup.&lt;/p&gt;

&lt;p&gt;The failure and mitigation can be understood as two different startup paths:&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%252F11%252Fa320c2df-3bf9-4327-82ba-985853ceb324.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%252F11%252Fa320c2df-3bf9-4327-82ba-985853ceb324.png%26w%3D3840%26q%3D75" alt="GitOps startup paths diagram comparing connection failures without Redis and NATS readiness checks with successful init-container validation" width="1920" height="1430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Deployment ordering and dependency readiness address different failure modes. Sync waves establish resource order; the init-container gate waits for TCP connectivity before the application starts.&lt;/em&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;What the Evidence Establishes&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The production incidents demonstrate that the startup race occurred and that manual intervention was needed. The implementation record documents the subsequent connectivity gate.&lt;/p&gt;

&lt;p&gt;They do not provide a controlled before-and-after comparison, repeated test runs, or a measured change in deployment success rate. We therefore treat this as an operational incident and an implementation change, rather than a quantified reliability benchmark.&lt;/p&gt;

&lt;p&gt;That distinction matters when evaluating infrastructure improvements: a real failure and a plausible fix are valuable engineering evidence, but they are not the same as a controlled experiment.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Migration Jobs Needed a Safe Recovery Strategy&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Existing Kubernetes Jobs couldn't always be updated and rerun in the way our deployment workflow required.&lt;/p&gt;

&lt;p&gt;For affected Jobs, we used Argo CD's &lt;code&gt;Replace=true&lt;/code&gt; and &lt;code&gt;Force=true&lt;/code&gt; sync options to allow replacement and recreation rather than relying on ordinary updates.&lt;/p&gt;

&lt;p&gt;Force replacement can delete and recreate a Job, causing its work to execute again. For database migrations, that is safe only when the operation is repeatable or has a deliberate recovery procedure.&lt;/p&gt;

&lt;p&gt;These flags don't make a migration idempotent. They change how resources are managed, not whether the work they perform can safely run twice.&lt;/p&gt;

&lt;p&gt;The lesson was that deployment recovery must account for the work a resource performs, not just whether Kubernetes can recreate it.&lt;/p&gt;

&lt;p&gt;Ordering and recovery dealt with resources coming up in the wrong sequence. The next set of failures came from the opposite direction: changes that applied cleanly and still broke environments that were already running.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. A Configuration Change Could Break Existing Tenants&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A change that worked perfectly on a fresh environment was not guaranteed to work on an old one. The platform had tenants that predated the change, and three separate incidents showed how a clean-looking update could still reach back and break them.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The Frontend Artifact Contained the Production API Endpoint&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We built the frontend centrally, with the production API endpoint embedded in the generated JavaScript. The endpoint appeared &lt;strong&gt;14 times across seven files&lt;/strong&gt; in the build artifact.&lt;/p&gt;

&lt;p&gt;Reusing that artifact in a customer environment could direct requests toward production even when the Kubernetes deployment appeared healthy.&lt;/p&gt;

&lt;p&gt;Rebuilding the frontend for every client was cleaner, but each build took several minutes and conflicted with our roughly two-minute boot target.&lt;/p&gt;

&lt;p&gt;We introduced a startup-time replacement mechanism that substituted the expected production endpoint with the client-specific endpoint.&lt;/p&gt;

&lt;p&gt;The startup script first checked that the expected production string existed. If it didn't, the container refused to start.&lt;/p&gt;

&lt;p&gt;This was a &lt;em&gt;fail-closed workaround&lt;/em&gt;, not a complete security control. It protected against the expected string being absent, but couldn't prove that every request used the correct endpoint or rule out other hardcoded endpoints.&lt;/p&gt;

&lt;p&gt;Runtime configuration or a properly environment-specific build would be a stronger long-term design.&lt;/p&gt;

&lt;p&gt;The incident reinforced an important distinction: a healthy deployment doesn't prove that the application is communicating with the correct environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Missing Secrets Caused Nine Deployment Deadlocks&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A chart change introduced a required secret or configuration value that existing client environments didn't all have.&lt;/p&gt;

&lt;p&gt;Affected pods entered &lt;code&gt;CreateContainerConfigError&lt;/code&gt; before the application started, leaving no useful application logs to inspect.&lt;/p&gt;

&lt;p&gt;Argo CD waited for the workload to become healthy. The current sync could remain stuck while the fix was waiting in a later Git revision, preventing the normal deployment workflow from applying the change needed to resolve the problem.&lt;/p&gt;

&lt;p&gt;We encountered this pattern nine times.&lt;/p&gt;

&lt;p&gt;We changed our deployment discipline: whenever a chart introduced a new required secret key, we updated existing client configurations in the same commit. We used &lt;code&gt;./scripts/&lt;/code&gt;&lt;a href="http://reseal-all.sh" rel="noopener noreferrer"&gt;&lt;code&gt;reseal-all.sh&lt;/code&gt;&lt;/a&gt; to reseal the key across existing tenant configurations.&lt;/p&gt;

&lt;p&gt;This made the chart change and its required configuration part of the same change set.&lt;/p&gt;

&lt;p&gt;A chart that works on a fresh installation can still break every existing tenant, so backward compatibility had to become part of the deployment process.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Tenant Isolation Required Deliberate Trade-offs&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We chose a namespace-per-client model, with separate application workloads and supporting services such as databases and caches.&lt;/p&gt;

&lt;p&gt;We kept cleanup manual because deleting an environment during an active customer evaluation was considered worse than paying for an unused one. The trade-off was that abandoned environments could continue consuming resources.&lt;/p&gt;

&lt;p&gt;Later, we separated development and production at the cluster level. Namespace separation alone wasn't enough to protect production from an operator accidentally running a destructive command against the wrong cluster.&lt;/p&gt;

&lt;p&gt;Namespaces remained one layer of isolation, not a complete security boundary. RBAC, secret access restrictions, network policies, and other controls still needed to match the threat model.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. A Successful Deployment Did Not Guarantee a Working Integration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Kubernetes can report a workload as healthy and Argo CD can report it as synced while the thing the workload is supposed to connect to is still broken. As the platform grew past 20 services, the gap between “deployed” and “actually working” became its own class of problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Certificate Issuance Became a Bottleneck&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Initially, we issued certificates per client. As environment provisioning became more frequent, certificate authority rate limits became a constraint.&lt;/p&gt;

&lt;p&gt;We moved first to a manually issued wildcard certificate, then to automated wildcard issuance using cert-manager with &lt;em&gt;DNS-01 validation.&lt;/em&gt; Certificate material was propagated into individual client namespaces.&lt;/p&gt;

&lt;p&gt;Each step traded one risk for another. The manual wildcard certificate eased the immediate issuance bottleneck but introduced renewal risk, since a human had to remember to renew it. Moving to cert-manager automated renewal and cut the manual work, while adding dependencies on the certificate controller, the DNS automation, its credentials, and secret propagation.&lt;/p&gt;

&lt;p&gt;Reliable certificate management takes more than successful issuance. Expiration, renewal status, propagation, and the certificate actually served to the client all have to be accounted for.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Deployed Integrations Weren't Always Working Integrations&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;As the platform grew to more than 20 services, we encountered problems that weren't obvious from deployment status.&lt;/p&gt;

&lt;p&gt;One configuration path could request broader Google permissions than intended. In another case, credentials existed but weren't wired into the service that needed them.&lt;/p&gt;

&lt;p&gt;We changed calendar and Gmail permissions to be opt-in per client and became more deliberate about verifying that credentials were connected to the components that depended on them.&lt;/p&gt;

&lt;p&gt;Three states are easy to conflate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The credential exists.&lt;/li&gt;
&lt;li&gt;The service is running.&lt;/li&gt;
&lt;li&gt;The feature works as intended.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each requires its own validation. Kubernetes health and Argo CD synchronization are useful signals, but they aren't substitutes for functional checks of external integrations.&lt;/p&gt;

&lt;p&gt;The broader lesson was that configuration must be traced all the way from the source of truth to the component that consumes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. Resource Allocation Was Limiting Capacity Unnecessarily&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The cluster had CPU to spare and was still blocking work. The limits we had set to keep any one client in check were doing their job too aggressively, and fixing that meant first understanding what the environments actually consumed, then recalculating capacity from the current configuration rather than an old assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Namespace Quotas Blocked Migrations despite Available CPU&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We introduced namespace quotas to prevent one client environment from consuming too much of the cluster.&lt;/p&gt;

&lt;p&gt;Once several services were running in a namespace, that quota started blocking migration Jobs, even though actual CPU consumption looked low against the reservations.&lt;/p&gt;

&lt;p&gt;A historical resource observation recorded approximately &lt;strong&gt;39 millicores of CPU usage against 1,230 millicores reserved.&lt;/strong&gt; The original record did not preserve the raw command output, an exact measurement timestamp, or repeated samples. It also contained an unresolved inconsistency, with another sentence referring to 1,225m out of 4.&lt;/p&gt;

&lt;p&gt;We retain the 39m and 1,230m figures as historical observations rather than treating them as a validated benchmark.&lt;/p&gt;

&lt;p&gt;We reduced CPU requests to better reflect observed consumption, removed the namespace quota that was blocking migrations, and retained per-container limits to constrain individual workloads. The configuration included a maximum of four CPUs and 4 GiB of memory per container.&lt;/p&gt;

&lt;p&gt;The observation helped identify a potential resource-allocation problem. It did not establish average CPU consumption over time or prove how many environments could run safely under peak load.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Recalculating Capacity Using the Current Configuration&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Capacity estimates are only as accurate as the resource configuration behind them. As services are added, enabled, or resized, the total CPU and memory requests per environment change, affecting how many environments a node can accommodate.&lt;/p&gt;

&lt;p&gt;We recalculated capacity by summing the CPU and memory requests of all enabled services in the current Helm configuration and comparing those totals against the node's allocatable resources.&lt;/p&gt;

&lt;p&gt;The calculation uses two constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU-bound capacity:&lt;/strong&gt; available allocatable CPU divided by total CPU requests per environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory-bound capacity:&lt;/strong&gt; available allocatable memory divided by total memory requests per environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The resulting estimates indicate which resource is likely to become the scheduling bottleneck first.&lt;/p&gt;

&lt;p&gt;These figures represent theoretical capacity, not guaranteed production limits. They do not fully account for workload bursts, operational headroom, scheduling constraints, or actual runtime consumption. Concurrent workload testing is needed to establish practical capacity under real operating conditions.&lt;/p&gt;

&lt;p&gt;The key lesson is that &lt;a href="https://geekyants.com/engineering/devops/scalability-performance-planning-service" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;capacity planning&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; must follow the current configuration. Estimates derived from an earlier chart version can become stale as the platform evolves, even when the underlying infrastructure remains unchanged.&lt;/p&gt;

&lt;p&gt;The four problems were all things that went wrong. It is worth stepping back to the thing that went right, and being just as precise about what that result does and does not prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Provisioning an Environment in About 110 Seconds&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The platform's README records a cold-provisioning observation of approximately &lt;strong&gt;110 seconds&lt;/strong&gt;, measured from a Git push until Argo CD reported the environment as &lt;code&gt;Synced+Healthy&lt;/code&gt;, starting with an empty namespace.&lt;/p&gt;

&lt;p&gt;This demonstrates that the ApplicationSet discovery, Helm rendering, and Argo CD reconciliation workflow could provision an environment without manually creating Kubernetes resources.&lt;/p&gt;

&lt;p&gt;The available record describes one measured run. It does not include raw timestamps, a sample count, or a script that reproduces the timing. We therefore present 110 seconds as a recorded cold-provisioning observation, not a guaranteed provisioning time or a statistical performance result.&lt;/p&gt;

&lt;p&gt;It is also important to understand what the endpoint represents. &lt;code&gt;Synced+Healthy&lt;/code&gt; describes Argo CD's view of the managed resources; it does not independently establish that every application feature, external integration, or customer workflow is functioning correctly.&lt;/p&gt;

&lt;p&gt;A complete environment still needed its dependencies, secrets, configuration, certificates, and integrations to work as intended.&lt;/p&gt;

&lt;p&gt;Reducing the time to create Kubernetes resources was useful. Making the resulting environment dependable was the harder engineering challenge.&lt;/p&gt;

&lt;p&gt;Each section so far has paired a finding with a note on how strong the evidence behind it is. Gathered in one place, that honesty becomes its own point: not every improvement here is backed by the same kind of proof, and it helps to see exactly which is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What the Implementation Shows, and What Stays Unmeasured&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The platform gave us concrete operational evidence about several failure modes. Not every improvement, though, is backed by a controlled benchmark, and the table below sorts each finding by the kind of evidence behind it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Finding&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Evidence available&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;What it establishes&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependency startup race&lt;/td&gt;
&lt;td&gt;Two documented client incidents with connection refusals and prolonged HTTP 503 readiness&lt;/td&gt;
&lt;td&gt;The failure occurred in production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis/NATS connectivity gate&lt;/td&gt;
&lt;td&gt;Implementation across 23 Deployments&lt;/td&gt;
&lt;td&gt;The startup guard was introduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU utilization versus reservations&lt;/td&gt;
&lt;td&gt;One historical &lt;code&gt;kubectl top&lt;/code&gt; observation&lt;/td&gt;
&lt;td&gt;A potential resource-request mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold provisioning&lt;/td&gt;
&lt;td&gt;One recorded run of approximately 110 seconds&lt;/td&gt;
&lt;td&gt;The workflow completed within that observed time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Current node capacity&lt;/td&gt;
&lt;td&gt;Live node allocatable resources and current chart requests&lt;/td&gt;
&lt;td&gt;A reproducible theoretical capacity calculation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We have not run a controlled A/B experiment comparing dependency startup with and without the readiness gate. The production incidents were uncontrolled observations, not repeated tests under identical conditions.&lt;/p&gt;

&lt;p&gt;That means we cannot quantify the gate's effect on startup success rate, median time to readiness, or recovery time from the available records.&lt;/p&gt;

&lt;p&gt;A minimal isolated test with a deliberately delayed Redis or NATS dependency would be the next step to quantify that improvement. Such a test should record repeated runs with and without the gate and preserve the actual output.&lt;/p&gt;

&lt;p&gt;The same principle applies to capacity. The current calculation is useful for planning, but concurrent workload testing would be necessary to validate a production operating limit.&lt;/p&gt;

&lt;p&gt;Distinguishing these evidence levels is part of the engineering discipline: &lt;em&gt;historical observations, calculations, and controlled experiments answer different questions.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why We Stopped Trusting “Deployed” as “Done”&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Four habits carried across the incidents, and each one came directly from a failure above. They share a single idea: a clean deployment is a claim about the system, not proof the system works, so each habit is a way of checking what the deployment status does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. We Evaluated Changes Across the Entire Environment.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A chart change could require a secret update; a service change could affect an integration; and certificate automation could introduce DNS and credential dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. We Separated Deployment Status from Application Correctness.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A successful sync didn't prove that a database was reachable, a frontend targeted the right API, or an integration worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. We Designed for Recovery.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A workflow needed a way to apply a fix when a resource was stuck, and a Job replacement strategy needed to account for whether its work could safely run again.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4. We Kept the Resource Model Current&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A capacity estimate built on an older chart grows misleading as services and their requests change, so the model has to be recalculated against the configuration actually running.&lt;/p&gt;

&lt;p&gt;These weren't isolated bugs. They exposed recurring assumptions about dependencies, configuration, resource allocation, and the lifecycle of existing environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A Reproducible GitOps Reference You Can Start From&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We created a public reference repository to demonstrate the GitOps provisioning pattern without exposing proprietary project configuration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/AdityaPrakash2811/gitops-environment-provisioning-template" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;gitops-environment-provisioning-template&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository illustrates the core provisioning structure, including client discovery through ApplicationSets, a shared Helm chart, and deployment configuration.&lt;/p&gt;

&lt;p&gt;It provides a starting point for engineers exploring how to organize a GitOps-driven environment provisioning workflow.&lt;/p&gt;

&lt;p&gt;The implementation makes the desired state explicit. The operational lessons show why that is not sufficient on its own: dependencies need readiness checks, configuration changes must account for existing tenants, and recovery procedures must respect the work performed by each resource.&lt;/p&gt;

&lt;p&gt;The historical production incidents and resource observations described in this article come from the original platform's operational records. The repository is a public reference implementation, not a claim that the original production environment or all its measurements can be reproduced from the template.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What “Push to Deploy” Actually Meant&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We started with a simple requirement: add a client to Git and provision an environment without manually creating Kubernetes resources.&lt;/p&gt;

&lt;p&gt;We got that flow working. The harder work was making it predictable when a database wasn't ready, a broker was still starting, a secret was missing, a certificate needed renewal, or a configuration change broke an existing tenant.&lt;/p&gt;

&lt;p&gt;GitOps gave us a way to declare and reconcile the desired state. It didn't remove the need to understand runtime behavior, security boundaries, resource consumption, integration correctness, or recovery.&lt;/p&gt;

&lt;p&gt;Our production incidents showed where the initial assumptions failed. Our resource analysis showed why capacity calculations must follow the current configuration. And our provisioning observation demonstrated that the core workflow could complete in roughly 110 seconds under the recorded conditions.&lt;/p&gt;

&lt;p&gt;The remaining challenge is to turn those operational improvements into repeatable measurements that quantify their impact.&lt;/p&gt;

&lt;p&gt;Reliable provisioning came from three things: making dependencies explicit, being honest about the limits of our evidence, and designing around how the system actually behaved rather than how we expected it to. That was the real work behind “push to deploy.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This Kind of Work Is Its Own Discipline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Building provisioning that stays reliable once real environments depend on it, dependency ordering, backward-compatible configuration, recovery that respects each resource, and capacity that tracks the live chart, is the work GeekyAnts does through its &lt;a href="https://geekyants.com/engineering/devops" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;DevOps engineering&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; practice, which covers CI/CD, Kubernetes, &lt;a href="https://geekyants.com/engineering/devops/infrastructure-as-code" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;infrastructure as code&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;, observability, and production reliability.&lt;/p&gt;

</description>
      <category>techtalks</category>
      <category>devops</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <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>My Flutter App Froze With Three Photos on Screen. Here's What I Was Doing Wrong</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Fri, 13 Nov 2026 08:16:51 +0000</pubDate>
      <link>https://dev.to/geekyants-inc/my-flutter-app-froze-with-three-photos-on-screen-heres-what-i-was-doing-wrong-11h5</link>
      <guid>https://dev.to/geekyants-inc/my-flutter-app-froze-with-three-photos-on-screen-heres-what-i-was-doing-wrong-11h5</guid>
      <description>&lt;p&gt;This blog explains how rethinking Flutter’s image-processing architecture fixed severe performance issues and improved rendering efficiency.&lt;/p&gt;

&lt;p&gt;How chasing the right optimizations in the wrong place locked up a Flutter carousel that had plenty of hardware to spare.&lt;/p&gt;

&lt;p&gt;I had done everything right.&lt;/p&gt;

&lt;p&gt;Isolates for heavy processing, caching for repeated renders, viewport-aware lazy loading so off-screen photos were not burning cycles. This was textbook &lt;a href="https://geekyants.com/engineering/mobile-engineering/mobile-app-performance-audits-optimization-services" rel="noopener noreferrer"&gt;Flutter performance optimization&lt;/a&gt;, but the app still lagged, even on a flagship Android phone with 12 GB of RAM.&lt;/p&gt;

&lt;p&gt;That is the thing about architectural mistakes. They do not care how clean your implementation is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The App, and Where It Started Hurting
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Rishab-ms/InstaFramer" rel="noopener noreferrer"&gt;InstaFramer&lt;/a&gt; is an open-source bulk image editor for Instagram creators. The core use case is simple: take a batch of photos, fit them into Instagram's aspect ratios without cropping, and export them. To fill the empty space around the photo, users can pick a white background, a black background, or an extended blur background (the same photo, blurred and stretched to fill the frame).&lt;/p&gt;

&lt;p&gt;The editor screen has a swipeable carousel. Select up to 20 photos, swipe through them, tweak the settings, and export.&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%2Fpdqstofb9oeoukaekvs8.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%2Fpdqstofb9oeoukaekvs8.png" alt=" " width="799" height="595"&gt;&lt;/a&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%2Fe2svmh2qvl5uu7y983mf.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%2Fe2svmh2qvl5uu7y983mf.png" alt=" " width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Even with white or black backgrounds, the carousel was not silky smooth. Performance was passable on a flagship device, but the moment blur entered the picture, it became completely unusable. For each card, showing the real-time blur preview, in which the original photo is centered and scaled over a blurred version of itself, required a heavy and repeated process: decoding the image, resizing it, running a Gaussian blur, and compositing the result every time the settings changed.&lt;/p&gt;

&lt;p&gt;I reached for the standard toolkit. Background isolates via compute() for the heavy lifting, a FutureBuilder in each PhotoCard to handle the async result, and Image.memory() to render the final Uint8List. Isolates are Dart's concurrency model; they run in separate memory spaces, so any data passed back to the main thread has to be serialized across that boundary.&lt;/p&gt;

&lt;p&gt;On paper, this is correct Flutter. In practice, it was burning memory and CPU cycles far beyond what a background preview pipeline should ever need.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Correct" Looked Like
&lt;/h2&gt;

&lt;p&gt;Here is the PhotoCard build method at this stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;child: FutureBuilder&amp;lt;Uint8List?&amp;gt;(
  future: _generatePreview(widget.photo, widget.settings),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return _buildLoadingState(); // spinner, "Processing preview..."
    }
    if (snapshot.hasError || !snapshot.hasData) {
      return _buildErrorState();
    }
    return AspectRatio(
      aspectRatio: widget.settings.aspectRatio.ratio,
      child: Image.memory(snapshot.data!, fit: BoxFit.contain),
    );
  },
),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And _generatePreview was calling imageProcessor.processPreview(photo, settings), which ran in a background isolate, decoded the full image, downsampled it, ran the Gaussian blur, and returned a Uint8List back across the isolate boundary.&lt;/p&gt;

&lt;p&gt;For one photo, this is fine. For 20 photos in a carousel, where each swipe could trigger a rebuild and changing the blur intensity slider could invalidate all cached previews simultaneously, this was a performance disaster in slow motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The App Froze While I Was Profiling the Freeze
&lt;/h2&gt;

&lt;p&gt;I found out exactly how bad it was during a single debugging session.&lt;/p&gt;

&lt;p&gt;At 10:54, I opened the CPU flame chart. The frames were taking close to a second to complete.&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%2Fwpctncepiqqhz3t94hja.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%2Fwpctncepiqqhz3t94hja.png" alt=" " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 10:55, the performance overlay confirmed what I was seeing: 34 FPS average, with orange jank bars on nearly every frame.&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%2Fmz40pglkbz1xz4ld51h2.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%2Fmz40pglkbz1xz4ld51h2.png" alt=" " width="800" height="197"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 10:57, while I was still looking at the data, this appeared:&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%2Ffryutf4tzvag9pk00zk8.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%2Ffryutf4tzvag9pk00zk8.png" alt=" " width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app had frozen while I was profiling the freeze.&lt;/p&gt;

&lt;p&gt;Three photos: Not ten, not twenty. Three. And the app was already choking.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Quick Note on Why Isolates Were Not the Problem
&lt;/h3&gt;

&lt;p&gt;Before going further: isolates are not the villain here. They are the right tool for CPU-heavy work, and they genuinely helped during the export phase (more on that later).&lt;/p&gt;

&lt;p&gt;The problem was specifically using isolates to generate UI previews: work that was happening on every render, on every card, in a live scrolling carousel. Every preview was a full round trip: decode on the isolate, Gaussian blur on the isolate, serialize the result into a Uint8List, ship it back across the isolate boundary, and hand it to the GPU to display.&lt;/p&gt;

&lt;p&gt;The question I had not asked yet was simple: does this actually need to be a CPU operation at all?&lt;/p&gt;

&lt;p&gt;I was not asking it yet. First, I tried to fix what I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fixes That Almost Worked
&lt;/h2&gt;

&lt;p&gt;The obvious move after a freeze like that is to reduce scope. Stop processing everything at once. Be smarter about when and what to compute. The instincts were sound. The problem was that acting on them meant optimizing a pipeline that should not have existed in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  False Fix 1: Viewport-Aware Lazy Loading
&lt;/h3&gt;

&lt;p&gt;The first patch came from a straightforward diagnosis: the carousel was firing off preview generation for all 20 photos the moment the screen loaded. Even cards the user had not looked at yet were running _generatePreview, spinning up isolates, and allocating memory in parallel.&lt;/p&gt;

&lt;p&gt;The fix was viewport gating. Only process photos that are actually visible or immediately adjacent. Every card beyond a distance of 1 from the current index gets a grey placeholder instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bool _isPhotoInViewport(int photoIndex, int currentIndex) {
  final distance = (photoIndex - currentIndex).abs();
  return distance &amp;lt;= 1;
}

child: isInViewport
    ? FutureBuilder&amp;lt;Uint8List?&amp;gt;(
        future: _generatePreview(widget.photo, widget.settings),
        ...
      )
    : _buildViewportPlaceholder(...),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked. Measurably. Instead of 20 concurrent decode-blur-serialize pipelines competing for memory, there were at most 3. The freeze window narrowed considerably. A 20-photo session that previously locked up immediately became usable for longer.&lt;/p&gt;

&lt;p&gt;But the root cause was completely intact. Every time you swiped to a new card, the full pipeline kicked in:&lt;/p&gt;

&lt;p&gt;Decode on the isolate → Gaussian blur on the isolate → Serialize the result → Cross the isolate boundary → Allocate a fresh Uint8List → Hand it to the GPU&lt;/p&gt;

&lt;p&gt;The viewport gate just reduced how many times it fired.&lt;/p&gt;

&lt;h3&gt;
  
  
  False Fix 2: LRU Cache + Cache-First Rendering
&lt;/h3&gt;

&lt;p&gt;The second patch addressed a different inefficiency: cards that had already been processed were being reprocessed on every rebuild. The cache was the logical answer.&lt;/p&gt;

&lt;p&gt;An LRU cache with maxCacheSize = 12 and priority-based eviction (current photo = 100, adjacent = 50, others = 10). The viewport logic moved from inside PhotoCard up to the carousel level (a cleaner separation that made the cache lookup straightforward). Before falling through to the FutureBuilder, check the cache first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (widget.previewCache.containsKey(cacheKey)) {
  return AspectRatio(
    aspectRatio: widget.settings.aspectRatio.ratio,
    child: Image.memory(widget.previewCache[cacheKey]!, fit: BoxFit.contain),
  );
}

// Cache miss, still falls through to FutureBuilder
return FutureBuilder&amp;lt;Uint8List?&amp;gt;(...);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cache hit was instant. No async, no spinner, no isolate. For photos the user had already visited, this felt genuinely snappy.&lt;/p&gt;

&lt;p&gt;For everything else, the full round trip was still happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Both Fixes Had in Common
&lt;/h2&gt;

&lt;p&gt;Each fix was a layer of insulation around the real problem. Viewport gating reduced how many cards triggered the pipeline. The cache reduced how often any given card triggered it. Between the two, the freezes became less frequent and took longer to surface. The blast radius shrank. But neither one touched the underlying assumption: that generating a blurred preview was a CPU task.&lt;/p&gt;

&lt;p&gt;Every cache miss, which means every first load of every card, was still doing the same thing: CPU decodes the image, CPU blurs it, CPU serializes the pixels into a Uint8List, that Uint8List crosses the isolate boundary, gets allocated on the main heap, and then gets handed to the GPU to draw.&lt;/p&gt;

&lt;p&gt;The GPU was the final recipient of this data every single time. It was rendering the pixels the CPU had spent so much work preparing.&lt;/p&gt;

&lt;p&gt;So: why was the CPU doing this at all?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Question I Had Been Avoiding
&lt;/h2&gt;

&lt;p&gt;Two commits in, the freeze required more photos to trigger. The jank required none.&lt;/p&gt;

&lt;p&gt;The assumption underneath all of it: the preview the user sees should be pixel-identical to the exported output. Many image-editing workflows separate fast edit previews from the final processed output. But I had been treating that as someone else's product decision, not a performance insight I could borrow.&lt;/p&gt;

&lt;p&gt;That assumption had survived two commits completely untouched. It was time to poke it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Test
&lt;/h3&gt;

&lt;p&gt;I rewrote PhotoCard to use Stack + AssetEntityImage + BackdropFilter, ran an export on the same photo through the existing CPU pipeline, and pulled both outputs up side by side on my phone.&lt;/p&gt;

&lt;p&gt;Then I zoomed in. Then I zoomed in more. I dragged both images around looking for any sign the GPU compositor had cut a corner somewhere. Color cast, edge softness, blur falloff, anything. Something had to be off. The CPU pipeline was doing actual Gaussian math. The widget tree was just compositing.&lt;/p&gt;

&lt;p&gt;There was nothing there.&lt;/p&gt;

&lt;p&gt;Same softness. Same falloff. Same color cast. The GPU compositor had been producing a visually identical result the entire time, as a byproduct of normal widget rendering. I had been bypassing it with a full decode-blur-serialize-transfer pipeline, paying the complete memory and compute cost, to produce the same output the widget tree was already giving me for free.&lt;/p&gt;

&lt;p&gt;Flutter was already solving my problem. I just was not asking it to.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Redesign
&lt;/h3&gt;

&lt;p&gt;Here is PhotoCard after the pivot, now using AssetEntityImage from the photo_manager package instead of Image.memory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class PhotoCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      child: AspectRatio(
        aspectRatio: settings.aspectRatio.ratio,
        child: Stack(
          fit: StackFit.expand,
          children: [
            _buildBackground(context),
            Center(
              child: Transform.scale(
                scale: settings.scale,
                child: AssetEntityImage(
                  photo,
                  isOriginal: false,
                  thumbnailSize: const ThumbnailSize.square(800),
                  fit: BoxFit.contain,
                  filterQuality: FilterQuality.medium,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildBackground(BuildContext context) {
    switch (settings.backgroundType) {
      case BackgroundType.extendedBlur:
        return Stack(
          fit: StackFit.expand,
          children: [
            AssetEntityImage(
              photo,
              isOriginal: false,
              thumbnailSize: const ThumbnailSize.square(150),
              fit: BoxFit.cover,
            ),
            BackdropFilter(
              filter: ui.ImageFilter.blur(
                sigmaX: settings.blurIntensity * 0.5,
                sigmaY: settings.blurIntensity * 0.5,
              ),
              child: Container(color: Colors.black.withOpacity(0.1)),
            ),
          ],
        );
      case BackgroundType.white:
        return Container(color: Colors.white);
      case BackgroundType.black:
        return Container(color: Colors.black);
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BackdropFilter applies the blur via the GPU compositor directly during the render pass, so there's no CPU pixel work involved. The background layer requests an intentionally small ThumbnailSize.square(150) thumbnail. Blur hides the resolution artifacts at this preview size, so 150 px is visually indistinguishable from full resolution once the shader is applied. The foreground (the layer the user actually looks at) gets ThumbnailSize.square(800). Higher fidelity where it matters, smaller where it does not.&lt;/p&gt;

&lt;p&gt;blurIntensity * 0.5 as sigma was tuned to match the visual feel of the CPU-rendered export. The side-by-side test confirmed it.&lt;/p&gt;

&lt;p&gt;There is no FutureBuilder. No spinner. No _generatePreview. No Uint8List. PhotoCard is now a StatelessWidget because there is genuinely no state left to manage.&lt;/p&gt;

&lt;p&gt;The full architecture shift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// BEFORE
Photo → compute() isolate → decode → Gaussian blur
      → Uint8List → isolate boundary → Image.memory() → GPU

// AFTER
AssetEntityImage (150px thumbnail) → BackdropFilter (GPU shader) → GP compositor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CPU no longer touches pixel data for previews.&lt;/p&gt;

&lt;p&gt;With PhotoCard stateless, allowImplicitScrolling: true on the carousel became safe. Previously, implicit scrolling could wake up off-screen cards and trigger preview processing. After the pivot, the actual comment left in the carousel code reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// We don't need complex viewport logic anymore because
// PhotoCard is now just lightweight widgets.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The viewport tracking was deleted. The LRU cache was deleted. The _previewCache parameter that had been threading through EditorScreen into PhotoCarousel into every PhotoCard was gone.&lt;/p&gt;

&lt;p&gt;Net diff of this single commit: +304 lines added, -783 lines deleted.&lt;/p&gt;

&lt;p&gt;The optimized solution is less code!&lt;/p&gt;

&lt;h2&gt;
  
  
  Export Is Where Isolates Actually Belong
&lt;/h2&gt;

&lt;p&gt;The GPU pivot fixed previews. Export is a genuine CPU job: decode, blur, composite, encode, write to disk.&lt;/p&gt;

&lt;p&gt;Previews needed to get off the CPU entirely. Export needs to use the CPU better.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batching Instead of Queuing
&lt;/h3&gt;

&lt;p&gt;The original export loop was sequential. One photo at a time, fully awaited, then the next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for (int i = 0; i &amp;lt; photos.length; i++) {
  final processedBytes = await _imageProcessor.processImage(asset, settings);
  await tempFile.writeAsBytes(processedBytes);
  await Gal.putImage(tempFile.path); // gal package — saves to device gallery
  yield i + 1;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is safe, but it is also slow. On a 10-photo batch, every idle CPU core means more time the user spends staring at a progress bar.&lt;/p&gt;

&lt;p&gt;The fix was controlled concurrency. Process three photos at a time with Future.wait, yield progress after each batch completes, then start the next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;final batchSize = 3; // currently hardcoded at 3

for (var batchStart = 0; batchStart &amp;lt; photos.length; batchStart += batchSize) {
  final batchEnd = (batchStart + batchSize).clamp(0, photos.length);
  final batch = photos.sublist(batchStart, batchEnd);

  final batchFutures = batch.asMap().entries.map((entry) async {
    final asset = entry.value;
    Uint8List? originBytes = await asset.originBytes;
    if (originBytes == null) return null;

    Uint8List? processedBytes = await _imageProcessor.processImage(
      originBytes, settings, isExportProcessing: true,
    );
    if (preserveMetadata) {
      processedBytes = _preserveMetadata(originBytes, processedBytes);
    }
    originBytes = null; // GC hint — more on this below

    final tempFile = File('${exportDir.path}/$exportFileName');
    await tempFile.writeAsBytes(processedBytes);
    await Gal.putImage(tempFile.path);
    await tempFile.delete();
    return globalIndex + 1;
  });

  final batchResults = await Future.wait(batchFutures);
  for (final result in batchResults) { yield result; }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why three and not five or eight? Processing more full-resolution images concurrently means more multi-megabyte buffers alive simultaneously. Push it too far and the memory pressure starts to resemble the original preview freeze. Three was a conservative starting point rather than a benchmarked optimum, low enough to stay well clear of the memory pressure that caused the original freeze. It has held up across everything I tested, from a three-year-old midrange Pixel to a current flagship, and I have not had reason to push it higher.&lt;/p&gt;

&lt;p&gt;Notice originBytes = null inside the batch closure. The export service loads the raw bytes, uses them for processing and metadata, then explicitly nulls the reference. This is a hint to the Dart garbage collector that the buffer is reclaimable. It does not guarantee immediate collection, but with three multi-megabyte buffers alive concurrently, narrowing the window each one stays pinned should reduce peak memory pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making Export Feel Instant
&lt;/h3&gt;

&lt;p&gt;Even after batching, there was a UX problem. Tapping "Export" felt unresponsive. The screen would hang on the editor view for a beat before the processing screen appeared. I never fully isolated the cause. The symptom was obvious and the fix was straightforward.&lt;/p&gt;

&lt;p&gt;Two things, back to back. First, emit a progress state at 0% before any work starts. This forces the UI to switch to the processing view immediately. Second, yield to the event loop before starting the export pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;emit(PhotosProcessingState(
  current: 0,
  total: currentState.photos.length,
  backgroundType: currentState.settings.backgroundType,
));

await Future.delayed(const Duration(milliseconds: 50));

await for (final progress in _exportService.exportPhotos(...)) {
  emit(PhotosProcessingState(...));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 50 ms delay gives the framework time to render the 0% state before the export pipeline starts competing for resources. With it, the processing view renders first and the heavy work runs behind it. The difference between "nothing happened" and "it is already working" is 50 milliseconds of intentional patience.&lt;/p&gt;

&lt;h2&gt;
  
  
  One More Pass: BackdropFilter vs ImageFiltered
&lt;/h2&gt;

&lt;p&gt;BackdropFilter got the preview off the CPU, but it was not the cheapest way to do it. BackdropFilter blurs whatever is already painted behind it, which means the engine has to save a layer and read back the framebuffer contents underneath. That machinery exists for effects like frosted glass over something dynamic behind it. In PhotoCard, the only thing being blurred is the single AssetEntityImage sitting directly below it in the Stack. Nothing dynamic behind it, so BackdropFilter's actual capability, blurring arbitrary content behind it, was never being used.&lt;/p&gt;

&lt;p&gt;ImageFiltered applies the same blur directly to its own child's paint pass, no readback required. I swapped it in and profiled both under the same stress test: eight full-range drags on the blur intensity slider, 90 sampled frames each, profile mode on the emulator. The average raster time was close: 2.56 ms for BackdropFilter against 2.09 ms for ImageFiltered. The gap that mattered was in the worst frames. BackdropFilter's slowest frame during the drag hit 29.49 ms, past the 16.67 ms budget for 60 FPS. ImageFiltered's worst frame stayed at 6.38 ms. The saveLayer and framebuffer readback that BackdropFilter needs are not a steady tax. They show up as occasional spikes, and a slider drag is exactly the kind of interaction that exposes them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;case BackgroundType.extendedBlur:
  return Stack(
    fit: StackFit.expand,
    children: [
      // Nothing sits behind this layer, so filter the image itself
      // rather than backdrop-filtering the stack.
      ImageFiltered(
        imageFilter: ui.ImageFilter.blur(
          sigmaX: settings.blurIntensity * 0.5,
          sigmaY: settings.blurIntensity * 0.5,
        ),
        child: AssetEntityImage(
          photo,
          isOriginal: false,
          thumbnailSize: const ThumbnailSize.square(150),
          fit: BoxFit.cover,
        ),
      ),
      Container(
        color: Colors.black.withOpacity(0.1),
      ),
    ],
  );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Actual Lesson
&lt;/h2&gt;

&lt;p&gt;I had done everything right. That was the problem.&lt;/p&gt;

&lt;p&gt;The isolates were correct. The cache was correct. The viewport gating was correct. Each one was a legitimate fix for a real inefficiency. Competently implemented, well-reasoned, and aimed squarely at the wrong thing.&lt;/p&gt;

&lt;p&gt;None of them asked the one question that actually mattered: does this work belong on the CPU at all?&lt;/p&gt;

&lt;p&gt;The GPU compositor handles blur as a byproduct of normal rendering. The thumbnail is already there. The shader runs in place. The CPU was running a full Gaussian pipeline to produce output the widget tree was already generating for free, one frame at a time, without being asked.&lt;/p&gt;

&lt;p&gt;Before reaching for isolates, before sizing your cache, before writing a single viewport check, figure out which layer the work actually belongs to.&lt;/p&gt;

&lt;p&gt;Flutter already had it handled. I just had to get out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Flutter Apps That Stay Fast Under Real Workloads
&lt;/h2&gt;

&lt;p&gt;Flutter performance problems are not always solved by adding more caching, concurrency, or processing logic. As this experience shows, the larger gain can come from identifying which part of the rendering architecture should handle the work and removing unnecessary processing from the path. For teams working through similar performance and architecture challenges, explore our &lt;a href="https://geekyants.com/flutter-app-development-services" rel="noopener noreferrer"&gt;Flutter app development services&lt;/a&gt;, which include application development, performance optimization, integrations, maintenance, and quality assurance.&lt;/p&gt;

</description>
      <category>flutter</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>Master Cursor Custom Rules to Align Gen AI with Your Code</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Thu, 05 Nov 2026 06:21:15 +0000</pubDate>
      <link>https://dev.to/geekyants-inc/master-cursor-custom-rules-to-align-gen-ai-with-your-code-3h04</link>
      <guid>https://dev.to/geekyants-inc/master-cursor-custom-rules-to-align-gen-ai-with-your-code-3h04</guid>
      <description>&lt;p&gt;Learn how to use Cursor Custom Rules to guide AI-assisted coding. Explore project rules, .mdc files, setup steps, practical use cases, and best practices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editor’s Note:&lt;/strong&gt; This article was &lt;strong&gt;originally written by Ajinkya Vinayak Palaskar, Software Engineer III&lt;/strong&gt;, and &lt;strong&gt;published in May 2025.&lt;/strong&gt; It was &lt;strong&gt;revised and updated by Harrini in October 2026&lt;/strong&gt; to reflect the latest Cursor Custom Rules features, rule formats, configuration options, and AI-assisted development practices. The technical content was &lt;strong&gt;reviewed by Ajinkya Vinayak Palaskar, Software Engineer III.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://geekyants.com/service/generative-ai-development-services" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;Generative AI&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; tools like Cursor are changing the way developers write code, but let’s be honest, the default AI behavior doesn’t always match how you or your team &lt;a href="https://geekyants.com/service/enterprise-software-development-services" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;builds software&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt;. Whether it’s naming conventions, project structure, or the way you wire up API calls, out-of-the-box AI can feel like working with a junior dev who doesn’t quite get the vibe yet.&lt;/p&gt;

&lt;p&gt;That’s where Cursor’s Custom Rules come in. Instead of adapting your codebase to fit AI’s suggestions, you can flip the script and make AI generate code that follows your standards, your structure, and your expectations.&lt;/p&gt;

&lt;p&gt;In this blog, we’ll dive into what Cursor’s rules are, how they work, and how you can use them to bring structure, consistency, and actual team alignment to your AI-powered workflow. Just real, dev-focused examples that help you go from “AI that kind of helps” to “AI that codes like your best junior dev (who doesn’t forget lint rules).”&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Understanding Cursor’s Custom Rules&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;At its core, Cursor’s Custom Rules feature gives developers a way to shape the behavior of the &lt;a href="https://geekyants.com/blog/from-chat-to-action-the-future-of-ai-assistants-with-react-native" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;AI assistant&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; so it actually respects your coding preferences. It’s not just about giving it tips; it’s about creating structured, repeatable rules that get applied every time you prompt it in certain files or folders.&lt;/p&gt;

&lt;p&gt;Cursor supports two types of rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Project Rules:&lt;/strong&gt; These are project-specific rules stored in .cursor/rules/ and committed to version control. They apply to everyone working in the repo, super useful for enforcing team-wide patterns, boilerplate structures, or naming conventions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User Rules:&lt;/strong&gt; These live locally and are scoped only to your Cursor environment. Think of them as your personal tweaks or power-ups for one-off or experimental cases. Cursor stores them in your user settings directory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;To sum it up:&lt;/strong&gt; Project rules = team alignment. User rules = personal productivity boosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How Do These Rules Actually Work?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cursor rules define instructions that guide an agent when it works on a project. Depending on how a rule is configured, it can be applied automatically based on the task, to specific files, or manually when selected.&lt;/p&gt;

&lt;p&gt;Rules can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Description:&lt;/strong&gt; Explains what the rule is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File patterns:&lt;/strong&gt; Define which files the rule targets when using file-specific rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instructions:&lt;/strong&gt; Tell an agent how to work with the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File references:&lt;/strong&gt; Add project context with references such as &lt;a class="mentioned-user" href="https://dev.to/src"&gt;@src&lt;/a&gt;/lib/validators.ts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cursor supports four application modes: &lt;strong&gt;Always Apply&lt;/strong&gt;, &lt;strong&gt;Apply Intelligently&lt;/strong&gt;, &lt;strong&gt;Apply to Specific Files&lt;/strong&gt;, and &lt;strong&gt;Apply Manually&lt;/strong&gt;. This lets teams choose whether an instruction should be active everywhere, selected based on relevance, tied to particular files, or triggered explicitly.&lt;/p&gt;

&lt;p&gt;One important distinction is that cursor rules guide the &lt;strong&gt;agent&lt;/strong&gt;. They do not automatically apply to every AI feature in Cursor, such as tab completion or inline edit. Rules should therefore complement tests, linting, type checking, CI, and code review rather than replace them.&lt;/p&gt;

&lt;p&gt;So instead of writing the same prompt over and over again, like "Please generate a Zod schema and name everything in camelCase," you just write the rule once, and Cursor applies it automatically in the right places.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What About&lt;/strong&gt; &lt;a href="http://AGENTS.md" rel="noopener noreferrer"&gt;&lt;strong&gt;AGENTS.md&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Cursor also supports &lt;a href="http://AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt; for project-level instructions. It can be useful when a project needs straightforward guidance without the more granular scoping available through .cursor/rules/*.mdc. For file-specific or manually triggered instructions, project rules provide more control.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Setting Up Custom Rules in Cursor&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Now that we know what rules are and why they matter, let’s get into how to actually set them up. The cursor makes this surprisingly smooth. You don’t need any weird config voodoo, just a well-structured JSON file, a clear prompt, and you’re good to go.&lt;/p&gt;

&lt;p&gt;The easiest way to get started is to use the built-in command:&lt;/p&gt;

&lt;p&gt;To create a rule, open &lt;strong&gt;Customize → Rules → Add Rule&lt;/strong&gt; in Cursor. Developers can also use /create-rule in Agent to create a rule from the current conversation. The command palette remains another way to access rule creation, with shortcuts varying by operating system.&lt;/p&gt;

&lt;p&gt;This will prompt you for a name for the rule and create a markdown file at this location:&lt;/p&gt;

&lt;p&gt;.cursor/rules/your-new-rule.mdc &lt;br&gt;
Here’s what a full rule looks like:&lt;/p&gt;

&lt;p&gt;description: Enforce Zod schemas in service layer &lt;br&gt;
globs: ["src/services/**/*.ts"] &lt;br&gt;
alwaysApply: true &lt;br&gt;
referencedFiles: ["src/lib/validators.ts"]&lt;/p&gt;

&lt;p&gt;When working in service files, always use Zod to validate inputs and outputs. Follow the existing pattern from &lt;code&gt;validators.ts&lt;/code&gt;. Don't skip validation unless explicitly told to.&lt;/p&gt;

&lt;p&gt;Let’s break this down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;description&lt;/strong&gt;: Short explanation of what the rule does&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;globs&lt;/strong&gt;: File patterns where the rule should apply (e.g., **/*.ts)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;alwaysApply&lt;/strong&gt;: If true, the rule is used without needing manual selection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;referencedFiles&lt;/strong&gt;: (Optional) Files used as examples or context for better AI responses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Body&lt;/strong&gt;: The actual instructions shown to the AI when working in matching files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To manage rules,&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;strong&gt;Customize → Rules&lt;/strong&gt; in Cursor. From there, rules can be added, edited, and managed by scope.&lt;/li&gt;
&lt;li&gt;You’ll see both User Rules and Project Rules&lt;/li&gt;
&lt;li&gt;Toggle, delete, or update them as needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now that you have got the structure down, let’s look at how developers can use Cursor Custom Rules to handle real-world problems.&lt;/p&gt;

&lt;p&gt;Use Case 1: Enforcing Consistent API Validation with Zod The Problem&lt;/p&gt;

&lt;p&gt;Your team uses Zod for request/response validation across all service files, but devs often forget to include schemas or name things consistently.&lt;/p&gt;

&lt;p&gt;Rule Example&lt;/p&gt;

&lt;p&gt;description: Enforce Zod schemas in service layer &lt;br&gt;
globs: ["src/services/**/*.ts"] &lt;br&gt;
alwaysApply: true &lt;br&gt;
referencedFiles: ["src/lib/zod-templates.ts"]&lt;/p&gt;

&lt;p&gt;Always use Zod to validate both request and response payloads. Reuse schema patterns from &lt;code&gt;zod-templates.ts&lt;/code&gt;. Do not allow untyped or loosely typed data to go through the service layer.&lt;/p&gt;

&lt;p&gt;This makes the AI generate pre-validated, strongly typed service methods every time, no reminders or rework needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Use Case 2: Enforcing Consistent File Structure for Features&lt;/strong&gt;
&lt;/h3&gt;

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

&lt;p&gt;Every new feature in your app should follow a clean structure like index.tsx, hooks.ts, types.ts, and api.ts, but different devs often structure things differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule Example&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;description: Enforce file structure for new feature folders &lt;br&gt;
globs: ["src/features/**/index.tsx"] &lt;br&gt;
alwaysApply: true&lt;/p&gt;

&lt;p&gt;When generating or modifying a feature in &lt;code&gt;src/features&lt;/code&gt;, make sure the folder contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.tsx&lt;/code&gt; as the entrypoint&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hooks.ts&lt;/code&gt; for all custom hooks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;types.ts&lt;/code&gt; for local types&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api.ts&lt;/code&gt; for all API calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Follow existing naming and organization patterns.&lt;/p&gt;

&lt;p&gt;With this rule, when the rule is active in Cursor Agent, it gives the agent consistent instructions for generating or modifying code. It does not automatically govern every AI feature in Cursor.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Use Case 3: Enforcing Unit Test Coverage with Vitest&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt;\&lt;br&gt;
You're using Vitest, and every utility function should have a corresponding test file. Devs often skip writing them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule Example&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;description: Ensure utility functions are covered by Vitest &lt;br&gt;
globs: ["src/utils/**/*.ts"] &lt;br&gt;
alwaysApply: true&lt;/p&gt;

&lt;p&gt;Whenever a new utility function is created or modified, also create a corresponding &lt;code&gt;.test.ts&lt;/code&gt; file using Vitest. Follow the structure and naming conventions used in existing test files.&lt;/p&gt;

&lt;p&gt;With this rule in place, AI writes tests along with your utilities, improving test coverage and helping juniors not skip QA steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Use Case 4: Auto-Wiring RPC Handlers with tRPC&lt;/strong&gt;
&lt;/h3&gt;

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

&lt;p&gt;You're using tRPC, and you want every new route to follow a specific handler format with proper input/output typing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule Example&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;description: Standardize tRPC handler creation &lt;br&gt;
globs: ["src/server/api/**/*.ts"] &lt;br&gt;
alwaysApply: true&lt;/p&gt;

&lt;p&gt;When generating new procedures, use &lt;code&gt;z.infer&lt;/code&gt; for input/output types. Use the &lt;code&gt;protectedProcedure&lt;/code&gt; or &lt;code&gt;publicProcedure&lt;/code&gt; wrapper from the existing router setup. Refer to &lt;code&gt;src/server/api/_utils.ts&lt;/code&gt; for common logic.&lt;/p&gt;

&lt;p&gt;With this, AI consistently generates boilerplate that aligns with your tRPC config without you needing to manually adjust every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Best Practices &amp;amp; Tips&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Getting started with Custom Rules is easy, but getting them to stick and scale well with your team takes a bit of finesse. Here are some battle-tested tips to help you make the most of them:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Make Rules Iterative, Not Perfect&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Don’t try to write the ultimate prompt on Day 1. Start small; even a 1-liner like “Use Zod in all services” can go a long way. Watch how Cursor responds, and improve the rule over time.&lt;/p&gt;

&lt;p&gt;Think of rules like code: ship early, refine often.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Be Specific in the Prompt&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Don’t just say “use tests"; say “use Vitest and place tests in the &lt;strong&gt;tests&lt;/strong&gt; folder next to the source file." The more specific the language, the more reliable the output.&lt;/p&gt;

&lt;p&gt;Use phrasing like "Always start with...”, “Never skip...”, “Follow the pattern from...", etc.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Review Rules as a Team&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If you’re on a team, treat your rules like coding conventions. Do quick async reviews, and align on when a rule should apply (alwaysApply: true vs. manual). This helps avoid confusion or overreach from the AI. Tests, linters, type checks, CI pipelines, security checks, and &lt;a href="https://geekyants.com/ai-powered-product-engineering/code-quality-engineering" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;u&gt;code reviews&lt;/u&gt;&lt;/strong&gt;&lt;/a&gt; should enforce requirements that need deterministic validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Keep It Human&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Don’t over-engineer your prompts. Write it like you’re telling a junior dev sitting next to you. That’s usually the sweet spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Final Thoughts&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Custom rules in Cursor are low-effort, high-impact. With just a few Markdown files, you can guide AI to follow your team’s coding style, enforce structure, and even auto-suggest best practices, all without writing extra logic or docs.&lt;/p&gt;

&lt;p&gt;I have found that whether you’re working solo or on a big team, these rules let you offload the repetitive reminders and keep your codebase clean and consistent.&lt;/p&gt;

&lt;p&gt;If you’ve ever wished AI could “just know how we do things around here,” this is how you get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;FAQs&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Are Cursor Custom Rules still supported in 2026?
&lt;/h3&gt;

&lt;p&gt;Yes, Cursor Custom Rules are still supported in 2026. They provide the agent with persistent instructions for coding standards, project patterns, workflows, and other development preferences. Project rules are stored as .mdc files in .cursor/rules/, while user rules apply across projects through Cursor’s customization settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can Cursor Rules reference project files?
&lt;/h3&gt;

&lt;p&gt;Cursor Rules can reference project files with @filename so Agent can use them as additional context. For example, a rule can reference &lt;a class="mentioned-user" href="https://dev.to/src"&gt;@src&lt;/a&gt;/lib/validators.ts when asking an agent to follow an existing validation pattern. This helps keep generated code aligned with established project conventions.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should we apply a cursor rule?
&lt;/h3&gt;

&lt;p&gt;A cursor rule is useful when an instruction is needed only for certain tasks but should not be tied to specific files. Cursor uses the rule’s description to decide when it is relevant, which makes this approach useful for project patterns, workflows, or domain-specific guidance.&lt;/p&gt;

</description>
      <category>ai</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>Automate the Obvious, Review the Rest: Designing AI-Powered Content Moderation</title>
      <dc:creator>GeekyAnts India Pvt Ltd</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:31:27 +0000</pubDate>
      <link>https://dev.to/geekyants-inc/automate-the-obvious-review-the-rest-designing-ai-powered-content-moderation-bie</link>
      <guid>https://dev.to/geekyants-inc/automate-the-obvious-review-the-rest-designing-ai-powered-content-moderation-bie</guid>
      <description>&lt;p&gt;This blog explains how to build an AI-powered content moderation system for text, images, and video while keeping human review in the loop.&lt;/p&gt;

&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%2F2o7o8bqkda8vixwgbzh5.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%2F2o7o8bqkda8vixwgbzh5.png" alt="Content lifecycle flowchart illustrating upload, async AI moderation, risk score, approval, rejection, or admin review" 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%2Fsvl3b1fvq8kfqu30h5qt.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%2Fsvl3b1fvq8kfqu30h5qt.png" alt="Dual AI moderation pipeline diagram for text content and S3 media uploads using one shared status model" 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;h3&gt;
  
  
  Shared data model
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;content_moderation_status on the source row: pending, approved, under_review, rejected.&lt;/li&gt;
&lt;li&gt;A content row as the generic moderation record.&lt;/li&gt;
&lt;li&gt;moderation_results for provider, risk_score, labels, ai_decision, and final_decision.&lt;/li&gt;
&lt;li&gt;reviewed_by on the result row when a human takes the last step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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;&lt;strong&gt;Do not overload "active" to mean "safe." Safety needs its own column.&lt;/strong&gt;&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;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%2Fxvmodgrnhhybcon5xdb6.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%2Fxvmodgrnhhybcon5xdb6.png" alt="Text moderation workflow from Hasura event trigger to OpenAI moderation and status update" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&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%2Fjyhnr5l5jxi0418pofv5.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%2Fjyhnr5l5jxi0418pofv5.png" alt="Risk score status diagram mapping OpenAI text scores and Rekognition confidence to approval outcomes" 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;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%2Foaeqk7pwl7gtn28ub3eb.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%2Foaeqk7pwl7gtn28ub3eb.png" alt="Moderation threshold calibration flow using labelled samples, AI scores, and human decisions" width="800" height="258"&gt;&lt;/a&gt;&lt;/p&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%2F8ujvw7hhu5ob9ax98rbk.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%2F8ujvw7hhu5ob9ax98rbk.png" alt="Media moderation SQS pipeline from S3 upload to ingest queue, Rekognition checks, results queue, and feed status" 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;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%2Fh7cq6bbt9vnatopbdzz4.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%2Fh7cq6bbt9vnatopbdzz4.png" alt="Image moderation scoring flow using Rekognition confidence and approval thresholds" width="799" height="219"&gt;&lt;/a&gt;&lt;/p&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;br&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;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%2Ffd1hoec90og1a9z4xo91.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%2Ffd1hoec90og1a9z4xo91.png" alt="Video moderation pipeline from S3 upload to Rekognition checks and final status" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&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;p&gt;→ Media A approved&lt;br&gt;
→ Media B under_review&lt;br&gt;
→ Caption approved&lt;br&gt;
→ Parent = under_review&lt;/p&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;br&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;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%2Fak8vrn86eqmwphehyp4e.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%2Fak8vrn86eqmwphehyp4e.png" alt="Content rejection notification flow from rejected status to email and push notification" width="800" height="232"&gt;&lt;/a&gt;&lt;/p&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%2Fz9ci9h6n555jtgrzc5ci.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%2Fz9ci9h6n555jtgrzc5ci.png" alt="Admin review flow for content under review from AI decision to moderator preview and final approval" width="799" height="263"&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%2F68m3kd6zu8hp824kqdvq.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%2F68m3kd6zu8hp824kqdvq.png" alt="Human moderation workflow from under-review content to final approval or rejection" width="799" height="392"&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%2Fkahe985yof7ovmg17yac.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%2Fkahe985yof7ovmg17yac.png" alt="Production feedback loop diagram tracking moderation latency, queue health, provider health, and decision quality" 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;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%2Fo9g8n3h5ui170j07umvz.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%2Fo9g8n3h5ui170j07umvz.png" alt="Moderation monitoring flow from calibrated defaults to drift detection and threshold adjustment" width="800" height="232"&gt;&lt;/a&gt;&lt;/p&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;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%2Fcbhtlqu249zcgawlq5p8.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%2Fcbhtlqu249zcgawlq5p8.png" alt="Lambda deployment pipeline from development push to AWS deployment and verification" width="799" height="219"&gt;&lt;/a&gt;&lt;/p&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;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%2Fc76zrazdk1nnak92159n.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%2Fc76zrazdk1nnak92159n.png" alt="Content moderation outcome flow from upload to AI review, admin review, and feed visibility" width="800" height="232"&gt;&lt;/a&gt;&lt;/p&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;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%2Fdgmhp0jzcpezb9d53hmg.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%2Fdgmhp0jzcpezb9d53hmg.png" alt="Content moderation flow for approving safe content, rejecting unsafe content, and reviewing uncertain cases" width="800" height="232"&gt;&lt;/a&gt;&lt;/p&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;strong&gt;&lt;a href="https://geekyants.com/ai" rel="noopener noreferrer"&gt;AI Engineering&lt;/a&gt;&lt;/strong&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;a href="https://geekyants.com/hire" rel="noopener noreferrer"&gt;Talk to our AI engineering team&lt;/a&gt;.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>nestjs</category>
      <category>webdev</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>
  </channel>
</rss>
