Memcached #
Memcached is an in-memory cache born from one philosophy: do one thing very well. No complex data structures, no persistence, no pub/sub — just a fast, lightweight, easily horizontally scalable key-value store. This minimalist design isn’t a weakness, it’s a strength. With a multi-threaded architecture that uses all CPU cores and a very simple protocol, Memcached handles very high throughput with very little overhead. In the TypeScript ecosystem, the memjs library provides a clean interface with built-in multi-server support and consistent hashing — you can add cache nodes without manually changing the client configuration.
Installation #
npm install memjs
npm install --save-dev @types/memjs
To run Memcached locally via Docker:
docker run -d --name memcached -p 11211:11211 memcached:1.6-alpine
Verify the server is running with telnet:
telnet localhost 11211
# type: stats
# type: quit
Memcached Architecture and How It Works #
Before writing code, it’s important to understand how Memcached manages memory and distributes data — because this directly affects design decisions in your application.
The Slab Allocator #
Memcached doesn’t allocate memory dynamically per item. It divides memory into slabs — fixed-size blocks. Each slab holds items whose sizes fit within its class.
Slab Class 1: item size 1 – 96 bytes
Slab Class 2: item size 97 – 120 bytes
Slab Class 3: item size 121 – 152 bytes
...
Slab Class 42: item size up to 1 MB
The implication: if you often store 100-byte items, slab class 2 will fill up while other slabs sit empty. Memory can’t be borrowed between slabs. This is a deliberate trade-off to avoid memory fragmentation.
LRU Eviction #
When memory is full and a new item wants to be stored, Memcached removes the Least Recently Used item from the same slab class. The removed item gives no notification whatsoever — applications must be ready to face a cache miss at any time.
flowchart TD
A[SET new item] --> B{Enough memory\nin the slab class?}
B -- Yes --> C[Store the item]
B -- No --> D{Any expired items\nin this slab class?}
D -- Yes --> E[Remove the expired item]
D -- No --> F[Remove the LRU item\nin this slab class]
E --> C
F --> CKey Distribution on Multi-Server #
Memcached has no built-in replication mechanism — every server is an independent node. The client is responsible for deciding which server stores a key, using consistent hashing.
flowchart LR
A[Key: produk:1001] --> B[Hash Function]
B --> C{Consistent\nHash Ring}
C --> D[Server A\n192.168.1.1:11211]
C --> E[Server B\n192.168.1.2:11211]
C --> F[Server C\n192.168.1.3:11211]With consistent hashing, adding or removing one server only redistributes a small fraction of keys (around 1/N of the total), not all of them. The memjs library implements this natively.
Connecting to Memcached #
import MemJS from "memjs";
// connect to a single server
const client = MemJS.Client.create("localhost:11211", {
failover: true, // try the next server if one fails
timeout: 1, // connection timeout in seconds
keepAlive: true, // keep the TCP connection alive
keepAliveDelay: 30, // send keepalive every 30 seconds
retries: 2, // retry count when a request fails
});
// connect to several servers (automatic consistent hashing)
const clientCluster = MemJS.Client.create(
"192.168.1.1:11211,192.168.1.2:11211,192.168.1.3:11211",
{
failover: true,
timeout: 1,
keepAlive: true,
}
);
// connect via environment variable — the common pattern in production
const clientFromEnv = MemJS.Client.create(
process.env.MEMCACHED_SERVERS ?? "localhost:11211",
{
username: process.env.MEMCACHED_USERNAME,
password: process.env.MEMCACHED_PASSWORD,
timeout: 1,
failover: true,
}
);
// close the connection when the application stops
process.on("SIGTERM", () => {
client.quit();
});
A Type-Safe Wrapper #
memjs returns a Buffer for all values — you need to serialize and deserialize manually. Create a thin wrapper to handle this:
import MemJS from "memjs";
class MemcachedClient {
private client: MemJS.Client;
constructor(servers: string, options?: MemJS.ClientOptions) {
this.client = MemJS.Client.create(servers, {
failover: true,
timeout: 1,
keepAlive: true,
...options,
});
}
async get<T>(key: string): Promise<T | null> {
const hasil = await this.client.get(key);
if (hasil.value === null) return null;
try {
return JSON.parse(hasil.value.toString()) as T;
} catch {
// the value isn't JSON (e.g. a plain string)
return hasil.value.toString() as unknown as T;
}
}
async set(key: string, value: unknown, ttlDetik: number = 0): Promise<boolean> {
const serialized = typeof value === "string" ? value : JSON.stringify(value);
return this.client.set(key, serialized, { expires: ttlDetik });
}
async add(key: string, value: unknown, ttlDetik: number = 0): Promise<boolean> {
// add only succeeds if the key DOESN'T exist — atomic
const serialized = typeof value === "string" ? value : JSON.stringify(value);
return this.client.add(key, serialized, { expires: ttlDetik });
}
async replace(key: string, value: unknown, ttlDetik: number = 0): Promise<boolean> {
// replace only succeeds if the key ALREADY exists — atomic
const serialized = typeof value === "string" ? value : JSON.stringify(value);
return this.client.replace(key, serialized, { expires: ttlDetik });
}
async delete(key: string): Promise<boolean> {
return this.client.delete(key);
}
async increment(key: string, delta: number = 1): Promise<number | null> {
const hasil = await this.client.increment(key, delta);
return hasil.value;
}
async decrement(key: string, delta: number = 1): Promise<number | null> {
const hasil = await this.client.decrement(key, delta);
return hasil.value;
}
async flush(): Promise<void> {
await this.client.flush();
}
quit(): void {
this.client.quit();
}
}
// singleton instance
let memcached: MemcachedClient;
export function getMemcached(): MemcachedClient {
if (!memcached) {
memcached = new MemcachedClient(
process.env.MEMCACHED_SERVERS ?? "localhost:11211"
);
}
return memcached;
}
Basic Operations #
Set and Get #
set stores a value with a given key. The third parameter is the TTL in seconds — a value of 0 means no expiration (the item survives until evicted or explicitly deleted).
const cache = getMemcached();
// store a simple string
await cache.set("app:status", "online", 60);
// store an object — automatically serialized to JSON
interface KonfigurasiApp {
tema: string;
bahasa: string;
batasUpload: number;
}
await cache.set(
"konfigurasi:global",
{ tema: "dark", bahasa: "id", batasUpload: 10485760 } satisfies KonfigurasiApp,
3600 // cache for 1 hour
);
// get a value
const status = await cache.get<string>("app:status");
// status = "online" or null if expired/missing
const config = await cache.get<KonfigurasiApp>("konfigurasi:global");
// config = { tema: "dark", ... } or null
Add and Replace — Conditional Operations #
add and replace are atomic operations useful for avoiding race conditions.
// add — ONLY succeeds if the key doesn't exist
// useful for initializing counters or as a simple lock primitive
const berhasil = await cache.add("init:migrasi-v2", "running", 300);
if (berhasil) {
// only one process gets here
await jalankanMigrasi();
await cache.delete("init:migrasi-v2");
} else {
console.log("Migration is already running in another process");
}
// replace — ONLY succeeds if the key already exists
// useful for updating a value you know is already in the cache
const diperbarui = await cache.replace("konfigurasi:global", konfigBaru, 3600);
if (!diperbarui) {
// the key has expired or been deleted — needs a fresh set
await cache.set("konfigurasi:global", konfigBaru, 3600);
}
Increment and Decrement #
Atomic counters — safe to use from several processes at once without worrying about race conditions.
// IMPORTANT: the key must be initialized with a numeric string value
// before it can be incremented
await cache.set("counter:page-view:home", "0");
// increment by 1 (default)
const viewCount = await cache.increment("counter:page-view:home");
console.log("Total views:", viewCount); // 1
// increment by N
await cache.increment("counter:page-view:home", 5); // 6
// decrement
await cache.decrement("counter:stok:produk-1001", 1);
// ANTI-PATTERN: implementing a counter with GET + SET
async function tambahViewSalah(key: string): Promise<void> {
const current = await cache.get<number>(key); // ✗ race condition here
const next = (current ?? 0) + 1; // two processes can read the same value
await cache.set(key, next); // then both store the same value
}
// CORRECT: use the atomic increment
async function tambahView(key: string): Promise<number | null> {
return cache.increment(key); // ✓ atomic, safe for concurrent access
}
Caching Patterns #
Cache-Aside (Lazy Loading) #
The most common and safest pattern — the application manages the cache explicitly.
async function ambilArtikel(id: string): Promise<Artikel | null> {
const key = `artikel:${id}`;
// 1. try the cache
const cached = await cache.get<Artikel>(key);
if (cached !== null) {
return cached;
}
// 2. cache miss — fetch from the database
const artikel = await db.findById("artikel", id);
if (!artikel) return null;
// 3. store in the cache
await cache.set(key, artikel, 1800); // cache for 30 minutes
return artikel;
}
async function updateArtikel(id: string, data: Partial<Artikel>): Promise<void> {
await db.update("artikel", id, data);
await cache.delete(`artikel:${id}`); // invalidate the cache
}
Stampede Protection — the Dog-pile Effect #
A cache stampede happens when many requests arrive at the same time the cache expires. All requests hit the database simultaneously.
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant R3 as Request 3
participant C as Cache
participant DB as Database
R1->>C: GET artikel:1
R2->>C: GET artikel:1
R3->>C: GET artikel:1
C-->>R1: null (expired)
C-->>R2: null (expired)
C-->>R3: null (expired)
R1->>DB: SELECT artikel WHERE id=1
R2->>DB: SELECT artikel WHERE id=1
R3->>DB: SELECT artikel WHERE id=1
Note over DB: ⚠️ 3 identical queries at onceThe solution is probabilistic early expiration or lock-based fetch:
// a simple lock pattern with add() to prevent stampedes
async function ambilDenganLock<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>
): Promise<T> {
// try the cache
const cached = await cache.get<T>(key);
if (cached !== null) return cached;
const lockKey = `lock:${key}`;
// try to acquire the lock — add() is atomic, only one succeeds
const dapatLock = await cache.add(lockKey, "1", 10); // lock for 10 seconds
if (dapatLock) {
// this process fetches from the DB
try {
const data = await fetchFn();
if (data !== null && data !== undefined) {
await cache.set(key, data, ttl);
}
return data;
} finally {
await cache.delete(lockKey);
}
} else {
// another process is fetching — wait briefly then try the cache again
await new Promise((resolve) => setTimeout(resolve, 50));
const hasil = await cache.get<T>(key);
if (hasil !== null) return hasil;
// if still null (lock timeout), fetch directly
return fetchFn();
}
}
// usage
const artikel = await ambilDenganLock(
`artikel:${id}`,
1800,
() => db.findById("artikel", id)
);
Namespaces and Mass Key Invalidation #
Memcached doesn’t support KEYS pattern* operations like Redis. To invalidate a group of keys at once, use the namespace versioning technique:
class NamespacedCache {
private cache: MemcachedClient;
constructor(cache: MemcachedClient) {
this.cache = cache;
}
// get the current namespace version
private async getNamespaceVersion(namespace: string): Promise<number> {
const versi = await this.cache.get<number>(`ns:${namespace}`);
if (versi !== null) return versi;
// initialize the version if it doesn't exist
await this.cache.set(`ns:${namespace}`, 1, 0); // no TTL for the namespace
return 1;
}
// build a key with the namespace + version
async buildKey(namespace: string, key: string): Promise<string> {
const versi = await this.getNamespaceVersion(namespace);
return `${namespace}:v${versi}:${key}`;
}
async get<T>(namespace: string, key: string): Promise<T | null> {
const fullKey = await this.buildKey(namespace, key);
return this.cache.get<T>(fullKey);
}
async set(namespace: string, key: string, value: unknown, ttl: number): Promise<void> {
const fullKey = await this.buildKey(namespace, key);
await this.cache.set(fullKey, value, ttl);
}
// invalidate the whole namespace by incrementing the version
// all old keys are automatically unreachable (different version)
// and will be evicted by LRU naturally
async invalidasiNamespace(namespace: string): Promise<void> {
await this.cache.increment(`ns:${namespace}`);
// if the namespace key doesn't exist, increment will fail
// reset it if necessary
const versi = await this.cache.get<number>(`ns:${namespace}`);
if (versi === null) {
await this.cache.set(`ns:${namespace}`, 1, 0);
}
}
}
const nsCache = new NamespacedCache(cache);
// store all articles with the "artikel" namespace
await nsCache.set("artikel", "1001", dataArtikel, 1800);
await nsCache.set("artikel", "1002", dataArtikel2, 1800);
// invalidate ALL articles at once with a single command
// (increment the namespace version — old keys are automatically unreachable)
await nsCache.invalidasiNamespace("artikel");
Multi-Level Caching #
For very high traffic applications, you can combine an in-process cache (Node.js memory) with Memcached. This reduces network latency for the most frequently accessed data.
flowchart LR
A[Request] --> B{L1 Cache\nIn-Process Memory}
B -- Hit --> G[Response]
B -- Miss --> C{L2 Cache\nMemcached}
C -- Hit --> F[Fill L1 Cache]
F --> G
C -- Miss --> D[(Database)]
D --> E[Fill L2 Cache]
E --> F// L1 cache: in-process with a Map and simple TTL
class InProcessCache {
private store = new Map<string, { value: unknown; expiresAt: number }>();
get<T>(key: string): T | null {
const item = this.store.get(key);
if (!item) return null;
if (Date.now() > item.expiresAt) {
this.store.delete(key);
return null;
}
return item.value as T;
}
set(key: string, value: unknown, ttlMs: number): void {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
delete(key: string): void {
this.store.delete(key);
}
}
class MultiLevelCache {
private l1 = new InProcessCache();
private l2: MemcachedClient;
constructor(memcached: MemcachedClient) {
this.l2 = memcached;
}
async get<T>(key: string): Promise<T | null> {
// L1: in-process (sub-millisecond)
const fromL1 = this.l1.get<T>(key);
if (fromL1 !== null) return fromL1;
// L2: Memcached (1-2ms over local network)
const fromL2 = await this.l2.get<T>(key);
if (fromL2 !== null) {
// fill L1 with a shorter TTL
this.l1.set(key, fromL2, 10_000); // 10 seconds in L1
return fromL2;
}
return null;
}
async set(key: string, value: unknown, ttlDetik: number): Promise<void> {
// write to both layers
this.l1.set(key, value, Math.min(ttlDetik * 1000, 10_000)); // max 10 seconds in L1
await this.l2.set(key, value, ttlDetik);
}
async delete(key: string): Promise<void> {
this.l1.delete(key);
await this.l2.delete(key);
}
}
const multiCache = new MultiLevelCache(cache);
// transparent usage — clients don't need to know there are two layers
async function ambilProdukCepat(id: string): Promise<Produk | null> {
const key = `produk:${id}`;
const cached = await multiCache.get<Produk>(key);
if (cached) return cached;
const produk = await db.findById("produk", id);
if (produk) {
await multiCache.set(key, produk, 300); // 5 minutes in Memcached, 10 seconds in memory
}
return produk;
}
Monitoring and Stats #
Memcached provides detailed statistics useful for monitoring cache health and diagnosing performance issues.
async function cekStatsMemcached(): Promise<void> {
// memjs provides access to stats via the internal client
// for full stats, we need direct TCP access or use the client's stats
// alternative approach: connect via the net module to read stats
const net = await import("net");
return new Promise((resolve, reject) => {
const socket = net.createConnection(11211, "localhost");
let data = "";
socket.on("connect", () => {
socket.write("stats\r\n");
});
socket.on("data", (chunk) => {
data += chunk.toString();
if (data.includes("END\r\n")) {
socket.end();
}
});
socket.on("end", () => {
const stats: Record<string, string> = {};
data.split("\r\n").forEach((line) => {
const parts = line.split(" ");
if (parts[0] === "STAT") {
stats[parts[1]] = parts[2];
}
});
// the most important metrics
const hitRate =
(Number(stats.get_hits) /
(Number(stats.get_hits) + Number(stats.get_misses))) *
100;
console.log("=== Memcached Stats ===");
console.log(`Uptime: ${stats.uptime} seconds`);
console.log(`Memory used: ${(Number(stats.bytes) / 1024 / 1024).toFixed(2)} MB`);
console.log(`Max memory: ${(Number(stats.limit_maxbytes) / 1024 / 1024).toFixed(2)} MB`);
console.log(`Hit rate: ${hitRate.toFixed(2)}%`);
console.log(`Cache hits: ${stats.get_hits}`);
console.log(`Cache misses: ${stats.get_misses}`);
console.log(`Evictions: ${stats.evictions}`);
console.log(`Total items: ${stats.curr_items}`);
console.log(`Active connections: ${stats.curr_connections}`);
console.log(`Total get operations: ${stats.cmd_get}`);
console.log(`Total set operations: ${stats.cmd_set}`);
resolve();
});
socket.on("error", reject);
});
}
Metrics to watch:
| Metric | Healthy Condition | Action if Problematic |
|---|---|---|
| Hit rate | > 80% | Review TTLs, add memory, or improve the caching strategy |
| Evictions | Close to 0 | Add memory capacity or reduce item sizes |
| Curr connections | Stable | Check the connection pool on the application side |
| Bytes / limit_maxbytes | < 90% | Add memory if approaching the limit |
Memcached vs Redis Comparison #
Choosing between Memcached and Redis is a decision that comes up often. Both are mature caching solutions, but they have different characteristics.
| Feature | Memcached | Redis |
|---|---|---|
| Data structures | Strings only | String, Hash, List, Set, Sorted Set, Stream |
| Threading | Multi-threaded | Single-threaded (with async I/O) |
| Persistence | None | RDB snapshot + AOF log |
| Pub/Sub | None | Yes |
| Transactions | None | Yes (MULTI/EXEC) |
| Scripting | None | Lua scripts |
| Cluster | Client-side distribution | Built-in Redis Cluster |
| Memory overhead | Very low | Higher (metadata per key) |
| Max value size | 1 MB | 512 MB |
| Primary use | Pure caching | Caching + other use cases |
Choose Memcached if:
✓ The need is pure caching without extra features
✓ Cached data is strings or simple objects
✓ Need multi-threading to use all CPU cores
✓ Memory overhead must be minimal
✓ A Memcached infrastructure is already running well
✓ Very simple horizontal scalability (add a node, done)
Choose Redis if:
✗ Need data structures like sorted sets for leaderboards
✗ Need pub/sub for inter-service communication
✗ Need a session store with independent field operations (Hash)
✗ Need robust distributed locks (atomic Lua scripts)
✗ Cached data must survive restarts (persistence)
✗ Need sliding window rate limiting (Sorted Sets)
Error Handling #
memjs throws errors for connection problems, but returns null for cache misses — the two need to be distinguished.
async function getAman<T>(key: string): Promise<T | null> {
try {
return await cache.get<T>(key);
// returns null for a cache miss — not an error
} catch (error) {
// network error or server unavailable
if (error instanceof Error) {
console.error(`Memcached error for key "${key}":`, error.message);
}
// return null so the application can fall back to the database
return null;
}
}
async function setAman(key: string, value: unknown, ttl: number): Promise<void> {
try {
await cache.set(key, value, ttl);
} catch (error) {
// failed to store in the cache — log but don't fail the main operation
// the application keeps running, just without a cache temporarily
console.error(`Failed to store in Memcached (key: "${key}"):`, error);
}
}
// a wrapper that makes the cache completely optional
// if Memcached dies, the application still works via the database
async function withFallback<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>
): Promise<T> {
// try the cache — if it errors, continue to fetchFn
const cached = await getAman<T>(key);
if (cached !== null) return cached;
const data = await fetchFn();
// store in the cache fire-and-forget — don't block the response
setAman(key, data, ttl).catch((err) => {
console.error("Cache write failed (ignored):", err);
});
return data;
}
Don’t let a Memcached failure fail a user’s request. The cache is an optional layer — if the cache is unavailable, the application must keep working by going straight to the database (degraded gracefully). Always catch Memcached errors and return null or continue without the cache.
When to Use Memcached #
Use Memcached for:
✓ Caching expensive database query results (reports, aggregations, complex joins)
✓ Caching slow external API responses
✓ Caching server-rendered pages or HTML fragments
✓ Storing rarely changing application configuration
✓ Simple counters with atomic increment/decrement
✓ Applications that already run Memcached and don't need Redis features
Don't use Memcached for:
✗ Session management — no hash structure for independent field updates
✗ Leaderboards or rankings — no sorted sets
✗ Pub/sub or message passing between services
✗ Data that must not be lost — no persistence
✗ Items larger than 1 MB — there's a value size limit
✗ Pattern-based mass invalidation — no SCAN like Redis
Summary #
- Memcached is a pure cache — no persistence, no complex data structures, no pub/sub. This minimalist design produces high throughput with very low memory overhead.
- The slab allocator and LRU eviction are Memcached’s foundation — understand that eviction can happen any time memory is full; applications must always be ready for cache misses.
- Consistent hashing allows adding server nodes without massive redistribution —
memjshandles this automatically from the server list you provide.add()andreplace()are useful atomic operations — useadd()for initialization or a primitive lock,replace()to update an existing value without the risk of creating a new key.- Namespace versioning is the way to invalidate a group of keys at once in Memcached — increment the namespace version so all old keys become automatically unreachable without deleting them one by one.
- Multi-level caching (in-process + Memcached) can significantly reduce latency for very frequently accessed data — use a shorter TTL in L1 to minimize stale data.
- Always catch Memcached errors and return null — the cache is an optional layer. A Memcached failure must not fail a user request; falling back to the database is the correct behavior.
- Monitor hit rate and evictions regularly — a hit rate below 80% indicates TTLs are too short or memory capacity is insufficient; high evictions are a signal to add memory.