Intro
In modern web development—especially within ecosystems like React and Next.js—bundle bloat is a silent performance killer. A single npm install for a minor utility can pull in heavy transitive dependencies, quietly adding hundreds of kilobytes to your client-side JavaScript bundles.
Unless team members are manually inspecting build outputs on every Pull Request, these spikes easily slip into production.
To solve this, I designed and built BundleDiff: an open-source CI/CD GitHub Action that calculates gzipped build sizes, computes delta reports against the base branch, writes metrics to database storage, and renders real-time dynamic SVG badges on Edge infrastructure.
Here is a deep dive into the architecture, technical decisions, and implementation details behind BundleDiff.
🏗️ Architecture Overview
The system consists of three main decoupled layers:
- The Core Runner (GitHub Action): A compiled TypeScript action running inside the CI container. It measures target build directories, calculates exact gzipped bytes, compares baseline metrics, and interacts with the GitHub REST API.
- State & Metrics Store (Supabase): Stores baseline measurements for primary branches using PostgreSQL to enable historical comparisons across builds.
- Badge Rendering Pipeline (Next.js Edge API): A serverless Edge function deployed on Vercel that queries real-time repository stats and generates sharp, inline SVG graphics directly on the fly.
🔬 1. Calculating Exact Gzipped Build Sizes
Measuring raw file sizes on disk isn't enough because browsers download compressed assets over HTTP/2 or HTTP/3. BundleDiff relies on calculating actual gzipped file sizes using Node.js filesystem streams and standard zlib compression.
Traversal and Buffer Reading
The core runner recursively scans the targeted build directory (e.g., .next/static, dist, or build), filtering for static assets (.js, .css, .html).
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
function getGzippedSize(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
const fileStream = fs.createReadStream(filePath);
const gzip = zlib.createGzip({ level: 9 }); // Maximum compression level
let size = 0;
fileStream
.pipe(gzip)
.on('data', (chunk: Buffer) => {
size += chunk.length;
})
.on('end', () => resolve(size))
.on('error', (err) => reject(err));
});
}
By streaming buffers directly into zlib.createGzip({ level: 9 }), BundleDiff accurately reflects the exact network transfer payload users will download in production.
📊 2. Delta Computation & Markdown Report Generation
When a Pull Request is opened, BundleDiff evaluates two states:
-
Target PR Directory (
pr-dir): The output generated by the current feature branch. -
Base Branch State (
base-dir/ Database Record): The metric stored whenmainlast compiled.
Calculating Delta & Bloat Thresholds
The action computes the delta (
) where:
If
> 0, the PR introduces bloat. BundleDiff formats this data into a standardized Markdown summary table and uses @octokit/rest to post or update a single comment on the PR thread, preventing comment spam across multiple commits.
🛡️ 3. Database Architecture (Supabase PostgreSQL)
To keep track of baseline sizes over time without relying on transient runner caches, baseline branch builds persist data directly to Supabase.
Database Schema
create table if not exists bundle_metrics (
id uuid default gen_random_uuid() primary key,
owner text not null,
repo text not null,
branch text not null,
total_size bigint not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Indexing for high-throughput lookup by owner/repo
create index idx_bundle_metrics_lookup on bundle_metrics (owner, repo, created_at desc);
Using Row Level Security (RLS) or project service_role tokens, the Action pushes a new metric record whenever a push occurs on main.
🏷️ 4. Dynamic SVG Badge Generation at the Edge
A key feature of BundleDiff is rendering a dynamic badge in repository README.md files showing real-time bundle sizes.
Instead of generating static image assets during CI, the badge endpoint (/api/badge) runs as a Vercel Edge Function:
- Intercepts the HTTP request containing
?owner=X&repo=Y. - Queries Supabase for the latest
total_sizerecorded formain. - Converts raw bytes into readable units (KB/MB).
- Returns a response with
Content-Type: image/svg+xmland Cache-Control headers(public, max-age=300, s-maxage=3600)to prevent heavy database hits while keeping badges fresh.
🛠️ Key Takeaways & Open Source Stack
Building BundleDiff required bridging several developer tools into a single cohesive workflow:
-
TypeScript &
@actions/corefor structured CI execution. - Supabase (PostgreSQL) for lightweight, persistent metric state.
- Next.js & Vercel Edge Network for sub-millisecond dynamic image responses.
The project is completely open source under the MIT License and published on the GitHub Marketplace.
- GitHub Repository: GitHub Repo: BundleDiff
- GitHub Marketplace: BundleDiff



Top comments (0)