<?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: Aman Kumar Singh</title>
    <description>The latest articles on DEV Community by Aman Kumar Singh (@moose978).</description>
    <link>https://dev.to/moose978</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%2F4039092%2Ff2930aaf-8b44-48d7-aeb4-7927c36f735c.png</url>
      <title>DEV Community: Aman Kumar Singh</title>
      <link>https://dev.to/moose978</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/moose978"/>
    <language>en</language>
    <item>
      <title>Node.js Error Handling: Operational vs Programmer Errors</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:09:58 +0000</pubDate>
      <link>https://dev.to/moose978/nodejs-error-handling-operational-vs-programmer-errors-561i</link>
      <guid>https://dev.to/moose978/nodejs-error-handling-operational-vs-programmer-errors-561i</guid>
      <description>&lt;p&gt;Error handling is where a lot of Node.js applications quietly fall apart. Not with a dramatic crash — with a request that hangs forever because a rejected Promise went unhandled, or a process that dies without explanation because an error was thrown somewhere no &lt;code&gt;try/catch&lt;/code&gt; could reach. Node's asynchronous, single-threaded nature makes errors behave in ways that trip up people coming from other languages, and getting error handling right is less about writing more &lt;code&gt;try/catch&lt;/code&gt; blocks than about understanding &lt;em&gt;where&lt;/em&gt; errors surface and &lt;em&gt;which&lt;/em&gt; ones you can actually recover from.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;Node.js runtime series&lt;/a&gt;. Here we cover the different ways errors propagate, the process-level safety nets, and the distinction that should drive your whole strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors propagate differently depending on the pattern
&lt;/h2&gt;

&lt;p&gt;The first thing to internalize is that how an error reaches you depends on which &lt;a href="https://amanksingh.com/blog/nodejs-async-patterns" rel="noopener noreferrer"&gt;async pattern&lt;/a&gt; produced it, and mixing them up is how errors get lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous errors&lt;/strong&gt; are the easy case. A &lt;code&gt;throw&lt;/code&gt; in synchronous code is caught by an enclosing &lt;code&gt;try/catch&lt;/code&gt;, exactly as you would expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invalidInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// throws synchronously&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;Callback errors&lt;/strong&gt; follow Node's error-first convention: the error arrives as the first argument, not by throwing. A &lt;code&gt;try/catch&lt;/code&gt; around a callback-based call will &lt;em&gt;not&lt;/em&gt; catch an error the callback reports, because the error is passed in, not thrown. You have to check the first argument every time:&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x&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;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// the only way the error reaches you&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;Promise and async/await errors&lt;/strong&gt; surface as rejections. With async/await, a rejected Promise makes the &lt;code&gt;await&lt;/code&gt; throw, so &lt;code&gt;try/catch&lt;/code&gt; works again — which is a big part of why async/await is the modern default. The trap is the Promise you forget to await or &lt;code&gt;.catch&lt;/code&gt;. A rejection with no handler becomes an &lt;em&gt;unhandled rejection&lt;/em&gt;, which does not go to any &lt;code&gt;try/catch&lt;/code&gt; and, in current Node, crashes the process by default.&lt;/p&gt;

&lt;p&gt;The through-line: &lt;code&gt;try/catch&lt;/code&gt; catches synchronous throws and awaited rejections, but not error-first callbacks and not un-awaited Promises. Most "the error just vanished" bugs come from expecting &lt;code&gt;try/catch&lt;/code&gt; to catch something it structurally cannot. Know which pattern you are in, and handle the error the way that pattern delivers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The process-level safety nets
&lt;/h2&gt;

&lt;p&gt;Node gives you two global handlers for errors that escape everything else, and understanding their role — as last resorts, not primary handling — matters.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;process.on("uncaughtException")&lt;/code&gt; fires when an error is thrown and nothing catches it anywhere. &lt;code&gt;process.on("unhandledRejection")&lt;/code&gt; fires when a Promise rejects with no handler. It is tempting to treat these as a convenient catch-all: log the error, keep running, move on. That is a mistake. When one of these fires, your application is in an &lt;em&gt;unknown state&lt;/em&gt; — an operation was interrupted at an arbitrary point, and whatever invariants it was maintaining may now be half-broken. Continuing to serve requests from a process in an undefined state can produce corrupted data and behavior far more confusing than a clean crash.&lt;/p&gt;

&lt;p&gt;The accepted practice is to treat an uncaught exception as fatal: log it with full detail through your &lt;a href="https://amanksingh.com/blog/structured-logging-nodejs" rel="noopener noreferrer"&gt;structured logger&lt;/a&gt;, and then let the process exit and be restarted fresh by your process manager or orchestrator. A clean restart returns you to a known-good state; limping along in a corrupted one does not. These handlers are for logging the thing you failed to handle and shutting down gracefully, not for pretending the error did not happen. Their real value is observability — knowing what escaped — plus a chance to flush logs and close connections before exiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction that drives everything: operational vs programmer errors
&lt;/h2&gt;

&lt;p&gt;The single most useful idea in Node error handling is separating two fundamentally different kinds of errors, because they call for opposite responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational errors&lt;/strong&gt; are expected problems that a correct program will still encounter because the world is unreliable: a network request timed out, a database connection dropped, a file was not found, user input failed validation, a downstream service returned a 503. These are not bugs. They are runtime conditions you should anticipate and handle gracefully — retry the request, return a clean 400 to the user, fall back to a cache, surface a helpful message. Your application should recover from these and keep running, because they will happen in normal operation no matter how good your code is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Programmer errors&lt;/strong&gt; are bugs: calling a function with the wrong arguments, reading a property of undefined, a typo that throws a &lt;code&gt;TypeError&lt;/code&gt;. These are not conditions to handle; they are mistakes in the code. Trying to "recover" from them is usually wrong, because you do not actually know what state the bug left things in. The right response to a programmer error is to let it crash the process, get logged, and be fixed — the fail-fast principle. Papering over a &lt;code&gt;TypeError&lt;/code&gt; with a broad &lt;code&gt;try/catch&lt;/code&gt; that swallows it just hides the bug and lets a corrupted process continue.&lt;/p&gt;

&lt;p&gt;Once you hold this distinction, your strategy clarifies. Handle operational errors deliberately and specifically, close to where they occur, with real recovery logic. Let programmer errors fail fast and loud so you find and fix them. The mistake to avoid in both directions is a giant &lt;code&gt;try/catch&lt;/code&gt; that catches everything and logs it the same way, which turns recoverable operational errors and fatal programmer bugs into the same undifferentiated "something went wrong" — losing the recovery you wanted for the first and hiding the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical guidelines
&lt;/h2&gt;

&lt;p&gt;A few habits fall out of all this. Always attach a &lt;code&gt;.catch&lt;/code&gt; (or wrap in &lt;code&gt;try/catch&lt;/code&gt; with &lt;code&gt;await&lt;/code&gt;) to every Promise; the un-awaited rejection is the most common lost error in Node. In an Express-style app, funnel operational errors to a central error-handling middleware so you produce consistent responses rather than scattering ad-hoc handling through every route — the thin-controller principle that keeps error logic in one place. Create custom error classes for your operational errors so you can distinguish a &lt;code&gt;ValidationError&lt;/code&gt; from a &lt;code&gt;NotFoundError&lt;/code&gt; and respond appropriately, using the &lt;a href="https://amanksingh.com/blog/typescript-type-narrowing" rel="noopener noreferrer"&gt;type narrowing&lt;/a&gt; that &lt;code&gt;instanceof&lt;/code&gt; gives you. And make sure everything that reaches a global handler is logged with enough context to actually debug it.&lt;/p&gt;

&lt;p&gt;Done well, error handling is not defensive clutter spread through the codebase. It is a clear model: know how each async pattern delivers its errors, handle operational errors close to where they happen with real recovery, let programmer errors crash so you fix them, and keep the process-level handlers as logging-and-exit safety nets rather than catch-alls. That model, more than any amount of &lt;code&gt;try/catch&lt;/code&gt;, is what keeps a Node service both resilient to the messy real world and honest about its own bugs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-error-handling" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>backend</category>
      <category>programming</category>
    </item>
    <item>
      <title>Node.js Memory Leaks and Profiling: Finding What Holds Memory</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:07:18 +0000</pubDate>
      <link>https://dev.to/moose978/nodejs-memory-leaks-and-profiling-finding-what-holds-memory-pmp</link>
      <guid>https://dev.to/moose978/nodejs-memory-leaks-and-profiling-finding-what-holds-memory-pmp</guid>
      <description>&lt;p&gt;A Node.js memory leak rarely announces itself. The service runs fine in testing, ships, and then over hours or days its memory creeps up until it hits the limit and the process is killed and restarted. Because a restart temporarily fixes it, leaks often hide for a long time behind an automatic restart policy, quietly costing you reliability and money. Finding them is a specific skill: you have to know the handful of patterns that cause leaks in Node, and the tools that let you see what is actually holding memory.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;Node.js runtime series&lt;/a&gt;. Here we cover how memory works in Node, the common leak sources, and how to profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  How memory works in Node
&lt;/h2&gt;

&lt;p&gt;Node uses V8's garbage collector, which automatically frees objects that are no longer reachable — that is, objects nothing in your running program can still reference. You do not free memory manually; you just stop referencing things, and the collector reclaims them. This is why a "leak" in a garbage-collected runtime is not memory you forgot to free. It is memory you are unintentionally still &lt;em&gt;referencing&lt;/em&gt;. Something is holding a reference to objects you are done with, so from the collector's point of view they are still in use and cannot be reclaimed.&lt;/p&gt;

&lt;p&gt;That reframing is the key to finding leaks. You are not hunting for a missing &lt;code&gt;free()&lt;/code&gt;. You are hunting for a reference that should have been dropped and was not — a growing structure, a listener never removed, a closure that captured more than you realized. Almost every Node leak is one of a few recognizable shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common leak sources
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Unbounded growth of a module-level structure.&lt;/strong&gt; The most common leak by far: a &lt;code&gt;Map&lt;/code&gt;, array, or object at module scope that you keep adding to and never remove from. A cache that never evicts, a &lt;code&gt;Map&lt;/code&gt; keyed by request ID that you populate but never delete, an array you push to on every event. Each entry holds its objects alive forever. Any collection that only ever grows is a leak waiting to happen; every cache needs an eviction policy or a size bound, which is exactly why &lt;a href="https://amanksingh.com/blog/redis-caching-best-practices" rel="noopener noreferrer"&gt;Redis caches&lt;/a&gt; use TTLs rather than growing without limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listeners that are added but never removed.&lt;/strong&gt; Every &lt;code&gt;emitter.on(...)&lt;/code&gt; holds a reference to its listener function, and through the closure, to everything that function captured. Add listeners without removing them — on every request, say — and they accumulate, along with everything they close over. Node even warns about this: the "possible EventEmitter memory leak detected, 11 listeners added" message is a direct signal you are registering listeners you never clean up. The fix is to remove listeners when you are done, or use &lt;code&gt;once&lt;/code&gt; for one-shot events so they clean themselves up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timers and intervals never cleared.&lt;/strong&gt; A &lt;code&gt;setInterval&lt;/code&gt; that is never cleared runs forever and keeps its callback — and its captured scope — alive for the life of the process. If you create intervals or timeouts tied to some resource, clear them when that resource goes away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closures holding more than expected.&lt;/strong&gt; A closure keeps alive everything in the scope it captured, even parts you do not use. A callback stored somewhere long-lived that happened to capture a large object keeps that whole object in memory. This one is subtle because the capture is implicit, and it is often the answer when a leak does not match the more obvious patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting that you have a leak
&lt;/h2&gt;

&lt;p&gt;Before profiling, confirm the leak is real and get a shape for it. Watch the process's memory over time under normal load. The signal of a leak is heap usage that trends upward and does not come back down after garbage collection — a sawtooth that ratchets higher rather than returning to a stable baseline. Normal memory rises and falls as the collector works; a leak is the baseline steadily climbing. Your &lt;a href="https://amanksingh.com/blog/structured-logging-nodejs" rel="noopener noreferrer"&gt;monitoring and structured logs&lt;/a&gt; should track heap-used over time so this trend is visible on a graph rather than discovered when the process gets killed. If memory grows unbounded under steady load, you have a leak worth chasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiling with heap snapshots
&lt;/h2&gt;

&lt;p&gt;The core tool for finding what is holding memory is the heap snapshot: a complete picture of every object in the heap at a moment in time. You capture one through the Chrome DevTools inspector (start Node with &lt;code&gt;--inspect&lt;/code&gt; and connect from Chrome, or use the &lt;code&gt;v8&lt;/code&gt; module or a library to write snapshots to disk) or through your platform's profiler.&lt;/p&gt;

&lt;p&gt;A single snapshot tells you what is in memory now, but the powerful technique is &lt;em&gt;comparison&lt;/em&gt;. Take a snapshot, exercise the suspected leaky path many times, force a garbage collection, and take a second snapshot. Then compare the two. Anything that grew substantially between the snapshots — thousands more of some object than before — is your leak, or the path to it. The comparison cuts through the noise of everything that is legitimately in memory and points at what is accumulating. From the growing object, the snapshot lets you trace the &lt;em&gt;retainer path&lt;/em&gt;: the chain of references keeping it alive, which leads you straight back to the module-level Map, the un-removed listener, or the uncleared interval that is the actual cause.&lt;/p&gt;

&lt;p&gt;For CPU issues rather than memory, the equivalent tool is a CPU profile, which shows where execution time is spent and is how you find the function pinning a core — often the same investigation that reveals work that should move to a &lt;a href="https://amanksingh.com/blog/nodejs-worker-threads" rel="noopener noreferrer"&gt;worker thread&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing leaks in the first place
&lt;/h2&gt;

&lt;p&gt;Most leaks are prevented by a few habits rather than found by heroic debugging. Bound every cache — a size limit or a TTL, never an unbounded growing Map. Remove listeners and clear intervals when the thing they belong to goes away, and prefer &lt;code&gt;once&lt;/code&gt; for one-shot events. Be conscious of what your long-lived closures capture. And put heap metrics on a dashboard so a slow climb is visible early, while it is a graph trending up rather than a 3 a.m. page about a process that keeps dying.&lt;/p&gt;

