The Big Pineapple infrastructure that powers 1.1.1.1, Gateway DNS, DNS Firewall, AS112, and other Cloudflare DNS services maintains more than 250 billion DNS cache entries at any given time. At this scale, even a single wasted byte per entry translates to over 250 gigabytes of memory consumption across the entire fleet. Through five successive modifications to cache entry storage, Cloudflare reduced the per-entry memory footprint by more than 50%, recovering approximately 100 terabytes of memory—equivalent to the RAM in 130 of the company's Gen 13 servers. The improvements also yielded performance gains: insert throughput climbed 43% and lookup latency fell 19%, demonstrating that the engineers did not sacrifice speed for space savings.

What the cache stores

Big Pineapple begins with an empty cache on cold start. As DNS queries arrive, entries accumulate until the cache reaches its maximum entry count, at which point older or less frequently accessed items are evicted to make room for new data. Cache size varies by data center, and when EDNS Client Subnet (ECS) is enabled, authoritative servers return different answers based on the client's network location, requiring the cache to store multiple versions of the same query. This multiplies both the number of entries and the memory each consumes, making the optimizations described here especially valuable for locations with heavy ECS usage.

Each cache item consists of a key-value pair. The key identifies what was queried:

pub struct CacheKey {
    qname: Name,
    qtype: Rtype,
    authenticated: bool,
    tag: Vec,
}

The value contains the DNS response itself: the answer, authority, and additional record sections, plus metadata such as creation time, a hit counter, and the Time-to-Live (TTL).

pub struct CacheEntry {
    timestamp: UnixTimeStamp,
    pub inception: Instant,
    pub ttl: Ttl,
    pub hits: u32,
    pub answers: Vec,
    pub authority: Vec,
    pub additional: Vec,
    pub errors: Vec,
    ...
}

Both structures contained room for improvement. Several fields relied on types that carried unnecessary overhead once the entry was stored in the cache.

Measuring memory impact

To evaluate each optimization, Cloudflare benchmarked by populating the cache with randomly generated entries matching the traffic distribution observed in production: 56% A records, 25% AAAA, and 19% TXT. Each entry contained between one and four records. TXT records served as a proxy for all non-A/AAAA record types, with sizes randomized between 64 and 224 bytes to approximate the average response size for variable-length record types.

Memory usage was tracked using a custom allocator wrapping Rust's System allocator that recorded the number and size of allocations per cache entry. Alongside memory, the team measured insert throughput and lookup latency across the full cache flow to ensure memory savings did not come at a performance cost. These inputs approximated production rather than reproducing it exactly. Actual process memory depends on traffic mix, cache occupancy, allocator state, and memory used outside the cache, so resident memory was also measured across production instances during the rollout.

Eliminating unused capacity

Vec<T> stores three fields: a pointer to heap-allocated data, the current length, and the total capacity. When an item is pushed, Vec checks whether the length exceeds capacity and reallocates if necessary. If room exists, it appends the item and increments the length. However, once a DNS response is stored in the cache, it is never modified again. The capacity field serves no purpose yet still costs 8 bytes per Vec. Additionally, over-allocated heap space is wasted when a Vec with capacity for eight items but only five stored leaves three unused slots on the heap.

1.png

Using Box<[T]> solves both problems. It cannot grow after creation, so it requires neither a capacity field nor reserved space for future elements. The same principle applies to String, which also carries a capacity field; Box<str> eliminates it. Each cache entry stores 8 Vec and String fields. Replacing them with Box<[T]> and Box<str> saves 8 bytes per field, totaling 64 bytes per entry. This also eliminates the excess heap memory that Vec reserves for future growth. Combined savings exceeded 15 terabytes across the 250 billion cache entries.

2.png

Consolidating record sections

Rather than storing the answer, authority, and additional sections in separate lists, Cloudflare consolidated them into a single list with offsets marking the start of each section. Since DNS record counts per section fit in a u16, the team could use a u16 (2 bytes) for each offset instead of the 8-byte pointer and 8-byte length that each separate Box<[T]> requires. This removed two lists, each with an 8-byte pointer and 8-byte length, and replaced them with two 2-byte offsets, saving 28 bytes per entry.

