Skip to main content

Guide

unstorage vs keyv vs cache-manager 2026

unstorage, keyv, and cache-manager compared for Node.js caching in 2026. Multi-driver Redis, filesystem, and memory tiers — pick the right storage abstraction.

·PkgPulse Team·
0

TL;DR

unstorage (by UnJS) is the modern universal storage layer — driver-based API supporting memory, filesystem, Redis, Cloudflare KV, Vercel KV, HTTP, and more. keyv is the simple key-value store with TTL — adapters for Redis, Postgres, SQLite, MongoDB, and other backends. cache-manager is the caching-focused library — multi-level caches (memory → Redis), TTL, tag-based invalidation, and wrapping. In 2026: unstorage for multi-driver storage in modern frameworks (Nuxt, Nitro), keyv for simple persistent key-value needs, cache-manager for multi-tier caching strategies.

Key Takeaways

  • unstorage: ~3M weekly downloads — UnJS ecosystem, powers Nuxt/Nitro storage, 20+ drivers
  • keyv: ~10M weekly downloads — simple get/set/delete with TTL, adapter-based backends
  • cache-manager: ~1M weekly downloads — multi-tier caching, wrap pattern, tag invalidation
  • unstorage supports getItem, setItem, getKeys, watch — filesystem-like API
  • keyv is the simplest — await kv.set("key", value, ttl) / await kv.get("key")
  • cache-manager's wrap() pattern: check cache → miss → compute → store → return

unstorage

unstorage — universal storage layer:

Basic usage

import { createStorage } from "unstorage"

// In-memory (default):
const storage = createStorage()

await storage.setItem("package:react", {
  name: "react",
  downloads: 5_000_000,
  score: 92.5,
})

const pkg = await storage.getItem("package:react")
// → { name: "react", downloads: 5000000, score: 92.5 }

// List keys:
const keys = await storage.getKeys("package:")
// → ["package:react", "package:vue", ...]

// Check existence:
const exists = await storage.hasItem("package:react")  // true

// Delete:
await storage.removeItem("package:react")

Drivers

import { createStorage } from "unstorage"
import fsDriver from "unstorage/drivers/fs"
import redisDriver from "unstorage/drivers/redis"
import cloudflareKVBindingDriver from "unstorage/drivers/cloudflare-kv-binding"

// Filesystem:
const fsStorage = createStorage({
  driver: fsDriver({ base: "./data" }),
})

// Redis:
const redisStorage = createStorage({
  driver: redisDriver({
    url: process.env.REDIS_URL,
    ttl: 3600,  // Default TTL: 1 hour
  }),
})

// Cloudflare KV (in Workers):
const kvStorage = createStorage({
  driver: cloudflareKVBindingDriver({ binding: "MY_KV" }),
})

Mount points (multi-driver)

import { createStorage } from "unstorage"
import memoryDriver from "unstorage/drivers/memory"
import redisDriver from "unstorage/drivers/redis"
import fsDriver from "unstorage/drivers/fs"

const storage = createStorage()

// Mount different drivers at different paths:
storage.mount("cache:", redisDriver({ url: process.env.REDIS_URL }))
storage.mount("data:", fsDriver({ base: "./data" }))
storage.mount("temp:", memoryDriver())

// Access transparently:
await storage.setItem("cache:package:react", { score: 92 })  // → Redis
await storage.setItem("data:config", { theme: "dark" })       // → Filesystem
await storage.setItem("temp:session:abc", { userId: 123 })    // → Memory

Watch for changes

// Watch for storage changes:
const unwatch = await storage.watch((event, key) => {
  console.log(`${event}: ${key}`)
  // "update:package:react"
  // "remove:package:vue"
})

keyv

keyv — simple key-value with TTL:

Basic usage

import Keyv from "keyv"

// In-memory (default):
const kv = new Keyv()

// Set with TTL (milliseconds):
await kv.set("package:react", { name: "react", score: 92 }, 3600_000)  // 1 hour

// Get:
const pkg = await kv.get("package:react")
// → { name: "react", score: 92 } (or undefined if expired)

// Delete:
await kv.delete("package:react")

// Clear all:
await kv.clear()

// Check if key exists:
const exists = await kv.has("package:react")

Storage adapters

import Keyv from "keyv"

// Redis:
const kv = new Keyv("redis://localhost:6379")

// PostgreSQL:
const kv = new Keyv("postgresql://user:pass@localhost:5432/db")

// SQLite:
const kv = new Keyv("sqlite://./data/cache.sqlite")

// MongoDB:
const kv = new Keyv("mongodb://localhost:27017/cache")

// With options:
const kv = new Keyv({
  uri: "redis://localhost:6379",
  namespace: "packages",    // Key prefix: "packages:key"
  ttl: 3600_000,            // Default TTL: 1 hour
  serialize: JSON.stringify,
  deserialize: JSON.parse,
})

Namespaces

