<?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: Saumya Karnwal</title>
    <description>The latest articles on DEV Community by Saumya Karnwal (@saumya_karnwal).</description>
    <link>https://dev.to/saumya_karnwal</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%2F3784885%2F3035e4a4-c758-4c47-b825-1a3097332ff2.jpeg</url>
      <title>DEV Community: Saumya Karnwal</title>
      <link>https://dev.to/saumya_karnwal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saumya_karnwal"/>
    <language>en</language>
    <item>
      <title>Line of Defense: Three Systems, Not One</title>
      <dc:creator>Saumya Karnwal</dc:creator>
      <pubDate>Sat, 28 Feb 2026 04:50:03 +0000</pubDate>
      <link>https://dev.to/saumya_karnwal/line-of-defense-three-systems-not-one-4h6o</link>
      <guid>https://dev.to/saumya_karnwal/line-of-defense-three-systems-not-one-4h6o</guid>
      <description>&lt;h2&gt;
  
  
  Three Systems, Not One
&lt;/h2&gt;

&lt;p&gt;"Rate limiting" gets used as a catch-all for anything that rejects or slows down requests. But there are actually three distinct mechanisms, each protecting against a different failure mode, each asking a different question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Question it asks&lt;/th&gt;
&lt;th&gt;What it protects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Load shedding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Is this server healthy enough to handle ANY request?"&lt;/td&gt;
&lt;td&gt;The server from itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Is THIS CALLER sending too many requests?"&lt;/td&gt;
&lt;td&gt;The system from abusive callers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Adaptive throttling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Is the DOWNSTREAM struggling right now?"&lt;/td&gt;
&lt;td&gt;Downstream services from this server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A rate limiter won't save you when your server is OOM-ing — every user is within their quota, the server is just dying. Load shedding won't stop one customer from consuming 80% of your capacity — total concurrency is fine, the distribution is unfair. And neither will prevent you from hammering a downstream service that's already struggling.&lt;/p&gt;

&lt;p&gt;These are complementary systems. Treating them as one thing — or building only one of the three — leaves gaps that show up exactly when you need protection most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Layers
&lt;/h2&gt;

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

&lt;p&gt;Each layer asks a different question. Each protects a different thing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Layer 1 — Load Shedding&lt;/strong&gt; protects this server from itself. Is memory pressure too high? Are there too many concurrent requests? Did a downstream just return RESOURCE_EXHAUSTED? If any of these are true, reject immediately — doesn't matter who the user is, doesn't matter what the request is. The building is at capacity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Layer 2 — Rate Limiting&lt;/strong&gt; protects the system from abusive users. Is this specific user, API key, or IP address sending more than their allowed share? This is the classic rate limiter — per-user counters, sliding windows, token buckets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Layer 3 — Adaptive Throttling&lt;/strong&gt; protects downstream services from this server. The server tracks its success rate when calling each downstream. If 20% of calls to the payment service are failing, it starts probabilistically dropping 20% of outbound calls — giving the payment service breathing room to recover.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why the Order Matters
&lt;/h2&gt;

&lt;p&gt;Layer 1 runs at the highest priority — before authentication, before request parsing, before anything. Here's why:&lt;/p&gt;

&lt;p&gt;If rate limiting (Layer 2) runs first, the server spends CPU checking Redis counters, computing sliding window math, and doing per-user lookups. Then it reaches Layer 1, which says "actually, the server is dying, reject everything." All that rate-limit computation was wasted on a request you were going to reject anyway.&lt;/p&gt;

&lt;p&gt;Load shedding is cheap — one atomic counter check or one GC flag read. It takes microseconds. Rate limiting might require a Redis round-trip. Run the cheap check first.&lt;/p&gt;

&lt;p&gt;Think of it like a nightclub. The fire marshal at the door (load shedding) doesn't check your ID. "Building is at capacity. Nobody gets in." Only if the building isn't full does the bouncer (rate limiter) check your guest list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Each Layer Catches That The Others Miss
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A bad deployment causes OOM.&lt;/strong&gt; Your new ML model eats 3x the expected memory. Layer 1 sees GC pressure spike and starts shedding. Layer 2 is blind — every user is within their rate limit. Layer 3 is blind — the downstream is fine. Without load shedding, you're relying on Kubernetes to OOM-kill the pod and restart it, which takes 30-60 seconds of full outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One customer sends 10x their normal traffic.&lt;/strong&gt; A migration script gone wrong. Layer 2 catches it immediately — their per-user counter crosses the threshold. Layer 1 might eventually catch it (if the extra traffic pushes overall concurrency past the limit), but it can't distinguish "one bad user" from "legitimate traffic spike." Layer 3 is blind — the downstream doesn't know which user caused the load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A downstream payment service enters degraded state.&lt;/strong&gt; It accepts 60% of requests, returns RESOURCE_EXHAUSTED on the rest. Layer 3 sees the failure rate climb and starts probabilistically dropping outbound calls — giving the payment service room to breathe. Layer 1 catches the RESOURCE_EXHAUSTED responses and triggers a reactive backoff. Layer 2 is completely blind — users are within their limits, the problem is downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A DDoS hits your API.&lt;/strong&gt; Thousands of IPs, each sending moderate traffic. Layer 1 catches it (total concurrency spikes). Layer 2 catches it (per-IP limits hit). Layer 3 is blind — this is an inbound problem, not outbound. Both layers contribute, but neither alone is sufficient — the DDoS might stay under per-IP limits while overwhelming total capacity, or it might come from one IP but stay under concurrency limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slow dependency causes thread pool exhaustion.&lt;/strong&gt; A database query that usually takes 5ms starts taking 2 seconds. Threads pile up waiting. Layer 1 sees concurrent request count spike toward the limit. Layer 3 would catch it if the dependency returned errors, but slow responses aren't errors — the threads just accumulate silently. Layer 2 is blind. This is the scenario where load shedding saves you — it's the only layer watching the server's actual resource consumption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No single layer handles everything. That's the point. They're complementary, not redundant.&lt;/p&gt;

