D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

Astro Kept 19 MB of Original Images in `_astro` With No Trace in the Build Log? One `.width` Read on a Glob Module

· · 9 min read
Astro Kept 19 MB of Original Images in `_astro` With No Trace in the Build Log? One `.width` Read on a Glob Module

The covers on this blog used to ship at 1600px everywhere, from the cards on the index page to the cover on each article page. The audit dated 8 September 2026, done after the domain cutover, listed it among the work still undone: 238 covers at 1600x894 were sent into a box roughly 230px wide with no srcset at all, so a single /blog page carried 20.5 MB of images. The same notes called those covers the largest byte item on the whole site, far ahead of the JavaScript.

The cause was no mystery. The covers lived in public/, so astro:assets never processed them, even though the astro:assets path had already proven itself for project images in the same repo. The fix only had to reuse that pattern, and in the end that is what it did. The catch was that the first attempt left one more file per cover than it asked for, and that file almost slipped through unnoticed.

The pattern that was already clean on the projects page

The decision about images had been made earlier: astro:assets was enough, with no script and no new dependency, because sharp already sits in Astro's dependency tree. The projects list was using it before the covers were. A separate script for making small thumbnails was never written, and the thumbnails went through astro:assets at two widths instead. The shape is an eager glob over files in public/, then one getImage call per width with modul.default as the source:

// src/pages/projects/index.astro, excerpt
const sumberThumb = import.meta.glob<{ default: ImageMetadata }>(
  '../../../public/images/thumbnail/*.webp',
  { eager: true },
);
// ...
  const modul = sumberThumb[`../../../public${proyek.image}`];
  if (!modul) continue;
  const kecil = await getImage({ src: modul.default, width: 320, format: 'webp' });
  const sedang = await getImage({ src: modul.default, width: 960, format: 'webp' });

The result was clean. The /projects list rows dropped from 4.15 MB to 0.17 MB, 96 percent, without adding a single file to public/.

The covers use the same pattern, an eager glob over the cover files and then getImage per width with src: modul.default, only with four widths. Those widths came from boxes that actually exist in the CSS, checked again in the browser, then doubled for dense screens, which put them at 320, 640, 960, and 1440.

Four widths requested, five files out

The problem was measured on 9 September 2026, while srcset was being added to those 238 covers. Four widths were requested per cover, but the output held five files per cover. The fifth was the 1600px original, copied into /_astro/ under a hashed name even though zero pages pointed to it. It cost 19 MB per deployment, and the build log carried not a single sign of it, because that file was never generated, only copied. The commit message for the fix calls it a trap that almost slipped through.

The trigger was one line that looked harmless. The first attempt took each cover's original dimensions straight from its image module, and my note marks the first of these two lines as the trigger:

width: modul.default.width,     // <- ini pemicunya
height: modul.default.height,

My notes keep the projects page as the point of comparison. The same pattern had been running there since its thumbnails moved to astro:assets, and that page never kept an original, because it only passes modul.default along without reading anything from it.

A Proxy that records every read

The explanation is in the code of the installed astro 7.3.1, the same version pinned in package.json at the fix commit. It starts at build time. When the command is build, the astro:assets:esm plugin emits the contents of an image imported through ESM as an asset via emitClientAsset, and an eager glob counts as an ESM import. So the original does get written to the output, and its fate is only decided later.

The image module itself is wrapped in a Proxy. Its getter is written as a code template, so the ${...} parts are filled in when the plugin generates the image module's code:

// node_modules/astro/dist/assets/utils/proxy.js (astro 7.3.1), excerpt
get(target, name, receiver) {
	if (name === 'clone') {
		return structuredClone(target);
	}
	if (name === 'fsPath') {
		return ${stringifiedFSPath};
	}
	${!isSSR ? `if (target[name] !== undefined && globalThis.astroAsset) globalThis.astroAsset?.referencedImages?.add(${stringifiedFSPath});` : ""}
	return target[name];
}

For clone, the getter returns structuredClone(target). For fsPath, it returns the file path. For any other property whose value is not undefined, the file path is recorded in globalThis.astroAsset.referencedImages. That recording line is only inserted when isSSR is false, and in vite-plugin-assets.js the value passed as isSSR is only true when buildOutput is "server" and the Vite environment is named ssr.

That referencedImages record is read in generate.js. The original is unlinked when its originalSrcPath is not in referencedImages, and the message about it goes through env.logger.debug, not info:

