t mutation engine. The following TypeScript implementation demonstrates the core logic, structured for production integration.
Step 1: Establish the Feature Mapping Registry
Hardcoding removal logic creates fragility. Instead, maintain a versioned registry that maps known polyfill packages to their corresponding web-features identifiers. This registry should be validated against the live dataset during CI to catch renames or deprecations before they reach production.
// registry/polyfill-map.ts
export interface PolyfillMapping {
packageName: string;
webFeatureId: string;
removalThreshold: string; // ISO date string
}
export const POLYFILL_REGISTRY: PolyfillMapping[] = [
{ packageName: '@ungap/structured-clone', webFeatureId: 'structured-clone', removalThreshold: '2023-04-15' },
{ packageName: 'array.prototype.flat', webFeatureId: 'array-flat', removalThreshold: '2022-07-15' },
{ packageName: 'whatwg-fetch', webFeatureId: 'fetch', removalThreshold: '2021-03-10' },
{ packageName: 'intersection-observer', webFeatureId: 'intersectionobserver', removalThreshold: '2020-09-01' },
];
Step 2: Validate Against Project Browser Targets
Global Baseline status is insufficient on its own. A feature marked as Widely available globally may still be unsupported in a project that explicitly targets older browser versions. The validation layer must cross-reference the web-features status with the project's browserslist configuration.
// engine/compatibility-validator.ts
import browserslist from 'browserslist';
import { POLYFILL_REGISTRY } from '../registry/polyfill-map';
export class CompatibilityValidator {
private projectTargets: string[];
constructor(browserslistConfig: string) {
this.projectTargets = browserslist(browserslistConfig);
}
async isRemovalCandidate(mapping: typeof POLYFILL_REGISTRY[0]): Promise<boolean> {
// 1. Verify global Baseline status (mock API call for demonstration)
const baselineStatus = await this.fetchBaselineStatus(mapping.webFeatureId);
if (baselineStatus !== 'high') return false;
// 2. Verify all project targets support the feature natively
const featureSupport = await this.fetchFeatureSupport(mapping.webFeatureId);
const allTargetsSupported = this.projectTargets.every(target => {
const [browser, version] = target.split(' ');
return featureSupport[browser]?.includes(version);
});
return allTargetsSupported;
}
private async fetchBaselineStatus(featureId: string): Promise<string> {
// In production, this queries the web-features dataset or a cached mirror
return 'high';
}
private async fetchFeatureSupport(featureId: string): Promise<Record<string, string[]>> {
// Returns browser version support matrix
return {
chrome: ['90', '91', '92', '93'],
firefox: ['88', '89', '90', '91'],
safari: ['14', '15', '16'],
};
}
}
Step 3: Execute Safe Manifest Mutation
Automatic source code rewriting introduces unacceptable risk. AST-based codemods frequently misinterpret dynamic imports, conditional polyfill loading, or framework-specific shims. The safe approach restricts automation to package.json mutation while flagging import sites for manual review.
// engine/manifest-mutator.ts
import fs from 'fs/promises';
import path from 'path';
export class ManifestMutator {
async removeDependency(projectRoot: string, packageName: string): Promise<{ success: boolean; warnings: string[] }> {
const pkgPath = path.join(projectRoot, 'package.json');
const raw = await fs.readFile(pkgPath, 'utf-8');
const pkg = JSON.parse(raw);
if (!pkg.dependencies?.[packageName] && !pkg.devDependencies?.[packageName]) {
return { success: false, warnings: ['Package not found in manifest'] };
}
delete pkg.dependencies?.[packageName];
delete pkg.devDependencies?.[packageName];
// Preserve formatting: trailing newline, key order, indentation
const formatted = JSON.stringify(pkg, null, 2) + '\n';
await fs.writeFile(pkgPath, formatted, 'utf-8');
return {
success: true,
warnings: [`Review import sites for '${packageName}' before committing.`],
};
}
}
Architecture Rationale
The design prioritizes determinism over convenience. By decoupling the removal decision from source code analysis, the system avoids false positives caused by dynamic module resolution or framework abstractions. The browserslist integration ensures that project-specific constraints override global Baseline signals. CI validation of the mapping registry prevents silent misconfigurations when the web-features dataset evolves. Finally, restricting automation to manifest mutation preserves developer trust: the tool handles dependency lifecycle management, while humans retain control over codebase modifications.
Pitfall Guide
1. Ignoring Project-Specific Browser Targets
Explanation: Assuming that a globally "Widely available" feature is safe to remove without checking browserslist will break builds for teams supporting legacy environments.
Fix: Always validate removal candidates against the project's explicit browser target configuration. Treat browserslist as the authoritative scope boundary.
2. Assuming Transitive Dependencies Are Covered
Explanation: Polyfills are often pulled in indirectly by third-party libraries. Removing a direct dependency does not guarantee the shim is eliminated from the final bundle.
Fix: Run a bundle analyzer or dependency tree audit (npm ls / yarn why) after manifest mutation to verify the polyfill is fully excised.
3. Automated Source Rewriting
Explanation: AST codemods frequently misinterpret conditional imports, dynamic require() calls, or framework-specific polyfill loaders, leading to silent runtime failures.
Fix: Restrict automation to package.json updates. Generate a precise import-site checklist and require manual verification before committing source changes.
4. Stale Feature Registry Mappings
Explanation: The web-features dataset evolves. Feature IDs get renamed, merged, or deprecated. A hardcoded mapping that isn't validated will silently misflag dependencies.
Fix: Implement CI checks that verify every registry ID against the live dataset. Fail the build if a mapping becomes invalid.
5. Over-Provisioning CI Permissions
Explanation: Granting broad repository permissions to automation workflows increases the blast radius of supply chain compromises or misconfigured jobs.
Fix: Apply least-privilege principles. Scope GitHub Actions to contents: write and pull-requests: write only. Use OIDC tokens instead of long-lived PATs where possible.
6. Skipping Post-Removal Validation
Explanation: Removing a polyfill from package.json does not automatically update lockfiles or rebuild artifacts. Stale caches can mask breaking changes.
Fix: Always run npm install / yarn install followed by a full test suite and bundle size comparison after manifest mutations.
7. Treating Polyfills as Pure JavaScript
Explanation: Some polyfills ship accompanying CSS, WebAssembly modules, or asset files. Removing the npm package leaves orphaned files in the repository.
Fix: Audit the package contents before removal. Delete associated assets and update build configurations that reference them.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Modern SaaS (Chrome/Firefox/Safari only) | Full automation with weekly PRs | Baseline status aligns with target matrix; low risk of legacy breakage | Reduces maintenance by ~6 hours/month |
| Enterprise Legacy (IE11/Safari 12 support) | Manual audit + dry-run reporting | browserslist constraints prevent automatic removal; manual review required | Adds ~2 hours/month for validation |
| Open Source Library | Baseline-aware peer dependency warnings | Consumers control their own browser targets; library should not enforce removal | Zero CI cost; improves consumer DX |
| High-Security Environment | Air-gapped dataset mirror + strict CI validation | Live API calls are prohibited; deterministic caching prevents supply chain exposure | Initial setup: 4 hours; ongoing: 0.5 hours/month |
Configuration Template
# .github/workflows/polyfill-retirement.yml
name: Polyfill Lifecycle Review
on:
schedule:
- cron: '0 8 * * 2' # Tuesdays at 08:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Run without applying changes'
required: false
default: 'true'
type: boolean
permissions:
contents: write
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run retirement engine
id: prune
run: |
npm run check:polyfills -- --mode=${{ github.event.inputs.dry_run == 'true' && 'dry-run' || 'apply' }}
- name: Create Pull Request
if: steps.prune.outputs.candidates > 0
uses: peter-evans/create-pull-request@v6
with:
commit-message: 'chore: retire obsolete polyfills per Baseline status'
title: 'Retire ${{ steps.prune.outputs.candidates }} polyfill(s) - Baseline Widely Available'
body: |
## Retirement Report
The following dependencies are redundant based on current Baseline status and project targets:
${{ steps.prune.outputs.report }}
## Action Required
- [ ] Verify import sites listed in the attached checklist
- [ ] Run `npm test` and validate bundle size delta
- [ ] Merge when CI passes
branch: chore/polyfill-retirement
delete-branch: true
Quick Start Guide
- Define your browser scope: Add a
browserslist field to package.json or create a .browserslistrc file. Example: defaults, not ie 11, not safari < 14.
- Install the retirement engine: Add the TypeScript/Node utility to your tooling directory. Ensure it reads
package.json, validates against browserslist, and cross-references the web-features dataset.
- Run a dry audit: Execute the tool in report-only mode. Review the generated list of removal candidates and verify that your project targets align with the Baseline status.
- Apply and validate: Run the tool in apply mode to update
package.json. Install dependencies, run your test suite, and compare bundle sizes before committing.
- Schedule automation: Add the GitHub Action workflow to your repository. Configure it to run weekly, generate PRs with detailed reports, and require manual import-site verification before merging.