&lt;p&gt;If Layer 2 has a bug or Redis goes down, Layer 1 still protects the server from overload. If Layer 1's threshold is set too high, Layer 2 still limits abusive users. If both fail, Layer 3 at least prevents a cascade into downstream services.&lt;/p&gt;

&lt;p&gt;Defense in depth. Not defense in one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2 Has Two Personalities: Reject or Delay
&lt;/h2&gt;

&lt;p&gt;Rate limiting (Layer 2) isn't one tool — it's two, with opposite behaviors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rejection&lt;/strong&gt; says "no." The request is over the limit. Return 429. The caller deals with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delay&lt;/strong&gt; says "wait." The request is over the limit, but instead of rejecting it, hold it in a queue and release it when the rate allows. The caller doesn't even know it was throttled — just that the response was a bit slow.&lt;/p&gt;

&lt;p&gt;Same goal (enforce a rate), completely different experience for the caller.&lt;/p&gt;

&lt;p&gt;The question is: when do you reject, and when do you delay?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reject when someone external is holding a connection.&lt;/strong&gt; A user called your API. Their HTTP connection is open. If you delay them, you're holding that connection — which means a thread, a socket, memory. Delay 500 users and you've exhausted your connection pool. Now legitimate users who are &lt;em&gt;under&lt;/em&gt; the limit can't get a connection. Your rate limiter just caused an outage for good users by being too nice to bad ones. Reject fast. Free the connection. Let the client's retry logic handle it.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Delay when your own system needs the request to succeed.&lt;/strong&gt; You're calling Stripe's payment API. You know their limit: 100 requests per second. The 101st request doesn't need to fail — it just needs to wait 10 milliseconds for the next second's budget. If you reject it instead, you need retry logic, backoff timers, dead letter queues, monitoring for the retries — an entire infrastructure to handle a problem that "just wait" solves.&lt;br&gt;
Five scenarios to build the intuition:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your public API gets a burst from a customer.&lt;/strong&gt; Reject. Return 429 instantly. The customer's SDK has built-in retry with exponential backoff. Your server processed the rejection in microseconds and moved on. If you delayed instead, 500 connections held open, connection pool starved, outage for everyone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You're sending 50,000 marketing emails through SendGrid.&lt;/strong&gt; Delay. SendGrid allows 500/sec. Queue all 50,000, drip them at 500/sec. Takes 100 seconds, every email delivered. If you rejected instead, 49,500 emails bounced in the first second. Now you need a dead letter queue and retry scheduling for a problem that "wait your turn" solves completely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your gRPC server receives internal traffic from an upstream service.&lt;/strong&gt; Reject. Return RESOURCE_EXHAUSTED. The upstream's adaptive throttler (Layer 3 on their side) sees the error and automatically backs off. The system self-heals. If you delayed instead, the upstream's gRPC deadline expires while its request sits in your queue. Timeout errors are worse than clean rejections — the upstream can't tell "server is slow" from "I'm being rate limited."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A batch job scrapes 10,000 records from a partner API nightly.&lt;/strong&gt; Delay. Partner allows 50 req/sec. Pace it perfectly — 3.3 minutes, all requests succeed, partner never sees a spike. If you rejected instead, 9,950 requests fail immediately, retry logic fires, you hammer the partner for 20 minutes instead of a clean 3-minute crawl.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A user calls your payment endpoint during checkout.&lt;/strong&gt; Reject. The user is staring at a button that says "Pay Now." A 200ms rejection with a "please try again" message is infinitely better than a 5-second delay where they think the page froze, hit refresh, and trigger a duplicate payment.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule is simple: &lt;strong&gt;reject when someone is waiting for the connection. Delay when you can afford to wait.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>ratelimiter</category>
    </item>
    <item>
      <title>Distributed Rate Limiting — Five Problems That Break Your Counters</title>
      <dc:creator>Saumya Karnwal</dc:creator>
      <pubDate>Fri, 27 Feb 2026 14:17:51 +0000</pubDate>
      <link>https://dev.to/saumya_karnwal/distributed-rate-limiting-five-problems-that-break-your-counters-454</link>
      <guid>https://dev.to/saumya_karnwal/distributed-rate-limiting-five-problems-that-break-your-counters-454</guid>
      <description>&lt;h2&gt;
  
  
  Why Local Rate Limiting Breaks
&lt;/h2&gt;

&lt;p&gt;A rate limiter on a single server works exactly as advertised. But most production systems aren't a single server — they're 10, 50, or 200 instances behind a load balancer. And that changes the math.&lt;/p&gt;

&lt;p&gt;If your limit is 100 requests per minute and you have 50 instances, the load balancer sprays traffic round-robin. Each instance sees ~2 requests per minute from any given user. Every instance says "well under the limit." Nobody rejects anything. The user sends 3,000 requests. All pass.&lt;/p&gt;

&lt;p&gt;Your per-instance rate limiter silently became a limit × num_instances rate limiter. You didn't change the code. You changed the deployment.&lt;/p&gt;

&lt;p&gt;The fix is shared state — usually Redis. All instances read and write to the same counter, so the global count is enforced globally. But the moment you introduce shared state over a network, five new problems appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: The Race You Can't See
&lt;/h2&gt;

&lt;p&gt;Two requests from the same user arrive at the same millisecond, hitting two different instances. Both call Redis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance A                         Instance B
──────────                         ──────────
GET counter → 99                   GET counter → 99
99 &amp;lt; 100? YES                      99 &amp;lt; 100? YES
SET counter → 100                  SET counter → 100

Both allowed. Real count: 101. Limit breached.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is TOCTOU — time-of-check-time-of-use. You read, decided, then wrote. But someone else read the same value in the gap between your read and your write.&lt;/p&gt;

&lt;p&gt;The fix sounds simple: use &lt;code&gt;INCR&lt;/code&gt; instead of &lt;code&gt;GET&lt;/code&gt; + &lt;code&gt;SET&lt;/code&gt;. Redis &lt;code&gt;INCR&lt;/code&gt; atomically increments and returns the new value. No gap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance A                         Instance B
──────────                         ──────────
INCR counter → 100                 INCR counter → 101
100 ≤ 100? ALLOW                   101 &amp;gt; 100? REJECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But what about more complex algorithms — sliding window, token bucket — where you need to read a value, do math, then conditionally write? You can't do that with one &lt;code&gt;INCR&lt;/code&gt;. You need Redis Lua scripts. A Lua script runs atomically on Redis's single thread — no other command can interleave.&lt;/p&gt;