// node_modules/astro/dist/assets/build/generate.js (astro 7.3.1), excerpt
if (transformsAndPath.originalSrcPath && !globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath)) {
  try {
    if (transformsAndPath.originalSrcPath) {
      env.logger.debug(
        "assets",
        `Deleting ${originalFilePath} as it's not referenced outside of image processing.`
      );
      await fs.promises.unlink(getFullImagePath(originalFilePath, env));
      // ...

So Astro does throw away originals that only served as raw material for the derivatives, and reading .width cancels that cleanup. A single property read is enough for the 1600px file to count as referenced outside of image processing.

The projects page pattern makes sense from here too. getImage in astro 7.3.1 reads fsPath and clone from the src of an ESM image, the two properties the getter handles before it reaches the recording line. After that, width and height are taken from the clone, and the clone is what serves as src from then on.

The quiet log is explained as well. Each generated derivative is logged with env.logger.info, while the deletion of an original only shows up at debug level. An original that stays behind was never generated, so it gets no line of its own. At the default log level, the correct state and the broken state print the same derivative lines, and neither says anything about the original.

Dimensions from constants, the module handed over whole

The rule now is a single one: a glob module may only be handed to getImage whole. When the original dimensions are needed, they come from somewhere else, such as a constant that has been checked against the files. In src/lib/sampul.ts the dimensions live in two constants, LEBAR_ASLI and TINGGI_ASLI (original width and height), and the hitung function returns width and height from them. The comment above the constants says the dimensions were measured directly against all 238 files and forbids replacing them with modul.default.width:

// src/lib/sampul.ts, excerpt
const LEBAR = [320, 640, 960, 1440];
 
/**
 * Dimensi asli seluruh sampul, diukur langsung ke 238 berkasnya.
 *
 * JANGAN diganti dengan `modul.default.width`. ...
 */
const LEBAR_ASLI = 1600;
const TINGGI_ASLI = 894;
 
// inside hitung()
  const varian = await Promise.all(
    LEBAR.map((lebar) => getImage({ src: modul.default, width: lebar, format: 'webp' })),
  );
  // ...
    width: LEBAR_ASLI,
    height: TINGGI_ASLI,

A comment in the same file records the measurement: the 19 MB per deployment disappeared entirely once the property read was removed. The file's git history holds a single commit, aefcf8b dated 9 September 2026, with the subject "perf(blog): kirim sampul sesuai lebar kotaknya, bukan 1600px ke semuanya", which says to send each cover at the width of its box instead of 1600px to all of them.

Counting files per image

Since the log is no help, the check has to look at the build output. Count how many files per image land in dist/client/_astro. If the count is one more than the number of widths requested, the extra one is the original. My notes keep two outputs of this check side by side: [(5, 238)] for the broken state, when something reads a property of the module, and [(4, 238)] for the correct one. Read it as five files for each of the 238 covers, then four.

Below is a simple shell version for a similar check. It is not the script that produced the output above. It groups .webp files by the part of the name before the first dot, so it only helps when the derivatives and the original share a prefix like that. Look at the naming in your own output folder first.

# simplified: group .webp files by base name, then count files per group
ls dist/client/_astro/*.webp | sed -E 's#.*/##; s/\..*//' | sort | uniq -c | awk '{print $1}' | sort -n | uniq -c

Each output line holds two numbers: how many images, then how many files per image. For images requested at four widths, the second number should be 4, not 5.

What the derivatives cost, and the file that bought nothing

Measured by summing the bytes of the candidates the browser actually picked, not through a page tool, the image weight of the blog index dropped from 19.57 MB to 1.89 MB at DPR1 and 5.47 MB at DPR2. That is about 90 percent lighter at DPR1 (1.89 divided by 19.57 is roughly 0.097) and about 72 percent at DPR2 (5.47 divided by 19.57 is roughly 0.28). The topic hubs went from 0.57 MB to 0.08 MB, an article from 0.24 MB to 0.03 MB, and the homepage from 0.37 MB to 0.05 MB. The starting figure of 19.57 MB does not match the 20.5 MB in the audit a day earlier. They come from two different measurements, and I am reporting both as they are.

The derivatives are not free. Build output grew from 147 MB to 189 MB, and that number counts against the Deployment Storage quota. The build on Vercel went from 55 seconds to 2 minutes. That increase buys much lighter pages. The fifth file bought nothing: 19 MB per deployment for a file that not a single page references.

The originals in public/ were deliberately left alone, because og:image still uses them and share card links that are already out there must not break. So the 1600px covers stay on the site for that purpose, and the hashed copy in /_astro/ was only an extra copy. The derivatives themselves land in /_astro/, which is already served as immutable for a year, and that was verified on the live site.

One rule for glob modules

An image module from import.meta.glob looks like a plain metadata object, but in astro 7.3.1 it is a Proxy that records property reads other than clone and fsPath. One read is enough for the original to count as used, and Astro stops removing it from the output without a single line in the log. The only safe thing to do with that module is to hand it to getImage whole. Original dimensions come from constants checked against the files, and every time this pattern is touched, count the files per image in the build output, because the log will not say anything.