How to Build a Rate-Limit-Aware Request Queue for WeatherAPI Without Burning Your Monthly Quota

Most quota problems aren’t caused by legitimate traffic. They’re caused by retry storms, cache misses on the same location within seconds of each other, and background jobs that nobody throttled when the product launched. By the time you notice the 429s, you’ve already burned a week’s worth of calls in an afternoon.

This post is about building a request queue that actually prevents that — with enough implementation detail to drop into a real backend. The examples are in JavaScript/Node because that’s what most people reaching for a weather API in 2025 are running, but the pattern ports cleanly to Python or C#.

First: Understand What You’re Rate-Limited Against

WeatherAPI’s limits are per-account per-month (total call volume) plus a per-second ceiling depending on plan. Those are two different constraints and they need two different solutions. Monthly quota is a counting problem. Per-second limits are a throughput problem. A naive queue often fixes one and makes the other worse.

If you solve burst by adding a delay between requests, you might accidentally spread calls out just enough that your nightly batch job runs all night instead of finishing in an hour — but you haven’t reduced total calls at all. Monthly quota stays untouched. The fix for that is deduplication and caching, not throttling.

The Two-Layer Pattern

A robust queue needs two layers:

  • A deduplication layer — checks whether you already have a fresh enough response for this location before making a network call at all.
  • A throttle layer — enforces the per-second ceiling on calls that actually need to go out.

Most implementations only have the second one, which means every concurrent worker happily queues up its own call for lat=51.50&lon=-0.12 within the same second, deduplicated by nothing.

The Deduplication Layer

The simplest version is an in-memory promise cache. When a request comes in for a location, check a Map keyed on a normalized location string. If there’s already an in-flight promise for that key, return it directly — multiple callers share the same response. If the promise resolved within your freshness window, return the cached value. Only if neither is true do you issue a new API call.

const inFlight = new Map();
const resultCache = new Map();
const FRESH_MS = 5 * 60 * 1000; // 5 minutes

async function fetchWeather(lat, lon) {
  const key = `${lat.toFixed(3)},${lon.toFixed(3)}`;

  const cached = resultCache.get(key);
  if (cached && Date.now() - cached.ts < FRESH_MS) {
    return cached.data;
  }

  if (inFlight.has(key)) {
    return inFlight.get(key);
  }

  const promise = callWeatherAPI(lat, lon).then(data => {
    resultCache.set(key, { data, ts: Date.now() });
    inFlight.delete(key);
    return data;
  }).catch(err => {
    inFlight.delete(key);
    throw err;
  });

  inFlight.set(key, promise);
  return promise;
}

The toFixed(3) on the coordinates matters. Without it, 51.5001 and 51.5002 get treated as different locations and both go to the network. Three decimal places gives you roughly 100-meter resolution — fine for weather, which doesn’t change meaningfully within that radius.

The freshness window is your biggest lever on monthly quota. Five minutes is conservative for current conditions; WeatherAPI’s realtime data updates roughly every 15–20 minutes depending on station. You could go to 10–15 minutes for most apps without any user-visible degradation. Forecast data changes less frequently still — HRRR cycles every hour, GFS every 6 hours. Caching forecast responses for 20–30 minutes is entirely reasonable.

The Throttle Layer

Once a call actually needs to go out, you want to ensure it doesn’t pile up against your per-second ceiling. A token bucket is the standard approach. You don’t need a library — a minimal implementation is about 20 lines:

class TokenBucket {
  constructor(ratePerSecond) {
    this.tokens = ratePerSecond;
    this.max = ratePerSecond;
    this.lastRefill = Date.now();
    this.queue = [];
    setInterval(() => this._refill(), 100);
  }

  _refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.max, this.tokens + elapsed * this.max);
    this.lastRefill = now;
    while (this.queue.length > 0 && this.tokens >= 1) {
      this.tokens -= 1;
      this.queue.shift()();
    }
  }

  acquire() {
    return new Promise(resolve => {
      if (this.tokens >= 1) {
        this.tokens -= 1;
        resolve();
      } else {
        this.queue.push(resolve);
      }
    });
  }
}

const bucket = new TokenBucket(5); // 5 requests/sec

async function callWeatherAPI(lat, lon) {
  await bucket.acquire();
  const url = `https://api.weatherapi.com/v1/current.json?key=YOUR_KEY&q=${lat},${lon}`;
  const resp = await fetch(url);
  if (!resp.ok) throw new Error(`API error ${resp.status}`);
  return resp.json();
}

Set ratePerSecond below your actual plan limit, not right at it. Leaving headroom means a brief burst doesn’t immediately 429 you. And the token bucket above is process-local. If you’re running multiple Node processes behind a load balancer, you need the bucket in Redis (or KeyDB if you want the OSS fork), not in-process memory — otherwise each process independently thinks it has N tokens and you’re actually hitting N × processes per second.

Retry Logic That Doesn’t Compound the Problem

When you do get a 429 or a transient 5xx, the worst thing to do is immediately retry. Exponential backoff with jitter is the standard answer — jitter being the part most people leave out, which means every parallel worker backs off for the exact same duration and then hammers the API again in unison.

async function withRetry(fn, maxAttempts = 3) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxAttempts - 1) throw err;
      const base = Math.pow(2, attempt) * 1000;
      const jitter = Math.random() * base;
      await new Promise(r => setTimeout(r, base + jitter));
    }
  }
}

Cap retry attempts at three. Beyond that, you’re either hitting a real outage (retrying faster won’t help) or you have a bug in your quota math (retrying at all is making things worse).

Monitoring Actual Quota Burn

The WeatherAPI dashboard shows monthly usage, but you want visibility in your own stack too — especially if multiple services share one API key. The simplest version: increment a counter in Redis on every successful call to callWeatherAPI, keyed by UTC month, and log a warning when you cross 80% of your plan limit. This takes about five minutes to add. A new endpoint that nobody realized was calling the API on every page load is exactly the kind of thing that doesn’t show up until the monthly bill lands.

Where This Still Falls Down

None of this helps if your location set is genuinely large and diverse — thousands of distinct coordinates with no nearby duplicates, all needing fresh data within a short window. At that point you’re not fighting queue design, you’re fighting the math of how many calls your plan allows. The real answer there is either upgrading your plan or switching to the batch=true endpoint, which lets you pack multiple locations into a single API call.

Also: none of this deduplication logic survives a process restart with in-memory caching. If your Node service restarts frequently — container recycling, deploys — add a Redis layer underneath so the warm cache survives. The in-memory layer is still worth keeping as L1; Redis round-trips add latency.

Before adding any of this infrastructure, check one thing first: does your code call fetchWeather unconditionally on every request to your own API, or only when something actually needs current conditions? A weather icon in the site header triggering a fresh API call on every page load — because nobody threaded the cache through properly — will beat any queue you build around it. Pull that thread before you pull any other.

Scroll to Top