<?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: Bharath Kumar</title>
    <description>The latest articles on DEV Community by Bharath Kumar (@bharath_kumar_39293).</description>
    <link>https://dev.to/bharath_kumar_39293</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3749483%2F3adde8be-ba7f-4e8d-8426-9d6db554d20a.png</url>
      <title>DEV Community: Bharath Kumar</title>
      <link>https://dev.to/bharath_kumar_39293</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bharath_kumar_39293"/>
    <language>en</language>
    <item>
      <title>I Built a Rate Limiter SDK from Scratch — Here's Every Decision I Made and Why</title>
      <dc:creator>Bharath Kumar</dc:creator>
      <pubDate>Sun, 05 Apr 2026 03:27:18 +0000</pubDate>
      <link>https://dev.to/bharath_kumar_39293/i-built-a-rate-limiter-sdk-from-scratch-heres-every-decision-i-made-and-why-54k4</link>
      <guid>https://dev.to/bharath_kumar_39293/i-built-a-rate-limiter-sdk-from-scratch-heres-every-decision-i-made-and-why-54k4</guid>
      <description>&lt;p&gt;I'm a final-year CS student who contributes to open source — Formbricks, Trigger.dev. While doing that I kept running into the same class of problems: rate limiting, retry logic, SDK reliability.&lt;br&gt;
So I built a rate limiter SDK from scratch. Not to follow a tutorial. To actually understand every decision.&lt;br&gt;
This post is about those decisions — why Redis over PostgreSQL, why sliding window over fixed window, why fail-open over fail-closed, and a few others. Each one taught me something that no tutorial ever explained.&lt;br&gt;
Live demo: &lt;a href="https://rate-limiter-sdk.vercel.app" rel="noopener noreferrer"&gt;https://rate-limiter-sdk.vercel.app&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/bharathkumar39293/Rate-Limiter-SDK" rel="noopener noreferrer"&gt;https://github.com/bharathkumar39293/Rate-Limiter-SDK&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What I built&lt;br&gt;
A rate limiter that any Node.js developer can drop into their app with one npm install:&lt;br&gt;
typescriptimport { RateLimiterClient } from 'rate-limiter-sdk'&lt;/p&gt;

&lt;p&gt;const limiter = new RateLimiterClient({&lt;br&gt;
  apiKey: 'your-api-key',&lt;br&gt;
  serverUrl: '&lt;a href="https://your-server.com" rel="noopener noreferrer"&gt;https://your-server.com&lt;/a&gt;'&lt;br&gt;
})&lt;/p&gt;

&lt;p&gt;const result = await limiter.check({ userId: 'user_123', limit: 100, window: 60 })&lt;/p&gt;

&lt;p&gt;if (!result.allowed) {&lt;br&gt;
  return res.status(429).json({ retryAfter: result.retryAfter })&lt;br&gt;
}&lt;br&gt;
One line. Everything handled. That's the goal of an SDK — hide the complexity so the developer never has to think about it.&lt;br&gt;
The stack: TypeScript, Node.js, Express, Redis, PostgreSQL, Docker. Let me walk through the decisions.&lt;/p&gt;

&lt;p&gt;Decision 1: Redis over PostgreSQL for the rate limiting logic&lt;br&gt;
This was the first question I had to answer. I already know PostgreSQL. Why bring in Redis at all?&lt;br&gt;
The answer is simple once you think about it.&lt;br&gt;
Rate limiting happens on every single request — before anything else runs. At scale that's thousands of times per second. PostgreSQL lives on disk. Every query is a disk read. That's fine for storing user data. It's not fine for something that needs to respond in under a millisecond.&lt;br&gt;
Redis lives in RAM. No disk. The difference is roughly 100 nanoseconds (Redis) vs 10 milliseconds (PostgreSQL). That's 100,000x faster.&lt;br&gt;
So the rule became clear: Redis for real-time decisions. PostgreSQL for permanent history. Different jobs, different tools.&lt;/p&gt;

