Vercel Skew Protection Deep Dive
How it prevents version mismatches during deployments and why your lazy-loaded chunks sometimes 404.
Deploy a new version while users have your app open, and things break. Lazy-loaded chunks 404. Server Actions fail with cryptic errors. Form submissions crash. This is version skew: client and server running different versions of your code. Vercel's Skew Protection fixes this at the platform level, but understanding how it works reveals important edge cases.
The Problem: Version Skew
Modern web apps are split into chunks. When users navigate, the browser fetches JavaScript bundles on demand. If you deploy while a user has the app open:
- User loads your app (version A)
- You deploy version B (new chunk hashes)
- User clicks a link, triggering a lazy import
- Browser requests
/chunks/page-abc123.js - Server returns 404 — that chunk doesn't exist in version B
// Version A (what user loaded)
const DashboardPage = lazy(() => import('./dashboard-abc123.js'));
// Version B (what server now has)
// dashboard-abc123.js doesn't exist
// dashboard-def456.js does
// Result: ChunkLoadError, broken UISame problem hits Server Actions. The client sends a request to an action that was renamed or removed in the new deployment.
How Skew Protection Works
Skew Protection uses version locking. Every framework-managed request includes the deployment ID that served the initial page. Vercel routes those requests to that specific deployment, not the latest one.
What Gets Pinned Automatically
- Static assets: JS bundles, CSS, images loaded by the framework
- Client-side navigations: Route transitions, data fetches
- Server Actions: Form submissions, mutations
- Prefetches: Route and data prefetching
The framework attaches the deployment ID via ?dpl= query param or x-deployment-id header:
// Request from client on deployment dpl_abc123
GET /_next/static/chunks/page-xyz.js?dpl=dpl_abc123
// Vercel routes to deployment dpl_abc123, not latest
// Chunk exists, request succeedsWhat Doesn't Get Pinned
Full-page navigations aren't pinned. When users:
- Hard refresh (Cmd+R)
- Enter a URL in the address bar
- Open a link in a new tab
They get the latest deployment. The framework detects the version mismatch and triggers a page reload.
Custom fetch() calls aren't pinned. Your own API requests don't include the deployment ID unless you add it:
// ❌ Not pinned (goes to latest deployment)
const data = await fetch('/api/users');
// ✓ Pinned (same deployment as page)
const deploymentId = process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID;
const data = await fetch(`/api/users?dpl=${deploymentId}`);
// Or via header
const data = await fetch('/api/users', {
headers: { 'x-deployment-id': deploymentId }
});Edge Cases That Still Break
1. API Routes Called Without Deployment ID
If your client fetches API routes directly (not through Server Actions), those requests go to the latest deployment:
// Client component
'use client';
// ❌ This request isn't skew-protected
useEffect(() => {
fetch('/api/user-preferences')
.then(res => res.json())
.then(setPreferences);
}, []);
// If /api/user-preferences was renamed or its response shape changed,
// version A client + version B API = errorsSolution: Use Server Actions for mutations, or pass the deployment ID to API fetches:
// Pin API requests to same deployment
const dplId = process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID;
useEffect(() => {
fetch(`/api/user-preferences?dpl=${dplId}`)
.then(res => res.json())
.then(setPreferences);
}, []);2. Long-Running Sessions
Skew Protection has a maximum age (default 24 hours, configurable). After that, old deployments stop receiving routed requests. Users on very long sessions hit errors.
// User opens app Monday morning
// You deploy Tuesday, Wednesday, Thursday
// By Friday, Monday's deployment is past max age
// User's lazy-loaded chunks 404Solution: For apps with long sessions (dashboards, admin panels), implement version checking:
// Poll for version changes
'use client';
import { useEffect } from 'react';
export function VersionChecker() {
useEffect(() => {
const currentVersion = process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID;
const checkVersion = async () => {
const res = await fetch('/api/version');
const { deploymentId } = await res.json();
if (deploymentId !== currentVersion) {
// Prompt user to refresh
showUpdateBanner();
}
};
const interval = setInterval(checkVersion, 60 * 1000); // Check every minute
return () => clearInterval(interval);
}, []);
return null;
}3. Cross-Origin Asset Requests
By default, Skew Protection ignores deployment IDs on cross-origin requests. If another domain embeds your assets:
// app-a.com embeds asset from app-b.com at build time
<script src="https://app-b.com/widget.js?dpl=dpl_old" />
// app-b.com deploys new version
// Cross-origin request ignores ?dpl=
// Request goes to latest deployment
// widget.js doesn't exist → 404Solution: Configure "Allowed Domains for Cross-Site Fetch" in Vercel settings:
- Go to the project serving assets (app-b.com)
- Settings → Advanced → Skew Protection
- Add allowed domains:
app-a.comor*.example.com
4. Stale Prefetch Cache
Next.js prefetches routes for fast navigation. If prefetch happens before deploy and navigation after:
// User hovers link → prefetch /dashboard (version A)
// You deploy version B
// User clicks link
// Prefetched data is from version A
// Page renders with version A data
// But subsequent fetches go to version B
// Result: UI inconsistencySkew Protection helps but doesn't cover browser-cached prefetch data. The framework detects mismatches and forces reloads, but initial render may use stale data.
5. Service Workers
Service workers cache assets. If your SW caches chunks from version A, deploys don't automatically invalidate that cache:
// SW cached: /chunks/page-abc.js (version A)
// Deploy version B
// User requests page → SW serves cached version A chunk
// Other requests go to version B
// Version mismatch in same page loadSolution: Version your SW and clear caches on activation:
// service-worker.js
const CACHE_VERSION = process.env.VERCEL_DEPLOYMENT_ID;
const CACHE_NAME = `app-${CACHE_VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});Configuration
Enable Skew Protection
Projects created after November 2024 have it enabled by default. For older projects:
- Vercel Dashboard → Project → Settings → Advanced
- Enable "Skew Protection"
- Set max age (default 24 hours)
- Redeploy
Set Skew Protection Threshold
When you fix a critical bug and want to force all clients to the new version:
- Go to the fixed deployment in Vercel
- Click the menu (···) → "Skew Protection Threshold"
- Click "Set"
This invalidates all deployments before the threshold. Old clients get errors and must reload.
Monitoring
Track skew protection in Vercel Monitoring:
// Filter requests page
skew_protection = 'active' // Successfully pinned to old deployment
skew_protection = 'inactive' // No pinning needed (same version)High "active" counts after deploys indicate healthy protection. If you see errors despite active protection, check for unpinned custom fetches.
Non-Vercel Platforms
Self-hosting or using other platforms? Implement version locking manually:
// 1. Expose deployment ID to client
// next.config.js
module.exports = {
env: {
NEXT_PUBLIC_BUILD_ID: process.env.BUILD_ID || Date.now().toString(),
},
};
// 2. Include in all requests
// lib/fetch.ts
export async function versionedFetch(url: string, options?: RequestInit) {
const buildId = process.env.NEXT_PUBLIC_BUILD_ID;
const separator = url.includes('?') ? '&' : '?';
return fetch(`${url}${separator}buildId=${buildId}`, options);
}
// 3. Server validates and routes
// This requires infrastructure support (multiple deployment versions running)Full implementation requires running multiple deployment versions simultaneously and routing based on the build ID header. Most platforms don't support this natively.
Best Practices
- Use Server Actions for mutations — they're automatically pinned
- Pin custom fetches by passing deployment ID
- Set appropriate max age — longer for apps with long sessions
- Monitor skew_protection metrics after deploys
- Version service workers if using offline support
- Add version checkers for very long-lived sessions
Skew Protection solves most version mismatch issues automatically. The edge cases that remain require awareness of what's pinned (framework requests) versus what isn't (custom fetches, cross-origin, service workers).
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement