Skip to main content

How to Reduce Rust Struct Memory Footprint: 5 Techniques, 56% Smaller

How to reduce Rust struct memory footprint: the 5 layout changes that shrank a real cache entry from 953 to 420 bytes, and what each one costs.

10 min read
Dashboard-style cover for reducing a Rust struct's memory footprint, showing five layout techniques and a 953-to-420-byte result

TL;DR Here’s how to reduce Rust struct memory footprint the way Cloudflare’s DNS cache team did: swap Vec/String for Box types, consolidate parallel lists, make the record owner optional, box large enum variants, and store hot data as raw wire-format bytes. Together those five changes cut a real cache entry from 953 bytes to 420 bytes (-56%), raised insert throughput 43%, and dropped lookup latency 19%. None of it depends on Cloudflare’s specific workload — the same five techniques apply to any Rust struct you cache, pool, or store by the million.

A Rust struct’s memory footprint is the total space it occupies — its own fields plus every heap allocation those fields own — and it’s almost never what you’d get by adding up each field’s logical size. Cloudflare’s DNS resolver caches host over 250 billion entries at any given moment, enough scale that a single wasted byte per entry costs the fleet more than 250GB of RAM. When their engineering team went looking for where that byte was hiding, they found 533 of them, sitting quietly in the layout of a struct nobody had re-measured since it was first written.

You don’t need Cloudflare’s scale to feel this. Any Rust service that caches responses, pools connections, or holds parsed records by the thousand pays the same tax. It’s the same instinct behind cutting Node.js heap memory in half with pointer compression or keeping a Rust LSP under 100MB: the savings usually live in layout, not logic. This post walks through the five techniques, in the order to apply them, with the trade-off each one actually costs.

How to reduce Rust struct memory footprint: what actually eats it

Padded, allocation-aware field sizes are the real number, not the sum of logical sizes on paper. Three defaults do almost all of the damage:

  • Vec of T and String reserve for growth. Both are three machine words — pointer, length, capacity — because they’re designed to be pushed to after creation. If a field is built once and never grows again, that capacity word (8 bytes on a 64-bit target) is dead weight, forever, in every instance.

  • Enums are sized to their largest variant. size_of for MyEnum is the size of the biggest variant plus a discriminant, not the size of whichever variant you actually have. A single rare 136-byte variant taxes every small, common variant too.

  • Field order creates padding. The compiler aligns each field to its own size, and it inserts padding between fields to satisfy that alignment — reordering fields from largest-to-smallest alignment often removes bytes the source code never asked for.

None of this shows up by reading the struct definition. It shows up when you measure it.

How do you find where a struct wastes memory?

Two tools cover almost every case. std::mem::size_of for your type gives you the stack-resident size — useful as a regression check (static_assertions::assert_eq_size! pins it in CI so nobody silently grows the struct again), but it stops at the first pointer. A Box of [u8] field reports as 16 bytes regardless of whether it points at 4 bytes or 4 kilobytes on the heap, so size_of alone will miss the biggest allocations in a struct built around boxed or vectored fields.

For the full picture, rustc’s unstable -Z print-type-sizes flag (nightly only) prints every field’s offset, size, and the padding between them:

Bash
RUSTFLAGS="-Z print-type-sizes" cargo +nightly build 2>&1 | grep -A 20 "type: `CacheEntry`"

That output is where the surprise bytes actually live — a 4-byte field followed by 4 bytes of alignment padding, an enum discriminant costing more than the data it tags, three Vec fields each carrying a capacity word nothing ever uses. Measure before you touch anything; “it feels smaller” is not a metric.

Struct layout comparison showing Vec of T at 24 bytes with pointer, length, and capacity versus Box of a slice of T at 16 bytes with only pointer and length, since the boxed slice can never grow after creation

The 5 techniques that cut a cache entry from 953 bytes to 420 bytes

Cloudflare applied these five changes, in roughly this order, to the CacheEntry/Record structs behind its DNS resolver cache:

#TechniqueWhat it removes
1Vec of T/StringBox of [T]/Box of strThe 8-byte capacity word on fields that never grow after construction
2Consolidate parallel lists into one, indexed by u16 offsetTwo extra Vec headers (48 bytes) on top of the one you keep — 28 bytes/entry net
3Option of Box of Name for the record ownerA full owner allocation when the owner matches the query name, the common case
4Box large enum variants (e.g. a 136-byte NAPTR case)The tax every small variant pays to fit the largest one
5Store records as raw wire-format bytes in one Box of [u8] with length prefixesPer-variant struct overhead, plus scattered heap allocations that hurt cache-line locality

Applied together, the result on Cloudflare’s real workload:

MetricBeforeAfterChange
Per-entry footprint953 bytes420 bytes-56%
Per-entry allocations1.1 KB461 bytes-58%
Insert throughput625,000 entries/s893,000 entries/s+43%
Lookup latency828 ns670 ns-19%

Fleet-wide, that footprint cut freed roughly 100 terabytes of RAM — the equivalent of about 130 servers’ worth of memory — and dropped measured production p99 memory per instance from 9.3GB to 5.3GB, a 43% reduction. None of the five techniques is exotic. What made the difference was applying all five to one struct instead of stopping after the easy one.

Bar comparison of four DNS cache metrics before and after the five layout changes: per-entry footprint down 56 percent, allocations down 58 percent, insert throughput up 43 percent, and lookup latency down 19 percent