&lt;p&gt;Decision 2: Sliding window over fixed window&lt;br&gt;
This is the one I get asked about most. Both algorithms count requests over a time window — but they behave very differently under pressure.&lt;br&gt;
Fixed window divides time into rigid buckets: 0-60s, 60-120s, and so on. Limit is 100 requests per bucket. Sounds fine.&lt;br&gt;
The problem: a user can send 100 requests at second 59 and another 100 at second 61. That's 200 requests in 2 seconds — double the limit — and both batches pass the check. The bucket boundary is a hole.&lt;br&gt;
Sliding window doesn't use buckets. The window always looks back exactly N seconds from right now. If you sent 100 requests in the last 60 seconds, you're blocked. Doesn't matter when the clock ticks over.&lt;br&gt;
The implementation uses a Redis sorted set. Each request is stored as an entry with its timestamp as the score. To check the limit:&lt;br&gt;
typescript// Remove entries older than the window&lt;br&gt;
await redis.zremrangebyscore(key, 0, now - windowMs)&lt;/p&gt;

&lt;p&gt;// Count what's left — these are all within the window&lt;br&gt;
const count = await redis.zcard(key)&lt;/p&gt;

&lt;p&gt;// Make the decision&lt;br&gt;
if (count &amp;gt;= limit) return { allowed: false, retryAfter: ... }&lt;/p&gt;

&lt;p&gt;// Allow — add this request&lt;br&gt;
await redis.zadd(key, now, requestId)&lt;br&gt;
Four lines of logic. The sliding window moves automatically because we always remove old entries before counting.&lt;br&gt;
Stripe uses sliding window. Cloudflare uses sliding window. There's a reason.&lt;/p&gt;

&lt;p&gt;Decision 3: Fail-open over fail-closed&lt;br&gt;
This was the most important design decision in the SDK client — and the one that took the longest to think through.&lt;br&gt;
When the rate limiter server is unreachable (network down, timeout, crash), the SDK has two options:&lt;/p&gt;

&lt;p&gt;Fail closed → block all requests. Safe, strict.&lt;br&gt;
Fail open → allow all requests. Risky, but resilient.&lt;/p&gt;

&lt;p&gt;I chose fail-open. Here's why.&lt;br&gt;
My rate limiter is a secondary service. It exists to protect the developer's app — not to be the app itself. If my server goes down and I fail closed, I just blocked every user of every app that's using my SDK. The developer's product is now broken because of my infrastructure problem.&lt;br&gt;
That's a worse outcome than allowing a few extra requests temporarily.&lt;br&gt;
typescript} catch (error: any) {&lt;br&gt;
  // Server unreachable — fail open&lt;br&gt;
  if (!error.response) {&lt;br&gt;
    console.warn('[RateLimiter] Server unreachable — failing open')&lt;br&gt;
    return { allowed: true, remaining: -1 }&lt;br&gt;
  }&lt;br&gt;
  return error.response.data&lt;br&gt;
}&lt;br&gt;
The remaining: -1 is a deliberate signal. Negative remaining means "we allowed this but couldn't actually check." Developers who want to monitor fail-open events can watch for it.&lt;br&gt;
The principle: never let your secondary service take down someone's primary app.&lt;/p&gt;

&lt;p&gt;Decision 4: Fire-and-forget for PostgreSQL logging&lt;br&gt;
Every request — allowed or rejected — gets logged to PostgreSQL for analytics. But I don't await the log call.&lt;br&gt;
typescriptconst result = await checkRateLimit(apiKey, userId, limit, window)&lt;/p&gt;

&lt;p&gt;// No await — fire and forget&lt;br&gt;
logRequest({ apiKey, userId, allowed: result.allowed, remaining: result.remaining })&lt;/p&gt;

&lt;p&gt;// Response goes out immediately&lt;br&gt;
return res.status(result.allowed ? 200 : 429).json(result)&lt;br&gt;
Why? Because the client doesn't care about logging. The decision is already made. If I await the PostgreSQL write, I'm adding ~5ms of latency to every single request — for something the client gets zero value from.&lt;br&gt;
Fire-and-forget: start the operation, send the response immediately, let the log finish in the background.&lt;br&gt;
The tradeoff: if the server crashes in that 5ms window, the log is lost. That's acceptable for analytics data.&lt;br&gt;
The rule: never make clients wait for things they don't care about.&lt;/p&gt;