&lt;p&gt;One network round-trip. One atomic operation. No race.&lt;/p&gt;

&lt;p&gt;The alternative is &lt;code&gt;WATCH&lt;/code&gt;/&lt;code&gt;MULTI&lt;/code&gt;/&lt;code&gt;EXEC&lt;/code&gt; — Redis's optimistic locking. You &lt;code&gt;WATCH&lt;/code&gt; a key, read it, start a &lt;code&gt;MULTI&lt;/code&gt; transaction, write your changes, and &lt;code&gt;EXEC&lt;/code&gt;. If anyone modified the watched key between your &lt;code&gt;WATCH&lt;/code&gt; and &lt;code&gt;EXEC&lt;/code&gt;, the transaction aborts and you retry. It's compare-and-swap over the network. More flexible than Lua, but slower under contention because of retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Redis Dies. Now What?
&lt;/h2&gt;

&lt;p&gt;Redis is down. Or the network between your service and Redis is partitioned. Every rate limit check fails. You have three options, and none of them are good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail open:&lt;/strong&gt; Allow all requests. Your system stays up, but you have no rate limiting. If Redis went down because of load, you just removed the only thing protecting you from more load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed:&lt;/strong&gt; Reject all requests. Congratulations, your rate limiter just became a denial-of-service attack on your own users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fall back to local:&lt;/strong&gt; Switch to per-instance in-memory counters with &lt;code&gt;global_limit / num_instances&lt;/code&gt; as the local limit. Inaccurate, but bounded.&lt;/p&gt;

&lt;p&gt;Option three is what most production systems do. But the transition is tricky. When Redis comes back, do you trust the Redis counter (which is stale) or the local counter (which is approximate)? Most teams reset the Redis counter on recovery and accept a brief window of inaccuracy.&lt;/p&gt;

&lt;p&gt;The deeper issue: &lt;strong&gt;how fast do you detect the failure?&lt;/strong&gt; If your Redis timeout is 500ms, every rate-limited request adds 500ms of latency while you wait to find out Redis is dead. You need a circuit breaker — after N consecutive timeouts, stop asking Redis for a cooldown period. Go straight to local. Check again in 10 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Your Servers Disagree About What Time It Is
&lt;/h2&gt;

&lt;p&gt;Window-based algorithms need to answer "which window does this request belong to?" That requires knowing what time it is. Across 50 servers, even with NTP, clocks drift by 10-50ms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server A:  10:00:00.000  (on time)
Server B:  10:00:00.150  (150ms ahead)
Server C:  09:59:59.900  (100ms behind)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 10:00:00 — the window boundary — Server C thinks it's still in the old window. Server B thinks the new window started 150ms ago. They compute different bucket IDs and increment different Redis keys.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server C:  INCR rate_limit:user123:window_599   ← old window
Server A:  INCR rate_limit:user123:window_600   ← new window
Server B:  INCR rate_limit:user123:window_600   ← new window

Requests at the boundary split across two keys.
Neither hits the limit. Both pass.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 60-second window, 150ms of skew is 0.25% — noise. For a 1-second window, it's 15% — a real problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixes:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use Redis's clock.&lt;/strong&gt; Let the Lua script call &lt;code&gt;redis.call('TIME')&lt;/code&gt; to determine the current window. One clock, one truth. Adds no extra round-trip since you're already in a Lua script.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use large windows.&lt;/strong&gt; If your window is 60 seconds, clock skew doesn't matter. If you need sub-second precision, you need to solve clock sync first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use token bucket.&lt;/strong&gt; No windows, no boundaries, no clock skew problem. The refill math is based on elapsed time (&lt;code&gt;now - last_refill&lt;/code&gt;), and small drift in "now" produces proportionally small drift in tokens. A 50ms clock difference on a 1-token-per-second refill rate means 0.05 tokens of error.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Problem 4: One User Melts Your Redis
&lt;/h2&gt;

&lt;p&gt;Per-user rate limiting means one Redis key per user. Most users generate 10 requests per minute. Then one user — or one bot — sends 50,000. Every request hits the same Redis key.&lt;/p&gt;

&lt;p&gt;Redis is single-threaded. A hot key means one user's traffic is serialized through one CPU core, and if that core is saturated, ALL other Redis operations on that shard slow down. One abusive user degrades rate limiting for everyone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Normal:   user:12345  →  10 INCR/min      (invisible)
Abusive:  user:99999  →  50,000 INCR/min  (hot key, one CPU core)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The layered fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reject early.&lt;/strong&gt; If a user is 10x over the limit, you know the answer without asking Redis. Keep a local approximate counter. If local count &amp;gt;&amp;gt; limit, reject immediately. Only call Redis when the count is near the threshold — the boundary where you actually need distributed accuracy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch concurrent checks.&lt;/strong&gt; If 200 requests from the same user arrive in the same millisecond, don't make 200 Redis calls. Batch them: one Redis call for the batch, then distribute the result to all 200 waiters locally. This is what production throttlers do — one network round-trip per batch, not per request.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shard the key.&lt;/strong&gt; Split &lt;code&gt;user:99999&lt;/code&gt; into &lt;code&gt;user:99999:0&lt;/code&gt;, &lt;code&gt;user:99999:1&lt;/code&gt;, ..., &lt;code&gt;user:99999:7&lt;/code&gt;. Each instance writes to a random shard. To check the total, sum all shards. You trade perfect accuracy for throughput — the sum might be slightly stale.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Problem 5: Three Regions, Three Redis Instances, One Limit
&lt;/h2&gt;

&lt;p&gt;You deploy in US-East, US-West, and EU-West. Each region has its own Redis. A user with a global limit of 1,000/min sends 400 requests to each region.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;US-East Redis:  user:123 → 400  (under 1000, allow)
US-West Redis:  user:123 → 400  (under 1000, allow)
EU-West Redis:  user:123 → 400  (under 1000, allow)

