TL;DR
- Base64 turns every 3 bytes into 4 characters, about 33% bigger. On a gzipped response an image's penalty shrinks to about 1% (1.010x of the original in our test), but an uncompressed response pays the full third.
- Rule of thumb: inline only an icon of a few KB that your own code owns and uses once. Serve everything else as a file, because an inlined image re-downloads with every revision of the file that holds it.
- In the browser use
FileReader.readAsDataURL. In Python usebase64.b64encode(...).decode("ascii").btoathrows on any character outside Latin-1.
A reviewer writes "just base64 it", and the image moves into the file that holds it. It can no longer be cached on its own, it comes back with every deploy of that page, and it is a third bigger before compression. Nobody on the thread can say what the change cost, because the diff only shows a long string.
Base64 adds a third; gzip hides it for images
Every 3 bytes of input become 4 characters of output, so the encoded size is 4/3 of the original plus = padding to fill the last group (RFC 4648, section 4). We encoded random bytes in Python 3.14 to see the ratio. Padding adds at most two characters, which is why the smallest input is slightly worse and larger ones settle at 4/3:
| Original bytes | Base64 bytes | Ratio |
|---|---|---|
| 1,000 | 1,336 | 1.3360x |
| 10,000 | 13,336 | 1.3336x |
| 100,000 | 133,336 | 1.3334x |
The usual objection is that gzip fixes this. For images it nearly does, on a compressed response. We used 50,000 random bytes as a stand-in for a PNG or JPEG, which are already compressed. As base64 they became 66,668 bytes. Gzip at level 9 shrank that text to 50,508 bytes, which is 1.010x the original binary. Gzip on the raw bytes gave 50,038 (1.001x).
That ~1% figure holds for already-compressed images. An uncompressed API response, an email body or a stylesheet your server does not compress pays the full third. Even in the best case, gzipped base64 stayed above the raw size in our test, so inlining at most ties on bytes.
SVG is the exception worth knowing. It is text, and a data URL does not require base64: the syntax is data:[<media-type>][;base64],<data> (MDN), so ;base64 is optional. In our reading, URL-encoding an SVG usually beats base64 because only the special characters grow. In our test a 13,800-byte SVG became 18,400 bytes as base64 (1.333x).
Inline an image only if it is tiny, code-owned and used once
Run each candidate through three questions:
- Is it small? Our rule of thumb is a few KB at most. That is a heuristic, not a measurement. A 16-pixel icon usually passes, and a photo almost never does.
- Does it change only when the file that embeds it changes? A logo mark in your CSS qualifies. A user avatar does not.
- Does it appear once? Each inline copy adds its full encoded size to the uncompressed file, to memory and to parse work. A compressed wire softens this, because gzip back-references the repeated copy. We embedded a 20,000-byte random payload as base64 once and it gzipped to 20,250 bytes. Embedded twice, it gzipped to 20,567 (1.016x), not 2.0x. You still ship the full size to anyone without compression.
If any answer is no, serve a file. A file gets its own URL, its own cache entry and its own headers, and lazy loading can skip it entirely.
Try this: search your stylesheets for
url(data:and run each match through the three questions. Every hit that fails one should become a file.
An inlined image re-downloads with every deploy
An inlined image has no cache entry of its own. It lives inside its host: your HTML page, your CSS bundle or your JS bundle. Change one line of that file, deploy, and every returning visitor downloads the image again with it. A served file keeps its own entry and its own validators through all of those deploys. A data URL has neither.
A quick piece of arithmetic, not a measurement: a 20,000-byte image inlined becomes 26,668 bytes. If its host file ships 10 times and a visitor returns after each deploy, that visitor downloads 266,680 bytes for an image that never changed. As a file, they download 20,000 bytes once. Compression shrinks the inlined number; the already-compressed file itself barely compresses at all.
A data: URL is also an opaque origin. MDN states it directly: "Data URLs are treated as unique opaque origins by modern browsers" and "top-level navigation to data: URLs is blocked in all modern browsers". You cannot link to one as a page or open one in a new tab to debug it. If you run a Content Security Policy, img-src must also allow data:, and in our reading that allows every data image, not only yours.
JavaScript: FileReader for files, TextEncoder before btoa
For a user-selected file, let the browser do it:
// `file` comes from <input type="file"> or a drop event
const reader = new FileReader();
reader.onload = () => console.log(reader.result.slice(0, 40));
reader.readAsDataURL(file);
// data:image/png;base64,iVBORw0KGgoAAAANSU
We ran this in Chromium with a 1x1 PNG. reader.result is the complete data URL, ready for img.src. If you need only the base64 part, take everything after the first comma.
btoa is the other tool people reach for, and it has a trap. It accepts only Latin-1 characters and throws on anything else:
try { btoa("café ☕"); } catch (e) { console.log(e.name); }
// InvalidCharacterError
const bytes = new TextEncoder().encode("café ☕");
console.log(btoa(String.fromCharCode(...bytes)));
// Y2Fmw6kg4piV
The fix is to encode the text to UTF-8 bytes first, then turn each byte into one character for btoa. That is for strings. The spread in String.fromCharCode(...bytes) passes every byte as a function argument, so it suits short strings only. On Node v24.22.1 it worked with 120,000 arguments and threw RangeError at 200,000. For image files, skip btoa and use readAsDataURL.
Python: b64encode returns bytes, so decode it
import base64
raw = bytes([0x89, 0x50, 0x4E, 0x47, 0xFB, 0xFF, 0xFE])
print(base64.b64encode(raw).decode("ascii"))
print(base64.urlsafe_b64encode(raw).decode("ascii"))
# iVBOR/v//g==
# iVBOR_v__g==
b64encode returns bytes, so .decode("ascii") gives you a string you can drop into an f-string such as f"data:image/png;base64,{encoded}". The standard alphabet uses + and /. urlsafe_b64encode swaps them for - and _ (RFC 4648, section 5), which is what you want in a URL or a filename.
Check the host file's size after every merge that touches images
Inlining usually happens by accident: a bundler option, a loader default, a helpful teammate. The symptom is a host file that grows by about 4/3 of an image's size. Catch it in three steps:
- Record the size of your CSS and JS bundles on your main branch, for example
wc -c dist/app.css. - After a merge, compare. A jump close to 4/3 of some image's byte size means that image got inlined.
- Run it through the three questions above. If it fails one, extract it and serve it as a file.
Put the size check in CI with a threshold, and the review comment stops being a guess about cost.
Sourcing note: ratios and sizes come from random bytes encoded on Python 3.14 on 2026-11-02. The gzip repeat test and the spread limit ran on 2026-11-03 on Node v24.22.1, and the other snippets ran on Node 24, Chromium and Python 3.14. The HTTP/2 and HTTP/3 point, the cache and CSP points and the SVG advice are our reading of how browsers behave, and the deploy example is arithmetic. The opaque-origin and navigation quotes come from MDN.
Top comments (4)
The measurement showing that gzip shrinks the base64 penalty down to roughly 1.010x is striking, but the cache invalidation trade-off is the real operational killer. Bundling even a small asset inside JS or HTML means every deployment busts the asset cache for returning visitors. Do you maintain an explicit byte threshold in CI to flag inlined assets before they slip into production bundles?
The 1.010x is the part of the story compression tells, and you are right that it is the smaller half. It measures one transfer over the wire. The cost you point at arrives on the next deploy, when a returning visitor re-downloads the inlined bytes inside whatever file changed around them. Gzip cannot touch that, which is why the article ends on the cache argument rather than the ratio.
Straight answer to your question: not yet mechanical on my side. The working line is the article's few-KB rule of thumb, enforced by review. The check worth building is small: scan the built output for data: URIs, report each with its decoded size, diff that list against the previous build, and fail when a new inline crosses the line. Once that report exists, the threshold debate becomes a number a reviewer can see.
전송할 때의 크기와 변경되지 않은 이미지가 호스트 파일의 배포마다 다시 내려오는 비용을 따로 설명한 부분이 좋았습니다. CI에서 번들 전체 크기뿐 아니라 새로 인라인된 자산 목록과 크기 변화도 함께 보여주면, 리뷰어가 어떤 이미지를 분리할지 판단하기 더 쉬울 것 같네요.
좋은 제안입니다. 리뷰어가 비용을 추측하지 않게 하는 게 글의 요점인데, 새로 인라인된 자산과 크기 변화를 CI가 직접 보여주면 그 요점이 완성됩니다. 구현도 크지 않습니다. 빌드 결과에서 data: URI를 추출해 자산별 크기와 이전 빌드 대비 변화를 리포트로 남기고, 새로 인라인된 자산이 몇 KB 기준선을 넘으면 빌드를 실패시키면 됩니다. 참고로 gzip이 벌기를 1% 정도로 줄여주는 건 전송 구간 이야기이고, 파일이 바뀔 때마다 인라인 이미지가 같이 다시 내려가는 비용은 압축으로는 없어지지 않습니다. 그래서 이런 리포트가 리뷰에서 가장 먼저 볼 수 있는 신호가 될 것 같습니다.