&lt;p&gt;The mental model to carry: in Node, memory is not freed, it is &lt;em&gt;released by becoming unreachable&lt;/em&gt;, so a leak is always a reference you failed to drop. When memory climbs, the question is never "what did I forget to free" but "what am I still holding onto." Answer that — usually with a snapshot comparison pointing at a growing collection — and the fix is almost always to drop the reference: evict the cache entry, remove the listener, clear the timer. It ties back to the same discipline that runs through the whole &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;runtime&lt;/a&gt;: understand what the process is actually holding, and keep it bounded.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-memory-leaks-and-profiling" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Scaling Node.js with the Cluster Module: Using All Your Cores</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:04:54 +0000</pubDate>
      <link>https://dev.to/moose978/scaling-nodejs-with-the-cluster-module-using-all-your-cores-33f0</link>
      <guid>https://dev.to/moose978/scaling-nodejs-with-the-cluster-module-using-all-your-cores-33f0</guid>
      <description>&lt;p&gt;A single Node.js process uses a single CPU core. Your server has eight. Out of the box, seven of them sit idle while one does all the work — which means a default Node deployment leaves most of the machine you are paying for unused. The cluster module is how you fix that: it runs multiple Node processes that share the same port, so all your cores serve requests. Understanding it, and where it sits relative to &lt;a href="https://amanksingh.com/blog/nodejs-worker-threads" rel="noopener noreferrer"&gt;worker threads&lt;/a&gt; and horizontal scaling, is what lets you actually use the hardware you have.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;Node.js runtime series&lt;/a&gt;. Here we cover clustering, why it works, and how it fits into a broader scaling strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one process is not enough
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;event loop&lt;/a&gt; is single-threaded, and that single thread runs on one core. This is fine for the I/O concurrency Node excels at — one thread juggles thousands of connections happily — but it means a single Node process can never use more than one core's worth of CPU. On a multi-core machine, that is a lot of wasted capacity, and it caps how much a single process can handle before the one core it uses is saturated.&lt;/p&gt;

&lt;p&gt;The cluster module solves this by running several Node processes instead of one. You start a master process that forks multiple worker processes — typically one per CPU core — and they all listen on the same port. Incoming connections are distributed across the workers, so requests are handled in parallel across all your cores. Eight cores, eight workers, eight requests being processed at once instead of one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cluster&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="s2"&gt;node:cluster&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;os&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="s2"&gt;node:os&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;cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPrimary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;availableParallelism&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fork&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// one worker per core&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exit&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;worker&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;cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fork&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// replace a worker that died&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;startServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// each worker runs the app&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operating system and Node handle distributing connections across the workers, so from your application's perspective each worker is just your normal server, running on its own core.&lt;/p&gt;

&lt;h2&gt;
  
  
  Processes, not threads
&lt;/h2&gt;

&lt;p&gt;A key distinction from worker threads: cluster workers are separate &lt;em&gt;processes&lt;/em&gt;, not threads. Each has its own memory, its own event loop, its own everything — they share nothing except the listening port. This is exactly why clustering works cleanly for scaling a web server: since the workers share no memory, there is no coordination between them at the application level, and one crashing does not corrupt the others. The master can simply fork a replacement, as in the &lt;code&gt;exit&lt;/code&gt; handler above, giving you a basic resilience win alongside the throughput one.&lt;/p&gt;

&lt;p&gt;It also means clustering and worker threads solve different problems and can be used together. Clustering multiplies your &lt;em&gt;request-handling capacity&lt;/em&gt; across cores. Worker threads offload &lt;em&gt;CPU-bound tasks&lt;/em&gt; off the main thread within a single process. A service might cluster across eight cores for throughput, and each worker might use a worker thread for the occasional heavy computation. They are complementary, not alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: workers share no state
&lt;/h2&gt;

&lt;p&gt;Because cluster workers have separate memory, anything you stored in process memory is per-worker, and this breaks two common patterns the moment you cluster.&lt;/p&gt;

&lt;p&gt;The first is in-memory state. If you cache data in a module-level variable, or keep user sessions in memory, each worker has its own copy, and a user whose requests land on different workers sees inconsistent data — logged in on one, logged out on the next. The fix is the same statelessness that &lt;a href="https://amanksingh.com/blog/horizontal-vs-vertical-scaling" rel="noopener noreferrer"&gt;horizontal scaling&lt;/a&gt; demands: push shared state out of process memory into something all workers reach, like Redis. Sessions, caches, and rate-limit counters all belong in a shared store, not a local variable, the moment you run more than one process.&lt;/p&gt;

&lt;p&gt;The second is anything that must happen exactly once. Scheduled jobs are the classic trap — if every worker runs the same cron task, a job meant to fire once now fires eight times, once per worker. You have to elect a single worker (or move the scheduler out of the clustered process entirely) so the job runs once. These are not clustering bugs; they are the natural consequence of going from one process to many, and they are exactly the constraints you also hit when scaling to multiple machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clustering versus running multiple containers
&lt;/h2&gt;

&lt;p&gt;Here is a question worth answering honestly, because the modern deployment landscape changes the calculus. The cluster module scales you across the cores of &lt;em&gt;one machine&lt;/em&gt;. But if you deploy on a platform that runs your app in containers and scales by adding more container instances — which most cloud and container platforms do — then the platform is already giving you multiple processes across cores, and adding the cluster module inside each container can be redundant or even counterproductive.&lt;/p&gt;

&lt;p&gt;The general guidance: if you self-host on a big multi-core box and run the process directly, the cluster module is how you use all the cores, and it is the right tool. If you deploy in containers behind an orchestrator that scales instances for you, prefer running one process per container and letting the platform scale the instance count — it is simpler, and the orchestrator handles the distribution, health checks, and replacement that you would otherwise be reimplementing with cluster. Either way the goal is identical: use every core, keep each process stateless, and let something distribute the load. Whether that "something" is the cluster master or a container orchestrator is a deployment choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where clustering fits in scaling
&lt;/h2&gt;

&lt;p&gt;Clustering is the bridge between a single process and true &lt;a href="https://amanksingh.com/blog/horizontal-vs-vertical-scaling" rel="noopener noreferrer"&gt;horizontal scaling&lt;/a&gt;. It gets you from one core to all the cores on one machine — vertical scaling of your process count, in effect. Beyond that, when one machine is not enough, you scale out to multiple machines behind a &lt;a href="https://amanksingh.com/blog/load-balancing-explained" rel="noopener noreferrer"&gt;load balancer&lt;/a&gt;, and the statelessness you were forced into by clustering is exactly what makes that next step easy: a stateless app that runs correctly across eight cluster workers already runs correctly across eight machines. The disciplines compound. Get the app stateless for clustering and you have done most of the work for scaling it as far as you will ever need.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-cluster-and-scaling" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>scaling</category>
      <category>performance</category>
    </item>
    <item>
      <title>Node.js Worker Threads: Moving CPU Work Off the Main Thread</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:02:31 +0000</pubDate>
      <link>https://dev.to/moose978/nodejs-worker-threads-moving-cpu-work-off-the-main-thread-3mi5</link>
      <guid>https://dev.to/moose978/nodejs-worker-threads-moving-cpu-work-off-the-main-thread-3mi5</guid>
      <description>&lt;p&gt;Node.js is superb at I/O and helpless at CPU-bound work on the main thread — a direct consequence of the single-threaded &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;event loop&lt;/a&gt;. The moment your code has to do something genuinely computational — resize an image, parse a huge document, hash passwords, run a data transformation — that work holds the one JavaScript thread and every other request waits behind it. Worker threads are Node's answer: a way to run JavaScript on separate threads so heavy computation happens off the main thread, leaving the event loop free to keep serving requests.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;Node.js runtime series&lt;/a&gt;. Here we cover what worker threads are for, when to use them, and — just as important — when not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: CPU work blocks everything
&lt;/h2&gt;

&lt;p&gt;Recall the core constraint. Node runs your JavaScript on a single thread, and that thread's availability is what lets Node juggle thousands of connections. A CPU-heavy function does not yield — it runs straight through, holding the thread the entire time:&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;app&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="s2"&gt;/resize&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;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="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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resizeImageSynchronously&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 800ms of pure CPU&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;send&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For those 800 milliseconds, the event loop cannot advance. No other request is served, no queued I/O callback runs, health checks time out. One CPU-bound endpoint can make an entire otherwise-fast service unresponsive under load. Making the function faster only shrinks the problem; it does not remove it. The real fix is to run that computation somewhere other than the main thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worker threads are
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;worker_threads&lt;/code&gt; module lets you spawn additional threads that each run JavaScript in their own isolated environment — their own V8 instance, their own event loop, their own memory. You hand a worker a task, it runs on a separate thread, and it sends the result back when done. Because it is a different thread, the heavy computation runs in parallel with the main thread, which stays free to keep the event loop turning.&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;Worker&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="s2"&gt;node:worker_threads&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;runTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;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="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./resize-worker.js&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;workerData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&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="s2"&gt;/resize&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;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="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;output&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;runTask&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// main thread stays free&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;send&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the main thread offloads the work and &lt;code&gt;await&lt;/code&gt;s the result like any other async operation, so the event loop keeps serving other requests while the worker computes. The CPU work still takes 800 ms, but it no longer freezes the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communication and its cost
&lt;/h2&gt;

&lt;p&gt;Worker threads do not share ordinary variables with the main thread — each has isolated memory — so they communicate by passing messages. You send data in with &lt;code&gt;workerData&lt;/code&gt; or &lt;code&gt;postMessage&lt;/code&gt;, and the worker sends results back with its own &lt;code&gt;postMessage&lt;/code&gt;. This message passing is the model to keep in mind, because it has a cost: data sent between threads is, by default, &lt;em&gt;copied&lt;/em&gt;. For large payloads that copy is not free, and if you are shuttling big buffers back and forth constantly, the copying overhead can eat into the benefit of parallelizing.&lt;/p&gt;

&lt;p&gt;Node offers ways to reduce this. &lt;code&gt;ArrayBuffer&lt;/code&gt;s can be &lt;em&gt;transferred&lt;/em&gt; rather than copied — ownership moves to the worker and the sender loses access, which is instant regardless of size — and &lt;code&gt;SharedArrayBuffer&lt;/code&gt; lets threads share a block of memory directly. These are worth knowing for high-throughput cases, but for most uses the plain copy is fine; just be aware that the boundary between threads is a real cost, not a free function call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a pool, not a worker per task
&lt;/h2&gt;

&lt;p&gt;Spawning a worker is not free — each one starts a fresh V8 instance, which takes time and memory. Creating a new worker for every request would trade your CPU problem for a startup-overhead problem. The standard pattern is a worker pool: create a fixed set of workers up front and hand incoming tasks to whichever one is free, reusing them across many tasks. This amortizes the startup cost and bounds how many threads you run, since spawning unlimited workers would oversubscribe your cores and slow everything down. Libraries like Piscina implement a solid pool so you do not have to build the queuing and lifecycle management yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use worker threads
&lt;/h2&gt;

&lt;p&gt;This is the part people get wrong, so it deserves emphasis: worker threads are for CPU-bound work, not I/O-bound work. If your task is waiting on a database, an API, or the disk, worker threads are the &lt;em&gt;wrong&lt;/em&gt; tool, and reaching for them will make your code more complex for no gain. Node already handles I/O concurrently on the main thread through the event loop — that is its whole strength. Wrapping a database query in a worker adds thread overhead and message-passing cost to solve a problem that did not exist, because the query was never blocking the thread in the first place.&lt;/p&gt;

&lt;p&gt;The test is simple: is the task spending its time &lt;em&gt;computing&lt;/em&gt; or &lt;em&gt;waiting&lt;/em&gt;? If it is computing — crunching numbers, transforming data, compressing, hashing — that is CPU-bound and a candidate for a worker. If it is waiting on something external, it is I/O-bound, and it belongs on the main thread with ordinary &lt;a href="https://amanksingh.com/blog/nodejs-async-patterns" rel="noopener noreferrer"&gt;async patterns&lt;/a&gt;. Getting this distinction right is most of using worker threads well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workers versus clustering versus a separate service
&lt;/h2&gt;

&lt;p&gt;Worker threads are one of three ways to get past the single-thread limit, and they solve a specific slice of it. Use &lt;strong&gt;worker threads&lt;/strong&gt; to move CPU-bound work off the main thread within one process. Use the &lt;a href="https://amanksingh.com/blog/nodejs-cluster-and-scaling" rel="noopener noreferrer"&gt;cluster module&lt;/a&gt; to run multiple Node processes so you use &lt;em&gt;all&lt;/em&gt; the machine's cores for handling requests, which is about throughput across the whole app rather than offloading one heavy task. And for very heavy or long-running computation, moving the work to a &lt;strong&gt;separate service&lt;/strong&gt; behind a &lt;a href="https://amanksingh.com/blog/message-queues-explained" rel="noopener noreferrer"&gt;message queue&lt;/a&gt; is often cleaner than keeping it in the request process at all, because it isolates the heavy work entirely and lets you scale it independently.&lt;/p&gt;

&lt;p&gt;Worker threads fit in the middle: heavier than an async call, lighter than a separate service, and exactly right when you have occasional CPU-bound tasks that would otherwise block the loop. Reach for them when the profiler shows the main thread pinned on computation — and leave them alone when it is just waiting on I/O.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-worker-threads" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>performance</category>
      <category>concurrency</category>
    </item>
    <item>
      <title>Node.js Streams and Backpressure: Processing Data Without Running Out of Memory</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:00:21 +0000</pubDate>
      <link>https://dev.to/moose978/nodejs-streams-and-backpressure-processing-data-without-running-out-of-memory-n5e</link>
      <guid>https://dev.to/moose978/nodejs-streams-and-backpressure-processing-data-without-running-out-of-memory-n5e</guid>
      <description>&lt;p&gt;The difference between a Node service that handles a 2 GB file on a 512 MB server and one that crashes trying is almost always streams. The naive approach reads the whole thing into memory, does something, and writes it out — and it works beautifully until the input is bigger than the memory you have. Streams process data in chunks as it flows, so memory stays flat regardless of how large the data gets. They are one of Node's most powerful features and one of its most avoided, usually because backpressure — the mechanism that keeps a fast producer from overwhelming a slow consumer — is misunderstood.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;Node.js runtime series&lt;/a&gt;. Here we cover the stream types, piping, and the backpressure that makes it all safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why streams instead of buffering everything
&lt;/h2&gt;

&lt;p&gt;Consider serving a large file. The obvious version reads it entirely, then sends it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;huge-video.mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// entire file into memory&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;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the file is 2 GB, this allocates 2 GB before sending a single byte, and a handful of concurrent requests exhausts your memory and takes the process down. The streaming version never holds more than a small chunk at a time:&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReadStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;huge-video.mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pipe&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads the file in pieces and writes each piece to the response as it goes, so memory usage stays low and constant no matter the file size, and the client starts receiving data immediately instead of after the whole file is buffered. The principle generalizes: any time you process data that is large, unbounded, or arriving over time, streaming keeps memory flat where buffering grows without limit. This is the same "process it in pieces, do not hold it all" instinct behind &lt;a href="https://amanksingh.com/blog/message-queues-explained" rel="noopener noreferrer"&gt;message queues&lt;/a&gt; at the system level.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four stream types
&lt;/h2&gt;