Total: 1,200 allowed. Limit is 1,000.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody noticed because no single Redis saw more than 400.&lt;/p&gt;

&lt;p&gt;Your options, ranked by pragmatism:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Split the quota.&lt;/strong&gt; Give each region &lt;code&gt;1000 / 3 = 333&lt;/code&gt;. Simple, but a user who only uses US-East gets 333 instead of 1,000. You're penalizing them for your architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over-provision the limit.&lt;/strong&gt; Set it to 800 and accept that the real effective limit is somewhere between 800 and 1,200 depending on distribution. For most use cases, "roughly 1,000" is good enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single global Redis.&lt;/strong&gt; All regions talk to one Redis in US-East. Accurate, but US-West adds 60-80ms and EU adds 100-150ms per request. For a rate limit check that should take 1ms, that's a 100x latency penalty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-region sync.&lt;/strong&gt; Each region publishes its local count every second. Each region subscribes to the others. You get eventual consistency with a 1-2 second window of inaccuracy. Complex to build, complex to debug, and you still have the "what if the sync is down" problem.&lt;/p&gt;

&lt;p&gt;Most teams pick option one or two. The engineering cost of options three and four is almost never justified by the accuracy gain. Rate limiting is about protection, not precision — being off by 20% is fine if it still prevents abuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Meta-Problem
&lt;/h2&gt;

&lt;p&gt;These five problems share a root cause: &lt;strong&gt;rate limiting is global state enforced locally.&lt;/strong&gt; Every instance needs to know the global count, but global knowledge has a cost — latency (network hops), availability (what if the store is down), and consistency (what if two instances disagree).&lt;/p&gt;

&lt;p&gt;You can't have all three. Pick two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accurate + Available:&lt;/strong&gt; Central store with local fallback (most common)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accurate + Fast:&lt;/strong&gt; Single instance, no distribution (doesn't scale)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast + Available:&lt;/strong&gt; Local-only with periodic sync (inaccurate)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every production rate limiter is a choice on this triangle. Understanding which trade-off your system made — and which failure mode it accepted — is the difference between "we have rate limiting" and "our rate limiting actually works."&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>ratelimiter</category>
    </item>
    <item>
      <title>Five Ways to Say "Slow Down" — A Field Guide to Rate Limiting Algorithms</title>
      <dc:creator>Saumya Karnwal</dc:creator>
      <pubDate>Thu, 26 Feb 2026 18:25:05 +0000</pubDate>
      <link>https://dev.to/saumya_karnwal/five-ways-to-say-slow-down-a-field-guide-to-rate-limiting-algorithms-g96</link>
      <guid>https://dev.to/saumya_karnwal/five-ways-to-say-slow-down-a-field-guide-to-rate-limiting-algorithms-g96</guid>
      <description>&lt;h2&gt;
  
  
  What Is Rate Limiting?
&lt;/h2&gt;

&lt;p&gt;Rate limiting is a rule: &lt;em&gt;no more than N requests in T time.&lt;/em&gt; 100 API calls per minute. 5 login attempts per 15 minutes. 1,000 database writes per second.&lt;/p&gt;

&lt;p&gt;At its core, it's a counter with a clock. A request comes in, you check the count, and you either let it through or reject it. That's the whole idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Need It
&lt;/h2&gt;

&lt;p&gt;Every system has a capacity — the load it can handle before performance falls off a cliff. Not a gentle slope. A cliff.&lt;/p&gt;

&lt;p&gt;A database tuned for 500 writes/sec doesn't get 1% slower at 501. It stays fine at 550, maybe a bit sluggish at 580, and then at 600 the query queue backs up, the connection pool exhausts, and latency goes from 50ms to 8 seconds in under a minute. Recovery takes even longer because the backed-up requests are still draining.&lt;/p&gt;

&lt;p&gt;Without rate limiting, three things go wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. One bad actor takes down everyone.&lt;/strong&gt; A single misconfigured client retrying in a tight loop can saturate your service. The other 10,000 well-behaved clients suffer equally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cascading failures amplify the damage.&lt;/strong&gt; When Service A slows down, Service B (which calls A) starts timing out. B's callers retry. A 20% overload on one service becomes a 300% overload on three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Recovery becomes harder than survival.&lt;/strong&gt; Even after the spike passes, the queue of backed-up requests keeps the system pinned. Without a way to shed load, you can stay degraded for minutes after the cause is gone.&lt;/p&gt;

&lt;p&gt;Rate limiting is the difference between "gracefully reject 5% of traffic during a spike" and "return errors to 100% of traffic for 10 minutes."&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Algorithms, Five Trade-offs
&lt;/h2&gt;

&lt;p&gt;But "rate limiting" isn't one algorithm. It's five, each with a different trade-off between accuracy, memory, burst tolerance, and implementation complexity. Each one is introduced below with how it works, what you give up, and when it's the right pick.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fixed Window Counter
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; Divide time into fixed intervals (e.g., 1-minute windows). Keep a counter per window. Increment on each request. If the counter exceeds the limit, reject. When the window ends, reset to zero.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; You get simplicity and near-zero memory (~16 bytes per key). You give up accuracy at window boundaries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Minute 1:                        Minute 2:
..............90 reqs at :59  |  90 reqs at :00..............
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both windows say "under 100, allowed." But in a 2-second real window spanning the boundary? 180 requests — nearly 2x your limit. In the worst case, a client can get double the allowed rate by timing requests around the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where to use it:&lt;/strong&gt; When the limit is a rough safety net, not a precise guarantee. Login throttling ("5 attempts per 15 minutes") is the classic case — even if someone exploits the boundary to squeeze out 10 attempts, that's still worthless for brute-force. API key quotas where "close enough" is fine. Anywhere you need something working in an hour, not a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Sliding Window Log
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; Instead of counting per window, store the exact timestamp of every request. When a new request arrives, evict all timestamps older than the window duration, then count what's left.&lt;/p&gt;

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

&lt;p&gt;Perfectly accurate. Zero boundary tricks. The window slides smoothly with every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; You get perfect precision. You give up memory. You're storing up to &lt;code&gt;limit&lt;/code&gt; timestamps per key — typically in a Redis sorted set. At 10,000 req/min across a million users, that's up to 10 billion timestamps. At 8 bytes each, that's ~80 GB of Redis just for rate limiting state.&lt;/p&gt;