&lt;p&gt;Decision 5: In-memory cache for API key validation&lt;br&gt;
Every request needs to validate the API key against PostgreSQL. But if I hit the database on every single request, I'm adding a DB round-trip to every rate limit check — defeating the purpose of using Redis for speed.&lt;br&gt;
The solution is an in-memory Set:&lt;br&gt;
typescriptconst validKeys = new Set()&lt;/p&gt;

&lt;p&gt;export async function authMiddleware(req, res, next) {&lt;br&gt;
  const apiKey = req.headers['x-api-key']&lt;/p&gt;

&lt;p&gt;// Fast path — already verified&lt;br&gt;
  if (validKeys.has(apiKey)) return next()&lt;/p&gt;

&lt;p&gt;// Slow path — first time seeing this key&lt;br&gt;
  const result = await db.query('SELECT id FROM api_keys WHERE key = $1', [apiKey])&lt;br&gt;
  if (result.rows.length === 0) return res.status(401).json({ error: 'Invalid API key' })&lt;/p&gt;

&lt;p&gt;// Cache it for next time&lt;br&gt;
  validKeys.add(apiKey)&lt;br&gt;
  next()&lt;br&gt;
}&lt;br&gt;
First request from a key: hits PostgreSQL (~5ms). Every subsequent request: hits the Set (~0.001ms). At scale that's thousands of database queries saved per second.&lt;br&gt;
The Set resets on server restart — which is fine. The DB is the source of truth. This is just a speed layer.&lt;/p&gt;

&lt;p&gt;Decision 6: Plain React over Next.js for the dashboard&lt;br&gt;
This one is simple but I get asked about it.&lt;br&gt;
The dashboard is an internal analytics tool. It shows request counts, blocked percentages, per-user breakdowns. Nobody is Googling for it. There are no public pages to index.&lt;br&gt;
Next.js is great for server-side rendering and SEO. Neither of those things matter for an internal dashboard that only authenticated users see.&lt;br&gt;
Adding Next.js for this use case is overengineering. Plain React, talking to the Express API, is exactly the right tool.&lt;br&gt;
The principle: use the simplest tool that solves the problem correctly.&lt;/p&gt;

&lt;p&gt;Decision 7: 2-second timeout on every SDK call&lt;br&gt;
The SDK calls my server on every limiter.check() call. If my server is slow — maybe it's under load, maybe it's in the middle of a deploy — the SDK should not hang the developer's app indefinitely.&lt;br&gt;
typescriptconst response = await axios.post(serverUrl, options, {&lt;br&gt;
  headers: { 'x-api-key': this.apiKey },&lt;br&gt;
  timeout: 2000  // give up after 2 seconds&lt;br&gt;
})&lt;br&gt;
Two seconds is the threshold. After that, the request times out, the catch block runs, and we fail-open. The developer's app never hangs waiting for my server.&lt;/p&gt;

&lt;p&gt;What I learned&lt;br&gt;
Building this taught me something I didn't expect: the interesting part of backend engineering is almost never the happy path.&lt;br&gt;
Anyone can write the code that works when everything is fine. The decisions that matter are:&lt;/p&gt;

&lt;p&gt;What happens when Redis goes down?&lt;br&gt;
What happens when the DB is slow?&lt;br&gt;
What happens when two requests arrive at the same millisecond?&lt;br&gt;
How do you make it fast without making it fragile?&lt;/p&gt;

&lt;p&gt;These are the questions that show up in production. Building this project — and contributing to Formbricks and Trigger.dev — forced me to think about all of them.&lt;br&gt;
That's why I built it. Not to add a line to a resume. To actually understand the problems.&lt;/p&gt;

&lt;p&gt;Links&lt;/p&gt;

&lt;p&gt;Live demo: &lt;a href="https://rate-limiter-sdk.vercel.app" rel="noopener noreferrer"&gt;https://rate-limiter-sdk.vercel.app&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/bharathkumar39293/Rate-Limiter-SDK" rel="noopener noreferrer"&gt;https://github.com/bharathkumar39293/Rate-Limiter-SDK&lt;/a&gt;&lt;br&gt;
My other project (webhook delivery engine): &lt;a href="https://web-hook-drop-t4k6.vercel.app" rel="noopener noreferrer"&gt;https://web-hook-drop-t4k6.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building something similar or have questions about any of these decisions — drop a comment. Happy to dig into it.&lt;/p&gt;

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