Key–value, the way you already know it.
GAThe KV surface is ioredis-compatible: the method names, arguments, and return shapes match what you use with Redis. Strings with TTLs, atomic counters, hashes, mget/mset, pipelines — and a command() escape hatch for anything not typed.
get / set / del / exists
import { cache } from './cache'
// Set with a 60-second TTL, only if the key is new:
await cache.set('session:abc', token, { ex: 60, nx: true })
await cache.get('session:abc') // token | null
await cache.exists('session:abc') // 1 | 0
await cache.del('session:abc') // number removedget() returns the value or null. set() takes an options object as its third argument (below). del() and exists() return counts, like Redis.
TTLs and conditions
await cache.set('k', 'v', { ex: 60 }) // expire in 60 seconds
await cache.set('k', 'v', { px: 1500 }) // expire in 1500 ms
await cache.set('k', 'v', { nx: true }) // set only if absent
await cache.set('k', 'v', { xx: true }) // set only if present
await cache.set('k', 'v', { keepTtl: true }) // overwrite value, keep existing TTL| Option | Meaning |
|---|---|
ex | Expire the key in this many seconds. |
px | Expire the key in this many milliseconds. |
nx | Set only if the key does not already exist. |
xx | Set only if the key already exists. |
keepTtl | Overwrite the value but keep the key’s current TTL instead of clearing it. |
set(key, id, { nx: true, ex: 30 }) is the classic single-round-trip lock: it succeeds for exactly one caller and self-expires, so a crashed holder can’t deadlock the key.Atomic counters
incr/incrby/decr/decrby are atomic on the server — no read-modify-write race:
await cache.incr('views') // 1
await cache.incrby('views', 10) // 11
await cache.decr('views') // 10
await cache.decrby('views', 5) // 5append, strlen, ranges of a string and hincrby, since the server never sees the value in the clear.Expiry: expire / ttl / persist
await cache.set('otp', code)
await cache.expire('otp', 300) // 300s to live
await cache.ttl('otp') // seconds remaining (-1 no TTL, -2 missing)
await cache.persist('otp') // drop the TTL, keep the valuettl() returns the seconds remaining, or -1 when the key exists with no TTL and -2 when it doesn’t exist — the standard Redis convention.
A namespace can set a default TTL in its settings. Any key a write leaves without an expiry gets that TTL; a key you persist() keeps none. Expired keys stop counting toward storage once they expire.
507 and OOM namespace storage limit reached; reads, deletes and expiry changes keep working, so you can free space. Namespaces on your own Redis aren’t capped.mget / mset
await cache.mset({ 'a': '1', 'b': '2', 'c': '3' })
await cache.mget('a', 'b', 'c') // ['1', '2', '3'] (null for missing)One round trip for many keys. mget() preserves order and returns null for keys that don’t exist.
Hashes
Store a small record under one key with hget/hset/hgetall/hdel/hincrby:
await cache.hset('user:42', { name: 'Ada', plan: 'pro' })
await cache.hget('user:42', 'name') // 'Ada'
await cache.hgetall('user:42') // { name: 'Ada', plan: 'pro' }
await cache.hincrby('user:42', 'logins', 1)
await cache.hdel('user:42', 'plan')Sets
Unordered, unique members, for dedupe and membership checks:
await cache.sadd('seen:2026-09-27', userId) // 1 if new, 0 if already there
await cache.sismember('seen:2026-09-27', userId) // 1 | 0
await cache.scard('seen:2026-09-27') // distinct users today
await cache.smembers('seen:2026-09-27')Sorted sets
Members ordered by score, for leaderboards and time-ordered indexes. Ties sort by member.
await cache.zincrby('leaderboard', 25, 'ada') // '35'
await cache.zadd('leaderboard', 'GT', 90, 'bo') // only raise a score
await cache.zrevrange('leaderboard', 0, 9, 'WITHSCORES') // top 10: ['bo', '90', 'ada', '35', …]
await cache.zrevrank('leaderboard', 'ada') // 1 (0-based), null if absent
await cache.zrange('leaderboard', '+inf', 50, 'BYSCORE', 'REV', 'LIMIT', 0, 5)Pipelines
Batch several commands into a single round trip with pipeline(). Each entry is an [command, ...args] array; results come back in order:
const [views, exists] = await cache.pipeline([
['incr', 'views'],
['exists', 'session:abc'],
])Raw command()
Anything the typed methods don’t cover, reach for command() — an array of the command and its arguments:
// Raw command for anything the typed methods don't cover.
// Still subject to the server-side allow-list — see Security.
await cache.command(['SET', 'k', 'v', 'EX', '60'])
await cache.command(['GETRANGE', 'k', '0', '3'])command() is still bound by the server-side allow-list. Destructive or introspection commands (FLUSHALL, KEYS, CONFIG, EVAL) are rejected — see the security model.Next: Queues for durable work, Pub/Sub for fan-out, or Backends & vendors to choose where a namespace’s keys actually live.