&lt;p&gt;If you have 2,000 users making 50 requests/day each? The sorted set holds 50 entries per key. Total memory: negligible. But scale that to millions of keys and the math stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where to use it:&lt;/strong&gt; When precision matters more than scale. Financial transaction limits where regulatory compliance demands &lt;em&gt;exact&lt;/em&gt; counting — the auditor doesn't care about "99.7% accurate." Database write protection where exceeding the threshold causes corruption, not just slowness. Low-volume, high-stakes APIs where "off by one" has real consequences. The key constraint: either the limit is small, or the user count is small. Ideally both.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Sliding Window Counter
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; A compromise between the first two. Keep counters for the current window and the previous window. Estimate the sliding total using weighted math based on how far into the current window you are.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit: 5/min    Current time: 1:18 (18 sec into window)

Previous window [0:00-1:00]:  5 requests
Current window  [1:00-2:00]:  3 requests

Weighted total = 5 × (42/60) + 3
               = 3.5 + 3
               = 6.5 ~ 7  →  DO NOT ALLOW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two counters per key. ~32 bytes of memory. Not sorted sets of timestamps — two integers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; You get near-perfect accuracy at minimal memory cost. You give up a small margin of error. Cloudflare measured 99.7% accuracy against a perfect sliding window. The 0.3% error comes from assuming requests were uniformly distributed in the previous window. In practice, your measurement noise is already larger than that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where to use it:&lt;/strong&gt; When you need accuracy at scale. Millions of keys, limited memory, and you can tolerate a rounding error smaller than your measurement noise. This is the default choice for most production rate limiters. If you're not sure which algorithm to pick, start here.&lt;/p&gt;

&lt;p&gt;Cloudflare uses this. AWS API Gateway uses this. Most "rate limit by API key" implementations in production use some variant of this.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Token Bucket
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; A different mental model. Instead of counting requests in a time window, imagine a bucket that fills with tokens at a steady rate. Each request consumes one token. If the bucket is empty, the request is rejected. The bucket has a maximum capacity, so tokens don't accumulate forever.&lt;/p&gt;

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

&lt;p&gt;Two parameters: the refill rate (long-term average) and the bucket capacity (maximum burst size).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; You get burst tolerance — short spikes are absorbed as long as the long-term average stays within limits. You give up strict per-window guarantees. A user can consume their entire bucket in a single burst, which means the instantaneous rate can be much higher than the average rate.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;it rewards idle time.&lt;/strong&gt; A user who hasn't called your API in 5 seconds has accumulated tokens and can burst. This matches how real humans use APIs — idle, idle, idle, &lt;em&gt;click click click click&lt;/em&gt;, idle. A window-based counter punishes that pattern. Token bucket embraces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where to use it:&lt;/strong&gt; When your users are bursty and that's expected. A developer building a dashboard fires 12 parallel API calls on page load, then nothing for 45 seconds. A mobile app syncs on wake. A CLI tool batches requests. In all these cases, the &lt;em&gt;average&lt;/em&gt; rate is fine — it's the &lt;em&gt;shape&lt;/em&gt; that doesn't fit a fixed window. Token bucket lets legitimate bursts through while still enforcing a long-term ceiling.&lt;/p&gt;

&lt;p&gt;Stripe, GitHub, and AWS EC2 all use token bucket for their public APIs. Google's Guava &lt;code&gt;RateLimiter&lt;/code&gt; library implements it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Leaky Bucket
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; The mirror image of token bucket. Requests pour into a fixed-size queue. The queue drains at a constant rate. If the queue is full when a new request arrives, it's rejected.&lt;/p&gt;

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

&lt;p&gt;If 50 requests arrive in one second, 1 goes through immediately, 10 wait in the queue, and 39 are rejected. The output is &lt;strong&gt;perfectly smooth&lt;/strong&gt; — always exactly the drain rate, no matter how spiky the input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; You get perfectly shaped output — no burst ever reaches the downstream system. You give up two things: (1) burst tolerance — even legitimate spikes get queued or rejected, and (2) latency — requests sit in the queue waiting their turn instead of being processed immediately.&lt;/p&gt;

&lt;p&gt;The critical difference from token bucket: token bucket &lt;em&gt;allows&lt;/em&gt; bursts and limits the average. Leaky bucket &lt;em&gt;eliminates&lt;/em&gt; bursts and smooths the output. They look similar on paper but behave very differently under spiky load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where to use it:&lt;/strong&gt; When your downstream truly cannot handle bursts — not even brief ones. An SMS provider that charges 5x for burst traffic. A legacy database that crashes above 100 writes/sec rather than degrading. A hardware device with a fixed processing rate. Network traffic shaping where you need constant bandwidth. Anywhere the &lt;em&gt;shape&lt;/em&gt; of the output matters as much as the &lt;em&gt;volume&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;NGINX's &lt;code&gt;limit_req&lt;/code&gt; module uses leaky bucket by default. Twilio and SendGrid submission queues work this way. Network QoS traffic shapers are leaky buckets over bytes instead of requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the Right One
&lt;/h2&gt;

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

&lt;p&gt;But there's a sixth option that doesn't fit neatly into the tree.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus: Adaptive Throttling — The Self-Tuning Client
&lt;/h3&gt;

&lt;p&gt;What if you don't want to pick a number at all?&lt;/p&gt;

&lt;p&gt;Google's SRE Book describes a pattern where the client tracks its own success rate and starts probabilistically dropping requests when the server is struggling:&lt;/p&gt;

&lt;p&gt;If 10% of your calls fail, you preemptively drop ~10% client-side. The server gets breathing room. As it recovers, your success rate climbs and you ramp back up automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; Internal service-to-service calls where you control both sides. No manual tuning. No threshold guessing. The system finds its own equilibrium. Implement it as a client interceptor (gRPC, HTTP middleware) that backs off when the backend returns overload errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Uncomfortable Truth
&lt;/h2&gt;

