Memcached #

Memcached is a very simple and very fast in-memory cache — designed with one goal: being an efficient key-value cache at large scale. Unlike feature-rich Redis with its many data types, Memcached only supports strings (max 1MB per value) with TTLs. This simplicity is its strength: Memcached scales horizontally with ease — add new nodes and clients automatically distribute data via consistent hashing without needing coordination between nodes. There’s no mature Dart package for Memcached, so this article builds a client from scratch using Socket — while also explaining how Memcached’s ASCII text-based protocol works.

Memcached vs Redis — Which to Choose? #

flowchart TD
    A{Caching needs?} --> B{Need complex\\ndata types?}
    B -- Yes\nHash/List/Set/SortedSet --> C[Redis]
    B -- No\nkey-value strings only --> D{Need data\\npersistence?}
    D -- Yes\nsurvive restarts --> C
    D -- No\ndata can be lost --> E{Need linear\\nhorizontal scaling?}
    E -- Yes\nmany cache nodes --> F[Memcached]
    E -- No\none/few nodes --> G{Need pub/sub\\nor scripting?}
    G -- Yes --> C
    G -- No --> H[Redis or Memcached\nboth are fine]
AspectMemcachedRedis
Data typesStrings onlyString, Hash, List, Set, Sorted Set, Stream
Persistence✗ none✓ RDB and AOF
Pub/Sub
Scripting✓ Lua
ClusteringLinear scaling, shared nothingCluster mode with coordination
Memory efficiencyMore efficient for stringsSlightly more wasteful
Multi-threading✓ (multi-core)Single-threaded (I/O multiplex)
Value limitMax 1MB per itemUp to 512MB
Best forPure caching, high scalabilityCache + additional features

The Memcached Protocol — ASCII Text Protocol #

Memcached uses a very simple ASCII text protocol — commands are sent as text strings over TCP:

SET:
  Request:  set <key> <flags> <exptime> <bytes>\r\n<value>\r\n
  Response: STORED\r\n

GET:
  Request:  get <key>\r\n
  Response: VALUE <key> <flags> <bytes>\r\n<value>\r\n
            END\r\n

DELETE:
  Request:  delete <key>\r\n
  Response: DELETED\r\n  or  NOT_FOUND\r\n

A Memcached Client from Scratch #

Since there’s no mature Dart package, we build a simple client using dart:io Socket:

import 'dart:io';
import 'dart:convert';

class MemcachedClient {
  final String host;
  final int port;
  Socket? _socket;
  final _buffer = StringBuffer();

  MemcachedClient({this.host = 'localhost', this.port = 11211});

  Future<void> hubungkan() async {
    _socket = await Socket.connect(host, port);
    _socket!.encoding = utf8;
    print('Connected to Memcached $host:$port');
  }

  Future<void> tutup() async {
    await _socket?.close();
    _socket = null;
  }

  // Send a command and wait for the response
  Future<String> _kirimPerintah(String perintah) async {
    if (_socket == null) throw StateError('Not connected to Memcached');

    _socket!.write(perintah);
    await _socket!.flush();

    // Read the response — wait until a complete line arrives
    final completer = Completer<String>();
    StreamSubscription<List<int>>? sub;

    sub = _socket!.listen((data) {
      final teks = utf8.decode(data);
      _buffer.write(teks);

      final respons = _buffer.toString();
      // Check whether the response is complete
      if (_responLengkap(respons)) {
        sub?.cancel();
        _buffer.clear();
        completer.complete(respons.trim());
      }
    });

    return completer.future.timeout(
      Duration(seconds: 5),
      onTimeout: () => throw TimeoutException('Memcached timeout'),
    );
  }

  bool _responLengkap(String respons) {
    // The response is complete if it ends with END, STORED, DELETED, etc.
    return respons.endsWith('END\r\n') ||
        respons.endsWith('STORED\r\n') ||
        respons.endsWith('NOT_STORED\r\n') ||
        respons.endsWith('DELETED\r\n') ||
        respons.endsWith('NOT_FOUND\r\n') ||
        respons.endsWith('ERROR\r\n') ||
        respons.endsWith('EXISTS\r\n');
  }