Step-by-step: apply this to your own struct

  1. Measure first. Run size_of for your type and, if you can use nightly, -Z print-type-sizes to see where the bytes actually go before changing anything.

  2. Swap non-growing Vec/String fields for Box of [T]/Box of str. If a field is set once at construction and never pushed to again, this is a free 8-byte-per-field cut with no behavior change — .to_vec().into_boxed_slice() or .into_boxed_str() at the call site.

  3. Look for parallel list fields that can become one list plus an index. Three Vec of Record fields for three record sections is three sets of Vec overhead; one Vec of Record with a u16 offset marking where each section starts is one.

  4. Make “usually redundant” fields Option of Box of T. If a field’s value equals some other field’s value most of the time (an owner name that matches the query, a default that matches a global), store None for the common case and allocate only for the exception.

  5. Box the rare, large enum variants. If one variant dominates your struct’s size but a small fraction of your instances, box just that variant so the common variants stop paying for it.

  6. Consider raw wire-format storage last, and only if reads are cheap to re-parse. This is the highest-payoff, highest-cost change — it removes per-variant struct overhead entirely, but every read now costs a parse or an index instead of a field access.

  7. Re-measure size, then re-benchmark insert and lookup — not just size_of. A smaller struct that’s slower to read is not a win; confirm throughput and latency moved the direction you expect before shipping.

Five-step flow for shrinking a Rust struct: measure with size_of and print-type-sizes, replace Vec and String with Box, consolidate parallel lists, box rare large enum variants, then re-measure and re-benchmark before shipping

What breaks when you box enum variants?

Boxing is not a free lever — it trades memory for indirection, and indirection has real costs that only show up under load.

Every boxed variant costs a heap allocation and a pointer chase. Where an unboxed enum reads a field directly off the stack or its containing struct, a boxed variant means following a pointer to wherever the allocator put it — worse for CPU cache locality, and a real allocator call on every construction. This is exactly why boxing only pays off when the boxed variant is rare: Cloudflare’s NAPTR records are a small slice of traffic behind A/AAAA, which together make up over 80% of what the cache actually serves. Box the common case instead and you’ve made the fast path slower to save memory nobody was spending.

Wire-format storage has the same shape of trade-off, at a larger scale. Storing records as raw bytes removes struct overhead entirely, but it means every read now parses or indexes into a buffer instead of matching a typed enum. That’s a good trade for a cache that’s read far more often than it’s deeply inspected — exactly the profile of a DNS resolver cache. It’s a bad trade for a hot loop that pattern-matches the same field thousands of times per second, where the CPU cost of re-parsing on every access will outweigh whatever memory you saved.

The failure mode to watch for either way: shipping the change, seeing the smaller size_of for your type, and skipping the throughput/latency re-benchmark from step 7. A struct that’s 56% smaller and 20% slower under real load is not the win it looks like on paper.

FAQ

Why does Vec of T use more memory than Box of [T] for the same data?

Vec of T stores three machine words — a pointer, a length, and a capacity — because it’s built to grow. Box of [T] only stores a pointer and a length, because it can never grow after it’s created. On a 64-bit target that’s 24 bytes versus 16 bytes per field before you’ve stored a single element of real data. If a field is populated once and never mutated again, which describes most cache entries, the capacity word is pure waste.

When does boxing an enum variant actually save memory?

Only when the boxed variant is rare relative to the others. A Rust enum is sized to fit its largest variant plus a discriminant, so one 136-byte variant forces every instance of that enum to reserve 136 bytes even if most real values are 4-16 bytes. Boxing the large variant shrinks the enum to roughly the size of the common case plus one pointer, at the cost of a heap allocation whenever that rare variant actually appears.

Does storing data as raw wire-format bytes always save memory?

It saves memory whenever the parsed representation carries overhead the raw bytes don’t — enum discriminants, Vec capacity, padding between variant-specific fields. The cost is CPU: every read has to parse or index into the byte buffer instead of matching on an already-typed value. It’s a good trade for entries read far more often than they’re deeply inspected, and a bad one for hot paths that pattern-match the same field thousands of times per second.

How do I measure a Rust struct’s actual memory footprint before optimizing?

Start with std::mem::size_of for your type for the shallow, stack-resident size, then follow every Box, Vec, and String field to add their heap allocations, since size_of alone won’t show those. For the full picture including padding, rustc’s unstable -Z print-type-sizes flag on nightly prints every field’s offset and the padding between them, which is usually where the surprise bytes are hiding.

Is this worth doing for a struct I only have a few thousand of?

Rarely, on its own. The throughput and latency gains in Cloudflare’s numbers came alongside the memory cut on a cache with hundreds of billions of entries, where cache-line locality compounds. For a few thousand instances, the bigger win is usually just replacing Vec/String with Box of [T]/Box of str on fields that never grow — it’s close to free and doesn’t need a rewrite to raw bytes to pay off.

If you’re chasing memory across the rest of your stack too, the same pointer-shrinking instinct is behind cutting Node.js heap memory in half with V8 pointer compression, and the same profile-first discipline shows up in sizing GPU memory for local model serving and in tracking down a Linux scheduler latency regression. If you’re weighing Rust for the workload in the first place, Rust’s safe GPU offload benchmarks are a useful second data point on what the language actually costs and saves at the systems layer.

Sources

Frequently asked questions

Share this article:
X LinkedIn

Google Search · Preferred sources

Prefer this site on Google

If you already read this writing, add umesh-malik.com as a Preferred Source. Google can then highlight it with a preferred badge in Top Stories, AI Overviews, and AI Mode — for you, not as a site-wide ranking boost.

Keep reading

Get new posts on AI, Claude Code & LLMs

New deep-dives on AI engineering, Claude Code, and developer tooling — follow along however you prefer.