&lt;p&gt;Rate limiting is admission control — it decides who gets rejected. And rejection has a cost. A rejected API call might mean a failed checkout or a user staring at a spinner.&lt;/p&gt;

&lt;p&gt;The algorithms above are tools. The harder question is policy: &lt;em&gt;Who&lt;/em&gt; gets limited? Per-IP is easy to circumvent. Per-API-key punishes shared keys. Per-user requires authentication before rate checking. And in a multi-tenant SaaS, your free-tier user and your enterprise customer probably shouldn't share a bucket.&lt;/p&gt;

&lt;p&gt;The algorithm is the mechanism. The policy is the product decision. Get both right.&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>ratelimiting</category>
      <category>backpressure</category>
    </item>
    <item>
      <title>How to Build Workflows That Never Lose Progress</title>
      <dc:creator>Saumya Karnwal</dc:creator>
      <pubDate>Tue, 24 Feb 2026 19:33:45 +0000</pubDate>
      <link>https://dev.to/saumya_karnwal/how-to-build-workflows-that-never-lose-progress-3ndd</link>
      <guid>https://dev.to/saumya_karnwal/how-to-build-workflows-that-never-lose-progress-3ndd</guid>
      <description>&lt;h2&gt;
  
  
  The Half-Deployed Model
&lt;/h2&gt;

&lt;p&gt;Imagine you're running an ML platform. A weekly cron job fires at 3 AM to retrain a customer's model. The pipeline has five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate training data from BigQuery&lt;/li&gt;
&lt;li&gt;Train the model on a Kubernetes cluster&lt;/li&gt;
&lt;li&gt;Push the model artifact to a registry&lt;/li&gt;
&lt;li&gt;Create a scoring configuration in the scoring service database&lt;/li&gt;
&lt;li&gt;Authorize the model for the customer's traffic&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 through 3 take about two hours and cost real money — compute time, BigQuery slots, container images. At 5:02 AM, step 3 completes. The model is trained and pushed.&lt;/p&gt;

&lt;p&gt;Step 4 calls the scoring service to create the config. The scoring service is in the middle of a routine database migration. Connection refused.&lt;/p&gt;

&lt;p&gt;Now you have a problem. The model is sitting in the artifact registry, trained and ready. But it can't serve traffic because there's no scoring config. The pipeline marks the whole run as "FAILED."&lt;/p&gt;

&lt;p&gt;What happens next depends on how you built the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you start over:&lt;/strong&gt; The 6 AM retry re-runs from step 1. Two more hours of BigQuery and Kubernetes compute, re-training a model that's identical to the one you already have. You just burned money and time rebuilding something that already exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you do nothing:&lt;/strong&gt; The model sits orphaned in the registry. The customer's production model is stale. A data scientist notices three days later and manually creates the config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you built a saga:&lt;/strong&gt; The system knows step 3 completed. It retries step 4. The scoring service comes back from its migration at 5:15 AM. Retry succeeds. Step 5 runs. By 5:20 AM the customer has a fresh model. Nobody was woken up. No work was wasted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's a Saga?
&lt;/h2&gt;

&lt;p&gt;The original problem was database transactions that span multiple systems — you can't use a single &lt;code&gt;BEGIN/COMMIT&lt;/code&gt; because the data lives in different databases.&lt;/p&gt;

&lt;p&gt;The solution: break the big transaction into a &lt;strong&gt;sequence of smaller steps&lt;/strong&gt;, each with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A known &lt;strong&gt;state&lt;/strong&gt; (pending, in-progress, complete, failed)&lt;/li&gt;
&lt;li&gt;The ability to &lt;strong&gt;retry&lt;/strong&gt; safely (idempotency)&lt;/li&gt;
&lt;li&gt;An optional &lt;strong&gt;compensating action&lt;/strong&gt; (undo what was done if we need to abort)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The state machine IS the recovery mechanism. You don't need a separate "recovery system" — you just need each step to be resumable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Idempotency Is the Hard Part
&lt;/h2&gt;

&lt;p&gt;The saga pattern sounds simple: track state, retry failed steps. But there's a catch. What if the step &lt;em&gt;did&lt;/em&gt; succeed, but you didn't get the confirmation?&lt;/p&gt;

&lt;p&gt;Picture this: your pipeline calls the scoring service to create a config. The service creates it, writes it to the database, and starts sending back a 200 response. At that exact moment, the network blips. Your pipeline gets a timeout. It thinks step 4 failed.&lt;/p&gt;

&lt;p&gt;On retry, the pipeline calls the scoring service again: "Create this config." If the service isn't idempotent, it creates a &lt;em&gt;second&lt;/em&gt; config. Now you have duplicate scoring entries, and the model might score users twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency&lt;/strong&gt; means: running the same operation twice produces the same result as running it once. The service checks "does this config already exist for this model version?" and if so, returns the existing one instead of creating a duplicate.&lt;/p&gt;

&lt;p&gt;This is the non-negotiable foundation. If a step can't be safely retried, the entire saga pattern breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Practice
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The State Machine
&lt;/h3&gt;

&lt;p&gt;Every deployment in the system has a status that tracks exactly where it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PENDING
   │
   ▼
DATA_GEN_IN_PROGRESS ──(fail)──▶ DATA_GEN_FAILED
   │                                     │
   ▼                                  (retry)
DATA_GEN_COMPLETE                        │
   │                              ◀──────┘
   ▼
TRAINING_IN_PROGRESS ──(fail)──▶ TRAINING_FAILED
   │                                     │
   ▼                                  (retry)
TRAINING_COMPLETE                        │
   │                              ◀──────┘
   ▼
PUSHING_IN_PROGRESS ──(fail)──▶ PUSH_FAILED
   │                                     │
   ▼                                  (retry)
PUSHING_COMPLETE                         │
   │                              ◀──────┘
   ▼
CONFIGURING ──(fail)──▶ CONFIG_PENDING
   │                          │
   ▼                    (reconciliation
READY                    loop retries)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like a lot of states. But each state is dirt-simple: "I know exactly what succeeded, and I know exactly what to do next."&lt;/p&gt;

&lt;h3&gt;
  
  
  Retry With Backoff
&lt;/h3&gt;