  // SET — store a value with a TTL
  // exptime: 0 = never expires, >0 = seconds, >2592000 = Unix timestamp
  Future<bool> set(String key, String value, {int exptime = 0, int flags = 0}) async {
    final bytes = utf8.encode(value).length;
    final perintah = 'set $key $flags $exptime $bytes\r\n$value\r\n';
    final respons = await _kirimPerintah(perintah);
    return respons == 'STORED';
  }

  // GET — fetch a value
  Future<String?> get(String key) async {
    final respons = await _kirimPerintah('get $key\r\n');

    if (respons.startsWith('END')) return null;  // doesn't exist

    // Parse the response: VALUE <key> <flags> <bytes>\r\n<value>\r\nEND
    final baris = respons.split('\r\n');
    if (baris.length < 3) return null;

    return baris[1];  // the second line is the value
  }

  // GETS — GET with a CAS token (for check-and-set)
  Future<({String? value, String? casToken})> gets(String key) async {
    final respons = await _kirimPerintah('gets $key\r\n');

    if (respons.startsWith('END')) return (value: null, casToken: null);

    final baris = respons.split('\r\n');
    final header = baris[0].split(' '); // VALUE <key> <flags> <bytes> <cas>
    return (
      value: baris[1],
      casToken: header.length > 4 ? header[4] : null,
    );
  }

  // DELETE — remove a key
  Future<bool> delete(String key) async {
    final respons = await _kirimPerintah('delete $key\r\n');
    return respons == 'DELETED';
  }

  // ADD — store only if it doesn't exist
  Future<bool> add(String key, String value, {int exptime = 0}) async {
    final bytes = utf8.encode(value).length;
    final respons = await _kirimPerintah(
      'add $key 0 $exptime $bytes\r\n$value\r\n',
    );
    return respons == 'STORED';
  }

  // REPLACE — update only if it already exists
  Future<bool> replace(String key, String value, {int exptime = 0}) async {
    final bytes = utf8.encode(value).length;
    final respons = await _kirimPerintah(
      'replace $key 0 $exptime $bytes\r\n$value\r\n',
    );
    return respons == 'STORED';
  }

  // CAS — Check And Set (update only if the CAS token is still valid)
  Future<bool> cas(String key, String value, String casToken, {int exptime = 0}) async {
    final bytes = utf8.encode(value).length;
    final respons = await _kirimPerintah(
      'cas $key 0 $exptime $bytes $casToken\r\n$value\r\n',
    );
    return respons == 'STORED';
    // STORED = update succeeded
    // EXISTS = someone changed it before us (conflict)
    // NOT_FOUND = the key no longer exists
  }

  // INCR / DECR — increment/decrement numeric values
  Future<int?> incr(String key, int delta) async {
    final respons = await _kirimPerintah('incr $key $delta\r\n');
    if (respons == 'NOT_FOUND') return null;
    return int.tryParse(respons);
  }

  Future<int?> decr(String key, int delta) async {
    final respons = await _kirimPerintah('decr $key $delta\r\n');
    if (respons == 'NOT_FOUND') return null;
    return int.tryParse(respons);
  }

  // STATS — Memcached server info
  Future<Map<String, String>> stats() async {
    final respons = await _kirimPerintah('stats\r\n');
    final map = <String, String>{};
    for (final baris in respons.split('\r\n')) {
      if (baris.startsWith('STAT ')) {
        final bagian = baris.split(' ');
        if (bagian.length >= 3) map[bagian[1]] = bagian[2];
      }
    }
    return map;
  }

  // FLUSH_ALL — delete all data (be careful in production!)
  Future<void> flushAll({int delay = 0}) async {
    await _kirimPerintah('flush_all $delay\r\n');
  }
}

