You ship a frontend change, merge the PR, deploy to production. Two days later your PM asks "what exactly changed on the pricing page last Tuesday?" and you're digging through git logs trying to reconstruct what the page looked like before and after.
Git tracks code. It doesn't track what the rendered page actually looked like to users.
A visual changelog fixes that. Capture a screenshot of key pages after every deploy, store them with timestamps, and you've got a browsable history of how your app looked at any point. Here's how to set one up.
The basic idea
After each deploy, a script hits a list of URLs and saves screenshots with metadata:
- URL
- Timestamp
- Git commit hash
- Deploy environment
You end up with a folder (or database) of timestamped screenshots you can scroll through. "What did the homepage look like three deploys ago?" becomes a five-second lookup instead of a twenty-minute archaeology project.
A minimal implementation
Here's a Node.js script that captures screenshots of a list of pages using Playwright:
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const PAGES = [
{ name: 'homepage', url: 'https://yourapp.com' },
{ name: 'pricing', url: 'https://yourapp.com/pricing' },
{ name: 'dashboard', url: 'https://yourapp.com/dashboard' },
];
const COMMIT = process.env.GIT_COMMIT || 'unknown';
const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-');
const OUTPUT_DIR = path.join(__dirname, 'changelog', TIMESTAMP);
async function capture() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
});
for (const page of PAGES) {
const tab = await context.newPage();
await tab.goto(page.url, { waitUntil: 'networkidle' });
await tab.screenshot({
path: path.join(OUTPUT_DIR, `${page.name}.png`),
fullPage: true,
});
console.log(`captured ${page.name}`);
}
// save metadata
fs.writeFileSync(
path.join(OUTPUT_DIR, 'meta.json'),
JSON.stringify({ commit: COMMIT, timestamp: TIMESTAMP, pages: PAGES.map(p => p.name) })
);
await browser.close();
}
capture().catch(console.error);
Run this as a post-deploy hook. In a GitHub Actions workflow:
- name: Capture visual changelog
run: node capture-changelog.js
env:
GIT_COMMIT: ${{ github.sha }}
The problem with running your own browser
This works. I used a setup like this for about four months. Then I hit the issues:
Memory. Chromium eats 300-500MB per instance. On a CI runner with 4GB RAM, capturing 15 pages sequentially takes a while. Doing it in parallel kills the runner.
Font rendering. Screenshots taken on Ubuntu CI runners look different from macOS or Windows. If you're comparing screenshots across deploys and your CI runner image updated, every single screenshot shows a "diff" that's really just font antialiasing.
Flaky waits. doesn't mean "the page looks right." SPAs that fetch data on mount will show loading spinners in half your captures. You end up writing per-page wait logic and maintaining it forever.
After dealing with all three I switched to hitting a screenshot API (I use ScreenshotRun for this) instead of running Playwright locally. Same script structure, but instead of launching a browser you make an HTTP call:
const fetch = require('node-fetch');
const fs = require('fs');
async function captureViaApi(url, outputPath) {
const response = await fetch(
`https://api.screenshotrun.com/capture?` +
`url=${encodeURIComponent(url)}&width=1440&height=900&full_page=true`,
{ headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
);
const buffer = await response.buffer();
fs.writeFileSync(outputPath, buffer);
}
No browser to manage, consistent rendering environment, and it handles the wait logic for you. Tradeoff is you're paying per screenshot, but for a changelog that captures 10-20 pages per deploy it's negligible.
Making the changelog browsable
A folder of PNGs isn't great for anyone except the person who set it up. Build a simple viewer.
The laziest version that actually works: generate an HTML file that lists screenshots grouped by deploy.
function generateIndex(changelogDir) {
const deploys = fs.readdirSync(changelogDir)
.filter(d => fs.existsSync(path.join(changelogDir, d, 'meta.json')))
.sort()
.reverse();
let html = '';
for (const deploy of deploys) {
const meta = JSON.parse(
fs.readFileSync(path.join(changelogDir, deploy, 'meta.json'))
);
html += `<h2>${deploy} — ${meta.commit.slice(0, 8)}</h2>`;
for (const page of meta.pages) {
html += `
<h3>${page}</h3>
<img src="${deploy}/${page}.png" style="max-width: 100%; border: 1px solid #ccc;" />
`;
}
}
html += '';
fs.writeFileSync(path.join(changelogDir, 'index.html'), html);
}
Deploy this to a static hosting bucket or serve it from an internal tool. PMs and designers can now browse the visual history without asking engineering.
Side-by-side diffing
The real power comes when you compare consecutive deploys. For each page, show the previous and current screenshot next to each other.
You can get fancier with pixel-level diffing using a library like :
const pixelmatch = require('pixelmatch');
const { PNG } = require('pngjs');
function diffScreenshots(beforePath, afterPath, diffPath) {
const before = PNG.sync.read(fs.readFileSync(beforePath));
const after = PNG.sync.read(fs.readFileSync(afterPath));
const diff = new PNG({ width: before.width, height: before.height });
const mismatchedPixels = pixelmatch(
before.data, after.data, diff.data,
before.width, before.height,
{ threshold: 0.1 }
);
fs.writeFileSync(diffPath, PNG.sync.write(diff));
return mismatchedPixels;
}
If is above zero, something changed visually. You can even fail the deploy or send a Slack notification when unexpected visual changes are detected.
What pages to track
Don't capture everything. Start with pages that matter most:
- Landing page and pricing — these change often and have business impact
- Auth flows — login, signup, password reset. Regressions here directly cost you users
- Core product screens — the 3-5 pages users spend the most time on
- Email templates — if you render them as HTML, screenshot those too
I started with 6 pages and now track about 20. Add pages when something breaks that you wish you'd been tracking. Don't over-engineer it upfront.
What I'd skip
- Pages with lots of dynamic content (dashboards with live data, feeds with user-generated content). The screenshots will always look "different" and you'll stop paying attention.
- Admin panels. Unless you've got non-technical admins who report UI bugs, probably not worth it.
- Every single marketing page. Track the ones that convert, not the ones that exist.
The whole setup takes maybe an afternoon. The first time someone asks "when did the pricing page change" and you pull it up in ten seconds, it pays for itself.
Top comments (0)