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 (1)
전송할 때의 크기와 변경되지 않은 이미지가 호스트 파일의 배포마다 다시 내려오는 비용을 따로 설명한 부분이 좋았습니다. CI에서 번들 전체 크기뿐 아니라 새로 인라인된 자산 목록과 크기 변화도 함께 보여주면, 리뷰어가 어떤 이미지를 분리할지 판단하기 더 쉬울 것 같네요.