---
title: "We Saved One Megabyte. iOS Gave Us Thirty-Five."
slug: "go-linker-memory-ios"
language: "en"
date: "2026-10-28T10:54:52.589Z"
updated: "2026-10-28T10:54:52.000Z"
description: "Tailscale cut 1 MB from a 15 MB iOS extension by removing linker relocations, then iOS 15 raised the limit by 35 MB. Both numbers matter."
canonical: "https://fix.slopit.io/go-linker-memory-ios/"
tags: ["go", "mobile", "performance", "engineering"]
---

Tailscale's iOS app runs as a Network Extension, which is how it can secure traffic from every app in the background. The trade is a memory limit: normal iOS apps get around 5 GB before the system kills them, and network extensions get 15 MB. With an M.

Baseline operation was using 10–12 MB. Seventy to eighty percent of the budget before doing anything useful. When it ran out, iOS killed the extension and the user's VPN went down, which is why the team had left features like HTTP/2 and UPnP out of the iOS app for years.

Their account of fixing it is one of the better engineering stories I've read, partly because of how it ends.

## The obvious work, and why it wasn't enough

They instrumented memory, profiled with pprof, and did the standard things: eliminate per-packet allocations, reuse buffers, break a few layers. It helped. Not enough.

Then they went into the Go runtime. `debug.SetGCPercent` more aggressively, trading CPU for memory reuse. And `GOMAXPROCS=1`, which saves memory in two ways: fewer system threads (each costs hundreds of kilobytes), and less allocator overhead.

The second one is the interesting detail. Go uses a slab allocator with size classes, and it keeps slabs per P — per scheduler context — to avoid locking. On a 64-core machine, the first 64 allocations of a 24-byte struct can each grab their own 8 KB slab. In a normal process that overhead is noise. At a 15 MB ceiling, it's the difference between having room for a feature and not.

## Dirty pages that should be clean

The biggest non-heap category was `__DATA_CONST`: 1.5 MB of "constant" data that iOS counted as dirty.

Why would constant data be dirty? Because of relocations. iOS requires position-independent executables, so function addresses can't be baked into the binary; instead the loader applies relocations at launch, rewriting memory. For `__DATA_CONST`, the OS applies the relocations and then marks the pages read-only. They're constant now, but they've been modified, so the OS must keep them.

The relocations were in Go's `pclntab`, the table that maps program counters to source lines and carries runtime metadata. Each relocation was of the form "add the executable's base address to X." But you can do that addition at runtime instead: store the offset, compute the address when needed.

They patched the Go linker to do exactly that. The result: `__DATA_CONST` dropped from 1,568 KB to 708 KB. Binary sizes shrank almost 5% for darwin/arm64 and 3% for linux/amd64. Linking the compiler got 30% faster and used 55% less RAM on darwin/arm64. The change landed in Go 1.18.

## The fork question

Tailscale maintains a fork of the Go toolchain, which people find surprising for a small startup. The author's defense is the best part of the post: they had a problem that was crashing users and blocking features, and they had engineers with expertise spanning the runtime, the toolchain, and the standard library. "You write code with the engineers you have."

He's careful to add the caveat that makes it credible: there may be no other startup for which working on the Go linker is a sensible choice. The lesson isn't "fork your toolchain"; it's that the decision depends on the team and the problem, and that the standard advice ("don't fork") assumes a team without the expertise.

## The ending

They ran `footprint` to confirm. `__DATA_CONST` was down to 708 KB, as predicted. They expected about 1 MB more free memory.

It went up 36 MB. While they were fixing the linker, iOS 15 shipped and quietly gave network extensions 35 MB more.

The post ends with "Thanks, iOS!" and a smile, and it's the most useful sentence in it. They did real, careful, upstreamed engineering — and the platform changed the constraint out from under them. The 1 MB was real; it just wasn't the thing that saved the app.

## What I take from it

Three things.

**Measure the constraint, not the model of it.** They were optimizing against iOS's accounting of their memory, which is not the same as their memory. That insight is what led them to `__DATA_CONST` at all.

**Platform baselines move.** A limit is a fact about today's OS. Any optimization project longer than a release cycle is a bet that the constraint will hold, and it might not.

**The fork was justified by specifics, not by principle.** "You write code with the engineers you have" is a good sentence because it's an argument about capability and problem, not a universal rule.

The twist doesn't undercut the work. It reframes it: the value wasn't only the megabyte. It was the understanding of where the memory went, which is the thing that lets you make the next decision.

*Source: [Hey linker, can you spare a meg? — Josh Bleecher Snyder, Tailscale](https://tailscale.com/blog/go-linker).*