&lt;p&gt;When a step fails, the system doesn't immediately retry in a tight loop. That would hammer a service that might already be struggling.&lt;br&gt;
Exponential backoff gives the downstream service time to recover. If it's a 30-second blip, attempt 2 or 3 catches it. If it's a longer outage, the system backs off gracefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Reconciliation Loop
&lt;/h3&gt;

&lt;p&gt;What if 5 retries aren't enough? The deployment state says &lt;code&gt;CONFIG_PENDING&lt;/code&gt;. The pipeline stops actively retrying. But it's not abandoned. A background process — the reconciliation loop — periodically scans for stuck deployments.&lt;br&gt;
When the downstream service recovers (maybe after a database migration, maybe after an outage), the reconciliation loop picks up the stuck deployments and completes them. No human intervention. No lost work.&lt;/p&gt;

&lt;p&gt;The user sees: "Deployment in progress — model trained, awaiting configuration." Not an error. Not a failure. Just... waiting, and it'll fix itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making Each Step Idempotent
&lt;/h2&gt;

&lt;p&gt;In practice, idempotency looks different for each type of operation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database writes&lt;/strong&gt;: Use &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; or check-before-write. If the row exists with the same key, it's a no-op.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API calls&lt;/strong&gt;: Include a unique request ID (sometimes called an idempotency key). The server caches results by this key — if it's seen the key before, it returns the cached result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State changes&lt;/strong&gt;: Read current state before deciding what to do. If the current state is already what you want, do nothing. This is how Kubernetes controllers work — they compare desired state to actual state on every loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is always the same: &lt;strong&gt;check if the work is already done before doing it again.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anatomy of a Good Saga
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. State Must Be Durable
&lt;/h3&gt;

&lt;p&gt;The state machine lives in a database, not in memory. If the orchestrator crashes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It restarts&lt;/li&gt;
&lt;li&gt;Reads the state from the database&lt;/li&gt;
&lt;li&gt;Picks up where it left off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the state was in memory, a crash means starting over. If it's in a database, a crash means a brief pause.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Compensating Actions for the Unhappy Path
&lt;/h3&gt;

