Get CV

Frontend

Memcached slabs and SSR pages of uneven size

The Vue SSR process cached full HTML in Memcached. Large article pages locked RAM into fat slabs. Small tile fragments had nowhere to live, so the hit rate fell while RSS stayed high.

Vue SSR on this site is a Node process behind nginx, not a SPA. I cached the rendered HTML in Memcached, keyed by locale plus path, to skip Node on the next request. It worked in the evening. By morning RSS was 64 MB on a 64 MB instance and the hit rate was 31 percent.

Visitors still waited on Node for the notes grid. The host looked full. Memcached was not leaking. It had locked RAM into fat slab classes. Once an 80 KB article page was stored, that chunk of RAM could only hold items of that class. A 3 KB tile fragment could not move in, even if the 80 KB page was never read again, until that item was evicted.

Memcached does not have a heap in the Redis sense. It cuts RAM into slab classes. Each class holds items in a narrow size bucket. Mixing article HTML and small fragments in one instance is how the hit rate falls while RSS stays at the ceiling.

Fat slabs hold article HTML, small keys cannot enter
Fat slabs hold article HTML, small keys cannot enter

What we actually stored

The notes grid uses five tile sizes: sm, md, lg, tall, wide. The SSR shell for the list page sat around 18 KB. A notes detail page with a long body sat between 70 KB and 95 KB, depending on the locale. German ran a bit longer than English. Russian sat in the middle.

I dumped stats slabs and stats items. One class around 82 KB held most of the RAM. The classes under 8 KB were almost empty. Evictions were happening in the small classes, not in the fat one. That is the trap. The memory looks used. The keys you need on every list request cannot land.

The growth factor was the default 1.25. That is fine for similarly sized values. It is a bad fit when you mix a JSON fragment, a list shell, and a full article. The article pages also carried the header, the footer, and the inlined i18n messages for the shell. One unused locale string in the bootstrap state was enough to bump a page into the next slab class.

Two pools, then none

First split was two Memcached instances in Docker: memcached-html with 48 MB for pages over 16 KB, memcached-frag with 16 MB for the rest. Hit rate on fragments came back. RSS on the HTML instance still sat at the ceiling, because article pages are read once and then sit there until LRU notices.

Pages go to nginx microcache, fragments stay in Memcached
Pages go to nginx microcache, fragments stay in Memcached

The better move was to stop caching full SSR HTML in Memcached at all. nginx already has a microcache in shared memory for GET responses. I set it to 10 seconds on the public notes and news paths. That covers the burst after a deploy without pinning 90 KB strings into slab classes for an hour.

What stayed in Memcached is small and boring on purpose:

  • rendered locale catalogs for the shell, about 4 KB each
  • the packed tile payload for the notes grid, one key per locale, under 6 KB
  • a flag that the Node process is warm, so nginx does not retry a dead upstream for every miss

Article HTML is rendered again when the microcache expires. Node CPU went up a little. Memcached RAM dropped by half, and the fragment hit rate went over 90 percent. That trade is the right one on a box that also runs Redis, MySQL, and php-fpm.

The Node heap is a different problem

After the slabs were quiet I still saw the Vue process grow. That was not Memcached. That was the SSR renderer keeping component caches and a compiled i18n message tree per request locale. Three locales times a warm renderer is fine. A leak shows up when a request sets a locale and the tree is stored on a global map keyed by a new string every time, for example en-US versus en.

I pinned the locale to en, ru, de at the nginx layer, the same prefix the Laravel API uses. The Node map now has three entries. Heap after a few thousand requests stays flat enough that a weekly restart in Docker is a habit, not a fix.

If you still want a page cache, put it in Redis as a gzipped string with a 45 second TTL, next to the API cache. Redis will evict by LRU across sizes. Memcached will not.

What I took from this

I measure stats slabs and fragment hit rate, not RSS alone. A full instance with evictions in the small classes is a size mix problem, not a missing cache.

I do not trust one Memcached pool for 90 KB HTML and 3 KB tiles. nginx microcache for 10 seconds on public GET is the page store. Memcached keeps catalogs, tile payloads, and a warm-flag under 6 KB.

The rule I keep: pin locale to en, ru, de at nginx before Node sees it. If a page cache is needed, gzip it in Redis. Do not pin article HTML into slab classes.

Back to notes