EdgeCases Logo
Mar 2026
Next.js
Deep
8 min read

Vercel Skew Protection Deep Dive

How it prevents version mismatches during deployments and why your lazy-loaded chunks sometimes 404.

vercel
deployment
skew-protection
version-mismatch
nextjs
production

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:

  1. User loads your app (version A)
  2. You deploy version B (new chunk hashes)
  3. User clicks a link, triggering a lazy import
  4. Browser requests /chunks/page-abc123.js
  5. 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 UI

Same 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 succeeds

What 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 = errors

Solution: 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 404

Solution: 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 → 404

Solution: Configure "Allowed Domains for Cross-Site Fetch" in Vercel settings:

  1. Go to the project serving assets (app-b.com)
  2. Settings → Advanced → Skew Protection
  3. Add allowed domains: app-a.com or *.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 inconsistency

Skew 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 load

Solution: 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:

  1. Vercel Dashboard → Project → Settings → Advanced
  2. Enable "Skew Protection"
  3. Set max age (default 24 hours)
  4. Redeploy

Set Skew Protection Threshold

When you fix a critical bug and want to force all clients to the new version:

  1. Go to the fixed deployment in Vercel
  2. Click the menu (···) → "Skew Protection Threshold"
  3. 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

Related Insights

Explore related edge cases and patterns

Next.js
Surface
Next.js 16: Dynamic by Default, Turbopack Stable, proxy.ts
8 min
Next.js
Deep
Next.js 'use cache': Explicit Caching with Automatic Keys
9 min

Advertisement