Skip to content

feat: add Redis cache components handler for "use cache" support#207

Open
nathanschram wants to merge 3 commits intofortedigital:feature/cache-componentsfrom
nathanschram:feature/cache-components
Open

feat: add Redis cache components handler for "use cache" support#207
nathanschram wants to merge 3 commits intofortedigital:feature/cache-componentsfrom
nathanschram:feature/cache-components

Conversation

@nathanschram
Copy link
Copy Markdown

Closes #152

Adds a Redis-backed CacheComponentsHandler implementation for Next.js 16's "use cache" directive, following the existing redis-strings handler patterns.

What this does:

  • New redis-strings-cache-components handler implementing the CacheComponentsHandler interface
  • Stores RSC Flight payloads (ReadableStream<Uint8Array>) as base64-encoded strings in Redis
  • Tag-based invalidation via Redis hash maps (__cc_sharedTags__, __cc_revalidatedTags__)
  • Timeout protection on all operations via AbortSignal (same as existing handler)
  • Supports Redis Cluster via the existing adapter
  • Updated example project (examples/redis-cache-components) to use the new handler
  • Package exports and typesVersions entry added
  • 25 unit tests covering all 5 interface methods

Usage:

const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings-cache-components").default;

module.exports = createRedisHandler({
  client,
  keyPrefix: "app:",
});

Design notes:

  • Uses separate Redis key namespaces (__cc_* prefix) to avoid collisions with the existing handler
  • refreshTags() is a no-op since revalidation state is queried directly from Redis on each get()/getExpiration() call
  • updateTags() currently does immediate invalidation (ignores durations parameter) - could add gradual expiration support later if needed

Happy to adjust the approach or add anything that's missing.

nathanschram and others added 2 commits March 13, 2026 09:09
Implements CacheComponentsHandler interface using Redis strings,
enabling distributed caching for Next.js 16+ "use cache" directive.

- Stores RSC Flight payloads as base64-encoded strings in Redis
- Tag-based invalidation via Redis hash maps
- Timeout protection on all Redis operations via AbortSignal
- Supports Redis Cluster via existing adapter
- Updates example project to use the new handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
25 tests covering get/set/refreshTags/getExpiration/updateTags:
- Cache miss/hit, expiry, tag revalidation, soft tags, keyPrefix
- Base64 stream encoding, pending entry await, tag storage
- Cursor pagination in updateTags, selective key deletion

Also formats cache-components-handler.ts and cache-handler.types.ts
to match project prettier config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@AyronK
Copy link
Copy Markdown
Collaborator

AyronK commented Mar 16, 2026

Awesome! 🚀🚀🚀

I will give it a try and review, hopefully this week.

@AyronK
Copy link
Copy Markdown
Collaborator

AyronK commented Mar 20, 2026

I’ve tested the implementation at a high level (without a deep dive into the code) and wanted to share a few general observations and suggestions.

Points to consider:

  1. The cache appears to be initialized at build time, unlike the standard cache handler which registers via an optional instrumentation hook. This could impact build pipelines that don’t have access to Redis during the build phase.

  2. After purging the Redis cache, subsequent requests still seem to return cached data until an explicit revalidation occurs. This suggests there may be an additional in-memory caching layer involved. It’s unclear whether this behavior originates from Next.js itself or from this implementation.

  3. In some cases, $undefined is serialized into the cache key. For example:
    ["bIfXW3nI_IPFKzSmb0rkD","80a833cb9240d56a3563b95ec78693015d837e7642",[{},"$undefined"]]
    I’m not sure if this is intentional, but it might be worth verifying.

  4. As a minor stylistic point, you could consider using .mjs instead of .js for the cache handler registration.

  5. The current implementation doesn’t seem as extensible as the standard cache handler. For instance, combining multiple handlers (e.g., via a composite pattern) doesn’t appear straightforward. The cache component handlers seem tightly coupled to the Redis implementation, so introducing an abstraction or strategy layer could improve flexibility and extensibility.


@nathanschram thanks a bunch! Overall, the basic tests indicate that the solution works as expected. I’m comfortable proceeding with it (pending a more in-depth code review) and iterating from there. That said, if you’d like to address any of the above points now, I’d definitely appreciate it. I will dive deeper into the code and in the meantime I look forward to a follow up from you, cheers!

