Production

Caching

Caching is an architectural pattern in NextRush, not a built-in feature — how to apply HTTP cache headers and a service-layer cache correctly.

No built-in caching package

NextRush does not ship a @nextrush/cache package. There is no middleware, extension, or registrar for caching in the 35-package registry. Everything on this page is a pattern you implement in your own application code — HTTP response headers the browser/CDN respects, plus an optional cache layer in your own service code. Nothing here is a NextRush feature.

Why there's no framework-level cache

Caching correctness depends entirely on your data's invalidation semantics — how long a resource is valid, what invalidates it, and whether staleness is acceptable. A generic framework-level cache either guesses wrong for your domain or forces a configuration surface large enough to become its own subsystem. NextRush keeps caching out of the core and middleware layers and gives you the primitives — HTTP headers and a plain ctx — to build the cache that fits your data.


Pattern 1 — HTTP cache headers

The cheapest and most effective cache is the one a browser or CDN applies without your server doing any work on a cache hit. Set Cache-Control with ctx.set() (packages/adapters/node/src/context.ts) on any response that is safe to cache:

import { createApp, createRouter, listen } from 'nextrush';

const app = createApp();
const router = createRouter();

router.get('/articles/:id', async (ctx) => {
  const article = await getArticle(ctx.params.id);

  // Public, cacheable for 60s; a stale copy may be served for another 30s
  // while a fresh copy is fetched in the background.
  ctx.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=30');
  ctx.json(article);
});

app.route('/', router);
listen(app, 8080);

Verified: booting this exact handler and requesting it returns the header unchanged —

$ curl -sD - -o /dev/null http://localhost:8931/articles/42
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=30
Content-Type: application/json; charset=utf-8
Content-Length: 37

Never cache authenticated or per-user responses as public

public tells any intermediary (CDN, shared proxy) it may cache and serve the response to other users. A response containing per-user data must use private (cacheable only by the requesting browser) or no-store (never cached), never public.

Common directives for an API:

DirectiveUse for
no-storeAny authenticated or per-user response
private, max-age=<n>Per-user data safe to cache in that user's browser only
public, max-age=<n>Identical response for all callers (public catalog, static lookups)
stale-while-revalidate=<n>Serve stale while refetching in the background — good for high-read, low-volatility data

Conditional requests (ETag / If-None-Match, Last-Modified / If-Modified-Since) follow the same pattern: compute the value from your data, set it with ctx.set(), and check the matching request header before doing the expensive work.


Pattern 2 — a cache layer in your service code

For data that's expensive to compute or fetch but doesn't map cleanly to an HTTP response (an aggregated report, a third-party API result, a DB query used by multiple routes), put a cache in the service/domain layer — not in a middleware or the route handler.

// services/article-service.ts
interface CacheStore {
  get<T>(key: string): Promise<T | undefined>;
  set<T>(key: string, value: T, ttlMs: number): Promise<void>;
  delete(key: string): Promise<void>;
}

class ArticleService {
  constructor(
    private readonly db: ArticleRepository,
    private readonly cache: CacheStore
  ) {}

  async getArticle(id: string) {
    const cached = await this.cache.get<Article>(`article:${id}`);
    if (cached) return cached;

    const article = await this.db.findById(id);
    await this.cache.set(`article:${id}`, article, 60_000);
    return article;
  }

  async updateArticle(id: string, data: UpdateArticleInput) {
    const updated = await this.db.update(id, data);
    await this.cache.delete(`article:${id}`); // invalidate on write
    return updated;
  }
}

CacheStore is your own interface — implement it with an in-memory Map for a single-process deployment, or a Redis client for anything that scales to more than one process (see Scaling for why a single process's in-memory cache doesn't work once you run multiple instances). The route handler stays thin and calls one service method; it never talks to the cache directly — that's the same "business logic out of the handler" rule the project applies everywhere else.

router.get('/articles/:id', async (ctx) => {
  const article = await articleService.getArticle(ctx.params.id);
  ctx.json(article);
});

Invalidation strategy

Pick one deliberately — an unstated invalidation strategy is a bug waiting to surface as stale data in production:

  • TTL-only — simplest. Accept that data can be stale for up to the TTL window. Fine for data where staleness has low cost (a homepage list, a public catalog).
  • Write-through invalidation — the mutation that changes the data also deletes or updates the cache entry (as in updateArticle above). Needed wherever staleness after a write would be user-visible and confusing (e.g., a user's own profile).
  • Event-driven invalidation — a write in one service publishes an event that other services/processes use to invalidate their own cache entries. Needed once caching spans more than one process, since TTL-only or write-through within a single process's memory won't reach the other instances.

Next steps

Was this helpful?

On this page