&lt;p&gt;Sometimes you need to abort, not retry. If a model is deployed but turns out to be bad, you don't just retry — you rollback.&lt;br&gt;
The compensating actions are the "undo" for each step. Not every step needs one (training data in BigQuery doesn't hurt anyone just sitting there), but state changes in production databases definitely do.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Visibility Into the State
&lt;/h3&gt;

&lt;p&gt;A saga that works perfectly but is opaque to users is almost as bad as one that fails. The user should be able to see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which step the deployment is on&lt;/li&gt;
&lt;li&gt;What failed and why&lt;/li&gt;
&lt;li&gt;Whether the system is retrying or waiting for manual intervention&lt;/li&gt;
&lt;li&gt;A "Retry" button for failed steps&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When You Need a Saga
&lt;/h2&gt;

&lt;p&gt;Two conditions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The operation spans multiple services or systems.&lt;/strong&gt; If it's a single database transaction, use a regular transaction. If it crosses service boundaries, you need a saga.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Partial completion is worse than complete failure.&lt;/strong&gt; If step 3 of 5 fails and you're left in a half-done state, that's a problem. The saga ensures you either complete or cleanly recover.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A quick gut check: if you find yourself writing code like "first do X, then do Y, and if Y fails... um..." — you need a saga.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where You've Seen This Pattern
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe's idempotency keys&lt;/strong&gt; — Every Stripe API call accepts an &lt;code&gt;Idempotency-Key&lt;/code&gt; header. If your server crashes after Stripe processes a charge but before you record the response, you retry with the same key. Stripe returns the original result. No double-charge. This is idempotency as a first-class API concept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes controllers&lt;/strong&gt; — The entire K8s control plane is a saga engine. Controllers compare desired state to current state on a reconciliation loop. If a controller crashes mid-action, it restarts, re-evaluates, and acts on the delta. It doesn't need to remember what it did — it looks at what exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airline booking systems&lt;/strong&gt; — When you book a flight, the system reserves a seat, charges your card, issues a ticket, and sends confirmation. If the charge fails, a compensating action releases the seat hold. If ticketing fails, it retries without re-charging. Each step knows what happened before it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>machinelearning</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>“You Can’t Do That” Is Not a User Experience</title>
      <dc:creator>Saumya Karnwal</dc:creator>
      <pubDate>Sun, 22 Feb 2026 11:32:48 +0000</pubDate>
      <link>https://dev.to/saumya_karnwal/you-cant-do-that-is-not-a-user-experience-1fm1</link>
      <guid>https://dev.to/saumya_karnwal/you-cant-do-that-is-not-a-user-experience-1fm1</guid>
      <description>&lt;h2&gt;
  
  
  The 28 Slack Messages
&lt;/h2&gt;

&lt;p&gt;Imagine you've built an ML platform. Product managers can deploy pre-built model templates to customers — select a template, pick a customer, click deploy. Easy.&lt;/p&gt;

&lt;p&gt;One Tuesday afternoon, a PM deploys a click-through rate model to a new customer. The customer onboarded three days ago. The model template needs at least 14 days of interaction data to train.&lt;/p&gt;

&lt;p&gt;The PM doesn't know this. Why would they? They're a product manager, not a data scientist. They click "Deploy," get a spinner for 90 seconds, and then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Training pipeline failed. Exit code 1.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No explanation. No guidance. No next step.&lt;/p&gt;

&lt;p&gt;So the PM does what anyone would do — they message the engineering channel on Slack: &lt;em&gt;"Hey, I tried to deploy CTR model to Customer Y and it failed. Can someone look?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An engineer investigates. Twenty minutes later: &lt;em&gt;"Oh, they only have 3 days of data. The model needs 14 days minimum. You'll have to wait until around March 5."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now multiply this by every PM, every customer, every model type with different requirements. You get 28 Slack messages a week, each one asking some variation of "why did this fail?" And each answer is something the system already knew — it just didn't bother to tell anyone.&lt;/p&gt;

&lt;p&gt;The system had all the information. It chose to say nothing useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern: Guide, Don't Block
&lt;/h2&gt;

&lt;p&gt;Most systems handle bad input the same way: reject it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User tries something invalid → "Error: Invalid request" → User confused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This technically works. The system is "safe." But the user is stuck, frustrated, and about to file a support ticket — which means a human ends up solving what the system should have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard rails&lt;/strong&gt; take a different approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User tries something → Validation fails → "Here's WHY, here's WHAT to fix, here's WHEN to retry"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system still prevents the bad thing from happening. But instead of slamming a door in your face, it puts up a guardrail on the highway — you can see the edge, you know you're drifting, and you correct before going off the cliff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Mode This Prevents
&lt;/h2&gt;

&lt;p&gt;Guard rails solve a specific problem: &lt;strong&gt;self-serve systems that generate support tickets instead of successful outcomes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Imagine you're building a platform where non-technical users trigger complex operations. Maybe it's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A product manager deploying an ML model to a customer&lt;/li&gt;
&lt;li&gt;A marketing team scheduling a push notification campaign&lt;/li&gt;
&lt;li&gt;An ops engineer provisioning infrastructure for a new region&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These users don't have the mental model of the system. They don't know the prerequisites. They don't know what "Error: insufficient data for training pipeline" means.&lt;/p&gt;

&lt;p&gt;Without guard rails, every failed operation becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → tries action → cryptic error → Slack message to engineering →
engineer investigates → "oh, you need 14 days of data first" →
user waits → tries again → maybe fails again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With guard rails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → tries action → "This customer has 3 days of data.
This model needs at least 14 days. Earliest deploy: March 5.
[Notify me when ready]"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Slack message. No engineering ticket. No frustration. The system explained the constraint and offered a next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Good Guard Rails Look Like
&lt;/h2&gt;

&lt;p&gt;Back to the ML platform. Here's what that PM should have seen instead of "Exit code 1":&lt;/p&gt;

&lt;h3&gt;
  
  
  The Validation Checklist
&lt;/h3&gt;

&lt;p&gt;Before the system accepts a deploy request, it runs a series of checks:&lt;/p&gt;

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

&lt;p&gt;Notice what this does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shows what passed&lt;/strong&gt; — the user knows they're not doing everything wrong&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explains what failed&lt;/strong&gt; — specific, not generic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gives a date&lt;/strong&gt; — "when" is more useful than "no"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offers a next action&lt;/strong&gt; — "Notify me" means they don't have to keep checking manually&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Thresholds Are Part of the Template
&lt;/h3&gt;

&lt;p&gt;Different models need different things. A simple bandit model might work with 7 days of data. A gradient-boosted model might need 30 days. A deep learning model might need 90 days and a GPU.&lt;/p&gt;

&lt;p&gt;The guard rail thresholds aren't hardcoded — they're defined by the people who know: the data scientists who built the template. When DS publishes a template, they specify:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Bandit&lt;/th&gt;
&lt;th&gt;LightGBM&lt;/th&gt;
&lt;th&gt;Deep Learning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Min data days&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Min interaction rows&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature table required&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU required&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The platform enforces these automatically. DS defines the rules once, every deployment is validated against them forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anatomy of a Good Guard Rail
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Validate Early, Not Late
&lt;/h3&gt;

&lt;p&gt;Don't let the user fill out a 10-step form, click "Submit," and THEN tell them step 2 was wrong. Validate as early as possible.&lt;/p&gt;

&lt;p&gt;Even better — validate &lt;em&gt;before they even start&lt;/em&gt;. If you know a customer doesn't have enough data, show a warning on the template selection page. Don't wait until they've configured everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Be Specific, Not Generic
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "Validation error"
❌ "Cannot deploy model"
❌ "Insufficient data"
✅ "Customer Y has 3 days of interaction data. CTR Bandit requires at least 14 days."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every word in the error message should reduce the user's uncertainty. If they read it and still don't know what to do, the message failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tell Them When, Not Just No
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "Not enough data. Try again later."
✅ "Not enough data. Earliest deploy date: March 5, 2026."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Later" is useless. A date is actionable. They can set a calendar reminder and come back.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Offer a Next Step
&lt;/h3&gt;

&lt;p&gt;The best guard rails don't just inform — they offer an action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Notify me when ready" (subscribe to a check)&lt;/li&gt;
&lt;li&gt;"Deploy with reduced accuracy" (accept the risk explicitly)&lt;/li&gt;
&lt;li&gt;"Contact DS team" (escalate with context pre-filled)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user should never be left staring at a message with nothing to click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where You've Seen This Pattern
&lt;/h2&gt;

&lt;p&gt;Once you recognize guard rails, you see them everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe's onboarding&lt;/strong&gt; doesn't say "Error 403: Account not verified." It shows a checklist of what's complete and what's missing, with time estimates and action buttons for each step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub branch protection&lt;/strong&gt; doesn't just reject your push to &lt;code&gt;main&lt;/code&gt;. It shows which checks failed (with links to logs), which reviews are missing (with names), and a "Re-run" button for flaky tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform plan&lt;/strong&gt; shows you exactly what will be created, changed, and destroyed &lt;em&gt;before&lt;/em&gt; you apply — so you make the call with full information, not after the damage is done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google's "Did you mean?"&lt;/strong&gt; — the original guard rail. You search for "pythn tutoral" and instead of "no results," you get a suggested correction and results anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all systems that chose to guide instead of block. The user is still prevented from doing the wrong thing — but they're never left staring at a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Need Guard Rails
&lt;/h2&gt;

&lt;p&gt;Two conditions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Non-expert users trigger complex operations.&lt;/strong&gt; If only senior engineers use the system, a terse error might be fine. If product managers, marketers, or ops people use it — they need guidance.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Failures are recoverable but wasteful.&lt;/strong&gt; If the system would eventually fail anyway (out of memory, bad training data, missing features), it's better to catch it before spending 2 hours and $50 in compute on a doomed training run.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The guard rails pattern works best at &lt;strong&gt;system boundaries&lt;/strong&gt; — where user intent meets system constraints. That's where the mismatch between "what I want to do" and "what the system can handle" is highest.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>product</category>
      <category>softwareengineering</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
