Back to blog

Building a Fast Local Cache with Node.js IPC Sockets

·6 min read
nodejsnextjscachingperformanceipc

If you've ever tried to implement a simple in-memory cache in Next.js, you've likely run into a frustrating problem: your cache keeps getting wiped. This happens because Next.js reloads modules during development (and in some production configurations), destroying any state you've stored in module scope.

In this post, I'll show you how to solve this using Unix domain sockets for inter-process communication (IPC), creating a persistent cache that lives outside your Next.js process.

The Problem with Module-Level Caching

You might think this would work:

// lib/cache.ts
const cache = new Map<string, any>();
 
export function get(key: string) {
  return cache.get(key);
}
 
export function set(key: string, value: any) {
  cache.set(key, value);
}

It won't. Next.js (especially with Turbopack or in development mode) reloads modules frequently. Each reload creates a fresh Map, and your cached data vanishes.

The "singleton pattern" tricks you might find online don't reliably work either:

// This is NOT reliable in Next.js
const globalCache = global as typeof globalThis & { cache?: Map<string, any> };
globalCache.cache = globalCache.cache || new Map();

Even global can get reset depending on how Next.js handles the module graph.

The Solution: External Cache Process

The fix is to move the cache outside the Next.js process entirely. We'll create a small daemon that:

  1. Runs as a separate process
  2. Stores cache data in memory
  3. Communicates via Unix domain sockets (fast, no network overhead)

Why Unix Domain Sockets?

  • Speed: No TCP/IP stack overhead, just direct kernel-level IPC
  • Simplicity: No ports to manage, just a file path
  • Security: Socket file permissions control access
  • Reliability: The OS handles connection management

Implementation

The Cache Server

First, let's create a simple cache server:

// cache-server.ts
import { createServer, Socket } from 'net';
import { existsSync, unlinkSync } from 'fs';
 
const SOCKET_PATH = '/tmp/app-cache.sock';
const cache = new Map<string, { value: any; expires: number }>();
 
// Clean up stale socket file
if (existsSync(SOCKET_PATH)) {
  unlinkSync(SOCKET_PATH);
}
 
const server = createServer((socket: Socket) => {
  socket.on('data', (data) => {
    try {
      const { action, key, value, ttl } = JSON.parse(data.toString());
      let response: any;
 
      switch (action) {
        case 'get': {
          const entry = cache.get(key);
          if (entry && entry.expires > Date.now()) {
            response = { ok: true, value: entry.value };
          } else {
            if (entry) cache.delete(key); // Clean expired
            response = { ok: true, value: null };
          }
          break;
        }
        case 'set': {
          const expires = Date.now() + (ttl || 60000); // Default 60s TTL
          cache.set(key, { value, expires });
          response = { ok: true };
          break;
        }
        case 'delete': {
          cache.delete(key);
          response = { ok: true };
          break;
        }
        case 'clear': {
          cache.clear();
          response = { ok: true };
          break;
        }
        case 'stats': {
          response = { ok: true, size: cache.size };
          break;
        }
        default:
          response = { ok: false, error: 'Unknown action' };
      }
 
      socket.write(JSON.stringify(response));
    } catch (err) {
      socket.write(JSON.stringify({ ok: false, error: 'Parse error' }));
    }
  });
});
 
server.listen(SOCKET_PATH, () => {
  console.log(`Cache server listening on ${SOCKET_PATH}`);
});
 
// Graceful shutdown
process.on('SIGTERM', () => {
  server.close();
  if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
  process.exit(0);
});

The Cache Client

Now the client that your Next.js app will use:

// lib/ipc-cache.ts
import { createConnection, Socket } from 'net';
 
const SOCKET_PATH = '/tmp/app-cache.sock';
 
function sendCommand(command: object): Promise<any> {
  return new Promise((resolve, reject) => {
    const socket: Socket = createConnection(SOCKET_PATH);
    let data = '';
 
    socket.on('connect', () => {
      socket.write(JSON.stringify(command));
    });
 
    socket.on('data', (chunk) => {
      data += chunk.toString();
    });
 
    socket.on('end', () => {
      try {
        const response = JSON.parse(data);
        if (response.ok) {
          resolve(response.value ?? response);
        } else {
          reject(new Error(response.error));
        }
      } catch {
        reject(new Error('Invalid response'));
      }
    });
 
    socket.on('error', (err) => {
      reject(err);
    });
 
    // Timeout after 1 second
    setTimeout(() => {
      socket.destroy();
      reject(new Error('Cache timeout'));
    }, 1000);
  });
}
 
