Home /

Free Redis hosting: a cache you can afford to lose

Compare Upstash and Redis Cloud by connection, limits and durability, then run a complete cache example with expiry and failure recovery.

  • Upstash supports HTTPS commands and native Redis clients; Redis Cloud Free suits small native-client prototypes; its TLS documentation conflicts.
  • The example keeps authoritative data outside Redis and checks expiry, invalidation, cache failure and recovery locally.

For a small shared cache, start the free Redis hosting decision with the connection your backend can make. Upstash offers HTTPS commands as well as native Redis TCP. Redis Cloud Free suits a small native-client prototype, with a 30 MB limit and a TLS documentation conflict that needs attention before you send sensitive data.

Neither plan gives the rest of your app somewhere to run. And if a single process can keep its own short-lived cache, an in-process map may be enough. Shared Redis earns its place when separate instances need the same cached values.

This comparison covers two managed free plans, using provider documentation checked on 27 October 2026. The complete cache module below was tested locally through a fake HTTP Redis service, including expiry, invalidation, errors and recovery. No hosted account, quota test or provider benchmark was used.

Two free plans, different constraints

DecisionUpstash Redis FreeRedis Cloud Free Essentials
Application connectionHTTPS REST or native Redis TCPNative Redis client using endpoint and port
SizePricing page disagrees: 250 MB headline/FAQ, 256 MB comparison limit. Plan within 250 MB until confirmed.30 MB database
Activity limits500,000 commands/month; 10 GB monthly bandwidth100 operations/second; 30 concurrent connections; 5 GB monthly total network bandwidth
State you can depend onDocumented disk persistence, with free-database inactivity archival and optional destructive evictionFree does not support persistence or backup/export through the Cloud console
Sensible starting taskSmall shared cache for an HTTP-oriented backendDisposable learning/prototype data for an existing native client, subject to transport requirements

The limits come from Upstash pricing, Redis Cloud's current Essentials plan table, and Redis Cloud's persistence and console backup documentation. Both free plans allow one free database per account. Redis Cloud's throughput figure assumes 1 KiB requests and is also constrained by bandwidth; it is not a promise of sustained traffic at that rate.

Upstash supports both protocols, so HTTP is an option rather than a requirement. Its command and extension compatibility still needs checking for your client or queue library. The REST adapter in this example cannot be pointed at a Redis Cloud host and port. A native Redis client is a different connection path; a cloud provider's management REST API is not an interchangeable Redis command endpoint.

Redis Cloud's dedicated TLS guide explicitly excludes Free Essentials. Its subscription comparison nevertheless lists encryption in transit for Free. Those pages do not give a consistent Free TLS promise. Check the database's actual connection settings before sending sensitive data. For this public-network cache example, the documented Upstash HTTPS route is the clearer fit. Redis Cloud Free remains a learning option only where its actual transport and disposable-state constraints are acceptable.

The Upstash pricing page also disagrees with itself about storage: its headline and FAQ say 250 MB, while the detailed comparison still says 256 MB. The table uses 250 MB for planning; confirm the allowance shown for your database rather than filling it to either advertised edge.

The 500K monthly Upstash allowance is supported by both pricing and its March 2025 plan-change announcement. An FAQ still mentions the former 10K daily limit. That is documentation drift, not an observed enforcement result here. Keep the plan's current limits visible in the console before depending on the last bit of capacity.

Keep the catalogue outside the cache

Suppose a backend serves a small product catalogue. The product repository owns the price. Redis holds a copy for sixty seconds, and the backend can fetch the product again if that copy is missing or unavailable.

Save this as cache.mjs. It needs Node.js 22 or newer for the APIs used here; the local checks ran on Node 26.2.0. loadProduct is the adapter to your authoritative repository: return the product or null, and throw when the source itself has failed.

// Server-side only: never ship the REST token in browser code.
export class UpstashRest {
  constructor({ url, token, timeoutMs = 1000 }) {
    const endpoint = new URL(url);
    const local = ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname);
    if (endpoint.protocol !== 'https:' && !(local && endpoint.protocol === 'http:')) {
      throw new Error('Use HTTPS for the hosted Redis endpoint');
    }
    if (!token || !Number.isInteger(timeoutMs) || timeoutMs < 1) {
      throw new Error('A token and a positive timeoutMs are required');
    }
    this.url = endpoint.href;
    this.token = token;
    this.timeoutMs = timeoutMs;
  }

  async command(parts) {
    const response = await fetch(this.url, {
      method: 'POST',
      headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(parts),
      signal: AbortSignal.timeout(this.timeoutMs),
    });
    if (!response.ok) throw new Error(`Redis HTTP ${response.status}`);
    const body = await response.json();
    if (!body || typeof body !== 'object' || 'error' in body || !('result' in body)) {
      throw new Error('Invalid or failed Redis REST response');
    }
    return body.result;
  }

  get(key) { return this.command(['GET', key]); }
  set(key, value, ttl) { return this.command(['SET', key, value, 'EX', ttl]); }
  del(key) { return this.command(['DEL', key]); }
}

// loadProduct is authoritative and async: return a product, or null if missing.
export function createProductCache({ redis, loadProduct, ttlSeconds = 60 }) {
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) throw new Error('Invalid TTL');
  const key = id => `catalogue:v1:${encodeURIComponent(String(id))}`;

  return {
    async get(id) {
      const errors = [];
      try {
        const raw = await redis.get(key(id));
        if (raw !== null) {
          const cached = JSON.parse(raw);
          if (!cached || cached.v !== 1 || !Object.hasOwn(cached, 'value')) {
            throw new Error('Invalid cache entry');
          }
          return { value: cached.value, cache: 'hit', errors };
        }
      } catch {
        errors.push('read');
      }

      // Deliberately outside either cache catch: source failures must propagate.
      const value = await loadProduct(id);
      if (value === undefined) throw new Error('Loader must return a value or null');
      try {
        // The envelope distinguishes a cached null/false/0 from a Redis miss.
        await redis.set(key(id), JSON.stringify({ v: 1, value }), ttlSeconds);
      } catch {
        errors.push('write');
      }
      return { value, cache: 'miss', errors };
    },

    // Call AFTER committing a source update. A rejection means stale data may remain.
    // This is not a transaction: TTL also bounds stale entries and races can refill them.
    async invalidate(id) {
      await redis.del(key(id));
    },
  };
}

The small {v, value} envelope matters. A Redis miss is null, while a cached record can contain a known missing product, false or zero. Testing the product value directly with if (value) would confuse those cases. A missing product is cached for the same TTL here; invalidate that ID after creating it, or it can remain absent until the negative cache expires.

Cache read and write errors are recorded in the result, but the source loader sits outside those catches. A broken product repository must still fail the request. Returning an empty product because both systems failed would hide a different problem.

Run it without a cloud account

Save this as smoke.mjs beside the module and run node smoke.mjs. The map is a local test substitute with a controllable clock; it does not emulate a provider's quotas or persistence.

import { createProductCache } from './cache.mjs';

let now = 0;
const entries = new Map();
const redis = {
  async get(k) {
    const e = entries.get(k);
    return e && e.expires > now ? e.raw : null;
  },
  async set(k, raw, ttl) { entries.set(k, { raw, expires: now + ttl * 1000 }); },
  async del(k) { entries.delete(k); },
};
let product = { id: 'mug', pricePence: 1200 };
const cache = createProductCache({
  redis, loadProduct: async id => id === 'mug' ? product : null, ttlSeconds: 60,
});
console.log(await cache.get('mug'));     // miss: 1200
console.log(await cache.get('mug'));     // hit: 1200
console.log(await cache.get('missing')); // miss: null
console.log(await cache.get('missing')); // hit: null
product = { id: 'mug', pricePence: 1400 }; // commit authoritative update first
await cache.invalidate('mug');
console.log(await cache.get('mug'));     // miss: 1400
now += 60000;                           // advance the local fake clock
console.log(await cache.get('mug'));     // miss: 1400 after expiry

The sequence is miss, hit, missing-product miss, missing-product hit, updated-price miss and an expired-entry miss. You can inspect the behavior without credentials or a network connection.

For an actual backend using Upstash, construct the adapter from server-side environment variables:

import { UpstashRest, createProductCache } from './cache.mjs';

const redis = new UpstashRest({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
// Supply your real loadProduct function when creating the cache.

Use the database's REST URL and token from the console, and keep both out of browser bundles and logs. The adapter follows the documented JSON-array command body and response shape. That is a documentation-based integration; no hosted call was made for this guide. Its one-second timeout is per Redis command. A failing GET followed by a failing SET can add two timeout waits, and the source loader needs its own deadline.

The deeper local checks sent the actual adapter's requests to a fake loopback HTTP service. Fourteen tests passed, covering command bodies and bearer headers, cached null/false/zero, deterministic TTL expiry, source-update invalidation, HTTP errors, a Redis error inside HTTP 200, malformed cached values, timeout, source failure and recovery. Those are application and HTTP-contract checks. They do not measure hosted latency, enforce a free allowance or establish Redis Cloud compatibility.

What happens when invalidation fails?

After committing a source update, call await cache.invalidate(id). A failed delete rejects rather than pretending the cache was cleared. In the local failure test, the old price remained readable until the fake clock reached its TTL; after expiry the next lookup returned the new source value.

That behavior is tolerable only for data allowed to be stale. Cache-aside has a race too: another reader can load an old source value before your update, then fill the cache after your delete. The TTL expires that copy from the time it was cached; it does not make the source update and Redis write atomic. Use the authoritative source for a checkout price or any decision requiring current data.

The official cache-pattern guide covers alternatives when this simple pattern is insufficient. This example has no stampede lock: many simultaneous misses can all query the source. Before adding one, establish whether the extra load is a real problem and what a stuck or expired lock would do.

Budget the operations you actually issue

For this module, a normal hit sends one GET. A miss sends GET and SET ... EX; an invalidation adds DEL. In a hypothetical month with 100,000 lookups and a 90% hit rate, that is 110,000 commands before invalidations and other users of the database. The arithmetic is an estimate for this code, not a traffic measurement or a promise that 100,000 visitors fit.

Value size and key count can hit the storage limit before the command allowance does. Keep payloads small, use TTLs and leave room for metadata and other keys. The free plans' storage and bandwidth allowances do not become interchangeable just because both products speak Redis.

Rate limiting has a different command budget. Upstash's SDK cost tables show that a regional sliding-window check can use three to five commands before optional extras, or none for some cached rejections; analytics adds a command. One library call is not necessarily one Redis command. Rate limiting needs a separate failure policy: automatically allowing a protected action while Redis is down could remove the spending limit.

Upstash persistence also does not mean every key is permanent. Its eviction setting is off by default, so a full database rejects writes. Enable it for a disposable cache and max-size eviction can delete keys from durable storage too, including non-TTL keys if necessary. Avoid mixing a disposable cache with irreplaceable queue state in that database.

Free databases can be archived after at least thirty inactive days, with warnings and a backup restoration route. That is different from an endpoint remaining continuously available. Adding a payment method is not a harmless switch to try: the current upgrade guide says it moves all free account resources to pay-as-you-go.

For this catalogue, a lost cache is recoverable because the products live elsewhere. If losing Redis would lose the product, the queue item or the only record of an operation, change the storage design before choosing the free host. The free-compute guide is the next reference for the backend and authoritative database that surround it.