How One Build Script Cut Image Weight 88% on a Marketing Site
A build-time Node.js image pipeline cut site images from 115 MB to 14 MB through resizing, JPEG encoding, and metadata stripping.
The 115 MB marketing site nobody noticed
Marketing sites accumulate image weight quietly. Nobody sets out to ship a site where the images alone weigh more than most desktop applications. It happens incrementally: a designer exports a hero at 2x resolution, someone uploads a screenshot straight from Figma, a blog post gets a header image nobody compresses, and a year later the site's total image payload is 115 MB.
We recently helped a client with exactly this situation. Their Next.js marketing site had grown to deliver 115 MB of images across roughly 50 pages. The hero image alone was 2.1 MB. The fix was not a redesign, not a new image CDN, not a heavy architectural investment. It was a single build-time script that reduced total image weight to 14 MB — an 88% reduction — and cleared a Lighthouse forced-reflow warning in the same pass.
This article covers how that script works, why build-time optimization is the right layer for this problem, and what it costs in tradeoffs.
Why marketing sites end up this heavy
Image weight tends to be an invisible failure because it lacks a dramatic trigger. A site does not break. Nothing crashes. Users do not report an error. The problem expresses itself as slower loads, worse search rankings, and higher bounce rates — all of which get misdiagnosed as other issues.
The root causes are structural, not a matter of anyone being careless.
Source assets are exported at the wrong size. Designers typically export images for the largest display size they anticipate, often at 2x for retina screens. A hero that renders at 1,440 pixels wide gets exported at 2,880 pixels. If that export is also a PNG or an uncompressed JPEG, the file is enormous before it ever touches the site.
Nobody owns image optimization. In a marketing site, images come from designers, content writers, and occasionally the engineering team. Each contributor optimizes — or fails to optimize — to their own standard. There is no single owner accountable for total payload, so the site drifts upward.
The framework does not save you. Next.js's built-in next/image component handles responsive sizing, lazy loading, and format negotiation at the request layer. But it is not a compression pipeline. If the source file is a 2.1 MB JPEG, next/image resizes it but does not meaningfully compress it. Garbage in, proportionally heavy output out.
Screenshots and exports bypass the pipeline entirely. A designer drops a full-resolution Figma export into the assets folder, and it ships as-is because no validation step checks whether the file is reasonable. This is how a 2.1 MB hero happens.
The pattern we noticed in this client's site was typical: a handful of very large images in hero positions carrying most of the weight, and a long tail of moderately heavy images across the rest of the pages. The fix needed to address the whole population, not just the worst offenders.
The right layer for the fix
There are several places image optimization can happen, and choosing the wrong one creates avoidable complexity.
Client-side or request-time optimization — tools like next/image and image CDNs — resize and format-convert on demand. This is flexible but adds a runtime dependency and a network round trip. For a content-heavy marketing site it also requires an infrastructure decision (a CDN, a provider) before anything improves.
Manual optimization — asking contributors to compress their own exports — fails for the same reason the problem started: no one owns it, and discipline erodes quickly.
Build-time optimization is the right layer for a marketing site whose images are known at build time. The images are static assets sitting in the repository. They do not change between builds. So the optimization can happen once, during the build, and the site ships with images that are already as small as they are going to get.
This is deterministic work. There is no ambiguity, no judgment call, no probabilistic component. Given a source image and a target format and quality, the optimizer produces a predictable output. That makes it a perfect candidate for a script — not an AI system, not a runtime service, just a reliable build step.
Octacer typically approaches this by writing a small Node.js script that runs as part of the build pipeline, using sharp for image processing and mozjpeg for JPEG encoding. Both are well-established libraries. sharp is a high-performance image processing library; mozjpeg is the JPEG encoder produced by Mozilla, known for producing smaller files at equivalent visual quality than the baseline libjpeg encoder.
How the build script works
The script walks the image directory, processes every image it finds, and replaces the originals with optimized versions. In practice it does four things per image.
1. Enumerate the target files
The script needs a defined scope. In this case it targeted .jpg, .jpeg, .png, and .webp files under the public images directory, excluding anything already optimized (typically by checking for a marker in the filename or by tracking a manifest).
const files = await glob('public/images/**/*.{jpg,jpeg,png,webp}');
2. Resize to a sane maximum dimension
Largest-in-practice matters more than largest-possible. A hero image that renders at 1,440 pixels wide does not need to be 2,880 pixels on disk. The script caps the longest edge at a known boundary, typically 2,048 pixels for heroes and 1,600 for content images.
const resized = sharp(inputPath)
.resize({ width: MAX_WIDTH, withoutEnlargement: true })
.toBuffer();
The withoutEnlargement flag matters: it prevents the script from upscaling images that are already smaller than the cap.
3. Encode with mozjpeg
The encoding pass is where most of the weight disappears. sharp composes with mozjpeg when the format and quality settings are specified, and the quality parameter can be tuned per image type. Hero images got a higher quality setting, content images a lower one.
const output = await sharp(resized)
.jpeg({ quality: 82, mozjpeg: true, progressive: true })
.toFile(outputPath);
The progressive flag is a small win most people skip. It enables progressive JPEG rendering, where a low-quality version of the image displays immediately and sharpens as the file downloads. Perceptually the image feels faster, and it coexists well with lazy loading.
4. Preserve EXIF only when it matters
By default, the script strips EXIF metadata — the camera settings, GPS coordinates, and software tags embedded in the file — unless a specific image needs to keep it (for example, product shots where the client wants to retain copyright metadata). Stripping EXIF removes weight that serves no rendering purpose and is a privacy improvement for any images that contain location data.
What changed in the numbers
The client's site had roughly 50 pages and an image library in the hundreds of files. The before-and-after is worth stating plainly because it shows the scale of what was shipping:
Total image weight
Total image weight: 115 MB → 14 MB — an 88% reduction.
Hero image: 2.1 MB → 239 KB — a 91% reduction on the single most visible asset on the site.
Lighthouse forced-reflow warning cleared. The optimization pass exposed that the hero image was being loaded without explicit dimensions, forcing the browser to reflow the layout once the image arrived. Adding width and height attributes as part of the same change removed the layout shift.
Hero image
The forced-reflow finding is a good illustration of why unoptimized images are rarely an isolated problem. The heavy image was also causing a layout shift, which is a distinct Core Web Vitals failure. Fixing the size opened the door to fixing the layout behavior in the same review cycle.
Lighthouse warning
The same pass also established an SEO baseline. With image weight down and the layout-shift warning cleared, the site's Core Web Vitals were in a defensible position. That matters because Core Web Vitals are a ranking signal, and the site now had a measurable, documented baseline to track against.
Finally, the exercise surfaced real ship-hygiene problems: several images of outdated banner versions sitting in the assets folder, a few files that were accidentally duplicated, and at least one image that was a full-resolution screenshot of an interface being used where a simple illustration would have served. The build script could not fix those by itself, but it made them visible — and clean-up followed naturally.
What good looks like after the change
The observable signals that the fix is working are concrete:
- Total image footprint is known and bounded. The site's image weight is now a number in the build output, not an unknown. If it regresses, the build can flag it.
- The heaviest assets are the ones you can see. With the hero down to 239 KB, the worst offender on the site is now within a reasonable budget instead of being an order of magnitude over it.
- The build is deterministic. The same source images produce the same optimized output every time. There is no runtime dependency, no CDN configuration, no external service that could degrade.
- Core Web Vitals are clean and documented. The Lighthouse warning is gone, and there is a baseline to compare against.
One detail worth noting: this approach does not remove the job of owning image quality. It removes the job of manually compressing every asset. A contributor still needs to export at a sensible resolution. But the script catches the failure mode where a 2,880-pixel hero or a full-resolution screenshot makes it into the repo — because the build step forces every image through the same optimization.
The tradeoffs you should know about
Build-time optimization is not the right answer for every site, and the tradeoffs deserve to be stated plainly.
It only works for images known at build time. If your images are user-generated, uploaded at runtime, or fetched from a CMS that changes frequently, build-time optimization does not help. In those cases, a request-time optimizer or an image CDN is the appropriate layer. This approach targets a static marketing site whose content changes through deployments, not through user uploads.
It converts everything to JPEG. PNGs with transparency become JPEGs, which means losing the alpha channel. For images that genuinely need transparency — logos, some illustrations, product shots with transparent backgrounds — the script needs to pass them through or convert them to WebP instead. In this client's site, the transparent assets were few, and the loss was acceptable. For a site with heavy transparency use, the script would need a format decision per image.
It does not fix lazy loading or layout shift by itself. The script shrinks the files. It does not add loading="lazy" attributes, does not set explicit dimensions, and does not reorder how images load. Those are separate changes. In this case the Lighthouse warning was addressed as part of the same work, but it required editing the rendering code, not just the build script.
There is a risk of over-optimizing. A site that regenerates all its images on every build will have a slower build. For a marketing site with hundreds of images this is measured in seconds, not minutes, and is a reasonable trade. For a site with tens of thousands of images, the build cost becomes a real consideration, and you would likely want to optimize only changed files or move to a different layer entirely.
When to reach for this pattern
This approach makes sense when most of these are true:
- Images live in the repository or are pulled deterministically during the build.
- The site is marketing or content-heavy, where pages rarely change without a deployment.
- No image CDN or runtime optimizer is already in place.
- Total image weight is an unknown, and you suspect it is a problem.
- The team wants a deterministic, auditable fix rather than a managed service.
It makes less sense when images are dynamic, when a CDN-based workflow is already established, or when build time is a hard constraint.
A practical next step
If your marketing site has never had its image payload measured, that is the first thing to check. There may be an opportunity to improve the site's Core Web Vitals without redesigning anything.
A useful starting point: run a build, measure the total size of everything under your images directory, and check the single largest file. If the total is in the tens of megabytes and the largest file is over a megabyte — with a hero that pulls its weight — the pattern in this article applies directly. The fix is a script, not a project.
Worth measuring the image directory? If you want a second opinion on whether your site's image pipeline has this problem, or help writing the build step, Octacer can look at the specifics with you.
Ready to Implement These Strategies?
Let's discuss how to apply these insights to your specific business challenges.
Schedule Consultation