TILLCACHE · KV

Key–value, the way you already know it.

GA

The 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.

01Basics

get / set / del / exists

ts
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 removed

get() returns the value or null. set() takes an options object as its third argument (below). del() and exists() return counts, like Redis.

02set options

TTLs and conditions

ts
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
OptionMeaning
exExpire the key in this many seconds.
pxExpire the key in this many milliseconds.
nxSet only if the key does not already exist.
xxSet only if the key already exists.
keepTtlOverwrite the value but keep the key’s current TTL instead of clearing it.
nx + ex is a lock
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.
03Counters

Atomic counters

incr/incrby/decr/decrby are atomic on the server — no read-modify-write race:

ts
await cache.incr('views')          // 1
await cache.incrby('views', 10)    // 11
await cache.decr('views')          // 10
await cache.decrby('views', 5)     // 5
Sealed namespaces
A namespace sealed with a customer key refuses counters, append, strlen, ranges of a string and hincrby, since the server never sees the value in the clear.
04TTL

Expiry: expire / ttl / persist

ts
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 value

ttl() 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.

Storage cap
Every namespace has a storage cap (100 MB on the free tier) covering its keys and queued messages. When it’s full, writes that would add data fail with HTTP 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.
05Batch

mget / mset

ts
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.

06Hashes

Hashes

Store a small record under one key with hget/hset/hgetall/hdel/hincrby:

ts
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')
07Sets

Sets

Unordered, unique members, for dedupe and membership checks:

ts
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')
08Sorted sets

Sorted sets

Members ordered by score, for leaderboards and time-ordered indexes. Ties sort by member.

ts
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)
09Pipeline

Pipelines

Batch several commands into a single round trip with pipeline(). Each entry is an [command, ...args] array; results come back in order:

ts
const [views, exists] = await cache.pipeline([
  ['incr', 'views'],
  ['exists', 'session:abc'],
])
10Escape hatch

Raw command()

Anything the typed methods don’t cover, reach for command() — an array of the command and its arguments:

ts
// 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'])
Allow-listed
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.