export const ipcCache = {
  async get<T>(key: string): Promise<T | null> {
    try {
      return await sendCommand({ action: 'get', key });
    } catch {
      return null; // Graceful fallback
    }
  },
 
  async set(key: string, value: any, ttl?: number): Promise<void> {
    try {
      await sendCommand({ action: 'set', key, value, ttl });
    } catch {
      // Silent fail - cache is optional
    }
  },
 
  async delete(key: string): Promise<void> {
    try {
      await sendCommand({ action: 'delete', key });
    } catch {
      // Silent fail
    }
  },
 
  async clear(): Promise<void> {
    try {
      await sendCommand({ action: 'clear' });
    } catch {
      // Silent fail
    }
  },
};

Usage in Next.js

Now you can use it anywhere in your Next.js app:

// app/api/data/route.ts
import { ipcCache } from '@/lib/ipc-cache';
 
export async function GET() {
  const cacheKey = 'expensive-data';
 
  // Try cache first
  const cached = await ipcCache.get<ExpensiveData>(cacheKey);
  if (cached) {
    return Response.json(cached);
  }
 
  // Fetch fresh data
  const data = await fetchExpensiveData();
 
  // Cache for 5 minutes
  await ipcCache.set(cacheKey, data, 5 * 60 * 1000);
 
  return Response.json(data);
}

Running the Cache Server

Add a script to your package.json:

{
  "scripts": {
    "cache-server": "tsx cache-server.ts",
    "dev": "concurrently \"npm run cache-server\" \"next dev\""
  }
}

Or run it as a background process:

# Start the cache server
node --import tsx cache-server.ts &
 
# Start Next.js
pnpm dev

Performance Considerations

Connection Pooling

For high-throughput scenarios, you might want to maintain persistent connections:

// lib/ipc-cache-pooled.ts
import { createConnection, Socket } from 'net';
 
const SOCKET_PATH = '/tmp/app-cache.sock';
const pool: Socket[] = [];
const POOL_SIZE = 5;
 
function getConnection(): Promise<Socket> {
  const socket = pool.pop();
  if (socket && !socket.destroyed) {
    return Promise.resolve(socket);
  }
 
  return new Promise((resolve, reject) => {
    const newSocket = createConnection(SOCKET_PATH);
    newSocket.on('connect', () => resolve(newSocket));
    newSocket.on('error', reject);
  });
}
 
function releaseConnection(socket: Socket) {
  if (!socket.destroyed && pool.length < POOL_SIZE) {
    pool.push(socket);
  } else {
    socket.destroy();
  }
}

Binary Protocol

For even better performance, consider using a binary protocol instead of JSON:

// Simple length-prefixed binary protocol
function encodeMessage(obj: object): Buffer {
  const json = JSON.stringify(obj);
  const length = Buffer.byteLength(json);
  const buffer = Buffer.alloc(4 + length);
  buffer.writeUInt32BE(length, 0);
  buffer.write(json, 4);
  return buffer;
}

Alternatives Considered

Redis

Redis is the obvious choice for external caching, but it:

  • Requires running another service
  • Has network overhead (even on localhost)
  • Is overkill for simple use cases

SQLite

SQLite could work for persistence, but:

  • Disk I/O is slower than memory
  • Requires file locking management
  • More complex setup for simple key-value storage

Shared Memory

Node.js doesn't have great native shared memory support. Libraries exist, but they're:

  • Platform-specific
  • Complex to set up correctly
  • Often require native modules

Unix domain sockets hit the sweet spot: fast, simple, and universally supported.

Conclusion

When you need persistent caching in Next.js that survives module reloads, moving the cache to a separate process via Unix domain sockets is an elegant solution. It's fast (kernel-level IPC), simple (just a socket file), and reliable (process isolation).

The implementation above is intentionally minimal. For production use, you might want to add:

  • Health checks
  • Automatic reconnection
  • Cache eviction policies (LRU, etc.)
  • Metrics and logging
  • Clustering support

But for many use cases, this simple approach is all you need.