The example now checks PHASE_PRODUCTION_BUILD before connecting to Redis,
matching the pattern used by the existing redis-minimal example. This
prevents build failures when Redis is not available during `next build`.

Also migrates from .js (CJS) to .mjs (ESM) for consistency with the
existing standard handler example.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nathanschram
Copy link
Copy Markdown
Author

Thanks for testing this out and for the detailed feedback - really helpful.

I've pushed a fix for point 1 and dug into the rest. Here's where I landed on each:

1. Build-time init - good catch. I've updated the example to check PHASE_PRODUCTION_BUILD before connecting to Redis, matching the pattern from your redis-minimal example. The handler factory itself was already lazy (only checks client.isReady on method calls), but the example was eagerly connecting at module evaluation time, which would fail during next build without Redis. Should be sorted now.

2. In-memory caching after purge - I think this is Next.js's built-in LRU cache layer rather than something from our handler. Next.js runs a createDefaultCacheHandler() LRU in front of custom handlers (in packages/next/src/server/lib/cache-handlers/default.ts). After purging Redis, the LRU still holds cached data until it's evicted or explicitly revalidated. You can control this via cacheMaxMemorySize in next.config (setting to 0 disables it). Might be worth adding a note about that in the README.

3. $undefined in cache keys - this looks like it's React's RSC Flight serialisation. When React serialises component params for the cache key, undefined values get encoded as $undefined to preserve them across the transport layer. It shows up in all cache handler implementations - not something specific to ours.

4. .mjs - done. I've renamed the example to .mjs and switched to ESM imports to match the redis-minimal example. Updated next.config.ts to reference the new file.

5. Extensibility - agreed this could be more composable. I deliberately matched the existing createHandler() factory pattern from redis-strings.ts for consistency, but I can see how an abstraction layer (base class or strategy interface) would help with combining handlers. That feels like a bigger architectural change across all handlers though - happy to help with that as a separate effort if you'd like to go that direction for 3.0.

Let me know if you'd like me to adjust anything else or if there are specific things to look at more closely.

@AyronK
Copy link
Copy Markdown
Collaborator

AyronK commented Mar 27, 2026

@nathanschram I cannot build the example project after the latest changes. (npm run build for the redis-cache-components project).

It just hangs and times out at this step Generating static pages using 23 workers (3/8) [ ==]. Do you experience the same locally?

image image

Comment on lines +15 to +22
// Only connect to Redis outside of `next build` — Redis is not available
// during the build phase. The handler checks client.isReady on each method
// call, so an unconnected client at build time is safe.
if (process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD) {
client.connect().catch((err) => {
console.warn("Redis connection failed:", err.message);
});
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not really solve the problem of handler saving to redis at built time. It only causes failures, the cache handler is still registered, still calls redis methods.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this is exactly why the npm run build does not work.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check how PHASE_PRODUCTION_BUILD is used in cache-handler.ts to properly implement skipping redis calls at built time.

@nathanschram
Copy link
Copy Markdown
Author

Hey - reproduced the hang locally. The root cause is that assertClientIsReady() throws when the client isn't connected during build, and Next.js doesn't wrap CacheHandler method calls in try/catch - so the workers crash and stall at "Generating static pages".

I've got a fix ready matching the redis-minimal pattern - the example conditionally exports a no-op handler during build and the Redis handler at runtime. Build completes 8/8 pages, all tests pass.

One question before I push: I also made the handler methods themselves degrade gracefully when the client isn't ready (returning undefined/0 instead of throwing) - since Next.js doesn't catch handler errors, this would also protect against transient Redis disconnections at runtime. Would you prefer that change too, or keep the throw behaviour for runtime error visibility? Happy either way.

@AyronK
Copy link
Copy Markdown
Collaborator

AyronK commented Mar 30, 2026

@nathanschram

One question before I push: I also made the handler methods themselves degrade gracefully when the client isn't ready (returning undefined/0 instead of throwing) - since Next.js doesn't catch handler errors, this would also protect against transient Redis disconnections at runtime. Would you prefer that change too, or keep the throw behaviour for runtime error visibility? Happy either way.

Good idea with the graceful error handling, let's do it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants