research/measuring-self-hosted-font-payload

How Much Does Self-Hosting Fonts Weigh? Measuring Latin woff2 Payloads

Engineering Note · October 26, 2026 · 8 minute read · Performance · Web fonts · Self-hosting

Self-hosted latin woff2 files are small: in our measurement, seven popular families came to 340,256 bytes across 16 files. The number that surprises people is how many @font-face blocks point at the same file, so the first rule of budgeting is to deduplicate by URL.

We measured this because moving a 24-family site off a third-party font host raised an obvious question: what does all of it weigh? Our own result from the repo is 57 font-face blocks pointing at 43 distinct files, about 780 KB for 24 families. That figure is measured. The seven-family experiment below is how we explain it, with a script you can run on your own list.

How do you measure latin woff2 payloads?

Fetch the CSS from the css2 endpoint with a modern Chrome user agent, split it into blocks, keep the latin blocks, collect unique woff2 URLs, and download each once. The endpoint tailors its answer to the browser. With a Chrome user agent we got per-subset blocks (each preceded by a comment such as /* latin */) pointing at woff2 files. With curl’s default user agent, one request for Barlow 400 returned a single block pointing at a .ttf file and no subsets.

We requested: Inter 400, 500, 600, 700 as separate weights; JetBrains Mono 400, 500, 700; Barlow 400, 600, 700; Bai Jamjuree 200, 300, 400, 500, 600; and Space Grotesk, Lexend, and Prompt at 400, 500, 600, 700. We chose the weights; they are not a recommendation. Python 3.11.15 with only the standard library:

import re, urllib.request, urllib.parse

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36")
FAMILIES = {                       # family -> weights to request
    "Inter":          [400, 500, 600, 700],
    "JetBrains Mono": [400, 500, 700],
    "Barlow":         [400, 600, 700],
    "Bai Jamjuree":   [200, 300, 400, 500, 600],
    "Space Grotesk":  [400, 500, 600, 700],
    "Lexend":         [400, 500, 600, 700],
    "Prompt":         [400, 500, 600, 700],
}
BLOCK = re.compile(r"/\*\s*([\w-]+)\s*\*/\s*@font-face\s*\{(.*?)\}", re.S)
URL = re.compile(r"url\((https://[^)]+\.woff2)\)")

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    return urllib.request.urlopen(req, timeout=30).read()

sizes = {}                          # url -> bytes, shared across families
rows = []
for fam, weights in FAMILIES.items():
    q = f"family={urllib.parse.quote_plus(fam)}:wght@{';'.join(map(str, weights))}&display=swap"
    css = get(f"https://fonts.googleapis.com/css2?{q}").decode()
    latin = [m.group(2) for m in BLOCK.finditer(css) if m.group(1) == "latin"]
    urls = [URL.search(b).group(1) for b in latin]
    distinct = list(dict.fromkeys(urls))          # dedupe by URL, keep order
    for u in distinct:
        if u not in sizes:
            sizes[u] = len(get(u))
    total = sum(sizes[u] for u in distinct)
    naive_total = sum(sizes[u] for u in urls)     # what you'd store without dedupe
    rows.append((fam, weights, len(latin), len(distinct), total, naive_total))

print(f"{'family':15s} {'weights':>7s} {'blocks':>6s} {'files':>5s} {'bytes':>8s} {'no-dedupe':>9s}")
for fam, w, b, f, t, n in rows:
    print(f"{fam:15s} {len(w):>7d} {b:>6d} {f:>5d} {t:>8d} {n:>9d}")
print(f"{'TOTAL':15s} {sum(len(r[1]) for r in rows):>7d} {sum(r[2] for r in rows):>6d} "
      f"{sum(r[3] for r in rows):>5d} {sum(r[4] for r in rows):>8d} {sum(r[5] for r in rows):>9d}")
family          weights blocks files    bytes no-dedupe
Inter                 4      4     1    48256    193024
JetBrains Mono        3      3     1    31432     94296
Barlow                3      3     3    67756     67756
Bai Jamjuree          5      5     5    58544     58544
Space Grotesk         4      4     1    22288     89152
Lexend                4      4     1    39680    158720
Prompt                4      4     4    72300     72300
TOTAL                27     27    16   340256    733792

The dedupe line is list(dict.fromkeys(urls)): it keeps the first occurrence of each URL, in order. Without it, the same files get stored repeatedly, and the no-dedupe column is 733,792 bytes against 340,256, about 2.16 times as much.

Which families are variable and which are static?

Compare blocks to files. Where files equal blocks, every weight is its own file. Where files collapse to one, the family is variable.

FamilyWeights requestedBlocksDistinct filesTotal bytesKind
Inter44148,256variable
JetBrains Mono33131,432variable
Space Grotesk44122,288variable
Lexend44139,680variable
Barlow33367,756static
Bai Jamjuree55558,544static
Prompt44472,300static

For the static families we also fetched each weight on its own to see the per-file sizes. Barlow 400, 600, and 700 were 22,196, 22,772, and 22,788 bytes. Bai Jamjuree 200 through 600 ranged from 11,452 to 11,868. Prompt 400 through 700 ranged from 17,940 to 18,160. Static weights of one family are within a few percent of each other, so a family’s weight count times its single-file size is a good estimate.

How do you estimate your own budget?

Count one file for each variable family and one file per weight for each static family, then multiply by sizes. This script uses the means from our table: 12 static files and 4 variable files.

VARIABLE = {"Inter": 48256, "JetBrains Mono": 31432, "Space Grotesk": 22288, "Lexend": 39680}
STATIC_FILES = [22196, 22772, 22788, 11452, 11784, 11596, 11868, 11844, 17940, 18096, 18104, 18160]

