Skip to main content

Command Palette

Search for a command to run...

Introducing @platformatic/memcached

A fast, zero-dependency memcached client for Node.js

Updated
10 min readView as Markdown
Introducing @platformatic/memcached

memcached has been a reliable cache for more than twenty years and still competes with newer options. It’s a simple, in-memory key/value store with very low latency, no background threads, and just one binary to deploy. However, most Node.js clients for memcached are outdated. Many were built before the current protocol, are no longer maintained, and don’t make the most of memcached’s performance.

We’ve been running memcached at Platformatic and noticed the clients we relied on were the slowest link. So we built our own: @platformatic/memcached, a minimal, high-performance client for Node.js built on the meta text protocol, with zero runtime dependencies and full request pipelining. In our benchmarks, it does over 350,000 SETs and 369,000 GETs per second on a single connection; roughly 3x faster than the fastest client we compared it against.


Why the meta protocol

memcached ships three wire protocols, and most Node.js clients use the wrong one.

Most older clients use the classic text protocol, which is wordy and requires parsing each response line by line. Its responses can be unclear: NOT_FOUND, ERROR, and CLIENT_ERROR are only identified by matching strings, so a typo or new error message can cause your client to lose sync without warning. The binary protocol is now **deprecated and isn’**t recommended for new projects.

The meta protocol, added in memcached 1.6, was made to replace both older protocols. It uses compact, single-line commands and responses, clear flags, and length-prefixed data blocks, so values with \r\n are handled safely. Every command, even delete, supports CAS, and the server returns opaque tokens so the client can match responses correctly. This is the recommended protocol, and our client uses it exclusively, supporting the meta commands mg, ms, md, ma, mn, as well as version and stats for health checks and observability.

import { Client } from '@platformatic/memcached'

const client = new Client('localhost:11211')

await client.set('greeting', 'hello', { ttl: 60 })
const value = await client.get('greeting')
console.log(value.toString()) // 'hello'

await client.close()

The same design that made @platformatic/kafka fast

We’ve done this before with @platformatic/kafka, which uses the same performance strategies for Kafka. That includes full request pipelining on a single connection, incremental buffer-based parsing, and combining socket writes into writev calls. Our memcached client uses the same approach.

Full request pipelining: memcached handles commands in order on each connection, so responses are matched using a FIFO queue of pending operations. There’s no need for per-request locking. You can send commands at the same time (for example, with Promise.all), and they’ll share socket writes and network trips.

import { Client } from '@platformatic/memcached'

const client = new Client('localhost:11211')

await client.set('greeting', 'hello', { ttl: 60 })
const value = await client.get('greeting')
console.log(value.toString()) // 'hello'

await client.close()

There’s no need for an extra batching layer here.

Opaque-token verification: Each command includes an opaque token (O flag) that the server sends back. The client checks this token on every response, so if the protocol gets out of sync, it’s caught right away instead of returning incorrect data. This is what separates a fast client from one that’s fast but sometimes wrong.

Incremental, Buffer-based parsing: Partial frames are handled across TCP chunks, and value bytes are read by length, not by scanning. If a value contains \r\n (the protocol’s frame delimiter), it’s still safe because the client doesn’t look for it inside the value.

Coalesced socket writes: Commands sent in the same synchronous block are grouped and sent together as a single writev system call. The autoPipelining option lets you control when flushing happens. The default, 'microtask', flushes at the next microtask checkpoint for low latency on light traffic. The 'tick' option flushes at the end of the event loop, like ioredis, so many concurrent handlers can combine their gets into one system call.

Zero runtime dependencies: The client only uses node:net. There are no extra dependencies to audit, no supply-chain risks, and no version conflicts.

The source code is written in a subset of TypeScript that can be erased, and it runs directly on Node.js after type stripping during development. The published package includes plain JavaScript and declaration files, so you just need Node.js version 22.12.0 or newer to use it.


The API

The client is intentionally simple, but it includes everything you need for a production cache:

  • get / gets: read a value, or read it with its CAS token for optimistic concurrency

  • set, add, cas: unconditional store, set-if-not-exists, and compare-and-swap, returning false on conflict instead of throwing

  • delete: optionally CAS-guarded, so the classic lock pattern is safe

  • incr / decr: atomic counters, returning the new value as a bigint

  • noop, version, stats: health checks and observability, with a fleet-wide statsAll() that never rejects on a single node failure

Here’s the lock pattern, using set-if-not-exists plus a token-checked unlock:

const acquired = await client.add('lock:job', 'my-token', { ttl: 30 })
if (acquired) {
 try {
   // ... critical section ...
 } finally {
   const current = await client.gets('lock:job')
   if (current?.value.toString() === 'my-token') {
     await client.delete('lock:job', { cas: current.cas })
   }
 }
}

Values are Buffers in and out; no implicit serialization, so you decide what goes in. Keys are printable ASCII without whitespace, at most 250 bytes.


Performance

Here’s SET/GET throughput against the notable Node.js memcached clients, each running with its best-known configuration. This client and memjs pipeline on a single connection; memcache-client and the 3rd-Eden memcached client get a 10-connection pool because they don’t pipeline the same way. Median of 3 runs, 50,000 operations at concurrency 500 against memcached:alpine on loopback, Node.js 24.

The exact numbers will vary depending on your machine, and you can reproduce them using node packages/memcached/benchmarks/compare-all.js. The main takeaway is that pipelining a single connection outperforms using a pool of ten connections. Older clients try to solve this with more hardware, but a better parser fixes it without extra cost.


Production features

The real test for a cache client is how it performs in real-world situations, so we made sure this one covers all the important features.

Client-side sharding: memcached doesn’t have a server-side clustering protocol, so each node is independent and sharding happens on the client. You can pass an array of addresses, and keys are routed using ketama-style consistent hashing (160 points per node on a 32-bit md5 ring). This way, adding or removing a node only remaps about 1/N of the keyspace. Routing stays the same across processes and restarts. If a node goes down, commands for its keys are rejected while it reconnects, but other nodes keep working. Keys aren’t rehashed to other nodes, which avoids stale reads when the node returns.

ElastiCache auto discovery: With a static server list, you need to update the config and restart every process when the cluster grows. AWS ElastiCache provides a configuration endpoint that clients can poll for the current node list. You can use this as the server address, and the client will set up the ring from the first response, re-poll at intervals, and only apply newer topology versions. This way, a stale or repeated response can’t roll the cluster back. If the endpoint can’t be reached, the last known topology stays in place and polling continues.

TLS and authentication: Both memcacheds:// URLs and explicit node:tls options are supported, including per-certificate configuration. memcached’s ASCII authfile mode also works (memcached -Y /path/to/authfile), with credentials in options or the URL. The client authenticates as the first command on every connection, including automatic reconnections, before any queued command is sent. SASL is not supported, since it uses the deprecated binary protocol.

Connection pooling: The memcached protocol doesn’t support multiplexing, so responses always come back in the order they were requested. This means a pipelined connection can get blocked if one large value is being transferred, while smaller gets wait behind it. By setting poolSize, each command goes to the pool member with the fewest outstanding requests, so small operations can move around a busy connection. Pooling works with sharding, giving you poolSize connections per node, with keys still routed to nodes first.

Reconnection with exponential backoff: The constructor connects right away in the background. Commands sent before the connection is ready are queued and sent once connected. If there’s a socket error, in-flight commands are rejected with ConnectionError, since memcached may have partially processed them and automatic retry could be unsafe. The client reconnects automatically with exponential backoff. The close() method waits for in-flight commands to finish, then closes everything safely.


Observability built in

Every client provides metrics and diagnostics through two exporter-agnostic interfaces that have almost no overhead when not used.

client.metrics() returns a plain object with counters and gauges, such as commands issued, completed, or failed per wire verb, pending pipeline depth, socket writes and flushes, reconnects, and bytes read or written. This is a stable API, and a Prometheus setup can scrape it using a collect() callback.

Per-operation events are published on node:diagnostics_channel, so subscribers only pay when they subscribe. If there are no subscribers, the client skips all payload allocation. The companion package, @platformatic/memcached-otel, listens to these events and turns each command into a single CLIENT span using OpenTelemetry database conventions. There’s no need for module patching or monkey-patching, and it traces every client in the process, even those created before it was enabled.

import { MemcachedInstrumentation } from '@platformatic/memcached-otel'
import { registerInstrumentations } from '@opentelemetry/instrumentation'

registerInstrumentations({
 instrumentations: [new MemcachedInstrumentation()]
})

Keys can contain sensitive data, so diagnostics never include values. Keys are only included if the client is created with diagnosticsIncludeKeys:true.


Why we needed it

This client isn't a hobby. It backs the memcached storage adapter for Platformatic Gateway request deduplication, the feature we announced recently for collapsing concurrent duplicate reads before a burst reaches your application. Gateway deduplication coordinates in-flight requests with short-lived locks and response buffers, and memcached is the storage engine for that coordination when you want it distributed across replicas. We needed a client that could sustain that traffic without becoming the bottleneck, and none of the existing ones could.


An honest status

@platformatic/memcached is currently private and experimental. It powers the gateway’s memcached adapter, and the API may change before it’s released publicly. This is intentional—we want to make sure it’s stable with real workloads before finalizing it. The client is fully tested against real memcached servers using Docker, including auth, TLS, interop, and the compiled package. The engineering is production-grade; it’s just the API that’s still being refined.


Try it

If you use memcached and want a faster client, or if you’re building on the meta protocol and need a reference implementation, give this a try:

npm install @platformatic/memcached

You can find the code on GitHub at platformatic/memcached, including both the client and the OpenTelemetry instrumentation. Benchmark scripts are included, so you can test the numbers on your own hardware.

A fast, correct cache client matters because caches sit on the hot path of almost everything you serve. Pipelining one connection instead of pooling ten, parsing by length instead of scanning, and verifying responses with opaque tokens.

That’s the difference between a client that’s merely fast and one you can trust at 350,000 operations per second. If you hit an edge case or a rough spot, open an issue; real workloads are exactly what will shake out the remaining ones.