// Separate data by namespace:
const packageCache = new Keyv({ namespace: "packages" })
const userCache = new Keyv({ namespace: "users" })
const sessionStore = new Keyv({ namespace: "sessions", ttl: 86400_000 })

await packageCache.set("react", { score: 92 })
await userCache.set("user-123", { name: "Royce" })

// Keys in Redis:
// "packages:react"
// "users:user-123"
// No collision between namespaces

As HTTP cache

import Keyv from "keyv"
import KeyvRedis from "@keyv/redis"
import http from "node:http"

const cache = new Keyv({ store: new KeyvRedis("redis://localhost:6379") })

// Cache-aside pattern:
async function getCachedPackage(name: string) {
  const cached = await cache.get(`pkg:${name}`)
  if (cached) return cached

  const data = await fetchFromNpm(name)
  await cache.set(`pkg:${name}`, data, 300_000)  // Cache 5 minutes
  return data
}

cache-manager

cache-manager — multi-tier caching:

Basic setup

import { caching } from "cache-manager"

// In-memory cache:
const cache = await caching("memory", {
  max: 1000,          // Max 1000 items
  ttl: 60 * 1000,     // Default TTL: 60 seconds
})

// Set / Get:
await cache.set("package:react", { name: "react", score: 92 })
const pkg = await cache.get("package:react")

// Delete:
await cache.del("package:react")

// Reset:
await cache.reset()

The wrap() pattern (cache-aside)

import { caching } from "cache-manager"

const cache = await caching("memory", { ttl: 300_000 })  // 5 min TTL

// wrap() = check cache → miss → compute → store → return:
async function getPackageScore(name: string): Promise<number> {
  return cache.wrap(`score:${name}`, async () => {
    // This only runs on cache miss:
    console.log(`Cache miss — computing score for ${name}`)
    const data = await fetchFromNpm(name)
    return calculateScore(data)
  })
}

// First call: cache miss → fetches and computes → stores → returns
await getPackageScore("react")  // ~500ms

// Second call: cache hit → returns immediately
await getPackageScore("react")  // ~1ms

Multi-tier caching (memory → Redis)

import { caching, multiCaching } from "cache-manager"
import { redisStore } from "cache-manager-ioredis-yet"

// Tier 1: Memory (fast, limited):
const memoryCache = await caching("memory", {
  max: 500,
  ttl: 30_000,  // 30 seconds
})

// Tier 2: Redis (slower, larger):
const redisCache = await caching(redisStore, {
  host: "localhost",
  port: 6379,
  ttl: 300_000,  // 5 minutes
})

// Multi-tier: checks memory first, then Redis:
const cache = multiCaching([memoryCache, redisCache])

// Read: memory → Redis → compute
// Write: stores in BOTH tiers
const score = await cache.wrap("score:react", async () => {
  return calculateScore("react")
})

Tag-based invalidation

import { caching } from "cache-manager"

const cache = await caching("memory", { ttl: 600_000 })

// Store with tags:
await cache.set("package:react", data, { tags: ["packages", "frontend"] })
await cache.set("package:vue", data, { tags: ["packages", "frontend"] })
await cache.set("package:express", data, { tags: ["packages", "backend"] })

// Invalidate all "frontend" packages:
await cache.store.tags?.invalidate(["frontend"])
// react and vue caches cleared, express still cached

Feature Comparison

Featureunstoragekeyvcache-manager
Multi-driver✅ (20+ drivers)✅ (adapters)✅ (stores)
TTL support✅ (driver-level)
Namespaces✅ (mount points)
Multi-tier cache
wrap() pattern
Tag invalidation
Watch/subscribe
Edge runtime✅ (CF KV, Vercel)
TypeScript
Weekly downloads~3M~10M~1M

When to Use Each

Choose unstorage if:

  • Building with Nuxt/Nitro — unstorage is the native storage layer
  • Need multiple storage backends mounted at different paths
  • Edge runtime support (Cloudflare KV, Vercel KV, Deno KV)
  • Want filesystem-like API (getItem, setItem, getKeys)

Choose keyv if:

  • Simple key-value storage with TTL — minimal API
  • Need namespaced data across Redis, Postgres, SQLite, or MongoDB
  • Lightweight — zero config for in-memory, one URL for backends
  • Just need get/set/delete with expiration

Choose cache-manager if:

  • Multi-tier caching strategy (memory → Redis → database)
  • The wrap() pattern is your primary caching pattern
  • Need tag-based cache invalidation
  • Building an API that benefits from layered cache architecture

Driver Architecture and Backend Switching

One of the most practical benefits of all three libraries is driver-based backend abstraction: you can develop locally with an in-memory store and switch to Redis in production by changing one configuration line, without touching application code. This pattern is well-established, but each library implements it with different tradeoffs that affect how cleanly the switch works.

