cd /news/ai-tools/your-cloudflare-workers-kv-rate-limi… · home topics ai-tools article
[ARTICLE · art-125486] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Your Cloudflare Workers KV rate limiter is probably attacking itself

A developer building a Cloudflare Worker that polls a status line endpoint every 10-20 seconds discovered that a KV-based rate limiter was exhausting the free plan's account-wide 100,000 daily get() quota, causing every KV read in the account to fail at once. The fix replaced the rate-limit check with caches.default edge cache reads, which carry no daily quota, and moved per-install data reads to a Durable Object per install so KV is only touched on cold start. The developer advises splitting hot-path checks from source-of-truth reads onto separate storage tiers and having rate limiters fail open.

by read3 min views3 publishedSep 10, 2026

I ship a small Cloudflare Worker (a Claude Code status line that pays users a cut of disclosed sponsor revenue -- not the point of this post, just context for where the traffic pattern came from). Every install polls /line every 10-20 seconds while the user is coding. That's a lot of requests hitting one Worker.

Rate limiting a Worker endpoint by install ID looks like a one-liner with KV:

async function checkLineRateLimit(env, installId) {
  const key = `ratelimit:${installId}`;
  const raw = await env.INSTALLS.get(key);
  const count = raw ? parseInt(raw, 10) : 0;
  if (count >= LIMIT) return { ok: false };
  await env.INSTALLS.put(key, String(count + 1), { expirationTtl: WINDOW_SECONDS });
  return { ok: true };
}

This worked in testing. It broke in production, and not because of an attack -- just real usage at real scale.

Workers KV on the free plan has an account-wide cap: 100,000 get() reads per day, not per namespace, not per key. Every single /line call was doing a get() just to check the rate limit, before it ever touched the actual install data. At normal polling frequency across a few hundred concurrent installs, that hot-path read alone was enough to burn through the daily quota -- and once it's gone, it's gone until midnight UTC. Every KV get() anywhere in the account starts failing with a hard error, including reads that have nothing to do with rate limiting.

The failure mode is brutal because it's silent until it isn't: everything works fine right up until the exact moment the account-wide counter ticks over, and then every /line call in production starts 500ing at once.

The rate limit check doesn't need durability. It doesn't need to survive a Worker restart. It just needs to answer "has this install called recently" for a few seconds, cheaply, at massive read volume. That's exactly what the platform's own edge cache is for, and unlike KV, caches.default reads don't count against any daily read quota at all:

async function checkLineRateLimit(env, installId) {
  const cache = caches.default;
  const cacheKey = new Request(
    `https://ratelimit.internal/line/install/${encodeURIComponent(installId)}`
  );
  try {
    const cached = await cache.match(cacheKey);
    const count = cached ? parseInt(await cached.text(), 10) : 0;
    if (count >= LIMIT) return { ok: false };
    await cache.put(
      cacheKey,
      new Response(String(count + 1), {
        headers: { "cache-control": `max-age=${WINDOW_SECONDS}` },
      })
    );
  } catch (e) {
    // fail open: a rate-limit check failing should never break the real request
  }
  return { ok: true };
}

Same interface, same call site, zero KV reads for this path. The try/catch matters as much as the cache swap: a rate limiter is a defensive layer, and defensive layers should fail open, not become a second way for the app to go down.

Fixing the hot path wasn't enough on its own, because the actual install data reads (not the rate limit, the real per-user state) were still hitting KV directly on every call. I moved those onto a Durable Object, one instance per install (idFromName(installId)), so KV is only touched once -- on that instance's true cold start -- and every call after that is served from the DO's own durable storage. The DO mirrors writes back to KV asynchronously so anything else that reads install:<id> directly out of KV (an admin dashboard, a cron job) still sees current data without knowing the DO exists.

The general shape of the fix, not just for this bug: if something is read on every request and doesn't strictly need cross-region strong consistency, it probably shouldn't be sharing a quota with your actual source-of-truth reads. Splitting "is this allowed" from "what's the data" onto different storage tiers means a hot path can run as hot as it wants without threatening to take down everything else sharing that account.

── more in #ai-tools 4 stories · sorted by recency
── more on @cloudflare 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/your-cloudflare-work…] indexed:0 read:3min 2026-09-10 ·