var_mean = sum(VARIABLE.values()) / len(VARIABLE)
static_mean = sum(STATIC_FILES) / len(STATIC_FILES)
print(f"variable: {len(VARIABLE)} files, mean {var_mean:.0f} B, range {min(VARIABLE.values())}-{max(VARIABLE.values())}")
print(f"static:   {len(STATIC_FILES)} files, mean {static_mean:.0f} B, range {min(STATIC_FILES)}-{max(STATIC_FILES)}")

def budget(variable_families, static_families, weights_each):
    return variable_families * var_mean + static_families * weights_each * static_mean

for v, s, w in [(1, 0, 0), (1, 1, 3), (2, 2, 4), (0, 3, 4)]:
    b = budget(v, s, w)
    print(f"{v} variable + {s} static x {w} weights -> {b:,.0f} B ({b/1024:.0f} KiB)")

blocks, files, kb, fams = 57, 43, 780, 24
print(f"production: {blocks} blocks -> {files} files; {blocks - files} blocks reused a file; ~{kb/fams:.1f} KB per family; ~{kb/files:.1f} KB per file")
variable: 4 files, mean 35414 B, range 22288-48256
static:   12 files, mean 16550 B, range 11452-22788
1 variable + 0 static x 0 weights -> 35,414 B (35 KiB)
1 variable + 1 static x 3 weights -> 85,064 B (83 KiB)
2 variable + 2 static x 4 weights -> 203,228 B (198 KiB)
0 variable + 3 static x 4 weights -> 198,600 B (194 KiB)
production: 57 blocks -> 43 files; 14 blocks reused a file; ~32.5 KB per family; ~18.1 KB per file

Read the last line as arithmetic on our reported production numbers, not a new measurement: 780 KB over 24 families is about 32.5 KB per family, below this sample’s 48,608 bytes per family (340,256 divided by 7). Per-family weight depends on which families and weights you pick, so treat both averages as context rather than a target.

Why do unused faces cost nothing until used?

This is general platform behavior, not something we measured here. A browser reads @font-face rules as declarations. It starts a download only when text on the page is styled with that family and weight and contains characters the face covers. The unicode-range descriptor narrows that further: a face is fetched only if some character on the page falls inside its range. That is why Google’s CSS splits each weight into subsets, and why a global stylesheet listing every family adds CSS bytes but no font requests on pages that do not use them.

That is also why we keep only the latin blocks for an English site. A visitor who never types a Cyrillic character never needed those subsets, and without them in your stylesheet the question never comes up.

What about font-display and preloading?

Set font-display: swap on every face. The browser then shows text in a fallback font immediately and swaps in the web font when it arrives, so a slow or failed font request cannot hide your content. The cost is a visible swap, which you reduce by choosing a fallback with similar metrics.

Preload only the base faces, meaning the one or two files every page uses above the fold. A <link rel="preload" as="font" type="font/woff2" crossorigin> in the head lets the browser start those downloads before it has parsed the stylesheet. Preloading is a commitment: a preloaded file downloads whether or not it is used, so preloading a seldom-used weight spends bytes on every page. Leave the rest to lazy discovery.

Failure modes and what this does not prove

  • Sizes are a snapshot. The version segment in each URL, such as v13 for Barlow and v20 for Inter in our fetch, changes when a font is updated, and sizes change with it. Rerun the script instead of reusing our table.
  • The endpoint is not a contract. The response depends on the user agent. We verified the Chrome 130 user agent string and nothing else, and a different one may return different blocks.
  • Latin only. Totals exclude every other subset. If your site needs latin-ext or another script, those blocks add files.
  • Seven families is a small sample. The means of 35,414 bytes (4 variable files) and 16,550 bytes (12 static files) describe this set, not fonts in general.
  • Bytes are not load time. We measured file sizes only. Delivery speed, caching, and render timing depend on your host and your visitors, and we did not test them.
  • Variable files cover more than you request. Inter was one 48,256-byte file for four requested weights. We did not check whether it also covers weights between those, so do not assume your design’s unrequested weights cost extra or nothing.

Frequently asked questions

How big is a latin woff2 font file from Google Fonts?

In our measurement, latin woff2 files ranged from 11,452 to 22,788 bytes for static single-weight files, and from 22,288 to 48,256 bytes for variable files. Those are snapshots of one fetch for seven families. Sizes change when a font is updated, so run the script yourself before budgeting.

Do variable fonts download one file for several weights?

Yes. When we requested four weights of Inter, the CSS contained four latin @font-face blocks that all pointed at one URL, so one 48,256-byte file served every weight. Static families such as Barlow, Bai Jamjuree, and Prompt returned a separate file per weight. Deduplicating by URL shows which kind you have.

Do unused @font-face declarations cost bandwidth?

Declaring a face costs only the CSS bytes. Browsers fetch a font file when text on the page actually needs that face, and with unicode-range subsets they fetch only the subsets whose characters appear. A face or subset that no rendered text uses is not downloaded.

Why do I need a modern browser user agent to fetch the CSS?

The Google Fonts CSS endpoint tailors its response to the requesting browser. With a Chrome user agent we got per-subset blocks pointing at woff2 files. With curl's default user agent we got a single block pointing at a .ttf file, with no subset split. Send a current browser user agent when scripting.

How do I estimate my own self-hosted font budget?

Count one file per variable family and one file per weight for each static family, then multiply by the measured sizes. From our sample, the mean variable latin file was 35,414 bytes and the mean static file was 16,550 bytes. Replace those with your own families' measured sizes for a real figure.

← All research