Prisma Next is in early access.Read the docs

Built-in: cache

The cache middleware serves repeated reads from an in-memory store, opted in per query with a cache annotation.

The cache middleware serves repeated reads from an in-memory store instead of the database. Caching is strictly opt-in per query: only queries annotated with a TTL are cached, everything else passes through untouched.

Use it for hot, repeatable reads such as dashboards, lookup tables, feature flags, navigation data, or expensive search results where a short stale window is acceptable.

Install the package and register the middleware:

Terminal
bun add @prisma-next/middleware-cache
src/prisma/db.ts
import { createCacheMiddleware } from '@prisma-next/middleware-cache';
import postgres from '@prisma-next/postgres/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };

export const db = postgres<Contract>({
  contractJson,
  url: process.env['DATABASE_URL']!,
  middleware: [createCacheMiddleware({ maxEntries: 1_000 })],
});

When you combine it with other middleware that implement intercept, register the cache first when cached rows should win. beforeExecute hooks still run before a cache hit is served, and afterExecute still fires afterward with result.source set to 'middleware'.

The cache is family-agnostic: unlike budgets and lints, it works on MongoDB runtimes as well as PostgreSQL.

Opt a query in

Annotate a read with cacheAnnotation and a ttl in milliseconds. On the SQL query builder, chain .annotate(...) onto the plan:

src/prisma/get-users-cached.ts
import { cacheAnnotation } from '@prisma-next/middleware-cache';

const plan = db.sql.public.user
  .select('id', 'email')
  .annotate(cacheAnnotation({ ttl: 60_000 }))
  .limit(10)
  .build();

const users = await db.runtime().execute(plan);

On the ORM API, pass the annotation through the query's meta callback:

src/prisma/get-user-cached.ts
import { cacheAnnotation } from '@prisma-next/middleware-cache';
import { db } from './db';

const user = await db.orm.public.User.first({ id }, (meta) =>
  meta.annotate(cacheAnnotation({ ttl: 60_000 })),
);

The first call runs against the database and stores the raw rows. A second call with the same query and parameters inside the TTL window is served from the store, and the driver is not invoked.

The annotation payload has three fields:

FieldTypeWhat it does
ttlnumberTime-to-live in milliseconds. Without a ttl, the annotation is inert and the query is not cached
skipbooleanWhen true, bypasses the cache for this call even though a ttl is set. Useful as a force-refresh knob
keystringOverrides the computed cache key with your own string, stored as-is

cacheAnnotation is declared read-only (applicableTo: ['read']). Passing it to a write such as create, update, or delete is both a type error and a runtime error, so a mutation cannot be cached by accident.

How keys and hits work

By default the cache key is a content hash of the executed query: the post-lowering statement plus its bound parameters, combined with the contract's storage hash. Two lookups with different parameter values land in different slots; the same lookup hits. Because schema migrations rotate the storage hash, entries cached before a migration cannot serve queries against the new schema.

Cache hits are still visible to the rest of the middleware chain: beforeExecute hooks have already run, the driver and onRow are skipped, and afterExecute fires on every middleware with result.source set to 'middleware' instead of 'driver', so logging and metrics keep working for cached reads.

The cache only acts at runtime scope. Queries executed inside a transaction or on an explicitly checked-out connection always go to the database, because read-after-write consistency is the caller's expectation there.

Options

OptionTypeDefaultWhat it controls
maxEntriesnumber1000Maximum entries in the default in-memory store; least-recently-used entries are evicted first. Only consulted when store is omitted
storeCacheStorein-memory LRUBring your own store implementation, for example one backed by Redis
clock() => numberDate.nowTime source used to stamp storedAt on committed entries. TTL expiry lives in the store implementation

Common gotchas

See also

On this page