Using the Client #

import 'dart:convert';

Future<void> main() async {
  final client = MemcachedClient(host: 'localhost', port: 11211);
  await client.hubungkan();

  try {
    // Basic SET and GET
    await client.set('greeting', 'Hello, Memcached!', exptime: 300);
    final nilai = await client.get('greeting');
    print(nilai); // 'Hello, Memcached!'

    // SET a JSON object
    final produk = {'id': 'P001', 'nama': 'Laptop', 'harga': 15000000};
    await client.set(
      'produk:P001',
      jsonEncode(produk),
      exptime: 3600,  // 1 hour
    );

    final cached = await client.get('produk:P001');
    if (cached != null) {
      final data = jsonDecode(cached) as Map<String, dynamic>;
      print('Product: ${data['nama']}');
    }

    // ADD — only if it doesn't exist
    final sukses = await client.add('kunci_baru', 'nilai', exptime: 60);
    print('Add succeeded: $sukses');

    // INCR for counters
    await client.set('view_count', '0', exptime: 86400);
    final count1 = await client.incr('view_count', 1);
    final count2 = await client.incr('view_count', 1);
    print('View count: $count2'); // 2

    // DELETE
    final dihapus = await client.delete('greeting');
    print('Deleted: $dihapus');

    // Server STATS
    final info = await client.stats();
    print('Available bytes: ${info['bytes']}');
    print('Current items: ${info['curr_items']}');
    print('Total hits: ${info['get_hits']}');
    print('Total misses: ${info['get_misses']}');

  } finally {
    await client.tutup();
  }
}

Check-and-Set (CAS) — Atomic Operations #

CAS enables safe updates in concurrent environments — an update only succeeds if nobody changed the value since we read it:

// Scenario: two processes try to update the same balance counter
Future<bool> updateSaldoAman(
  MemcachedClient client,
  String kunci,
  double delta,
) async {
  const maxRetry = 5;

  for (int percobaan = 0; percobaan < maxRetry; percobaan++) {
    // 1. Read the current value along with the CAS token
    final (:value, :casToken) = await client.gets(kunci);

    if (value == null || casToken == null) {
      // The key doesn't exist — create it
      final nilaiAwal = delta.toString();
      return await client.add(kunci, nilaiAwal);
    }

    // 2. Calculate the new value
    final saldoLama = double.tryParse(value) ?? 0;
    final saldoBaru = (saldoLama + delta).toStringAsFixed(2);

    // 3. Update with CAS — only succeeds if the token is still valid
    final berhasil = await client.cas(kunci, saldoBaru, casToken);

    if (berhasil) {
      print('Balance updated successfully: $saldoLama$saldoBaru');
      return true;
    }

    // Invalid token — someone changed it first, try again
    print('CAS conflict, attempt ${percobaan + 1}/$maxRetry');
    await Future.delayed(Duration(milliseconds: 10 * (percobaan + 1)));
  }

  throw StateError('Failed to update after $maxRetry attempts');
}

Consistent Hashing for Memcached Clusters #

Memcached has no built-in clustering — clients are responsible for distributing keys to the right nodes using consistent hashing:

import 'dart:convert';

class MemcachedCluster {
  final List<MemcachedClient> _nodes;
  final List<_VirtualNode> _ring = [];
  static const int _virtualNodesPerServer = 150;

  MemcachedCluster(List<String> servers)
      : _nodes = servers.map((s) {
          final parts = s.split(':');
          return MemcachedClient(
            host: parts[0],
            port: int.parse(parts[1]),
          );
        }).toList() {
    _bangunRing();
  }

  void _bangunRing() {
    for (int i = 0; i < _nodes.length; i++) {
      for (int v = 0; v < _virtualNodesPerServer; v++) {
        final kunci = 'node$i:virtual$v';
        final hash = _hash(kunci);
        _ring.add(_VirtualNode(hash: hash, nodeIndex: i));
      }
    }
    _ring.sort((a, b) => a.hash.compareTo(b.hash));
  }