&lt;p&gt;Node has four kinds of streams, and once you know them the API stops looking arbitrary.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Readable&lt;/strong&gt; streams are sources you read from: a file being read, an incoming HTTP request, &lt;code&gt;process.stdin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writable&lt;/strong&gt; streams are destinations you write to: a file being written, an HTTP response, &lt;code&gt;process.stdout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplex&lt;/strong&gt; streams are both readable and writable, with the two sides independent — a TCP socket, which you both send to and receive from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transform&lt;/strong&gt; streams are duplex streams where the output is a transformation of the input: compression, encryption, or parsing, where bytes go in one side and modified bytes come out the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Streams are built on the &lt;a href="https://amanksingh.com/blog/nodejs-async-patterns" rel="noopener noreferrer"&gt;EventEmitter&lt;/a&gt; pattern — a readable stream emits &lt;code&gt;data&lt;/code&gt; events as chunks arrive and an &lt;code&gt;end&lt;/code&gt; event when it is done. You can listen to those events directly, but you rarely should, because doing so by hand is where the backpressure bugs come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piping connects streams safely
&lt;/h2&gt;

&lt;p&gt;The idiomatic way to move data from a readable to a writable stream is &lt;code&gt;pipe&lt;/code&gt;, which connects them and handles the flow control for you:&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;readable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads from the source, passes chunks through the transform, and writes to the destination, and it manages the pace automatically. You can chain pipes to build a pipeline — read a file, gzip it, write it out — and each stage only receives data as fast as the next stage can accept it. That automatic pacing is backpressure, and it is the reason to prefer &lt;code&gt;pipe&lt;/code&gt; (or the modern &lt;code&gt;pipeline&lt;/code&gt; helper) over wiring up &lt;code&gt;data&lt;/code&gt; events yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backpressure: the concept that makes streams safe
&lt;/h2&gt;

&lt;p&gt;Here is the problem streams have to solve. A readable stream might produce data far faster than a writable stream can consume it — reading from a fast local disk and writing to a slow network socket, for instance. If the reader just keeps pushing chunks at the writer, the unconsumed chunks pile up in memory, and now you have reinvented the exact out-of-memory problem streams were supposed to prevent, just with extra steps.&lt;/p&gt;

&lt;p&gt;Backpressure is the feedback signal that prevents this. When you write to a writable stream, its &lt;code&gt;write()&lt;/code&gt; method returns a boolean: &lt;code&gt;true&lt;/code&gt; means "keep going," &lt;code&gt;false&lt;/code&gt; means "my buffer is full, pause." A properly behaved reader sees that &lt;code&gt;false&lt;/code&gt;, stops producing, and waits for the writable stream to emit a &lt;code&gt;drain&lt;/code&gt; event signaling it has caught up, then resumes. This back-and-forth keeps the amount of in-flight data bounded no matter how mismatched the two speeds are. The producer is throttled to the consumer's pace, so memory stays flat even when a fast source feeds a slow sink.&lt;/p&gt;

&lt;p&gt;The good news is that &lt;code&gt;pipe&lt;/code&gt; and &lt;code&gt;pipeline&lt;/code&gt; implement this handshake for you. When you pipe a readable into a writable, Node automatically pauses the source when the destination's buffer fills and resumes it on &lt;code&gt;drain&lt;/code&gt;. This is the single biggest reason to use piping rather than manual &lt;code&gt;data&lt;/code&gt; event handlers: the moment you write your own &lt;code&gt;on("data")&lt;/code&gt; loop that calls &lt;code&gt;write()&lt;/code&gt; without checking its return value, you have disabled backpressure and reintroduced the memory blow-up. Let piping do it, and backpressure is handled correctly by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use pipeline for error handling
&lt;/h2&gt;

&lt;p&gt;One practical upgrade over raw &lt;code&gt;pipe&lt;/code&gt;: the &lt;code&gt;stream.pipeline&lt;/code&gt; function. Plain &lt;code&gt;pipe&lt;/code&gt; does not forward errors well — if a stage fails mid-flow, you can leak file descriptors or leave streams half-open. &lt;code&gt;pipeline&lt;/code&gt; wires the stages together with proper error propagation and cleanup:&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;pipeline&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="s2"&gt;node:stream/promises&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="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReadStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGzip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWriteStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;output.txt.gz&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;// resolves when done, rejects (and cleans up) if any stage fails&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you the memory safety of streaming, the automatic backpressure of piping, and error handling that does not leak resources — which is what you want in production. It ties directly into the &lt;a href="https://amanksingh.com/blog/nodejs-error-handling" rel="noopener noreferrer"&gt;error-handling&lt;/a&gt; patterns for the rest of your Node code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for streams
&lt;/h2&gt;

&lt;p&gt;The signal is data that is large, unbounded, or time-based. Big file uploads and downloads, processing a multi-gigabyte log or CSV, proxying data between services, real-time feeds, anything where the total size is unknown or could exceed memory — all of these are streams. If you ever find yourself reading an entire large thing into a variable before processing it, that is the moment to ask whether a stream would keep your memory flat instead. Streams have a reputation for being fiddly, but nearly all of that reputation comes from people handling &lt;code&gt;data&lt;/code&gt; events manually and getting backpressure wrong. Use &lt;code&gt;pipeline&lt;/code&gt;, let it manage the flow, and streams become the straightforward, memory-safe tool they were designed to be — a direct application of keeping the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;event loop&lt;/a&gt; free and memory bounded that defines good Node.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-streams-and-backpressure" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>streams</category>
      <category>performance</category>
    </item>
    <item>
      <title>Node.js Async Patterns: Callbacks, Promises, and async/await</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 09:57:34 +0000</pubDate>
      <link>https://dev.to/moose978/nodejs-async-patterns-callbacks-promises-and-asyncawait-4k63</link>
      <guid>https://dev.to/moose978/nodejs-async-patterns-callbacks-promises-and-asyncawait-4k63</guid>
      <description>&lt;p&gt;Asynchronous code is the native language of Node.js. Because the runtime hands off I/O and keeps the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;event loop&lt;/a&gt; free, almost everything useful you do — reading a file, querying a database, calling an API — returns its result later, not immediately. How you express "do this, then when it finishes do that" has evolved through three generations in Node, and while modern code lives almost entirely in the third, understanding all three is what lets you read any codebase and avoid the traps each one carries.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;Node.js runtime series&lt;/a&gt;. Here we walk from callbacks through Promises to async/await, plus the EventEmitter pattern that sits alongside them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callbacks and the pyramid problem
&lt;/h2&gt;

&lt;p&gt;The original async pattern in Node is the callback: you pass a function that gets called when the operation completes. Node's convention is the "error-first" callback, where the first argument is an error (or null) and the rest is the result.&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;config.json&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;utf8&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;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, and for a single operation it is perfectly clear. The trouble starts when operations depend on each other, because each dependent step nests inside the previous callback:&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a.json&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;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b.json&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;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;c.json&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;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="c1"&gt;// finally do something with a, b, c&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is "callback hell" — the rightward drift, the repeated error checks, the difficulty of following the flow. Worse than the ugliness is the error handling: every callback has to check and forward its own error, and forgetting one means a silent failure. Callbacks are not wrong, and you will still see them in older APIs and event-based code, but for sequential async logic they scale badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promises make async composable
&lt;/h2&gt;

&lt;p&gt;A Promise is an object representing a value that will exist eventually — it is pending, then either fulfilled with a value or rejected with an error. The shift Promises bring is that async operations become &lt;em&gt;values you can pass around and chain&lt;/em&gt;, rather than callbacks you nest.&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a.json&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;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b.json&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;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;c.json&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;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;handleError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nesting flattens into a chain, and — the big win — a single &lt;code&gt;.catch&lt;/code&gt; at the end handles an error from any step. A rejection skips the remaining &lt;code&gt;.then&lt;/code&gt; handlers and jumps to the nearest &lt;code&gt;.catch&lt;/code&gt;, so you get centralized error handling for free instead of a check in every callback. Promises also compose in ways callbacks cannot. &lt;code&gt;Promise.all&lt;/code&gt; runs several operations concurrently and waits for all of them, which is how you parallelize independent work:&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;c.json&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;Running independent operations with &lt;code&gt;Promise.all&lt;/code&gt; instead of sequentially is one of the easiest performance wins in Node, since the three reads happen at once rather than one after another. Related combinators — &lt;code&gt;Promise.allSettled&lt;/code&gt; when you want every result regardless of failures, &lt;code&gt;Promise.race&lt;/code&gt; for the first to finish — cover the other common shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  async/await is Promises that read like sync code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;async/await&lt;/code&gt; is not a replacement for Promises; it is syntax built on top of them. An &lt;code&gt;async&lt;/code&gt; function returns a Promise, and &lt;code&gt;await&lt;/code&gt; pauses the function until a Promise settles, giving you the value. The payoff is that asynchronous code reads top-to-bottom like ordinary synchronous code, while still being non-blocking underneath.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a.json&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;b&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b.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;return&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same logic as the Promise chain, but the control flow is linear, and errors are handled with an ordinary &lt;code&gt;try/catch&lt;/code&gt; — the same construct you use for synchronous errors, which is exactly the unification covered in the &lt;a href="https://amanksingh.com/blog/nodejs-error-handling" rel="noopener noreferrer"&gt;error handling&lt;/a&gt; post. It is the right default for essentially all new Node code.&lt;/p&gt;

&lt;p&gt;The one pitfall to name: &lt;code&gt;await&lt;/code&gt; in a loop runs operations one at a time, which is correct when each depends on the last but a silent performance killer when they are independent. If the iterations do not depend on each other, collect the Promises and &lt;code&gt;await Promise.all&lt;/code&gt; instead of awaiting inside the loop. And remember the &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;event-loop&lt;/a&gt; lesson — &lt;code&gt;await&lt;/code&gt; only yields on genuinely async work; awaiting something that does heavy synchronous computation still blocks the thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  EventEmitter for streams of events
&lt;/h2&gt;

&lt;p&gt;Callbacks, Promises, and async/await all model a &lt;em&gt;single&lt;/em&gt; future value. Some things in Node are not one value but a &lt;em&gt;stream&lt;/em&gt; of events over time — a server receiving many requests, a file being read in chunks, a socket emitting data repeatedly. For those, Node uses the EventEmitter pattern: an object emits named events, and you register listeners that run each time an event fires.&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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request&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;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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* runs for every request */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;emitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&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="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* runs for every chunk */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;emitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&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="cm"&gt;/* runs one time */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;on&lt;/code&gt; subscribes to every occurrence, &lt;code&gt;once&lt;/code&gt; to a single one. This is a different shape from a Promise — a Promise resolves exactly once, while an emitter fires as often as the event occurs — and it is the foundation that &lt;a href="https://amanksingh.com/blog/nodejs-streams-and-backpressure" rel="noopener noreferrer"&gt;streams&lt;/a&gt; are built on. When you find yourself wanting a Promise to resolve multiple times, an EventEmitter (or an async iterator) is the pattern you actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to use
&lt;/h2&gt;

