Arcwayv0.3.0
Cookbook

Caching

Cache-aside pattern, invalidation, per-user caches, and rate counters

Patterns for caching expensive computations and database queries.

Cache-Aside Pattern

Compute on miss, serve from cache on hit:

// api/dashboard/stats.js
export const GET = {
  handler: async (ctx) => {
    const { cache, db } = ctx;

    const stats = await cache.wrap(
      `dashboard:stats:${ctx.req.session.userId}`,
      async () => {
        const [users, posts, revenue] = await Promise.all([
          db('users').count('* as count').first(),
          db('posts').where('published', true).count('* as count').first(),
          db('orders').sum('total as sum').first(),
        ]);
        return {
          totalUsers: Number(users.count),
          publishedPosts: Number(posts.count),
          totalRevenue: Number(revenue.sum) || 0,
        };
      },
      '5m',
    );

    return { data: stats };
  },
};

Cache Invalidation on Mutation

Delete the cache key when the underlying data changes:

// api/posts/[id].js
export const PUT = {
  handler: async (ctx) => {
    const { db, cache } = ctx;
    const { id } = ctx.req.query;

    await db('posts').where('id', id).update(ctx.req.body);
    const updated = await db('posts').where('id', id).first();

    // Invalidate the cached version
    await cache.delete(`post:${id}`);

    return { data: updated };
  },
};

export const DELETE = {
  handler: async (ctx) => {
    const { db, cache } = ctx;
    const { id } = ctx.req.query;

    await db('posts').where('id', id).delete();
    await cache.delete(`post:${id}`);

    return { data: { ok: true } };
  },
};

Or invalidate from an event listener to keep cache logic out of route handlers:

// listeners/posts/updated.js
export default async (ctx) => {
  const { cache } = ctx;
  await cache.delete(`post:${ctx.event.payload.id}`);
};

Per-User Cache Keys

Scope cache keys to individual users to avoid data leaks:

// api/feed.js
export const GET = {
  handler: async (ctx) => {
    const { cache, db } = ctx;
    const userId = ctx.req.session.userId;

    const feed = await cache.wrap(
      `feed:${userId}`,
      async () => {
        return db('posts')
          .join('follows', 'posts.author_id', 'follows.following_id')
          .where('follows.follower_id', userId)
          .orderBy('posts.created_at', 'desc')
          .limit(50)
          .select('posts.*');
      },
      '1m',
    );

    return { data: feed };
  },
};

Short-Lived Rate Counters

Use the cache as a simple rate-limiting store with a TTL:

// api/sms/send.js
export const POST = {
  handler: async (ctx) => {
    const { cache } = ctx;
    const userId = ctx.req.session.userId;
    const key = `sms_sent:${userId}`;

    const count = (await cache.get(key)) || 0;
    if (count >= 5) {
      return { status: 429, error: { code: 'RATE_LIMITED', message: 'Too many SMS requests. Try again later.' } };
    }

    // Increment counter; set a 1-hour TTL on first hit
    await cache.set(key, count + 1, count === 0 ? '1h' : undefined);

    // ... send the SMS ...
    return { data: { ok: true } };
  },
};

On this page