  // Consistent hash — a simple MD5-like hash
  int _hash(String kunci) {
    int hash = 0;
    for (final char in kunci.codeUnits) {
      hash = (hash * 31 + char) & 0x7fffffff;
    }
    return hash;
  }

  // Determine the node based on the key (consistent hashing)
  MemcachedClient _pilihNode(String kunci) {
    if (_nodes.length == 1) return _nodes[0];

    final hash = _hash(kunci);

    // Find the first virtual node whose hash is >= the key hash
    for (final vNode in _ring) {
      if (vNode.hash >= hash) {
        return _nodes[vNode.nodeIndex];
      }
    }

    // Wrap-around: use the first node in the ring
    return _nodes[_ring.first.nodeIndex];
  }

  Future<void> inisialisasi() async {
    for (final node in _nodes) {
      await node.hubungkan();
    }
    print('Memcached cluster: ${_nodes.length} nodes connected');
  }

  // Operations automatically go to the right node
  Future<bool> set(String key, String value, {int exptime = 0}) {
    return _pilihNode(key).set(key, value, exptime: exptime);
  }

  Future<String?> get(String key) {
    return _pilihNode(key).get(key);
  }

  Future<bool> delete(String key) {
    return _pilihNode(key).delete(key);
  }

  Future<void> tutup() async {
    for (final node in _nodes) await node.tutup();
  }
}

class _VirtualNode {
  final int hash;
  final int nodeIndex;
  const _VirtualNode({required this.hash, required this.nodeIndex});
}

// Cluster usage
Future<void> main() async {
  final cluster = MemcachedCluster([
    'memcached-1:11211',
    'memcached-2:11211',
    'memcached-3:11211',
  ]);

  await cluster.inisialisasi();

  // Automatic distribution to nodes based on the key
  await cluster.set('user:1001', '{"nama": "Budi"}', exptime: 3600);
  await cluster.set('user:1002', '{"nama": "Siti"}', exptime: 3600);

  final user = await cluster.get('user:1001');
  print(user);

  await cluster.tutup();
}

Caching Patterns with Memcached #

The Cache-Aside Pattern #

Future<Map<String, dynamic>?> ambilProduk(
  MemcachedClient mc,
  String id,
  Future<Map<String, dynamic>?> Function(String) dariDB,
) async {
  final kunci = 'produk:$id';

  // 1. Check the cache
  final cached = await mc.get(kunci);
  if (cached != null) {
    return jsonDecode(cached) as Map<String, dynamic>;
  }

  // 2. Fetch from the database
  final produk = await dariDB(id);
  if (produk == null) {
    // Negative caching — prevent a thundering herd to the database
    // Store a "not found" marker for 60 seconds
    await mc.set(kunci, '__not_found__', exptime: 60);
    return null;
  }

  // 3. Store in the cache
  await mc.set(kunci, jsonEncode(produk), exptime: 3600);
  return produk;
}

Cache Stampede Prevention #

// The thundering herd problem: many simultaneous requests hit the database
// when a cache entry expires

Future<Map<String, dynamic>?> ambilDenganLock(
  MemcachedClient mc,
  String id,
  Future<Map<String, dynamic>?> Function(String) dariDB,
) async {
  final kunci = 'produk:$id';
  final kunciLock = 'lock:produk:$id';

  // Check the cache
  final cached = await mc.get(kunci);
  if (cached != null && cached != '__loading__') {
    return jsonDecode(cached) as Map<String, dynamic>;
  }

  // Use ADD as a lock — only one succeeds
  final dapatLock = await mc.add(kunciLock, '1', exptime: 10);

  if (!dapatLock) {
    // Someone else is loading — wait a bit and try again
    await Future.delayed(Duration(milliseconds: 100));
    return ambilDenganLock(mc, id, dariDB); // retry
  }

  try {
    // We load the data
    final produk = await dariDB(id);
    if (produk != null) {
      await mc.set(kunci, jsonEncode(produk), exptime: 3600);
    }
    return produk;
  } finally {
    await mc.delete(kunciLock);
  }
}