These savings do not always map directly to the number of bytes removed from individual fields. Rust inserts padding to satisfy alignment requirements and rounds a struct's size up to a multiple of its alignment. Removing a small field can therefore eliminate additional padding. For instance, Cloudflare also packed several boolean fields into a single bitflag. This reduced surrounding padding, causing the struct to shrink by more than the size of the individual booleans.

3.png

Inferring the record owner

Each DNS record has an owner—the domain the record belongs to. In many cases, this owner is identical to the domain being queried. For example, a query for example.com A returns two records with the same owner:

$ dig example.com A

;; ANSWER SECTION:
example.com.        300    IN    A        198.51.100.1
example.com.        300    IN    A        198.51.100.2

However, when a CNAME is involved, the record owner can differ from the queried domain:

$ dig example.com A

;; ANSWER SECTION:
example.com.        300    IN    CNAME    cdn.example.com.
cdn.example.com.    300    IN    A        198.51.100.1
cdn.example.com.    300    IN    A        198.51.100.2

The DNS wire format handles repeated owners using name compression, as defined in RFC 1035. Rather than encoding the same domain twice, subsequent occurrences store a 2-byte pointer to the first occurrence. A domain like www.example.com can encode just www followed by a pointer to where example.com already appeared in the message. This works well on the wire, but in the cache, the full owner name was stored alongside each record. Following compression pointers during cache lookups is expensive on the hot path, so memory was traded for speed.

Most records, however, have an owner identical to the queried domain. For those, the owner can be dropped entirely and inferred at read time. When the owner differs, such as the A records behind a CNAME, the full name is stored.

pub struct Record {
    owner: Option,
    class: Class,
    ttl: Ttl,
    rtype: Rtype,
    data: RecordData,
}

When owner is None, response construction restores the queried domain from the cache key, avoiding a heap allocation. This means the record is no longer self-contained, but the cache key is already available during every lookup. When the owner differs, Some stores a pointer to the full name on the heap. In practice, most cached records have an owner identical to the queried domain, so the majority require no heap allocation for the owner field.

4.png

Optimizing enum sizes

Rust enums are sum types: each variant can carry different data, but the enum is always the size of its largest variant.

pub enum Option {
    Some(T),
    None,
}

Option is either Some and holds a value, or None and holds nothing. Both variants take the same amount of memory. The enum stores a tag indicating the active variant, followed by space large enough for the largest variant's data. When the variant is None, that space is unused.

For record data, it seemed natural to store each DNS record type as an enum variant:

pub enum RecordData {
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    Txt(Txt),
    Naptr(Naptr),
    Svcb(Svcb),
    // ...
}
5.png

However, the enum is always as large as its largest variant. In this case, that was NAPTR at 136 bytes, which stores three variable-length text fields, a domain name, and two integers. As a result, the full enum, including the variant tag and padding, became 144 bytes. An A record only needs 4 bytes, and an AAAA record needs 16 bytes. A and AAAA make up over 80% of traffic, so most records wasted over 120 bytes on padding. Since a single cache entry can store many records, this quickly added up.

Boxing larger variants

To address this problem, Cloudflare boxed the larger variants of the enum, moving them to a separate heap allocation. The enum then stores an 8-byte pointer to the heap, where the data takes up only the size it actually requires.

pub enum RecordData {
    // Small and common variants are stored inline
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    // Large variants are stored on the heap
    Txt(Box),
    Naptr(Box),
    Svcb(Box),
    // ...
}
6.png

For A and AAAA records, this saves 120 bytes per record. Smaller variant types like TXT and CNAME also benefit. They still occupy the 24-byte enum, but their heap allocation is sized to their actual data rather than padded to 144 bytes. NAPTR, the largest variant, actually costs slightly more. It now adds the cost of a heap pointer and allocation overhead. But NAPTR records are rare in practice, so the tradeoff is worth it.

Costs of boxing

Boxing introduces two costs. The first is allocator overhead. Each boxed variant becomes a separate heap allocation, and allocators round up to the nearest size class. Big Pineapple uses jemalloc, an allocator designed for multithreaded, allocation-heavy workloads. jemalloc groups allocations of similar sizes into fixed-size bins. A TXT record requests 32 bytes and fits exactly into a 32-byte bin, wasting nothing, but an MX record requests 40 bytes and rounds up to 48, wasting 8 bytes.