&lt;p&gt;For new code, async/await is the default, backed by Promises and their combinators for concurrency. Reach for &lt;code&gt;Promise.all&lt;/code&gt; whenever independent operations can run at once. Use EventEmitter when you are modeling repeated events rather than a single result. And recognize callbacks when you meet them in older APIs — you can usually wrap a callback-based function in a Promise (Node's &lt;code&gt;util.promisify&lt;/code&gt; does exactly this) to bring it into the modern style. Underneath all of them is the same runtime behavior: the operation is handed off, the thread stays free, and your continuation runs when the result is ready. The patterns are just increasingly pleasant ways to express that one idea.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-async-patterns" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>async</category>
      <category>backend</category>
    </item>
    <item>
      <title>The Node.js Event Loop Explained: How Single-Threaded Serves Thousands</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 09:54:37 +0000</pubDate>
      <link>https://dev.to/moose978/the-nodejs-event-loop-explained-how-single-threaded-serves-thousands-9gd</link>
      <guid>https://dev.to/moose978/the-nodejs-event-loop-explained-how-single-threaded-serves-thousands-9gd</guid>
      <description>&lt;p&gt;Almost every confusing thing about Node.js — why a slow function freezes your entire server, why &lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; does not run immediately, why a Promise resolves before a timer you set first — traces back to one mechanism: the event loop. Node runs your JavaScript on a single thread, and the event loop is how a single thread manages to serve thousands of concurrent connections without blocking. Understand it and the rest of Node stops being a collection of quirks and becomes a coherent system. Misunderstand it and you will eventually write the one function that takes the whole process down.&lt;/p&gt;

&lt;p&gt;This is the anchor of a series on the Node.js runtime, which goes deep on &lt;a href="https://amanksingh.com/blog/nodejs-async-patterns" rel="noopener noreferrer"&gt;async patterns&lt;/a&gt;, &lt;a href="https://amanksingh.com/blog/nodejs-streams-and-backpressure" rel="noopener noreferrer"&gt;streams&lt;/a&gt;, &lt;a href="https://amanksingh.com/blog/nodejs-worker-threads" rel="noopener noreferrer"&gt;worker threads&lt;/a&gt;, &lt;a href="https://amanksingh.com/blog/nodejs-cluster-and-scaling" rel="noopener noreferrer"&gt;clustering&lt;/a&gt;, &lt;a href="https://amanksingh.com/blog/nodejs-memory-leaks-and-profiling" rel="noopener noreferrer"&gt;memory and profiling&lt;/a&gt;, and &lt;a href="https://amanksingh.com/blog/nodejs-error-handling" rel="noopener noreferrer"&gt;error handling&lt;/a&gt;. Start here, because all of them assume this model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single-threaded, but not single-tasking
&lt;/h2&gt;

&lt;p&gt;The headline fact is that your JavaScript in Node runs on a single thread. There is one call stack, and only one thing executes at a time. The obvious question is how that serves thousands of simultaneous requests, and the answer is the distinction that everything hinges on: Node does your &lt;em&gt;JavaScript&lt;/em&gt; on one thread, but it does not do your &lt;em&gt;I/O&lt;/em&gt; there.&lt;/p&gt;

&lt;p&gt;When your code asks to read a file, query a database, or make a network call, Node does not sit and wait. It hands that operation off — to the operating system, or to a small pool of background threads managed by a C library called libuv — and immediately moves on to run other JavaScript. When the I/O finishes, the result comes back as a callback that Node queues up to run when the thread is free. So the single thread is almost never blocked waiting; it is constantly doing useful work while dozens of I/O operations proceed in the background. This is why Node is exceptional at I/O-heavy workloads like APIs and proxies, and it is the whole reason the runtime exists.&lt;/p&gt;

&lt;p&gt;The critical corollary: this only works if your JavaScript never hogs the thread. Node's concurrency comes from the thread being free to pick up the next callback. A function that runs a long synchronous loop holds the thread and every other request waits behind it. That is the failure mode to keep in mind through everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the event loop actually is
&lt;/h2&gt;

&lt;p&gt;The event loop is the coordinator that decides what runs next on that single thread. It is a loop that runs continuously, and on each turn it works through a series of phases, each with its own queue of callbacks. Simplified, the phases in order are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timers&lt;/strong&gt; — callbacks scheduled by &lt;code&gt;setTimeout&lt;/code&gt; and &lt;code&gt;setInterval&lt;/code&gt; whose time has come.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending callbacks&lt;/strong&gt; — certain deferred I/O callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll&lt;/strong&gt; — where the loop spends most of its time: it retrieves new I/O events and runs their callbacks (an incoming request, a finished file read).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check&lt;/strong&gt; — callbacks scheduled with &lt;code&gt;setImmediate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close&lt;/strong&gt; — cleanup callbacks like a socket closing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On each iteration, the loop enters a phase, drains that phase's queue of ready callbacks, and moves to the next. When it has gone through all phases, it loops again. If there is nothing left to do — no timers pending, no I/O outstanding — the loop exits and the process ends. This phase structure explains a lot of Node's ordering behavior, including why &lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; and &lt;code&gt;setImmediate(fn)&lt;/code&gt; can fire in either order depending on context: they live in different phases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microtasks jump the queue
&lt;/h2&gt;

&lt;p&gt;There is a second, higher-priority queue layered on top of the phases: the microtask queue, which holds resolved Promise callbacks (the &lt;code&gt;.then&lt;/code&gt; handlers, and the continuations after &lt;code&gt;await&lt;/code&gt;) and &lt;code&gt;process.nextTick&lt;/code&gt; callbacks. The rule that matters: microtasks run &lt;em&gt;between&lt;/em&gt; every operation, not on a phase of their own. After each individual callback finishes, and before the loop moves on, Node completely drains the microtask queue.&lt;/p&gt;

&lt;p&gt;This is why a Promise resolved "later" in your code can run before a &lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; you scheduled "first":&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;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&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="nb"&gt;Promise&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;promise&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// Output:&lt;/span&gt;
&lt;span class="c1"&gt;// promise&lt;/span&gt;
&lt;span class="c1"&gt;// timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Promise's callback is a microtask, so it runs as soon as the current synchronous code finishes, before the loop even gets to the timers phase. The timer, despite a delay of zero, has to wait for the next timers phase. Once you internalize that microtasks drain between operations while timers and I/O wait for their phase, Node's execution order stops being surprising. It also carries a warning: because microtasks run to exhaustion before the loop proceeds, a Promise chain that endlessly schedules more microtasks can starve the loop and block I/O just as badly as a synchronous loop can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocking the loop is the cardinal sin
&lt;/h2&gt;

&lt;p&gt;Everything about Node's performance model comes down to keeping the event loop turning. Because there is one thread for your JavaScript, any single piece of code that runs too long delays every pending callback behind it — every queued request, every ready I/O result. A CPU-heavy task is the classic culprit:&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;app&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="s2"&gt;/report&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;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="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="nf"&gt;crunchNumbersForTwoSeconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// blocks the whole process&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;json&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While &lt;code&gt;crunchNumbersForTwoSeconds&lt;/code&gt; runs, the event loop cannot advance. Not one other request is served, no matter how many are waiting. For those two seconds, your multi-thousand-connection server handles exactly nobody. This is the single most important thing to understand about Node in production: the runtime is superb at juggling I/O, and helpless against CPU-bound work on the main thread.&lt;/p&gt;

&lt;p&gt;The fix is not to make the main thread do the heavy work faster; it is to get the heavy work off the main thread entirely. Genuinely CPU-bound tasks belong in &lt;a href="https://amanksingh.com/blog/nodejs-worker-threads" rel="noopener noreferrer"&gt;worker threads&lt;/a&gt;, which run JavaScript on separate threads for exactly this purpose, or in a separate service reached through a &lt;a href="https://amanksingh.com/blog/message-queues-explained" rel="noopener noreferrer"&gt;message queue&lt;/a&gt;. To use every core on the machine rather than one, you run multiple Node processes with the &lt;a href="https://amanksingh.com/blog/nodejs-cluster-and-scaling" rel="noopener noreferrer"&gt;cluster module&lt;/a&gt;. Both are ways of respecting the same rule: the event loop must stay free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model to carry forward
&lt;/h2&gt;

&lt;p&gt;Hold onto this picture and the rest of Node follows from it. One thread runs your JavaScript. I/O is handed off and comes back as queued callbacks, so the thread stays busy instead of waiting. The event loop cycles through phases, draining callbacks, with microtasks squeezing in between every operation at higher priority. And the whole thing works only as long as no callback monopolizes the thread. &lt;a href="https://amanksingh.com/blog/nodejs-async-patterns" rel="noopener noreferrer"&gt;Async patterns&lt;/a&gt; are how you write code that cooperates with this model, &lt;a href="https://amanksingh.com/blog/nodejs-streams-and-backpressure" rel="noopener noreferrer"&gt;streams&lt;/a&gt; are how you process large data without blocking, and &lt;a href="https://amanksingh.com/blog/nodejs-memory-leaks-and-profiling" rel="noopener noreferrer"&gt;profiling&lt;/a&gt; is how you find the code that accidentally does. It all comes back to the loop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://amanksingh.com/blog/nodejs-event-loop-explained" rel="noopener noreferrer"&gt;amanksingh.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>performance</category>
      <category>backend</category>
    </item>
    <item>
      <title>Structured Logging for Node.js</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:06:18 +0000</pubDate>
      <link>https://dev.to/moose978/structured-logging-for-nodejs-1b8p</link>
      <guid>https://dev.to/moose978/structured-logging-for-nodejs-1b8p</guid>
      <description>&lt;p&gt;In &lt;a href="https://amanksingh.com/blog/monitoring-saas-production" rel="noopener noreferrer"&gt;Monitoring a SaaS in Production&lt;/a&gt; we wired up metrics and alerts so the team knows something is wrong within minutes of it happening. Metrics tell you a spike exists. They rarely tell you why. That's a logging problem, and it's the one we're solving here.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;strong&gt;&lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Full Stack SaaS Masterclass&lt;/a&gt;&lt;/strong&gt;, a build-it-for-real series taking a multi-tenant SaaS from an empty folder to production. Module 4 covers the operational concerns that separate a demo from a product a team can actually run. Logging sits at the base of that stack: incident response, support tickets, and audit trails all eventually route back to "what does the log say."&lt;/p&gt;

&lt;p&gt;Most Node.js apps start with &lt;code&gt;console.log&lt;/code&gt; and stay there far longer than they should, because it works fine on a laptop with one request at a time. It stops working the moment two requests are in flight concurrently and their log lines interleave in a shared terminal, or a customer support ticket needs an answer and the only tool available is &lt;code&gt;grep&lt;/code&gt; against a wall of unstructured text. Structured logging fixes that, and it's cheap enough to set up early that there's little reason to defer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why plain text logs stop working
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;console.log&lt;/code&gt; call produces a string. Strings are fine for a human staring at a terminal, and actively hostile to any system trying to query logs at scale. Once logs move to CloudWatch, Datadog, or any aggregator, every downstream tool ends up parsing that string back apart with regex to extract the fields it actually needs: request ID, user ID, status code, duration. Regex-based parsing is brittle. A one-word change to a log message breaks every saved search and alert built against it.&lt;/p&gt;

&lt;p&gt;Structured logging inverts the approach: instead of writing a sentence and hoping someone can parse it later, you write a JSON object with named fields from the start.&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;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-09-14T10:22:31.482Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8f14e45f-ceea-467e-add1-a889b9f6bfaa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"organizationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_9f2c1a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"usr_71b30d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST /invoices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durationMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Failed to create invoice: unique constraint violation"&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;Every field here is queryable without parsing. "Show me every 500 for organization &lt;code&gt;org_9f2c1a&lt;/code&gt; in the last hour" is a filter, not a regex. That difference is the entire value proposition, and it compounds as the system grows: the tenth engineer debugging an incident benefits from the same structured fields the first one set up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a logging library and wiring it into NestJS
&lt;/h2&gt;

&lt;p&gt;Node.js has a few solid structured logging libraries. &lt;code&gt;pino&lt;/code&gt; is a reasonable default for a NestJS backend. It's built around minimizing per-call overhead (JSON serialization with a fast path, rather than the general-purpose &lt;code&gt;JSON.stringify&lt;/code&gt; most loggers rely on), and it integrates cleanly with NestJS through &lt;code&gt;nestjs-pino&lt;/code&gt;.&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;// main.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;NestFactory&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/core&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;Logger&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-pino&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;AppModule&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;./app.module&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;bootstrap&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;app&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;NestFactory&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="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bufferLogs&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Logger&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&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="c1"&gt;// logger.module.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;Module&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;LoggerModule&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-pino&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;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;LoggerModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forRoot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;pinoHttp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LOG_LEVEL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;redact&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;req.headers.authorization&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;req.headers.cookie&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;*.password&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;*.token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;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;req&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="na"&gt;url&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;url&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
          &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statusCode&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;statusCode&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&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;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pino-pretty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;singleLine&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="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppLoggerModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter in that config beyond the obvious. First, &lt;code&gt;redact&lt;/code&gt; is not an afterthought; it's the difference between a log line and a leaked credential, and it belongs in the initial setup rather than a follow-up ticket after a security review flags it. Second, the &lt;code&gt;pino-pretty&lt;/code&gt; transport is scoped to development only. In production you want raw JSON going to stdout, because that's what your log aggregator expects, and pretty-printing it first just adds CPU work nobody reads.&lt;/p&gt;

&lt;p&gt;With that in place, injecting the logger into a service is direct:&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;Injectable&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;InjectPinoLogger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PinoLogger&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-pino&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;InvoiceService&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="nd"&gt;InjectPinoLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;InvoiceService&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PinoLogger&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;createInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Creating invoice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// ... invoice creation logic&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&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="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;err&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invoice creation failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;err&lt;/code&gt; field matters here: pino's default error serializer expands an &lt;code&gt;Error&lt;/code&gt; object into its message and stack trace as separate JSON fields, rather than the string interpolation you'd get from &lt;code&gt;console.log(error)&lt;/code&gt;. That's what lets you search for a specific exception type across every service without opening individual log lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threading a correlation ID through every log line
&lt;/h2&gt;

&lt;p&gt;A single log line is rarely useful on its own during an incident. What you actually want is every log line that belongs to one request, across every service it touched, and structured fields alone don't get you there unless every one of those lines shares a common ID. That's what a correlation ID (often called a request ID or trace ID) is for.&lt;/p&gt;

&lt;p&gt;The cleanest way to propagate it through a NestJS app without threading it as a parameter through every function call is Node's &lt;code&gt;AsyncLocalStorage&lt;/code&gt;, which gives each request its own isolated context that survives across &lt;code&gt;await&lt;/code&gt; boundaries.&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;// request-context.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;AsyncLocalStorage&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;async_hooks&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;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;RequestContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;requestId&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;organizationId&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;AsyncLocalStorage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RequestContext&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRequestId&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;requestContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-context&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// request-context.middleware.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;Injectable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NestMiddleware&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="nx"&gt;NextFunction&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requestContext&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;./request-context&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;RequestContextMiddleware&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestMiddleware&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;use&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="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="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextFunction&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;requestId&lt;/span&gt; &lt;span class="o"&gt;=&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;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;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&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="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;requestContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;requestId&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="nf"&gt;next&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;Any logger call anywhere in that request's call stack, including inside a queue job triggered by it if the ID is forwarded onto the job payload, can now pull &lt;code&gt;getRequestId()&lt;/code&gt; and attach it automatically. Wire it into the pino config's &lt;code&gt;mixin&lt;/code&gt; option so it's never something an engineer has to remember to pass explicitly:&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;LoggerModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forRoot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;pinoHttp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;mixin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getRequestId&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;This is the single highest-leverage addition to a logging setup after structure itself. Without it, reconstructing "everything that happened during this one failed checkout" means guessing at timestamps and hoping nothing else was happening concurrently. With it, it's one query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs: what structured logging costs you
&lt;/h2&gt;

&lt;p&gt;None of this is free, and the costs deserve an honest look before you treat it as an obvious win.&lt;/p&gt;

&lt;p&gt;Structured logs are less pleasant to read directly in a terminal during local development, which is why the &lt;code&gt;pino-pretty&lt;/code&gt; transport above exists specifically to soften that for local work while keeping production output as raw JSON. Serialization has a real, if small, CPU cost per log call, which matters if a hot path logs on every iteration of a loop rather than once per request. And redaction configuration needs upkeep. Every new field that might carry a credential, a password, an API key, a session token, needs to be added to the redact list, and that list is easy to forget about once the initial setup is done.&lt;/p&gt;

&lt;p&gt;The upside outweighs those costs for almost any SaaS backend past the earliest prototype stage, but it's worth naming them rather than pretending structured logging is a pure upgrade with no maintenance surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production pitfalls worth planning for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Logging PII into unstructured fields.&lt;/strong&gt; Structure doesn't prevent someone from putting a customer's email address into a free-form &lt;code&gt;msg&lt;/code&gt; string or a &lt;code&gt;properties&lt;/code&gt; object nobody's reviewing. Redaction rules only catch fields they know about by name; treat any new field added to a log call with the same scrutiny you'd apply to an API response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log volume outpacing what you're willing to pay for.&lt;/strong&gt; Structured JSON logs are larger per line than a terse text message, and verbose &lt;code&gt;debug&lt;/code&gt;-level logging left on in production multiplies that. Set &lt;code&gt;LOG_LEVEL&lt;/code&gt; per environment, default to &lt;code&gt;info&lt;/code&gt; in production, and reserve &lt;code&gt;debug&lt;/code&gt; for a deliberate, time-boxed investigation rather than a permanent setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Losing logs during a graceful shutdown.&lt;/strong&gt; If the process exits before the transport finishes flushing buffered log lines, whatever explains a crash is exactly what's missing from the aggregator. Make sure your shutdown hook waits for the logger to flush before the process exits, particularly if logs are batched before being sent off-box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correlation IDs that stop at the HTTP boundary.&lt;/strong&gt; A request ID that only exists inside the API process is far less useful than one forwarded onto every downstream call: the BullMQ job it enqueues, the outbound HTTP call to a third-party API, the message published to another service. Forward it deliberately at every boundary, or the trail goes cold exactly where an incident usually gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Structured logs are JSON objects with named fields, not sentences, which makes them queryable by an aggregator instead of requiring brittle regex parsing.&lt;/li&gt;
&lt;li&gt;Pino is a solid default for a NestJS backend: fast JSON serialization, first-class NestJS integration through &lt;code&gt;nestjs-pino&lt;/code&gt;, and built-in redaction and error serialization.&lt;/li&gt;
&lt;li&gt;Redaction rules for credentials and tokens belong in the initial setup, not a follow-up ticket after a security review.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AsyncLocalStorage&lt;/code&gt; lets you attach a request-scoped correlation ID to every log line automatically, which is the difference between reconstructing an incident in one query versus guessing at timestamps.&lt;/li&gt;
&lt;li&gt;Keep production log level at &lt;code&gt;info&lt;/code&gt; by default and reserve &lt;code&gt;debug&lt;/code&gt; for deliberate, time-boxed investigation, since verbose logging has a real cost once it's shipped to an aggregator.&lt;/li&gt;
&lt;li&gt;Forward correlation IDs across every boundary, HTTP calls, queue jobs, webhooks, or the trail goes cold exactly where an incident gets hard to debug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is structured logging?&lt;/strong&gt;&lt;br&gt;
Structured logging means writing log entries as objects with named, typed fields (like &lt;code&gt;requestId&lt;/code&gt;, &lt;code&gt;statusCode&lt;/code&gt;, &lt;code&gt;organizationId&lt;/code&gt;) instead of free-form text sentences. It lets log aggregators and query tools filter and search on those fields directly, rather than parsing a message string with regex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is pino better than winston for a Node.js backend?&lt;/strong&gt;&lt;br&gt;
Both are capable structured logging libraries. Pino is generally the faster choice for JSON serialization and integrates directly with NestJS through &lt;code&gt;nestjs-pino&lt;/code&gt;, which is why it's a common default for new NestJS projects. Winston has a longer track record and a larger plugin ecosystem, which can matter if you need a specific transport it already supports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I correlate logs across multiple services in a request?&lt;/strong&gt;&lt;br&gt;
Generate or forward a correlation ID (often called a request ID or trace ID) at the edge of your system, store it in an &lt;code&gt;AsyncLocalStorage&lt;/code&gt; context for the duration of the request, and forward it explicitly on every downstream call, including queue jobs and outbound HTTP requests. Every log line that reads from that context can then attach the same ID automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should never appear in a log line?&lt;/strong&gt;&lt;br&gt;
Passwords, session tokens, API keys, authorization headers, and full payment card details should never be logged in plain form. Configure redaction rules for known sensitive field names at the logger level, and treat any new field added to a log call with the same review scrutiny you'd apply to an API response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I log at debug level in production?&lt;/strong&gt;&lt;br&gt;
Generally no. Default production log level to &lt;code&gt;info&lt;/code&gt; and reserve &lt;code&gt;debug&lt;/code&gt; for a deliberate, time-boxed investigation, since debug-level logging multiplies volume and cost across every request without a corresponding benefit most of the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does structured logging replace the need for a metrics or tracing system?&lt;/strong&gt;&lt;br&gt;
No. Metrics tell you something is wrong quickly, tracing shows you how a request moved across services, and logs explain the specific reason a given request failed. They're complementary, and the correlation ID described here is what lets you move between all three during an incident without losing context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Previous: &lt;a href="https://amanksingh.com/blog/monitoring-saas-production" rel="noopener noreferrer"&gt;Monitoring a SaaS in Production&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next: &lt;a href="https://amanksingh.com/blog/audit-logs-you-can-trust" rel="noopener noreferrer"&gt;Audit Logs You Can Trust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Start of series: &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Choosing the Right Tech Stack for Your SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the Author
&lt;/h2&gt;

&lt;p&gt;Hi, I'm &lt;strong&gt;Aman Singh&lt;/strong&gt; — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.&lt;/p&gt;

&lt;p&gt;I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portfolio: &lt;a href="https://amanksingh.com" rel="noopener noreferrer"&gt;https://amanksingh.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/amansingh1501" rel="noopener noreferrer"&gt;https://github.com/amansingh1501&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Product: &lt;a href="https://vowerole.com" rel="noopener noreferrer"&gt;https://vowerole.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email: &lt;a href="mailto:aman97aman@gmail.com"&gt;aman97aman@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>nestjs</category>
      <category>logging</category>
      <category>backend</category>
    </item>
    <item>
      <title>Audit Logs You Can Trust</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:06:16 +0000</pubDate>
      <link>https://dev.to/moose978/audit-logs-you-can-trust-823</link>
      <guid>https://dev.to/moose978/audit-logs-you-can-trust-823</guid>
      <description>&lt;p&gt;In &lt;a href="https://amanksingh.com/blog/structured-logging-nodejs" rel="noopener noreferrer"&gt;Structured Logging for Node.js&lt;/a&gt;, I covered how to turn scattered &lt;code&gt;console.log&lt;/code&gt; calls into structured, queryable events. That solves the "what happened in the system" problem. It does not solve a different, harder problem: proving what a specific user did to a specific piece of data, at a specific time, in a way that a customer's compliance team or your own support engineer can trust months later.&lt;/p&gt;

&lt;p&gt;That is what an audit log is for. This is part of the &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Full Stack SaaS Masterclass&lt;/a&gt; series, and it sits at the point in a product's life where "we should probably log who deleted that" stops being a nice-to-have and starts being a support ticket, a security incident, or a line item in an enterprise contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why audit logs are a different beast than application logs
&lt;/h2&gt;

&lt;p&gt;Application logs answer "why did this request fail" or "how long did this query take." They're written for engineers, they're allowed to be noisy, and losing a percentage of them during an incident is an acceptable tradeoff for keeping the system up.&lt;/p&gt;

&lt;p&gt;Audit logs answer "who did this, and can we prove it." The audience is different too: support staff investigating a complaint, a security team responding to an incident, an auditor checking SOC 2 evidence, or a customer's own admin looking at their organization's activity feed. Each reader needs a record that is complete, immutable, and attributable to a real actor.&lt;/p&gt;

&lt;p&gt;This changes the requirements in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Completeness matters more than throughput.&lt;/strong&gt; Dropping an audit entry under backpressure is worse than slowing down the request that triggered it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutability matters.&lt;/strong&gt; If the same code path that writes the log can also update or delete it, the log has no evidentiary value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor identity matters more than a stack trace.&lt;/strong&gt; You need "user 412 in organization 9, acting through API key X," not just "PATCH /invoices/88 returned 200."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A team building a SaaS product from scratch often reaches for audit logs too early, wiring them into every table with a generic trigger before they know which actions need auditing. I'd resist that. Start with the actions a customer, an auditor, or your support team would actually ask about, and expand as real questions come in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually belongs in an audit entry
&lt;/h2&gt;

&lt;p&gt;A useful audit record needs enough context to answer "who, what, when, where, and what changed" without a database migration to add a missing column later. A reasonable baseline schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;audit_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;organizations&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;actor_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;users&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;actor_type&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- 'user' | 'api_key' | 'system'&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;-- e.g. 'invoice.updated'&lt;/span&gt;
  &lt;span class="n"&gt;target_type&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;-- e.g. 'invoice'&lt;/span&gt;
  &lt;span class="n"&gt;target_id&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ip_address&lt;/span&gt;    &lt;span class="n"&gt;inet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_agent&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;before_state&lt;/span&gt;  &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;after_state&lt;/span&gt;   &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;      &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;idx_audit_logs_org_time&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;audit_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;idx_audit_logs_target&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;audit_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of these choices are worth explaining rather than assuming.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;action&lt;/code&gt; is a stable string, not a free-text description. Something like &lt;code&gt;invoice.updated&lt;/code&gt; or &lt;code&gt;member.role_changed&lt;/code&gt; lets you filter, alert, and build a customer-facing activity feed without parsing prose. Treat this naming with the same discipline you'd use for event names in an event-driven system: pick a convention early (&lt;code&gt;resource.verb&lt;/code&gt;) and don't deviate per team.&lt;/p&gt;

&lt;p&gt;Storing &lt;code&gt;before_state&lt;/code&gt; and &lt;code&gt;after_state&lt;/code&gt; as separate JSONB columns, rather than one combined diff, makes it trivial to render "changed status from &lt;code&gt;draft&lt;/code&gt; to &lt;code&gt;sent&lt;/code&gt;" without re-deriving anything from a patch document. There's a storage cost, though. For large records, log only the fields that changed, not the entire row, or a customer's big JSON blob will bloat this table fast.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;organization_id&lt;/code&gt; on every row is non-negotiable in a multi-tenant system. Every query against this table should assume tenant scoping is the first filter, not an afterthought applied at the API layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing audit entries without polluting business logic
&lt;/h2&gt;

&lt;p&gt;The naive approach is to call an &lt;code&gt;auditLogService.log(...)&lt;/code&gt; at the end of every controller method that changes something. It works, but it's easy to forget, and it couples business logic to audit concerns in a way that makes both harder to test.&lt;/p&gt;

&lt;p&gt;A cleaner pattern in NestJS is an interceptor that reads metadata off the route and emits an audit event after the handler succeeds, combined with a decorator so the intent is visible on the route itself:&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;// audit-log.decorator.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;SetMetadata&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUDIT_ACTION_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;auditAction&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuditOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;action&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;targetType&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Audit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nc"&gt;SetMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AUDIT_ACTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&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="c1"&gt;// audit-log.interceptor.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;CallHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ExecutionContext&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;NestInterceptor&lt;/span&gt;&lt;span class="p"&gt;,&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;Reflector&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/core&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;Observable&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;rxjs&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;tap&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;rxjs/operators&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;AUDIT_ACTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AuditOptions&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;./audit-log.decorator&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;AuditLogService&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;./audit-log.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;AuditLogInterceptor&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestInterceptor&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;reflector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Reflector&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;auditLogService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditLogService&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;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CallHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&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;options&lt;/span&gt; &lt;span class="o"&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;reflector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AuditOptions&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;AUDIT_ACTION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHandler&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;options&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;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&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;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&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;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;tap&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;void&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;auditLogService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;organizationId&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;actorId&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;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="na"&gt;actorType&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;targetType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;targetId&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;id&lt;/span&gt; &lt;span class="o"&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;ipAddress&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;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;userAgent&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;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="na"&gt;metadata&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;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// invoices.controller.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;Controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Patch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UseInterceptors&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;AuditLogInterceptor&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;../audit-log/audit-log.interceptor&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;Audit&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;../audit-log/audit-log.decorator&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;invoices&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;UseInterceptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuditLogInterceptor&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;InvoicesController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&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;Audit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;targetType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice&lt;/span&gt;&lt;span class="dl"&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="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&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;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UpdateInvoiceDto&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoicesService&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the service method free of audit-specific code and makes audited actions visible by scanning routes rather than reading every service. The interceptor only runs after the handler succeeds, which is deliberate: you generally want to audit what happened, not what was attempted and rejected. If you also need to record failed or denied attempts, useful for security-sensitive actions like permission changes, add a second interceptor or a guard-level hook rather than overloading this one.&lt;/p&gt;

&lt;p&gt;One thing this pattern does not capture well is &lt;code&gt;before_state&lt;/code&gt;. If you need the previous value for a diff, fetch it in the service before the mutation and pass it through request-scoped context, or have the service call the audit writer directly for that case. Don't force every audited action through the same mechanism if the data shape genuinely differs; an inconsistent implementation that captures the right data beats a uniform one that captures the wrong data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the log tamper-evident, not just tamper-resistant
&lt;/h2&gt;

&lt;p&gt;Restricting write access to the &lt;code&gt;audit_logs&lt;/code&gt; table with database permissions is the first layer: no application role should have &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; grants, only &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;SELECT&lt;/code&gt;. But permissions protect against accidental misuse, not against someone with superuser access or a compromised deployment credential.&lt;/p&gt;

&lt;p&gt;For genuine tamper-evidence, the common technique is hash chaining. Each row stores a hash of its own content plus the hash of the previous row, so altering any historical entry breaks the chain and is detectable on verification.&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;createHash&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;crypto&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;computeEntryHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;previousHash&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;occurredAt&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;action&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;targetId&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;actorId&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="kr"&gt;string&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;payload&lt;/span&gt; &lt;span class="o"&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previousHash&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;occurredAt&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetId&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorId&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;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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;Whether you need this depends entirely on your compliance target. Most early-stage SaaS products don't need cryptographic hash chains; restricted database grants, an append-only table, and shipping a copy of the logs to a write-once store (S3 with Object Lock, for instance) cover the realistic threat model. Reach for hash chaining when a specific compliance framework or a customer's security review demands proof of non-tampering, not by default. Building it earlier than that is complexity your team maintains for years without a corresponding benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention, access, and the customer-facing activity feed
&lt;/h2&gt;

&lt;p&gt;An audit log that nobody can query is just storage cost. Two consumers usually want access: your internal support and security teams, and the customer's own admins, who increasingly expect an "activity log" tab in the product itself.&lt;/p&gt;

&lt;p&gt;For the internal case, keep the raw &lt;code&gt;audit_logs&lt;/code&gt; table as the source of truth and build read paths on top of it rather than giving broad database access to support staff. For the customer-facing case, expose only a filtered subset: internal actions like background jobs, staff impersonation, or debugging generally should not appear in a customer's activity feed, and fields like raw IP addresses may need scrubbing.&lt;/p&gt;

&lt;p&gt;Retention is a policy decision as much as a technical one. Regulated customers commonly expect years of retention; a typical SaaS product without that requirement can move audit rows older than a year or two into cheaper storage, keeping the hot table fast for recent activity and active investigations. Partitioning &lt;code&gt;audit_logs&lt;/code&gt; by month, similar to a high-volume event table in PostgreSQL, makes this rollover mechanical instead of a manual job you have to remember to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production pitfalls worth naming directly
&lt;/h2&gt;

&lt;p&gt;A few mistakes show up repeatedly once teams start relying on audit logs for real incidents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writing the audit entry inside the same transaction as the business mutation, then rolling both back on an unrelated later error.&lt;/strong&gt; A failure elsewhere in the transaction can silently erase evidence that something was attempted. Decide deliberately whether audit writes should be transactional or best-effort and asynchronous; both are valid, but pick one on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging the actor as a service account instead of the real user&lt;/strong&gt; when actions go through a background job. If a scheduled job acts on a user's behalf, the entry should still record which user's data triggered it, ideally alongside the original request that queued the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No index on &lt;code&gt;(organization_id, occurred_at)&lt;/code&gt;.&lt;/strong&gt; Without it, "show recent activity" becomes a sequential scan on a table that only grows, and it gets slower every month rather than staying flat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating audit logs and structured application logs as the same system.&lt;/strong&gt; They have different durability, access control, and retention needs. Mixing them into one log stream makes it hard to give a customer's admin visibility into their activity without exposing internal debug traces.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Audit logs answer "who did this and can we prove it," a different requirement from application logging that deserves its own storage, schema, and access controls.&lt;/li&gt;
&lt;li&gt;Design the schema around actor identity, a stable action name, and before/after state, not a free-text description that's hard to query later.&lt;/li&gt;
&lt;li&gt;Use a decorator plus interceptor in NestJS to keep audit writes visible on the route and out of business logic, but don't force every action through the same mechanism if the data genuinely differs.&lt;/li&gt;
&lt;li&gt;Restrict write access to append-only at the database level first; add cryptographic hash chaining only when a real compliance requirement asks for it.&lt;/li&gt;
&lt;li&gt;Separate the customer-facing activity feed from the raw internal audit table, scrubbing internal-only actions before exposing it.&lt;/li&gt;
&lt;li&gt;Plan retention and partitioning early; an unindexed, unpartitioned audit table is a quiet way a SaaS database slows down over time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between an audit log and an application log?&lt;/strong&gt;&lt;br&gt;
An application log helps engineers debug behavior and can tolerate sampling or occasional loss. An audit log is a compliance and trust artifact recording who did what to which resource, and it needs completeness, immutability, and a real actor identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should audit log writes be synchronous or asynchronous with the business action?&lt;/strong&gt;&lt;br&gt;
Either can work, but decide on purpose. Same-transaction writes guarantee the log and the action succeed or fail together, at the cost of coupling. Asynchronous writes protect the request path from audit-write latency, at the cost of needing their own retry or dead-letter handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need blockchain or cryptographic hash chaining for audit logs?&lt;/strong&gt;&lt;br&gt;
Only if a specific compliance framework or customer security review requires proof of non-tampering beyond restricted database access. For most SaaS products, an append-only table with revoked update and delete permissions is a proportionate starting point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long should I retain audit logs?&lt;/strong&gt;&lt;br&gt;
It depends on customer compliance needs more than any general rule. Regulated industries often expect multi-year retention; otherwise, keep recent data in a fast, indexed table and move older rows to cheaper storage on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should the customer-facing activity feed show the same data as the internal audit table?&lt;/strong&gt;&lt;br&gt;
No. Show customers their own actions in a readable format, and exclude internal-only entries like staff impersonation. The raw table is your source of truth; the feed is a filtered view over it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should audit logging live in a NestJS application?&lt;/strong&gt;&lt;br&gt;
As a dedicated module invoked through a decorator and interceptor, rather than scattered &lt;code&gt;service.log()&lt;/code&gt; calls. This keeps audited actions visible on route definitions and testable in isolation from the business logic they observe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Previous: &lt;a href="https://amanksingh.com/blog/structured-logging-nodejs" rel="noopener noreferrer"&gt;Structured Logging for Node.js&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next: &lt;a href="https://amanksingh.com/blog/feature-flags-safe-rollouts" rel="noopener noreferrer"&gt;Feature Flags and Safe Rollouts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Start of series: &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Choosing the Right Tech Stack for Your SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the Author
&lt;/h2&gt;

&lt;p&gt;Hi, I'm &lt;strong&gt;Aman Singh&lt;/strong&gt; — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.&lt;/p&gt;

&lt;p&gt;I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portfolio: &lt;a href="https://amanksingh.com" rel="noopener noreferrer"&gt;https://amanksingh.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/amansingh1501" rel="noopener noreferrer"&gt;https://github.com/amansingh1501&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Product: &lt;a href="https://vowerole.com" rel="noopener noreferrer"&gt;https://vowerole.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email: &lt;a href="mailto:aman97aman@gmail.com"&gt;aman97aman@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>nestjs</category>
      <category>postgres</category>
      <category>backend</category>
    </item>
    <item>
      <title>Feature Flags and Safe Rollouts</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:06:14 +0000</pubDate>
      <link>https://dev.to/moose978/feature-flags-and-safe-rollouts-39d0</link>
      <guid>https://dev.to/moose978/feature-flags-and-safe-rollouts-39d0</guid>
      <description>&lt;p&gt;In &lt;a href="https://amanksingh.com/blog/audit-logs-you-can-trust" rel="noopener noreferrer"&gt;Audit Logs You Can Trust&lt;/a&gt;, we built a system that answers "who changed what, and when." Feature flags answer a related question: "who gets to see this, and how much of it." Once you trust your audit trail, the natural next step is controlling what actually ships behind it. A flag toggle is exactly the kind of change your audit log should be recording.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Full Stack SaaS Masterclass&lt;/a&gt;, a build-it-for-real series that takes a multi-tenant SaaS from an empty repo to production. We're deep into Module 4 now: the unglamorous plumbing that separates a demo from something you can run a business on.&lt;/p&gt;

&lt;p&gt;Feature flags get a bad reputation because teams either skip them and ship risky all-or-nothing deploys, or bolt on a full experimentation platform before they have a single paying customer. What you actually need, most of the time, is much simpler than either extreme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why feature flags earn their place
&lt;/h2&gt;

&lt;p&gt;The core problem a flag solves is decoupling deployment from release. Without flags, "merge to main" and "customers see this" are the same event. That's fine until it isn't: a half-finished billing rewrite sits in a long-lived branch for weeks, and the eventual merge is a nerve-wracking event instead of a routine one.&lt;/p&gt;

&lt;p&gt;With flags, you can merge incomplete work behind a flag that's off in production. The code ships continuously; the &lt;em&gt;behavior&lt;/em&gt; ships when you decide it's ready. That single decoupling is worth more than the fancier things flags enable later, like percentage rollouts or experiments.&lt;/p&gt;

&lt;p&gt;There's a second benefit that gets less attention: flags give you a fast, code-free rollback. If a new feature causes an incident, flipping a boolean in the flags table is a lot faster than reverting a deploy and waiting for CI to build and ship a new image. For anything touching payments, auth, or data integrity, that speed difference matters.&lt;/p&gt;

&lt;p&gt;The tradeoff is real, though. Flags are conditional logic, and conditional logic nobody cleans up is technical debt with a timer running. A flag left in after a full rollout becomes a permanent &lt;code&gt;if&lt;/code&gt; statement nobody remembers the reason for. Codebases accumulate a dozen "temporary" flags from a year ago, still checked on every request, still branching in tests nobody wants to write. Plan for removal on the day you add the flag, not as an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flag types, and picking the right one
&lt;/h2&gt;

&lt;p&gt;Not every flag needs the same lifecycle or the same evaluation logic. Lumping them together is how flag systems become unmanageable. I find it useful to separate them into four categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release toggles&lt;/strong&gt; guard incomplete or risky features during rollout. They're meant to be short-lived: turn on, verify, delete the flag and the branching code within a sprint or two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ops toggles&lt;/strong&gt; (kill switches) protect the system itself. Think "disable the recommendation engine if the third-party API is down" or "turn off background email digests during a database migration." These can live indefinitely; they're a safety valve, not a rollout mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission toggles&lt;/strong&gt; gate a feature by plan tier or organization, like exposing SSO configuration only to enterprise customers. These are effectively long-lived entitlements, closer to authorization than to a rollout flag, and they usually deserve their own model rather than reusing your rollout table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experiment toggles&lt;/strong&gt; split traffic to compare outcomes. This is the category people usually mean by "feature flags" in a growth context, and it carries the most operational overhead: consistent bucketing, an analytics pipeline, and a plan for what happens when the experiment ends. Don't reach for it until you have a hypothesis worth testing and enough traffic for a meaningful signal.&lt;/p&gt;

&lt;p&gt;For most SaaS teams before product-market fit, release toggles and a handful of ops toggles cover nearly everything. Permission toggles come naturally once you have paid tiers. Experiment toggles are the category worth deferring the longest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a flag service: Postgres and Redis
&lt;/h2&gt;

&lt;p&gt;You don't need a vendor for this. A table, a cache, and a small service take you a long way, and the mental model stays simple: flags are just rows you query and update like anything else in your system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;feature_flags&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rollout_percentage&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rollout_percentage&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AND&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;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;feature_flag_overrides&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;flag_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;feature_flags&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="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flag_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;organization_id&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 overrides table matters more than it looks. It's how you handle "turn this on for one pilot customer" without touching the global rollout percentage. Overrides always win over the percentage rule, and that precedence needs to be explicit in the evaluation logic.&lt;/p&gt;

&lt;p&gt;Evaluation happens on every request that touches a flagged code path, so it needs to be fast and it needs to survive a Redis outage gracefully. Cache-aside with a short TTL handles the common case; a Postgres fallback handles the cache being cold or down.&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;// feature-flags.service.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;Injectable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Logger&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;InjectRepository&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/typeorm&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;Repository&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;typeorm&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;createHash&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;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&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;ioredis&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;FeatureFlag&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;./feature-flag.entity&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;FeatureFlagOverride&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;./feature-flag-override.entity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FlagContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;organizationId&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;CACHE_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&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;FeatureFlagsService&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;logger&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;Logger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FeatureFlagsService&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;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;InjectRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FeatureFlag&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;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Repository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureFlag&lt;/span&gt;&lt;span class="o"&gt;&amp;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;InjectRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FeatureFlagOverride&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;overrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Repository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureFlagOverride&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;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Redis&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="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FlagContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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;flag&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="nf"&gt;getFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;flag&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;false&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;override&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="nf"&gt;getOverride&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizationId&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;override&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;override&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="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;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rolloutPercentage&lt;/span&gt; &lt;span class="o"&gt;&amp;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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rolloutPercentage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInRolloutBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rolloutPercentage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;isInRolloutBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&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;percentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;organizationId&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;digest&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;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32BE&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="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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&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;async&lt;/span&gt; &lt;span class="nf"&gt;getFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureFlag&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`flag:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&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;err&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Redis unavailable for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;FeatureFlag&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;flag&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;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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;key&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;flag&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&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;flag&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CACHE_TTL_SECONDS&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;flag&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;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getOverride&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flagId&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;organizationId&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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;flagId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&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;Hashing the flag key with the organization ID makes the rollout consistent: the same organization always lands in the same bucket for a given flag, so a customer doesn't flicker between "has the feature" and "doesn't" on every request. Changing the flag key changes the bucket assignment too, which stops one flag's rollout from correlating with another's in unplanned ways.&lt;/p&gt;

&lt;p&gt;Wrap the check in a small guard or decorator so route handlers stay clean:&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;// feature-flag.decorator.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;SetMetadata&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FEATURE_FLAG_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;featureFlag&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;RequireFeatureFlag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;SetMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FEATURE_FLAG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/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="c1"&gt;// feature-flag.guard.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;CanActivate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&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="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;Reflector&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/core&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;FeatureFlagsService&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;./feature-flags.service&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;FEATURE_FLAG_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;./feature-flag.decorator&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;FeatureFlagGuard&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&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;reflector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Reflector&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;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeatureFlagsService&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="nf"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reflector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FEATURE_FLAG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHandler&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;key&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;true&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;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getRequest&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;organizationId&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;organizationId&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;Every write to &lt;code&gt;feature_flags&lt;/code&gt; or &lt;code&gt;feature_flag_overrides&lt;/code&gt; should go through the audit logging service from the previous article. "Who turned this on" is exactly the question you'll want answered during an incident retro.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollout strategy and rolling back
&lt;/h2&gt;

&lt;p&gt;A percentage rollout only helps if you pair it with something to watch. Ramping 5 percent, then 25, then 100 over a few days catches a regression before it reaches everyone, but only if someone is actually watching error rates and support tickets during that window. A rollout plan without a monitoring plan is just a slower way to break things for everyone.&lt;/p&gt;

&lt;p&gt;The other half of a safe rollout is a documented, low-friction path back to off: the person on call knows where the flags table lives, has permission to flip it, and doesn't need a deploy to do so. If your only rollback mechanism is redeploying an older image, you haven't actually decoupled deployment from release.&lt;/p&gt;

&lt;p&gt;For genuinely risky operations, an ops toggle checked at the top of a job or worker, not just in the HTTP layer, is worth the extra wiring. A queue consumer checking &lt;code&gt;isEnabled('new-billing-sync', ...)&lt;/code&gt; before processing a job can be paused independently of the API tier, which matters when the risky code runs outside a request-response cycle entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs and where teams get burned
&lt;/h2&gt;

&lt;p&gt;The most common failure mode is flag accumulation. Teams rarely get the implementation itself wrong. Every flag is a fork in your test matrix; ten flags produce something closer to two to the tenth power of possible states, most of which nobody tests. Set a rule early: release toggles get removed within a sprint or two of reaching 100 percent, with a backlog ticket created the day the flag is added.&lt;/p&gt;

&lt;p&gt;Build versus buy is worth an honest look. Vendors like LaunchDarkly, Unleash, and Flagsmith solve real problems: multivariate flags, SDKs across languages, targeting UIs, and analytics tied to experiments. If your team runs many experiment-style flags with product managers who need a UI, that tooling earns its cost quickly. But release and ops toggles, which cover most of what a small SaaS team needs, are simpler to reason about with a Postgres table and a Redis cache, and they don't add another vendor to your uptime dependency graph. Reach for the vendor only when usage actually demands it.&lt;/p&gt;

&lt;p&gt;A subtler pitfall: flags gating database schema changes. If a flag controls whether code writes to a new column, and you roll it back after data has already been written under the new behavior, you can end up with inconsistent rows that neither code path expects. Schema-affecting flags need a data rollback plan, not just a code rollback plan, and that plan should exist before the flag reaches 100 percent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Feature flags decouple deployment from release, which turns merges into routine events and rollbacks into a config change instead of a redeploy.&lt;/li&gt;
&lt;li&gt;Separate flags by purpose: release toggles, ops toggles, permission toggles, and experiment toggles each have different lifecycles and different owners.&lt;/li&gt;
&lt;li&gt;A Postgres table plus a Redis cache handles release and ops toggles well; save vendor platforms for when you have real experimentation or targeting needs.&lt;/li&gt;
&lt;li&gt;Hash the flag key with a stable identifier (organization or user ID) for consistent, sticky percentage rollouts.&lt;/li&gt;
&lt;li&gt;Every flag needs a removal plan from the day it's created, and schema-affecting flags need a data rollback plan, not just a code rollback plan.&lt;/li&gt;
&lt;li&gt;Route flag changes through the same audit logging you already built; a toggle in production is a change worth recording.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a feature flag and a kill switch?&lt;/strong&gt;&lt;br&gt;
A kill switch is a specific type of feature flag, usually what we'd call an ops toggle. It stays in the codebase indefinitely and exists to disable a risky or unstable code path quickly, rather than to manage a rollout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should feature flag state live in the database or in environment variables?&lt;/strong&gt;&lt;br&gt;
Environment variables require a redeploy or restart to change, which defeats the main benefit of flags: changing behavior without shipping code. Database-backed flags, cached in Redis, let you toggle behavior instantly while keeping a change history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long should a feature flag live before it's removed?&lt;/strong&gt;&lt;br&gt;
For a release toggle, days to a couple of sprints after it reaches full rollout. Ops toggles and permission toggles can live indefinitely by design, since they serve a different, ongoing purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a third-party feature flag service like LaunchDarkly for a small SaaS?&lt;/strong&gt;&lt;br&gt;
Not at first. A flags table with a Redis cache covers release and ops toggles, which is what most small teams need. Consider a vendor once you need multivariate experiments, non-engineer-facing targeting UIs, or SDKs across many languages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test code that's behind a feature flag?&lt;/strong&gt;&lt;br&gt;
Write tests for both states explicitly, don't rely on the default. Treat the off state as current production behavior and the on state as the new behavior under test, and keep both covered until the flag is removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can feature flags cause data inconsistency?&lt;/strong&gt;&lt;br&gt;
Yes, particularly when a flag controls a schema change or new write path. Rolling a flag back after it's written data under the new behavior can leave rows in a state neither code path handles. Plan the data rollback alongside the flag, not just the code rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Previous: &lt;a href="https://amanksingh.com/blog/audit-logs-you-can-trust" rel="noopener noreferrer"&gt;Audit Logs You Can Trust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next: &lt;a href="https://amanksingh.com/blog/deploying-a-fullstack-saas" rel="noopener noreferrer"&gt;Deploying a Full Stack SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Start of series: &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Choosing the Right Tech Stack for Your SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the Author
&lt;/h2&gt;

&lt;p&gt;Hi, I'm &lt;strong&gt;Aman Singh&lt;/strong&gt; — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.&lt;/p&gt;

&lt;p&gt;I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portfolio: &lt;a href="https://amanksingh.com" rel="noopener noreferrer"&gt;https://amanksingh.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/amansingh1501" rel="noopener noreferrer"&gt;https://github.com/amansingh1501&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Product: &lt;a href="https://vowerole.com" rel="noopener noreferrer"&gt;https://vowerole.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email: &lt;a href="mailto:aman97aman@gmail.com"&gt;aman97aman@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>node</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Deploying a Full Stack SaaS</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:06:11 +0000</pubDate>
      <link>https://dev.to/moose978/deploying-a-full-stack-saas-5g06</link>
      <guid>https://dev.to/moose978/deploying-a-full-stack-saas-5g06</guid>
      <description>&lt;p&gt;The &lt;a href="https://amanksingh.com/blog/feature-flags-safe-rollouts" rel="noopener noreferrer"&gt;previous article&lt;/a&gt; in this series covered feature flags: how to decouple deploying code from releasing it, so a bad idea can be turned off without a rollback. That only pays off if deploying itself is a boring, repeatable event rather than a stressful one. This article is about making it boring.&lt;/p&gt;

&lt;p&gt;This is part of the &lt;strong&gt;&lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Full Stack SaaS Masterclass&lt;/a&gt;&lt;/strong&gt;, and it sits at a point where the temptation to overbuild is strongest. Deployment invites that kind of overreach. Engineers reach for Kubernetes and blue-green everything before they have the traffic or the team that justifies any of it. I want to walk through the choices in the order I'd actually make them, honest about which ones are earned early and which aren't.&lt;/p&gt;

&lt;p&gt;None of what follows assumes a platform team. It assumes a small team shipping a real product, choosing infrastructure that won't fight them at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a deployment target without overbuilding
&lt;/h2&gt;

&lt;p&gt;There are roughly three tiers of deployment target, and the right one depends on where you actually are, not where you expect to be in two years.&lt;/p&gt;

&lt;p&gt;A single VM (or a couple) running Docker Compose is the honest starting point for most SaaS products. It's the same compose discipline from the &lt;a href="https://amanksingh.com/blog/docker-local-development-setup" rel="noopener noreferrer"&gt;local Docker setup&lt;/a&gt; article, pointed at a real box instead of your laptop, fronted by Caddy or Nginx for TLS. It's unglamorous. It's also completely capable of running a real product with real paying customers. The failure mode people worry about, a VM going down and taking the app with it, is a real risk, but one you can measure against your actual uptime requirements instead of assuming away.&lt;/p&gt;

&lt;p&gt;A managed container platform, ECS Fargate on AWS, Cloud Run on GCP, or a PaaS like Render or Fly.io, is the next tier. You give up some control over the machine in exchange for the platform handling restarts, health checks, and scaling. This is where I'd point most teams once more than one engineer touches infrastructure, since the operational surface area stays small enough that nobody needs to become a full-time platform engineer.&lt;/p&gt;

&lt;p&gt;Kubernetes is the tier I'd defer the longest. It solves real problems, multi-service orchestration and fine-grained scaling across a large team, but demands operational maturity (RBAC, network policies, ingress, cluster upgrades) most SaaS products never need. Reaching for it because "serious companies use it" is exactly the complexity this series argues against taking on early. Once you have several services and teams with genuinely different scaling needs, the calculus changes. Until then, it's a cost with no offsetting benefit.&lt;/p&gt;

&lt;p&gt;The tradeoff repeats throughout this series: more control costs more operational effort, and that effort should go only where a concrete problem justifies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build and release pipeline
&lt;/h2&gt;

&lt;p&gt;Whatever the target, the pipeline shape is the same: build once, test the build, then promote the same artifact through environments. Building separately for staging and production is a subtle trap: what you test in staging and what you ship to production end up being two different builds.&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;# .github/workflows/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run lint&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run test&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build API image&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker build \&lt;/span&gt;
            &lt;span class="s"&gt;--target production \&lt;/span&gt;
            &lt;span class="s"&gt;-t ${{ secrets.ECR_REGISTRY }}/api:${{ github.sha }} \&lt;/span&gt;
            &lt;span class="s"&gt;-f apps/api/Dockerfile .&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Push image&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws ecr get-login-password --region us-east-1 | \&lt;/span&gt;
            &lt;span class="s"&gt;docker login --username AWS --password-stdin ${{ secrets.ECR_REGISTRY }}&lt;/span&gt;
          &lt;span class="s"&gt;docker push ${{ secrets.ECR_REGISTRY }}/api:${{ github.sha }}&lt;/span&gt;

  &lt;span class="na"&gt;deploy-production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update ECS service&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws ecs update-service \&lt;/span&gt;
            &lt;span class="s"&gt;--cluster saas-production \&lt;/span&gt;
            &lt;span class="s"&gt;--service api \&lt;/span&gt;
            &lt;span class="s"&gt;--force-new-deployment \&lt;/span&gt;
            &lt;span class="s"&gt;--task-definition saas-api:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions here matter more than they look. Tests run before the image is even built, so a broken build never gets tagged with a commit SHA and never becomes a deploy candidate. And the image is tagged with the git SHA rather than &lt;code&gt;latest&lt;/code&gt;, which is what makes rollback a one-line command instead of an archaeology project: you always know exactly which commit is running, and reverting just means pointing the service at a previous SHA.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;environment: production&lt;/code&gt; block does real work too. GitHub Environments let you require a manual approval before that job runs, a cheap, low-ceremony gate that catches the "I meant to push to a feature branch" class of mistake before it reaches real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-downtime deploys and health checks
&lt;/h2&gt;

&lt;p&gt;A deploy that drops requests, even for a few seconds, is a deploy your users notice. Avoiding that has little to do with clever infrastructure. It comes down to the platform knowing when a new instance is actually ready before it stops sending traffic to the old one.&lt;/p&gt;

&lt;p&gt;That starts with an honest health check endpoint, not just a &lt;code&gt;200 OK&lt;/code&gt; that ignores whether the app can actually do its job:&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;// apps/api/src/health/health.controller.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;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="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;HealthCheckService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TypeOrmHealthIndicator&lt;/span&gt;&lt;span class="p"&gt;,&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/terminus&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;RedisHealthIndicator&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;./redis-health.indicator&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;health&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;HealthController&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;health&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HealthCheckService&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;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TypeOrmHealthIndicator&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;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RedisHealthIndicator&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="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;check&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pingCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;database&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isHealthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redis&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;This endpoint checks the dependencies the API actually needs to serve a request, not just whether the Node process is alive. A process can run perfectly fine while its database connection pool is exhausted, and a health check that only pings the process itself will happily route traffic to an instance that can't serve it.&lt;/p&gt;

&lt;p&gt;Point your platform's readiness probe at this endpoint and give it a real grace period before marking an instance unhealthy. On ECS, that's the target group's health check on the load balancer; behind Nginx in Compose it's a manual healthcheck, though Compose alone doesn't give you the traffic draining a real load balancer does. The platform should wait for a new instance's health check to pass, then drain in-flight connections from the old instance rather than killing it outright. That draining step is the actual mechanism behind "zero-downtime," not anything magical about the deploy command itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database migrations as part of the deploy
&lt;/h2&gt;

&lt;p&gt;Migrations are where deploys go wrong in ways that are hard to notice until they've caused an incident. The core rule: a migration has to be safe while the previous version of the code is still serving traffic, because during a rolling deploy, old and new code run side by side for some window of time, however short.&lt;/p&gt;

&lt;p&gt;That rules out dropping a column the old code still reads, or renaming something the old code references by its previous name, in the same deploy that ships the code change. The safe pattern splits it into stages: add the new column, deploy code that writes to both old and new, backfill, deploy code that reads only the new column, then drop the old one in a later migration once nothing depends on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Stage 1: additive, safe with old code still running&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;billing_email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Stage 2, a later migration, once new code is the only code running:&lt;/span&gt;
&lt;span class="c1"&gt;-- ALTER TABLE organizations DROP COLUMN legacy_billing_contact;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run migrations as an explicit release step, not something the app does on boot. Letting every instance run migrations on startup means N instances racing to apply the same migration during a rolling deploy, an intermittent failure that's miserable to debug because it only shows up under real deploy timing.&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;# One-off migration step before the ECS service update in deploy.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run migrations&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;aws ecs run-task \&lt;/span&gt;
      &lt;span class="s"&gt;--cluster saas-production \&lt;/span&gt;
      &lt;span class="s"&gt;--task-definition saas-migrate:${{ github.sha }} \&lt;/span&gt;
      &lt;span class="s"&gt;--launch-type FARGATE \&lt;/span&gt;
      &lt;span class="s"&gt;--network-configuration "..." \&lt;/span&gt;
      &lt;span class="s"&gt;--overrides '{"containerOverrides":[{"name":"migrate","command":["npm","run","migration:run"]}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it as a separate task before the service update means the schema is ready before any new code that depends on it receives traffic. It also happens exactly once, regardless of how many instances the service scales to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback and production pitfalls
&lt;/h2&gt;

&lt;p&gt;Because every image is tagged with a git SHA, rolling back is the same command as rolling forward, pointed at a previous tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecs update-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster&lt;/span&gt; saas-production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service&lt;/span&gt; api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--force-new-deployment&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--task-definition&lt;/span&gt; saas-api:a1b2c3d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that command, or its equivalent, somewhere every engineer can find it without digging, ideally in a runbook rather than someone's shell history. The value of a fast rollback comes entirely from how quickly someone can find and run it under pressure.&lt;/p&gt;

&lt;p&gt;A few pitfalls show up repeatedly once a team deploys for real. Coupling migrations and code into one inseparable step is the big one, already covered above. Skipping a genuine health check in favor of a bare liveness probe is another: it hides exactly the failures, a dead connection pool, an unreachable Redis, that a deploy is most likely to introduce. Not tagging images with something traceable to a commit turns rollback into guesswork. And treating staging as optional because "it's basically the same as prod" removes the one environment where a broken migration gets caught before it reaches customers.&lt;/p&gt;

&lt;p&gt;It comes down to deciding, deliberately, what "safe to deploy" means for your app, then encoding that decision into the pipeline so it doesn't depend on someone remembering it under pressure. No exotic tooling required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Match deployment complexity to your actual team and traffic: a single VM with Docker Compose is a legitimate production target, ECS/Cloud Run/a PaaS is the right next step for most teams, and Kubernetes should wait for a concrete multi-service, multi-team problem.&lt;/li&gt;
&lt;li&gt;Build one artifact per commit, tag it with the git SHA, and promote that same artifact through environments instead of rebuilding per environment.&lt;/li&gt;
&lt;li&gt;A real health check verifies dependencies (database, Redis), not just that the process is alive; the platform should wait for it before draining the previous instance's traffic.&lt;/li&gt;
&lt;li&gt;Run database migrations as an explicit, single release step before the code deploy, and design schema changes to be safe while old and new code run side by side.&lt;/li&gt;
&lt;li&gt;Rollback should be the same mechanism as deploy, pointed at a previous SHA; keep that command in a runbook, not in someone's memory.&lt;/li&gt;
&lt;li&gt;Deploying isn't about clever tooling. It's about deciding what "safe" means for your app and encoding that decision into the pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need Kubernetes to deploy a production SaaS?&lt;/strong&gt;&lt;br&gt;
No. Kubernetes solves orchestration problems that show up with many services and teams. A single VM with Docker Compose, or a managed platform like ECS Fargate or Cloud Run, is a legitimate production target until a concrete multi-service problem justifies the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I achieve zero-downtime deploys?&lt;/strong&gt;&lt;br&gt;
The platform waits for a new instance's health check to pass before routing traffic to it, then drains in-flight connections from the old instance before terminating it. That depends on a health check verifying real dependencies and a load balancer that supports draining, not on any single deploy command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should database migrations run automatically when the app starts?&lt;/strong&gt;&lt;br&gt;
No. Running migrations on boot means every instance in a rolling deploy races to apply the same one. Run migrations as a single, explicit step before the code deploy, ideally as a one-off task rather than baked into startup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the fastest way to roll back a bad deploy?&lt;/strong&gt;&lt;br&gt;
Tag every built image with its git SHA and keep a runbook command that redeploys a previous tag. Because the artifact for any past commit already exists, rollback is the same operation as a forward deploy, just pointed at an older tag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to rename or drop a database column in the same deploy as the code change?&lt;/strong&gt;&lt;br&gt;
Not if the deploy is rolling, since old and new code briefly run side by side. Split the change: add the new column, deploy code that writes both, backfill, deploy code that reads only the new column, then drop the old one once nothing references it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I move from a single VM to a managed platform like ECS or Cloud Run?&lt;/strong&gt;&lt;br&gt;
When keeping the VM healthy and scaled starts costing real engineering time, or when you need more than one instance and want the platform to handle health checks and traffic shifting for you. It's a reasonable second step for most teams, well before Kubernetes is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Previous: &lt;a href="https://amanksingh.com/blog/feature-flags-safe-rollouts" rel="noopener noreferrer"&gt;Feature Flags and Safe Rollouts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next: &lt;a href="https://amanksingh.com/blog/scaling-saas-beyond-one-server" rel="noopener noreferrer"&gt;Scaling a SaaS Beyond One Server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Start of series: &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Choosing the Right Tech Stack for Your SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the Author
&lt;/h2&gt;

&lt;p&gt;Hi, I'm &lt;strong&gt;Aman Singh&lt;/strong&gt; — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.&lt;/p&gt;

&lt;p&gt;I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portfolio: &lt;a href="https://amanksingh.com" rel="noopener noreferrer"&gt;https://amanksingh.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/amansingh1501" rel="noopener noreferrer"&gt;https://github.com/amansingh1501&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Product: &lt;a href="https://vowerole.com" rel="noopener noreferrer"&gt;https://vowerole.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email: &lt;a href="mailto:aman97aman@gmail.com"&gt;aman97aman@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>aws</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>Scaling a SaaS Beyond One Server</title>
      <dc:creator>Aman Kumar Singh</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:06:09 +0000</pubDate>
      <link>https://dev.to/moose978/scaling-a-saas-beyond-one-server-4jbo</link>
      <guid>https://dev.to/moose978/scaling-a-saas-beyond-one-server-4jbo</guid>
      <description>&lt;p&gt;In &lt;a href="https://amanksingh.com/blog/deploying-a-fullstack-saas" rel="noopener noreferrer"&gt;Deploying a Full Stack SaaS&lt;/a&gt;, I got the app running in production on a single instance: Docker images, a CI/CD pipeline, one NestJS container and one Postgres database behind it. That setup will carry a real SaaS further than most people expect. It also has a hard ceiling. The day you hit it, "just add another server" turns out to be a much bigger sentence than it sounds.&lt;/p&gt;

&lt;p&gt;This is Module 4 of the &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Full Stack SaaS Masterclass&lt;/a&gt;, and this article is about what actually changes when you go from one instance to several. It's less about picking an autoscaling number and more about the assumptions your app quietly made when it only ever ran on one box.&lt;/p&gt;

&lt;p&gt;What follows is the application-level changes that have to happen before more servers help you at all, rather than a walkthrough of configuring Kubernetes. Get those wrong and adding capacity just spreads the same bugs across more machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one server works until it very suddenly doesn't
&lt;/h2&gt;

&lt;p&gt;A single instance is simple because everything lives in one place: in-memory session storage, an in-process rate limiter, a WebSocket connection map, a scheduled job that runs "once" because there's only one process to run it. None of that is wrong on day one. It's the correct amount of complexity for the traffic you have.&lt;/p&gt;

&lt;p&gt;The problem is that these shortcuts are invisible until you add a second instance. Then a user logs in, the load balancer routes their next request to a different container, and their session doesn't exist there. A cron job that ran "once" now runs twice, because both instances think they're the only one. A rate limiter that tracked requests per process now lets through double the traffic, because each instance has its own counter.&lt;/p&gt;

&lt;p&gt;None of this shows up in code review. It shows up in production, as a support ticket that says "I got logged out for no reason" or "I got charged twice." So before scaling out, the real task is finding every place your app assumed there was exactly one of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the process stateless before you make it plural
&lt;/h2&gt;

&lt;p&gt;The single most important property for horizontal scaling is that any instance can handle any request, with no memory of what happened before. If that's true, a load balancer can route however it wants and you can add or remove instances without anyone noticing.&lt;/p&gt;

&lt;p&gt;Sessions are the classic offender. If you're using JWTs for auth (as covered earlier in this series), you're already stateless on that front, since the token carries everything the server needs to verify the user. If you're using server-side sessions, they need to live in Redis, not in process memory, so every instance reads the same session store.&lt;/p&gt;

&lt;p&gt;Health checks matter just as much here, because a load balancer that can't tell a broken instance from a healthy one will happily send traffic to a dying container.&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;// health.controller.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;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="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;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;HealthCheckService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TypeOrmHealthIndicator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;MemoryHealthIndicator&lt;/span&gt;&lt;span class="p"&gt;,&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/terminus&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;health&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;HealthController&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="nx"&gt;health&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HealthCheckService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TypeOrmHealthIndicator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MemoryHealthIndicator&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="nd"&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;live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;live&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Liveness: is the process even running? No dependency checks.&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&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="nd"&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;ready&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;HealthCheck&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Readiness: can this instance actually serve traffic right now?&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;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pingCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;database&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;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkHeap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;memory_heap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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 distinction matters once you're behind a load balancer or an orchestrator. Liveness answers "should this container be restarted?" Readiness answers "should traffic be sent here right now?" A container can be alive but not ready, for instance while it's warming up a database connection pool during startup, or draining connections during a graceful shutdown.&lt;/p&gt;

&lt;p&gt;Graceful shutdown is the other half of this. When an instance is about to be terminated (during a deploy, a scale-down, or a spot instance reclaim), it needs to stop accepting new work, finish in-flight requests, and close its database and Redis connections cleanly.&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;// main.ts&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;bootstrap&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;app&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;NestFactory&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="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enableShutdownHooks&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;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGTERM&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="nx"&gt;server&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="c1"&gt;// stop accepting new connections&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&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="c1"&gt;// run onModuleDestroy hooks: DB, Redis, queues&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;app&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;Skip this and every deploy or scale-down event drops requests mid-flight. It's a common source of intermittent 502s that never show up in local testing, because locally you never kill the process while it's handling a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database becomes the real bottleneck
&lt;/h2&gt;

&lt;p&gt;Once the app tier is stateless and horizontally scaled, the next constraint shows up almost immediately: Postgres. Every app instance opens its own connection pool, and Postgres connections are not cheap; each one holds a backend process and a meaningful chunk of memory. Five instances with a pool of 20 each is 100 connections before you've served a single extra user, well past what Postgres defaults expect from a busy fleet.&lt;/p&gt;

&lt;p&gt;This is where connection pooling at the infrastructure level earns its keep. PgBouncer sits between your app instances and Postgres, multiplexing many app-level connections onto a much smaller number of real database connections.&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;# pgbouncer.ini (relevant section)&lt;/span&gt;
&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;databases&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="s"&gt;saas_prod = host=postgres-primary port=5432 dbname=saas_prod&lt;/span&gt;

&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pgbouncer&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="s"&gt;pool_mode = transaction&lt;/span&gt;
&lt;span class="s"&gt;max_client_conn = &lt;/span&gt;&lt;span class="m"&gt;1000&lt;/span&gt;
&lt;span class="s"&gt;default_pool_size = &lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;transaction&lt;/code&gt; pooling mode is the one that actually helps at scale: a client (your app) borrows a real connection only for the duration of a transaction, then gives it back. It does mean you lose session-level features like advisory locks held across statements or &lt;code&gt;SET&lt;/code&gt; commands that are meant to persist, so it's worth auditing anything relying on session state before flipping this on.&lt;/p&gt;

&lt;p&gt;The other lever is read replicas. Reporting queries, dashboards, and analytics reads don't need to hit the primary, and separating them protects write throughput for the paths that actually matter, like checkout or signup.&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;// database.module.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;databaseProviders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PRIMARY_CONNECTION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useFactory&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_PRIMARY_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// writes and anything read-after-write sensitive&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;initialize&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="na"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REPLICA_CONNECTION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useFactory&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_REPLICA_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// dashboards, analytics, exports&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;initialize&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 honest tradeoff is replication lag. A user who just updated their profile and immediately reloads the page can hit a replica that hasn't caught up yet and see stale data. The usual fix is routing anything read-after-write (the response to a mutation, or a page loaded right after a form submit) to the primary, and reserving replicas for reads that can tolerate a little staleness. Most reporting and list views fall into that category comfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move shared state into Redis, deliberately
&lt;/h2&gt;

&lt;p&gt;Once you have more than one instance, anything that needs to be shared across the fleet has to live somewhere all instances can see it. Redis is the natural home for this because it's fast enough to sit in the request path and simple enough to reason about.&lt;/p&gt;

&lt;p&gt;Rate limiting is the clearest example. An in-memory rate limiter on each instance only limits requests to that instance, so a user hitting five instances effectively gets five times the limit.&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;// redis-rate-limiter.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;Injectable&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="nx"&gt;Redis&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;ioredis&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;RedisRateLimiter&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;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Redis&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;isAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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;count&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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="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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowSeconds&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;limit&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 same pattern applies to WebSockets. If your app supports live features (notifications, presence, collaborative editing), a socket connected to instance A can't broadcast directly to a client connected to instance B. Socket.IO's Redis adapter solves this by publishing events through Redis pub/sub, so any instance can reach any connected client regardless of which instance it's on.&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;// socket-adapter.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;IoAdapter&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/platform-socket.io&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;createAdapter&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;@socket.io/redis-adapter&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;createClient&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;redis&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;RedisIoAdapter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;IoAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;adapterConstructor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;createAdapter&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;async&lt;/span&gt; &lt;span class="nf"&gt;connectToRedis&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;pubClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_URL&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;subClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pubClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;pubClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;subClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;adapterConstructor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAdapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pubClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;createIOServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;any&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createIOServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&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;adapter&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;adapterConstructor&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;server&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 same reasoning extends to scheduled jobs and locks. A cron-style job that assumes "there's only one process" will fire once per instance once you scale out. Distributed locks (Redis's &lt;code&gt;SET NX&lt;/code&gt; with an expiry, or a library that wraps that pattern) keep only one instance actually executing the job while the others no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling the fleet without scaling the chaos
&lt;/h2&gt;

&lt;p&gt;With the app stateless and shared state centralized, horizontal scaling becomes mostly an infrastructure decision. On AWS, that usually means an Application Load Balancer in front of an ECS or Fargate service, with a target-tracking scaling policy based on CPU or request count per target.&lt;/p&gt;

&lt;p&gt;The tradeoff worth naming: autoscaling adds capacity reactively, after a metric crosses a threshold, and new containers take time to start and pass their readiness check. If traffic spikes faster than a new instance can come online, you'll see elevated latency during the gap. Capacity planning still matters here: autoscaling only handles the variance around a baseline you've already sized reasonably.&lt;/p&gt;

&lt;p&gt;A few pitfalls show up reliably here. Deploys that don't drain connections drop requests exactly when you're proving the new version works. Health checks that only check "is the process up" rather than "can it reach the database" let broken instances stay in rotation. And a thundering herd on cache expiry, where many instances simultaneously miss a Redis key and hammer Postgres at once, can turn a scaling event into an outage. Staggered TTLs or a simple lock around cache repopulation avoids that specific failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Horizontal scaling only helps once the application is stateless: no in-memory sessions, no per-process rate limits, no assumptions about being the only running instance.&lt;/li&gt;
&lt;li&gt;Health checks need to distinguish liveness (is the process alive) from readiness (can it serve traffic), and graceful shutdown must drain in-flight requests before a container exits.&lt;/li&gt;
&lt;li&gt;The database, not the app tier, is usually the first real bottleneck. PgBouncer and read replicas buy headroom, but replication lag means read-after-write traffic still needs the primary.&lt;/li&gt;
&lt;li&gt;Redis becomes the shared brain for the fleet: rate limiting, distributed locks, and WebSocket fan-out via the Redis adapter all rely on it.&lt;/li&gt;
&lt;li&gt;Autoscaling reacts to load after the fact; it complements capacity planning, it doesn't replace it.&lt;/li&gt;
&lt;li&gt;Most scaling incidents come from state assumptions baked in during the single-server phase, not from the load balancer or the orchestrator itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When should a SaaS move from one server to multiple instances?&lt;/strong&gt;&lt;br&gt;
When you have concrete evidence of a ceiling: CPU or memory consistently near limits during normal traffic, response times degrading under real load, or a single point of failure that's become an actual operational risk. Scaling out before that just adds coordination overhead for no benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do sticky sessions solve the statelessness problem?&lt;/strong&gt;&lt;br&gt;
They mask it rather than solve it. Sticky sessions route a user's requests back to the same instance, avoiding in-memory session bugs, but losing that instance logs out everyone attached to it, and they defeat even load distribution. Fixing statelessness at the source (Redis-backed sessions or JWTs) is more durable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many app instances does a typical SaaS need?&lt;/strong&gt;&lt;br&gt;
There's no universal number; it depends on request volume, average response time, and how much headroom you want for traffic spikes and deploys. What matters more than the count is that each instance is interchangeable, so you can adjust the number without touching application logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does PgBouncer replace connection pooling in the ORM?&lt;/strong&gt;&lt;br&gt;
No, they solve different layers. The ORM's pool manages connections from a single app process; PgBouncer manages connections across all your app processes to the database itself. You typically want both, with the ORM's pool sized modestly since PgBouncer is doing the heavy multiplexing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What breaks first when a stateful app is scaled out without fixing that?&lt;/strong&gt;&lt;br&gt;
Usually sessions and rate limiting, since both silently assume a single process. Scheduled jobs running multiple times is the next common one, followed by WebSocket features that only reach some connected clients depending on which instance they land on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Kubernetes required to scale a SaaS beyond one server?&lt;/strong&gt;&lt;br&gt;
No. ECS or Fargate behind an Application Load Balancer gets you horizontal scaling with far less operational overhead, and it's a reasonable place to stay for a long time. Kubernetes earns its complexity once you have many services, teams, or workloads that genuinely need its scheduling and orchestration flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Previous: &lt;a href="https://amanksingh.com/blog/deploying-a-fullstack-saas" rel="noopener noreferrer"&gt;Deploying a Full Stack SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Next: &lt;a href="https://amanksingh.com/blog/disaster-recovery-and-backups" rel="noopener noreferrer"&gt;Disaster Recovery and Backups&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Start of series: &lt;a href="https://amanksingh.com/blog/choosing-the-right-tech-stack-for-saas" rel="noopener noreferrer"&gt;Choosing the Right Tech Stack for Your SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the Author
&lt;/h2&gt;

&lt;p&gt;Hi, I'm &lt;strong&gt;Aman Singh&lt;/strong&gt; — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.&lt;/p&gt;

&lt;p&gt;I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portfolio: &lt;a href="https://amanksingh.com" rel="noopener noreferrer"&gt;https://amanksingh.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/amansingh1501" rel="noopener noreferrer"&gt;https://github.com/amansingh1501&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Product: &lt;a href="https://vowerole.com" rel="noopener noreferrer"&gt;https://vowerole.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Email: &lt;a href="mailto:aman97aman@gmail.com"&gt;aman97aman@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>scaling</category>
      <category>nestjs</category>
      <category>aws</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