unstorage's driver system is the most granular. Each driver is a separate import (unstorage/drivers/redis, unstorage/drivers/fs, unstorage/drivers/vercel-kv), which means you only pay the bundle cost for drivers you use. The mount API allows multiple drivers to be active simultaneously at different key prefixes, which is genuinely useful for architectures where some data should persist to Redis (shared across instances) while other data should be local to the filesystem (machine-local, ephemeral). When unstorage uses this in Nuxt/Nitro, the nitro.config.ts storage option directly maps to unstorage mount points, making the framework's caching behavior configurable without code changes.

keyv's adapter system uses a connection string URI as the primary configuration API (new Keyv("redis://localhost:6379")), which maps naturally to environment variable patterns in 12-factor applications. Changing CACHE_URL from a sqlite:// connection string to redis:// in your environment switches backends entirely. The trade-off is less control over connection pooling and driver-specific configuration compared to unstorage's explicit driver objects. The @keyv/redis, @keyv/postgres, and @keyv/sqlite packages are official adapters; community adapters exist for DynamoDB, Etcd, and Hazelcast.

cache-manager v5 redesigned its store API to be promise-based throughout (earlier versions mixed callbacks and promises). The caching() factory function takes either a string ("memory") or a store factory object (like redisStore from cache-manager-ioredis-yet), and returns a cache instance with uniform async methods. The multiCaching function is cache-manager's most distinctive feature: it creates a tiered cache where reads check each tier in order and writes propagate to all tiers. A wrap() call on a multi-tier cache checks L1 memory first (~microseconds), then L2 Redis (~milliseconds), then executes the computation only on a complete miss — giving you automatic cache warming where a Redis hit also populates the in-memory tier for subsequent requests.

TTL Semantics and Expiration Behavior

TTL handling differs subtly across the three libraries and backend combinations, and these differences matter for correctness. keyv stores TTL information alongside the value in its serialized JSON format (it adds a wrapper object { value, expires }). This means TTL is enforced by keyv itself at read time, not necessarily by the underlying backend's native TTL mechanism. For Redis backends, keyv also sets the native Redis EX TTL, so the key expires even if never read. For SQLite and Postgres backends, keyv's TTL is soft — the key exists in the database but returns undefined when read after expiration. Expired keys are only physically deleted when a cleanup process runs (configurable with @keyv/sql's iterationLimit option).

unstorage's TTL is handled entirely by the driver and inherits whatever the backend natively supports. Redis's native TTL is set via the driver's ttl option (default for all keys) or per-item by passing { ttl: seconds } as the third argument to setItem. Filesystem and memory drivers have their own TTL implementations. This means TTL behavior is consistent with the backend's semantics — Redis keys expire accurately, memory keys use setTimeout-based expiration — but you need to be aware of per-driver TTL semantics rather than a single unified model.

cache-manager's TTL is specified in milliseconds and applies to all cache tiers in a multiCaching setup unless overridden per-call. A subtle behavior: on a wrap() call that hits L2 Redis but misses L1 memory, cache-manager populates L1 with the full original TTL rather than the remaining TTL of the L2 entry. This can cause L1 to hold values longer than their L2 expiration time, creating a window where L1 serves stale data after L2 has expired. For most use cases this is acceptable, but time-sensitive data (rate limit counters, feature flags with rapid rollouts) should use the store's TTL configuration carefully or avoid multi-tier caching for those specific keys.

Nuxt/Nitro Integration and the useStorage Composable

unstorage powers Nuxt 3's server-side storage API directly. The useStorage() composable in Nuxt server routes and middleware is a thin wrapper around unstorage's createStorage, configured by the nitro.config.ts storage field. This means you can use unstorage knowledge directly in Nuxt contexts: useStorage("cache") returns an unstorage instance mounted at the cache: prefix. Route rule caching (routeRules: { "/api/**": { cache: { maxAge: 60 } } }), ISR static generation, and server component caching in Nuxt all flow through unstorage under the hood.

For non-Nuxt Node.js applications using keyv or cache-manager, there are mature ecosystem integrations worth knowing. keyv-express provides Express middleware for HTTP response caching. cache-manager integrates with NestJS via @nestjs/cache-manager, which is the official NestJS caching module and uses cache-manager's multi-tier semantics for decorator-based caching (@CacheKey, @CacheTTL). If you're building a NestJS application and want to add Redis-backed caching with an in-memory L1 tier, @nestjs/cache-manager with cache-manager's multiCaching configuration is the path of least resistance.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on unstorage v1.x, keyv v5.x, and cache-manager v5.x.

A note on TTL behavior across the three: unstorage delegates TTL enforcement to the underlying driver (Redis TTL, filesystem stat-based expiry), meaning behavior varies by driver. keyv implements TTL consistently across all adapters using a expires_at column/field regardless of backend. cache-manager uses TTL natively in its multi-level cache system and propagates TTL settings from each store configuration, making it the most predictable for time-sensitive cache invalidation in production environments.

Compare caching, storage, and key-value packages on PkgPulse →

See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.