7.png

The second cost is poor memory locality. Without boxing, the record enum values for a cache entry sit in a single contiguous allocation. With boxing, data for each boxed variant lives in a separate heap region. Reading it requires following a pointer, and when that pointer lands far from the rest of the entry, the CPU has to fetch a new cache line. With millions of cache entries, boxed data ends up scattered across the heap rather than packed together. Neither cost is catastrophic on its own, but eliminating both, as the next section shows, yields a measurable improvement in both memory usage and lookup latency.

Storing records in wire format

An obvious next step would be to store the full DNS response in wire format, patching only per-client fields like the message ID on each lookup. But this has drawbacks. DNSSEC records are only included when the client sets the DO (DNSSEC OK) flag. Storing a complete wire format message means either caching two variants, one with DNSSEC and one without, or filtering them out of an already-built message. There is also a cost to parsing the full message on every lookup, which the enum approach avoids by storing already-parsed records.

8.png

As a middle ground, Cloudflare stores just the record data as raw bytes, while keeping the rest of the cache entry as structured fields. Instead of a list of parsed enum variants, the team stores the records as a single Box<[u8]> containing each record encoded as a 2-byte length prefix followed by its raw bytes. This eliminates the per-variant enum overhead and the boxed heap allocations from the previous optimization. The data also becomes packed contiguously, which improves CPU cache locality. The tradeoff is that records can no longer be randomly indexed. The system has to iterate through the buffer sequentially. This adds some complexity for features like round-robin rotation of A/AAAA records, but since record counts per entry are small, the cost is negligible.

When building a DNS response from cached records, most record types can be copied directly from the buffer into the outgoing message. Previously, each parsed record had to be serialized field by field back into DNS wire format. The new layout skips that work for A, AAAA, TXT, and all DNSSEC record types by copying their encoded bytes directly. Only records containing domain names, such as CNAME, NS, MX, and SOA, still require parsing so DNS name compression can be applied. Since records that support direct copying make up the vast majority of traffic, this change reduces work on the lookup path. Combined with improved memory locality, this reduced cache lookup latency by 5% in benchmarks.

To build the record data buffer, Cloudflare writes into a reusable scratchspace buffer that persists across cache insertions. Since previous writes have already grown it, the buffer rarely needs to be reallocated. Records vary in size, so the exact buffer size is not known until they have been serialized. Once the records are in the scratchspace buffer, a Box<[u8]> is allocated and the data is memcopied into it. This replaces the separate allocation for each boxed record with one allocation for all record data. It also avoids the waste from shrinking a Vec<u8>, where the allocator may not be able to reclaim the unused tail of the original allocation. In the benchmark, this change alone increased cache insert throughput by 13%.

Results

9.png

Production measurements show how the benchmarked per-entry savings translated to whole-process resident memory. Memory usage across Big Pineapple instances dropped in steps rather than all at once, as each release introduced one or more of the optimizations described above. The rollout began on May 18, 2026, and completed across all services on July 6, 2026.

As each release rolled out, restarted instances began with empty caches and consumed more memory as those caches filled. The stable plateaus therefore represent steady-state memory usage better than the initial dips. Per-instance memory usage dropped across all percentiles. At p99, memory dropped from 9.3 GB to 5.3 GB, a 43% reduction in resident memory. At p90, memory dropped from 6.5 GB to 3.8 GB, a 42% reduction. Instances with fuller caches saw the largest absolute savings.

In benchmarks, the five optimizations reduced the per-entry memory footprint from 953 bytes to 420 bytes, a 56% reduction. Per-entry allocations dropped from 1.1 KB to 461 bytes. The reductions measured in production are smaller because resident memory includes the cache alongside all other process data. After the rollouts settled, aggregate working-set memory across the fleet was roughly 100 terabytes lower. Performance also improved: cache insert throughput increased by 43%, while lookup latency dropped by 19%.

Cloudflare plans to reinvest the freed memory into increasing cache capacity without increasing memory usage, which improves cache hit rates and reduces upstream query volume. The company is also exploring further optimizations to the cache itself.