<?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: Hakim Saoudi</title>
    <description>The latest articles on DEV Community by Hakim Saoudi (@saoudih).</description>
    <link>https://dev.to/saoudih</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%2F1887101%2Fb33fb9bd-9e68-4f05-b69c-c400fc5c449b.png</url>
      <title>DEV Community: Hakim Saoudi</title>
      <link>https://dev.to/saoudih</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saoudih"/>
    <language>en</language>
    <item>
      <title>A rate limiter changes when it leaves memory</title>
      <dc:creator>Hakim Saoudi</dc:creator>
      <pubDate>Sun, 23 Aug 2026 10:47:00 +0000</pubDate>
      <link>https://dev.to/saoudih/a-rate-limiter-changes-when-it-leaves-memory-4hog</link>
      <guid>https://dev.to/saoudih/a-rate-limiter-changes-when-it-leaves-memory-4hog</guid>
      <description>&lt;h1&gt;
  
  
  A rate limiter changes when it leaves memory
&lt;/h1&gt;

&lt;p&gt;Rate limiting can start with a &lt;code&gt;Map&lt;/code&gt; and a counter. That is enough for a&lt;br&gt;
single process. The design changes when two workers need to enforce the same&lt;br&gt;
limit.&lt;/p&gt;

&lt;p&gt;Imagine a limit of 100 requests per minute for one user. Worker A sees 60&lt;br&gt;
requests. Worker B sees 60 more. Each worker stays below the limit, but the&lt;br&gt;
user has sent 120 requests. The application has enforced two local limits, not&lt;br&gt;
one shared limit.&lt;/p&gt;

&lt;p&gt;That is the point where storage becomes part of rate-limiting behaviour. The&lt;br&gt;
backend affects the result you return, the latency of the check, and the way&lt;br&gt;
the service behaves when the backend fails.&lt;/p&gt;
&lt;h2&gt;
  
  
  Memory is a local policy
&lt;/h2&gt;

&lt;p&gt;An in-memory limiter has useful properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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;&amp;gt;=&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Too Many Requests&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application adds no network dependency. A check stays inside the process,&lt;br&gt;
which makes the implementation easy to test and cheap to run.&lt;/p&gt;

&lt;p&gt;That limiter protects one process. Restart the process and the state vanishes.&lt;br&gt;
Add a second worker and each worker starts with its own view of traffic. That&lt;br&gt;
may be the intended policy for a small service. It becomes a correctness bug&lt;br&gt;
when the application presents one shared limit to its users.&lt;/p&gt;

&lt;p&gt;The first question is therefore simple: should all workers see the same state?&lt;br&gt;
If the answer is no, memory may be enough. If the answer is yes, the limiter&lt;br&gt;
needs a shared backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shared backend changes the request path
&lt;/h2&gt;

&lt;p&gt;Redis is a strong default for a shared limiter that sits on a busy request path.&lt;br&gt;
It keeps the state close to the operation and supports atomic updates through&lt;br&gt;
Lua scripts. The cost is a network call, connection management, and a new&lt;br&gt;
failure domain.&lt;/p&gt;

&lt;p&gt;PostgreSQL fits a different situation. An application that already depends on&lt;br&gt;
PostgreSQL may prefer to keep rate-limit state in the same operational system.&lt;br&gt;
UPSERTs and transactions can coordinate updates, but hot keys can create row&lt;br&gt;
contention. A database call on every request also deserves a clear latency&lt;br&gt;
budget.&lt;/p&gt;

&lt;p&gt;My starting defaults are concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use memory for one process, local development, and limits that are meant to
stay local;&lt;/li&gt;
&lt;li&gt;use Redis when several workers share a busy limit;&lt;/li&gt;
&lt;li&gt;use PostgreSQL when it already belongs to the service and the limiter does
not sit on its hottest path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These defaults can change with the workload. Write down the trade-off before&lt;br&gt;
choosing an adapter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure behaviour belongs to the design
&lt;/h2&gt;

&lt;p&gt;A shared limiter can fail while the application is handling a request. The&lt;br&gt;
service needs a policy for that case.&lt;/p&gt;

&lt;p&gt;Consider a Redis timeout. A retry may recover from a short network problem, but&lt;br&gt;
it also adds latency to the user request. A circuit breaker can stop repeated&lt;br&gt;
calls to a failing backend, but the limiter still needs a decision while the&lt;br&gt;
circuit is open. A fallback can keep the endpoint available, but an&lt;br&gt;
allowing fallback may remove the protection that the limiter was supposed to&lt;br&gt;
provide.&lt;/p&gt;

&lt;p&gt;Endpoint risk should decide the policy. A public read endpoint and a login&lt;br&gt;
endpoint do not carry the same risk. A service may choose an allowing fallback&lt;br&gt;
for one route and a rejecting fallback for another. That decision belongs in&lt;br&gt;
the route's design, not in an undocumented default inside the storage adapter.&lt;/p&gt;

&lt;p&gt;The same applies to a local denial cache. It can prevent repeated blocked&lt;br&gt;
requests from reaching Redis, but it cannot replace shared state. It reduces&lt;br&gt;
pressure on the backend. It does not make several workers agree about a new&lt;br&gt;
request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the limiter API stable
&lt;/h2&gt;

&lt;p&gt;The application should express its policy through a small contract: check one&lt;br&gt;
identifier, check a batch, and clear state when needed. The storage adapter can&lt;br&gt;
change behind that contract.&lt;/p&gt;

&lt;p&gt;That is the direction I am taking with RateLock. The project provides local,&lt;br&gt;
Redis, and PostgreSQL adapters with the same core checking model. The Redis&lt;br&gt;
adapter uses Lua scripts for atomic operations. The PostgreSQL adapter uses&lt;br&gt;
UPSERTs. The local adapter keeps state in memory and adds no runtime service.&lt;/p&gt;

&lt;p&gt;The application keeps the same shape while the deployment moves from one&lt;br&gt;
process to several workers. The operational differences stay visible in the&lt;br&gt;
adapter and its configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision I would make first
&lt;/h2&gt;

&lt;p&gt;Before comparing four algorithms or running a benchmark, write down three&lt;br&gt;
constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The number of processes that must share the limit.&lt;/li&gt;
&lt;li&gt;The fallback behaviour during a backend timeout.&lt;/li&gt;
&lt;li&gt;The latency budget for one check.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those answers narrow the storage choice faster than a feature table. They also&lt;br&gt;
give you a test plan. You can test two workers against one key, inject a Redis&lt;br&gt;
failure, and measure the result that the route returns.&lt;/p&gt;

&lt;p&gt;Rate limiting starts as a counter. Once several workers share traffic, the&lt;br&gt;
counter becomes a distributed-systems decision. Choose the storage and failure&lt;br&gt;
policy with the endpoint in front of you.&lt;/p&gt;

&lt;p&gt;RateLock's documentation and examples are available at&lt;br&gt;
&lt;a href="https://ratelock-docs.vercel.app/" rel="noopener noreferrer"&gt;https://ratelock-docs.vercel.app/&lt;/a&gt;.&lt;/p&gt;

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