Data URI grammar for fonts
The full syntax inside url(...) in a @font-face src. Every part except ;base64 is optional in the URI spec, but for fonts each part is load-bearing.
| Part | Required for fonts? | Notes |
|---|---|---|
data: | Yes | The scheme. Always literal data: — not data://. |
<mime> | Yes (in practice) | Empty MIME defaults to text/plain and browsers will not parse the bytes as a font. Use font/woff2, font/woff, font/ttf, or font/otf. |
;base64 | Yes | Omitting ;base64 makes the payload percent-decoded text, which can't represent a binary font. |
,<payload> | Yes | Standard base64 alphabet (A-Z a-z 0-9 + / =). URL-safe alphabet (- _) is rejected by atob even if some minifiers pass it through. |
format("woff2") | Recommended | Lives outside the URL, not inside the data URI. The browser uses it to skip downloads it couldn't parse; it does not validate the bytes against it. |
MIME types the browser accepts for font data URIs
Modern browsers accept the WHATWG-registered font/* types and, for legacy compat, the older application/font-* aliases. Anything else may render the font invisible.
| MIME | Accepted? | Notes |
|---|---|---|
font/woff2 | Yes (preferred) | WHATWG-registered, since 2018 |
font/woff | Yes | WHATWG-registered |
font/ttf | Yes | WHATWG-registered |
font/otf | Yes | WHATWG-registered |
application/font-woff2 | Yes (legacy alias) | Older alias still widely deployed; works but prefer font/woff2 |
application/x-font-woff | Yes (legacy alias) | Pre-WHATWG |
application/octet-stream | Browser-dependent | Some Chromium builds accept it; Safari historically refused. Don't rely on it. |
text/plain (or empty) | No | Browser will not parse the bytes as a font, even if the bytes are valid WOFF2. |
What `lightningcss` does with each base64 variant
Verified on 15 May 2026 against lightningcss v1 (the minifier Next.js and Tailwind v4 use). Each variant is the exact same WOFF2 bytes wrapped differently — the only thing that changes is whether the rule survives minification.
| Variant | Output | Verdict |
|---|---|---|
| Standard base64 | Rule preserved, base64 byte-identical | The reference behaviour. Always emit standard alphabet from your encoder. |
Missing padding (= stripped) | Rule preserved, payload byte-identical | Both lightningcss and browsers tolerate this. Standard says pad; both don't care. |
URL-safe alphabet (- _) | Rule preserved, alphabet **not** converted | lightningcss bug — output is byte-broken because browsers' atob will reject - and _. Re-encode standard before minifying. |
| Whitespace inside payload | **Rule DROPPED** — only font-family survives | Production gotcha: pretty-printed CSS that wraps the base64 across lines breaks fonts after Next.js / Tailwind v4 build. Strip whitespace before deploying. |
| Quoted URL with internal spaces | Rule preserved | Quoting (url("data:...")) lets you space-pad legally — but you still can't space-pad the base64 itself. |
Browser tolerance via `atob` (the WHATWG forgiving-base64 decode)
Browsers use the WHATWG forgiving-base64 algorithm for data URIs. We tested each variant via Node's atob — same algorithm, same answers. Anything atob accepts, the browser will load.
| Variant | atob result | Notes |
|---|---|---|
| Standard alphabet, with padding | OK | Reference |
| No padding | OK | Forgiving decode adds it |
| Embedded newlines | OK | ASCII whitespace is silently stripped before decoding |
| Embedded spaces | OK | Same |
| Embedded tabs | OK | Same |
URL-safe alphabet (- _) | **Throws Invalid character** | WHATWG decode only allows A-Z a-z 0-9 + / = |
Any other character (., *, accented letter) | **Throws Invalid character** | Whatever character escaped your encoder — the browser will reject the whole URI |
Cookbook
Copy-pasteable @font-face blocks for the patterns that actually work. If you want a comparison with linking external font files instead, see base64 vs external files; if you need a build-time encoder, see the automation guide.
Single inlined WOFF2 with system fallback
ExampleThe minimum viable inline @font-face. Always include the system fallback because Outlook desktop strips @font-face entirely.
@font-face {
font-family: "Brand";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(data:font/woff2;base64,<BASE64_PAYLOAD>) format("woff2");
}
body {
font-family: "Brand", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}Inline WOFF2 with external WOFF2 fallback
ExamplePattern for critical above-the-fold text: inline the smallest critical weight, lazy-load the rest from a normal URL. Browsers fetch external src only if the inline one fails.
@font-face {
font-family: "Brand";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(data:font/woff2;base64,<BASE64_PAYLOAD>) format("woff2"),
url(/fonts/Brand-Regular.woff2) format("woff2");
}CSP that allows inlined fonts
ExampleIf your CSP has a font-src directive at all, you must include data: or the inlined @font-face fails with a console warning and falls back to the system font. The silent-failure version of this mistake is the most common base64-font bug we see.
Content-Security-Policy: font-src 'self' data:;
Build-time inlining with Vite asset.inline threshold
ExampleVite and Webpack auto-inline assets under a configurable size as data URIs. Setting this carefully avoids accidentally inlining a 200 KB font and bloating the CSS payload.
// vite.config.ts
export default {
build: {
assetsInlineLimit: 8192, // 8 KB — covers small critical subsets only
},
};Minifier-safe: never line-wrap base64
Examplelightningcss (Next.js / Tailwind v4) silently drops @font-face when the base64 payload contains newlines. Some encoders default to 76-column wrapping (RFC 2045 MIME) — pass a flag to disable, or strip whitespace before embedding.
// Node: standard base64 (no wrapping)
fs.readFileSync(fontPath).toString('base64')
// openssl: -A disables line wrapping
openssl base64 -A -in font.woff2 -out font.b64
// Linux base64: -w 0 disables
base64 -w 0 font.woff2Benchmarks
Numbers we measured for this page. Every byte count below comes from a real Chrome User-Agent request to fonts.googleapis.com on , with the response WOFF2 fetched and counted directly.
Base64 inflation across real font files
Encoded on 15 May 2026 with Node 24 Buffer.toString('base64'). The inflation ratio is exactly 4/3 every time — that's a mathematical property of base64, not an implementation choice. The CSS framing adds a flat ~157 bytes regardless of font size, so the relative overhead disappears as fonts grow.
| Font | Raw bytes | Base64 bytes | Ratio | CSS block bytes |
|---|---|---|---|---|
| Geist Latin WOFF2 | 28,356 | 37,808 | 1.333× | 37,965 |
| Geist Mono Latin WOFF2 | 31,288 | 41,720 | 1.333× | 41,877 |
| Inter Latin 400 WOFF | 30,696 | 40,928 | 1.333× | 41,083 |
| Liberation Sans Regular TTF | 139,512 | 186,016 | 1.333× | 186,174 |
Methodology: Each font binary was loaded with `fs.readFile`, encoded with `toString('base64')`, and wrapped in a standard 6-line `@font-face` block. Byte counts are exact — no rounding.
Wire-size recovery with gzip and brotli
The CSS framing + base64 layer adds redundancy that gzip and brotli can claw back. Headline finding: **for already-compressed formats like WOFF2, gzipped base64 CSS is within 1% of the raw binary**. But for uncompressed formats like TTF, **base64 + gzip is 17% larger than just gzipping the raw binary** because the base64 transform fights compression on data that wasn't already maxed out.
| Font | Raw bin gzip | Base64 CSS gzip | vs raw gzip | Brotli (CSS) | vs raw brotli |
|---|---|---|---|---|---|
| Geist Latin WOFF2 | 28,384 | 28,632 | +0.9% | 28,529 | +0.6% |
| Geist Mono Latin WOFF2 | 31,316 | 31,578 | +0.8% | 31,490 | +0.6% |
| Inter Latin 400 WOFF | 30,584 | 30,842 | +0.8% | 30,743 | +1.0% |
| Liberation Sans TTF | 90,438 | 106,153 | **+17.4%** | 96,022 | **+22.6%** |
Methodology: Same fixtures, compressed with Node's `zlib.gzipSync` (default level 9 equivalent) and `zlib.brotliCompressSync` at default quality. For WOFF2 and WOFF the binary is already entropy-coded, so the base64 wrapper has no real compressible structure left. TTF retains a lot of internal redundancy (table padding, sparse glyph descriptions) that compresses well in raw form but gets scrambled by the base64 layer.
Cumulative effect on a 5-weight design system
Inline-everything sounds free for small fonts; multiply by 5 weights × 2 styles and the payload starts to matter. These are the same Geist Latin numbers, projected.
| Strategy | Per-weight cost (gz) | 10 weights/styles |
|---|---|---|
| 10 separate WOFF2 fetches | ~28 KB each | ~280 KB total (cacheable, parallel) |
| 10 base64 @font-face in a single CSS file | ~28.6 KB each | ~286 KB in one render-blocking CSS file |
| Inline only critical (1 weight) + external for the rest | ~28 KB critical, ~28 KB × 9 cached external | ~28 KB inline + ~252 KB cached — same total, no render block |
Methodology: Projected from the per-weight gzip numbers above. The trade-off isn't bytes but cacheability and render-blocking — base64 @font-face must arrive inside the CSS, so it can't be cached independently across pages.
Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
lightningcss drops the rule when base64 has whitespace
Verified bug — silentWe ran a 28 KB WOFF2 with newlines wrapped every 76 columns through lightningcss.transform({ minify: true }). The output was @font-face{font-family:S} — the src URL was discarded entirely. No warning. The font silently vanishes after build. Some encoders (RFC-2045-compliant MIME ones, including openssl base64 without -A) wrap at 76 columns by default. Strip whitespace before embedding: base64 -w 0 on Linux, openssl base64 -A, or in Node just .toString('base64') which never wraps.
lightningcss preserves URL-safe base64 but browsers reject it
Verified — output broken for browsersReplace every + with - and every / with _ in a base64 payload, run it through lightningcss — the minifier passes the URL-safe characters through unchanged. Then the browser's atob throws Invalid character and the font fails to load. Standard base64 (RFC 4648 §4) is what data: URIs require; URL-safe (RFC 4648 §5) is for putting the payload in URL paths and query strings, not in data: URIs. If your encoder has a --url-safe flag, don't pass it for font CSS.
Missing `=` padding is fine in both atob and lightningcss
200 OK — but the spec disagreesRFC 4648 requires base64 strings to be padded to a multiple of 4 with =. WHATWG's forgiving-base64 algorithm (which atob and the browser data: URI decoder use) re-adds the padding before decoding. lightningcss does the same. So you can strip trailing = chars and everything works — but a stricter consumer (a non-browser tool, a third-party CSS parser) may reject. Save bytes elsewhere; emit the padding.
Empty MIME defaults to `text/plain` — silent font failure
200 OK on CSS parse, font never rendersRFC 2397 says an empty MIME in a data URI defaults to text/plain; charset=US-ASCII. The browser parses the @font-face block successfully, downloads the data URI, then refuses to interpret the bytes as a font because the MIME isn't a font type. There is no console warning. Always declare font/woff2 (or whatever format the bytes are) explicitly.
Outlook on Windows strips @font-face entirely
Browser-engine specific — design around itOutlook on Windows desktop renders email through Microsoft Word's HTML engine, not a browser engine. Word strips every @font-face rule regardless of whether the src is external or inlined. There is no workaround that gets your brand font into Outlook desktop — design the fallback so the email still looks like itself when the system font kicks in. The HTML email embedding guide covers the full Outlook-aware pattern.
CSP `font-src 'self'` silently blocks inlined fonts
200 OK on CSS load, font blocked with console warning onlyIf your Content-Security-Policy has a font-src directive (or falls back to default-src), it must explicitly include data: for browsers to load inlined font URIs. font-src 'self' allows /fonts/file.woff2 but blocks data:font/woff2;base64,... — the browser logs Refused to load the font 'data:...' because it violates the following Content Security Policy directive. The CSS itself parses fine; the font fetch is blocked. Update the header to font-src 'self' data:.
Webpack and Vite 4 KB asset-inline threshold
Build-time auto-inlining — usually intentionalBoth Webpack 5 and Vite default to inlining any imported asset smaller than 4 KB as a data URI in the bundled CSS. For small icon fonts and critical-text subsets this is exactly what you want. For full WOFF2 files (typically 20–60 KB each) it defaults to keeping them external. The threshold is a config option — assetsInlineLimit in Vite, Rule.parser.dataUrlCondition.maxSize in Webpack — so it's a deliberate choice masquerading as magic.
SourceMaps blow up when inlined fonts are in CSS
200 OK — but devtools may refuse to loadBrowsers refuse to display SourceMaps larger than ~10 MB. A few inlined WOFF2 files (each ~30 KB → ~40 KB base64) inside a CSS bundle quickly pushes the SourceMap past that ceiling because the SourceMap embeds the source for each token. Strip data URIs from SourceMap inputs (postcss-discard-sourcemap-data or the equivalent in your toolchain), or build the @font-face CSS as a separate file with sourcemaps disabled.
Frequently asked questions
What is the actual base64 size inflation, exactly?
Mathematically 4/3 — every 3 input bytes become 4 output characters, with up to 2 = chars of padding to round to a multiple of 4. We re-measured against four real fonts (28 KB WOFF2, 31 KB WOFF2, 30 KB WOFF, 139 KB TTF): the ratio was 1.333× to four decimal places in every case. The CSS framing (@font-face { ... src: url(...) format(); }) adds a fixed ~157 bytes regardless of font size — so the *relative* CSS overhead falls below 0.1% past about 200 KB.
Does gzip recover the base64 inflation?
Depends on whether the font was already compressed. For WOFF2 (which is brotli-compressed internally) and WOFF (zlib-compressed), gzipping the base64'd CSS recovers to within 1% of the raw binary's gzipped size — the base64 layer adds almost zero entropy. For TTF and OTF (uncompressed formats), gzipping the base64'd CSS is **17–23% larger** than gzipping the raw binary, because the base64 transform scrambles the table-level redundancy that gzip would otherwise exploit. If you must inline, ship WOFF2.
Which CSS minifiers are safe with base64 @font-face?
We verified lightningcss (the minifier Next.js, Tailwind v4, and Parcel use) byte-perfectly preserves a standard-base64 payload. **It does not** handle two cases: (1) base64 with embedded whitespace (newlines / spaces) silently DROPS the whole @font-face rule, leaving only the font-family; (2) URL-safe base64 (- and _) is preserved unchanged in the output, but browsers' atob then rejects it. Both failures are silent at minify time. Use the validator above on the build artifact to catch them.
Are URL-safe `-` and `_` ever OK in a font data URI?
No. The data: URI scheme (RFC 2397) references standard base64 (RFC 4648 §4), which uses + and /. URL-safe base64 (RFC 4648 §5) uses - and _ and is for situations where you want to embed the payload in a URL path or query string without further escaping. Browsers' WHATWG forgiving-base64 decode — which atob and the data: URI decoder both use — only allows the standard alphabet. Tested: any - or _ causes atob to throw Invalid character. Re-encode if your source produced URL-safe.
Is padding (the trailing `=`) optional?
In practice yes, in principle no. The spec (RFC 4648 §3.2) requires padding. Both browsers' WHATWG forgiving-base64 decode and the lightningcss minifier silently re-add it, so a payload missing = characters works end-to-end. But some stricter parsers (server-side CSS validators, third-party content scanners, older Java/.NET decoders) will reject. Cost to add the padding is at most 3 characters; just emit it.
Should I include WOFF and TTF fallbacks in the same @font-face?
Almost never. Modern browsers (every supported version since 2018) accept WOFF2; the inlined WOFF fallback never gets used. Each fallback you inline costs another ~33% on top of its raw size, in CSS that's render-blocking. If you support a browser old enough to need WOFF, link the WOFF externally and inline only the WOFF2: src: url(data:font/woff2;base64,...) format('woff2'), url(/fonts/x.woff) format('woff');
Why does my inlined font fail with no error in Chrome devtools?
Six likely silent failures, in order of how often we see them: (1) font-src CSP directive without data:; (2) MIME type is missing or text/plain; (3) base64 contains whitespace and the minifier stripped the rule; (4) base64 uses URL-safe alphabet (- _); (5) @font-face is inside a <style> block that itself failed CSP; (6) the format() hint disagrees with the actual font bytes. Open the Network tab and search for data:font — if the entry is missing, the rule was dropped at minify or parser time. Otherwise paste the block into the validator above.
Is there a hard size limit on data URIs in CSS?
Not in any browser engine in active development. The old 32 KB IE9 cap is long gone and IE is dead. Modern Chrome, Firefox, Safari, and Edge all handle multi-MB font data URIs without limit-related errors. The binding constraints are performance (parsing a 5 MB inline font blocks the main thread for 50–200 ms) and cacheability (an inlined font can't be cached across pages independently of the CSS). Bundler warnings (Webpack at 4 KB, Vite at 4 KB) are *guidance* thresholds for asset-inlining decisions, not hard limits.
Does inlining fonts help or hurt LCP / FCP?
Hurts more often than helps. A render-blocking CSS file with a 40 KB inlined WOFF2 delays first paint by however long the CSS takes to download and parse. An external <link rel="preload" as="font"> lets the font fetch start in parallel with the CSS, and font-display: swap means the fallback text renders immediately. Inline only when (a) the font is tiny (<5 KB subset), (b) it's truly critical (logotype, headline), and (c) you're confident the CSS itself is on the critical path. The base64 vs external comparison has the full numbers.
Can SourceMaps break because of inlined fonts?
Yes — multi-MB inlined fonts can blow SourceMaps past the ~10 MB threshold devtools refuse to parse, because the SourceMap embeds each source token. The whole map silently fails to load and you lose readable stack traces. Strip data URIs from SourceMap inputs (postcss-discard-sourcemap-data and equivalents) or generate the @font-face CSS as a separate sourcemap-disabled file.
Why doesn't Outlook desktop render my inlined font?
Outlook on Windows uses Microsoft Word's HTML rendering engine, which strips every @font-face rule before rendering — inlined or external, base64 or URL. There is no workaround that gets the brand font into Outlook desktop. Design the fallback (-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) so the email still reads like itself. Apple Mail, iOS Mail, Gmail web, and Gmail mobile all do render inlined fonts correctly. The HTML email guide covers the full pattern.
Are inlined fonts privacy-friendly?
Yes — that's one of the genuine wins. No font CDN sees the visitor, no third-party tracker fires from the font loader, no GDPR cookie-consent banner needs to gate the font request. The font lives in your own CSS file under your own domain. For GDPR-strict deployments where this is the motivation, also see self-hosting Google Fonts the GDPR-compliant way — same privacy property, without the base64 inflation.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.