Memcached Anti-Patterns #

Storing Values Larger than 1MB #

// ANTI-PATTERN: Memcached rejects values > 1MB (default)
final dataBesar = List.generate(100000, (i) => {'id': i, 'data': 'xxx'});
await client.set('data_besar', jsonEncode(dataBesar));
// ✗ SERVER_ERROR object too large for cache

// CORRECT: split into chunks or use Redis, which is more flexible
final chunks = <String>[];
final json = jsonEncode(dataBesar);
for (int i = 0; i < json.length; i += 900000) {
  chunks.add(json.substring(i, (i + 900000).clamp(0, json.length)));
}

// Store the chunk count and each chunk
await client.set('data:chunks', chunks.length.toString());
for (int i = 0; i < chunks.length; i++) {
  await client.set('data:chunk:$i', chunks[i]);
}

Keys That Are Too Long #

// ANTI-PATTERN: keys > 250 characters aren't supported by Memcached
final keyPanjang = 'produk:kategori:elektronik:laptop:gaming:asus:rog:2024';
// Might still be okay, but...
final keyTerlalu = 'a' * 300; // ✗ error: key too long

// CORRECT: hash long keys into short keys
import 'dart:convert';
String hashKey(String panjang) {
  // MD5 or SHA256 for consistent, short keys
  final bytes = utf8.encode(panjang);
  // Use the crypto package for a proper hash
  return bytes.fold(0, (a, b) => a ^ b).toRadixString(16).padLeft(8, '0');
}

Not Handling Cache Misses Properly #

// ANTI-PATTERN: assuming the cache always has the value
final nilai = await client.get('produk:P001');
final harga = jsonDecode(nilai!)['harga']; // ✗ crashes on a cache miss (null)!

// CORRECT: always handle cache misses
final nilai = await client.get('produk:P001');
if (nilai == null) {
  // Cache miss — fetch from the database
  final produk = await database.ambilProduk('P001');
  if (produk != null) {
    await client.set('produk:P001', jsonEncode(produk), exptime: 3600);
  }
  return produk;
}
return jsonDecode(nilai) as Map<String, dynamic>;

Summary #

  • Memcached is pure caching — no persistence, pub/sub, or complex data types. Choose Memcached if you only need string caching with TTLs and easy horizontal scaling.
  • ASCII text protocol — commands are sent as text strings over TCP: set key flags exptime bytes\r\nvalue\r\n. This makes debugging easy with telnet localhost 11211.
  • Max 1MB per value — for larger data, split into several chunks or use Redis, which doesn’t have this limit by default.
  • Max 250 characters per key — hash long keys using MD5 or SHA256 to keep keys short and valid.
  • CAS (Check-and-Set) enables safe concurrent updates — gets returns the value along with a token, cas ensures an update only succeeds if nothing changed between the read and the write.
  • Consistent hashing on the client side distributes keys to the right nodes without node-to-node coordination — adding or removing nodes only moves a subset of keys, not all of them.
  • ADD as a distributed lock — ADD only succeeds if the key doesn’t exist, making it a useful primitive for locks and thundering-herd prevention.
  • Negative caching — store a “not found” marker (e.g. __not_found__) with a short TTL to prevent repeatedly querying the database for data that genuinely doesn’t exist.
  • STATS provides important operational metrics: get_hits, get_misses, evictions, curr_items, bytes — monitor these to determine the right memory size and hit rate.
  • For features beyond caching (pub/sub, structured sessions, complex rate limiting, sorted sets) — choose Redis. For pure caching that needs to scale across many nodes, Memcached remains a solid choice.

← Previous: Redis   Next: Flutter →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact