Build, Deploy, and Ship Intermediate 10 min read

Asset Versioning and Cache Busting

Implement effective cache strategies for your React Shopify theme. Learn content hashing, versioned filenames, and cache invalidation to ensure users get updates while maximizing performance.

When you deploy an update, users need to get the new code. But aggressive caching—essential for performance—can serve stale files. Asset versioning solves this by changing filenames when content changes, forcing browsers to fetch fresh versions while still enabling long-term caching.

The Caching Problem

┌─────────────────────────────────────────────────────────────────┐
│ THE CACHING DILEMMA │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Without versioning: │
│ ──────────────────── │
│ main.js ──▶ Browser caches for 1 year │
│ ──▶ You deploy fix │
│ ──▶ User still sees old cached version ❌ │
│ │
│ With versioning: │
│ ───────────────── │
│ main.abc123.js ──▶ Browser caches for 1 year │
│ ──▶ You deploy fix │
│ main.def456.js ──▶ Different filename = fresh download ✓ │
│ ──▶ Old file naturally expires │
│ │
└─────────────────────────────────────────────────────────────────┘

Content Hashing in Vite

Vite automatically adds content hashes to filenames:

vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
// Include hash in filenames
entryFileNames: '[name]-[hash].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name]-[hash][extname]',
},
},
},
});

This produces:

  • main-a1b2c3d4.js
  • vendor-e5f6g7h8.js
  • main-i9j0k1l2.css

When code changes, the hash changes, and browsers fetch the new file.

Shopify Asset Handling

Shopify’s CDN adds its own caching layer. Here’s how to work with it:

Option 1: Let Shopify Handle Versioning

Shopify appends a ?v= parameter based on file modification time:

{{ 'main.js' | asset_url }}
{%- comment -%} Outputs: //cdn.shopify.com/s/.../main.js?v=1234567890 {%- endcomment -%}

The downside: ?v= changes on every deploy, even if the file didn’t change.

Option 2: Content-Hashed Filenames

Better approach—use content hashes in the filename itself:

{%- comment -%} Generate a manifest of hashed filenames during build {%- endcomment -%}
{{ 'main-a1b2c3d4.js' | asset_url }}

But how do you know the hash at build time? You generate a manifest.

Build Manifest Strategy

Generate a manifest file during build:

scripts/generate-manifest.ts
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
interface Manifest {
[key: string]: string;
}
function generateManifest() {
const distDir = 'dist';
const files = readdirSync(distDir);
const manifest: Manifest = {};
// Map unhashed names to hashed names
for (const file of files) {
if (file.endsWith('.js') || file.endsWith('.css')) {
// Extract base name: main-abc123.js -> main
const match = file.match(/^(.+?)-[a-f0-9]+\.(js|css)$/);
if (match) {
const baseName = match[1];
const ext = match[2];
manifest[`${baseName}.${ext}`] = file;
}
}
}
// Write manifest
writeFileSync(
'theme/snippets/asset-manifest.liquid',
generateLiquidManifest(manifest)
);
console.log('Generated asset manifest:', manifest);
}
function generateLiquidManifest(manifest: Manifest): string {
let liquid = '{%- comment -%}\n Auto-generated asset manifest\n Do not edit manually\n{%- endcomment -%}\n\n';
liquid += '{%- liquid\n';
for (const [key, value] of Object.entries(manifest)) {
const varName = key.replace('.', '_').replace('-', '_');
liquid += ` assign ${varName} = '${value}'\n`;
}
liquid += '-%}\n';
return liquid;
}
generateManifest();

Use in your theme:

{% comment %} layout/theme.liquid {% endcomment %}
{%- comment -%} Load the manifest {%- endcomment -%}
{% render 'asset-manifest' %}
{%- comment -%} Use the versioned filenames {%- endcomment -%}
<script type="module" src="{{ main_js | asset_url }}"></script>
<script type="module" src="{{ vendor_react_js | asset_url }}"></script>
<link rel="stylesheet" href="{{ main_css | asset_url }}">

Liquid-Based Versioning

Alternative approach using Liquid variables:

{% comment %} snippets/asset-version.liquid {% endcomment %}
{%- comment -%}
Update this version string when deploying new assets.
This forces cache invalidation across all assets.
{%- endcomment -%}
{%- assign asset_version = 'v2.3.1' -%}
{% comment %} layout/theme.liquid {% endcomment %}
{% render 'asset-version' %}
<script type="module" src="{{ 'main.js' | asset_url }}?{{ asset_version }}"></script>

Simple but requires manual updates.

Automated Version Injection

Inject version during build:

scripts/inject-version.ts
import { readFileSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';
// Get version from package.json or git
const packageJson = JSON.parse(readFileSync('package.json', 'utf-8'));
const version = packageJson.version;
// Or use git commit hash
const gitHash = execSync('git rev-parse --short HEAD').toString().trim();
// Update Liquid snippet
const template = `{%- comment -%}
Auto-generated during build
Version: ${version}
Git: ${gitHash}
Built: ${new Date().toISOString()}
{%- endcomment -%}
{%- assign asset_version = '${version}-${gitHash}' -%}
`;
writeFileSync('theme/snippets/asset-version.liquid', template);
console.log(`Injected version: ${version}-${gitHash}`);

Add to build script:

{
"scripts": {
"build": "npm run build:vite && npm run build:version && npm run build:copy",
"build:version": "tsx scripts/inject-version.ts"
}
}

Cache Headers Strategy

While Shopify controls CDN headers, understand the caching layers:

┌─────────────────────────────────────────────────────────────────┐
│ CACHING LAYERS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Browser Cache │
│ └─ Controlled by Cache-Control headers │
│ └─ Shopify sets: public, max-age=31536000 (1 year) │
│ └─ Content-hashed files can cache forever │
│ │
│ 2. Shopify CDN │
│ └─ Automatically caches theme assets │
│ └─ Invalidated on theme publish │
│ └─ ?v= parameter forces fresh fetch │
│ │
│ 3. Service Worker (if you add one) │
│ └─ You control this layer │
│ └─ Can implement sophisticated strategies │
│ │
└─────────────────────────────────────────────────────────────────┘

Service Worker for Advanced Caching

For more control, add a service worker:

src/service-worker.ts
const CACHE_NAME = 'theme-v1.0.0';
const PRECACHE_ASSETS = [
'/assets/main.js',
'/assets/vendor-react.js',
'/assets/main.css',
];
// Install: precache critical assets
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_ASSETS);
})
);
});
// Activate: clean up old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});
// Fetch: serve from cache, fall back to network
self.addEventListener('fetch', (event: FetchEvent) => {
// Only cache same-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
// Cache-first for assets
if (event.request.url.includes('/assets/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
// Cache the new response
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
}
});

Register in your main entry:

src/entries/main.tsx
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/assets/service-worker.js');
});
}

Handling Updates

Notify users when new versions are available:

src/lib/update-checker.ts
export function checkForUpdates() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('controllerchange', () => {
// New service worker activated - page should refresh
showUpdateNotification();
});
}
function showUpdateNotification() {
// Create a toast/banner
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = `
<p>A new version is available!</p>
<button onclick="window.location.reload()">Refresh</button>
<button onclick="this.parentElement.remove()">Later</button>
`;
document.body.appendChild(notification);
}

Debugging Cache Issues

Tools for diagnosing caching problems:

src/lib/cache-debug.ts
export function debugCacheStatus() {
// Check what's in the browser cache
if ('caches' in window) {
caches.keys().then((names) => {
console.log('Cache names:', names);
names.forEach((name) => {
caches.open(name).then((cache) => {
cache.keys().then((requests) => {
console.log(`Cache "${name}" contains:`, requests.map(r => r.url));
});
});
});
});
}
// Check resource timing for cache hits
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const cached = resources.filter((r) => r.transferSize === 0);
console.log('Cached resources:', cached.map((r) => r.name));
console.log('Downloaded resources:', resources.filter(r => r.transferSize > 0).map(r => ({
name: r.name.split('/').pop(),
size: r.transferSize,
})));
}
// Run in console: debugCacheStatus()
if (typeof window !== 'undefined') {
(window as any).debugCacheStatus = debugCacheStatus;
}

Cache Invalidation Strategies

Different approaches for different scenarios:

┌─────────────────────────────────────────────────────────────────┐
│ CACHE INVALIDATION STRATEGIES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Strategy │ When to Use │
│ ──────────────────┼─────────────────────────────────────────── │
│ Content Hash │ JS/CSS that changes frequently │
│ (main-abc123.js) │ Best for: code bundles │
│ │ │
│ Query String │ Assets that rarely change │
│ (logo.png?v=1.0) │ Best for: images, fonts │
│ │ │
│ Version Folder │ Complete redesigns │
│ (/v2/assets/...) │ Best for: major releases │
│ │ │
│ No Cache │ API responses, dynamic content │
│ (Cache-Control: │ Best for: cart data, user info │
│ no-store) │ │
│ │
└─────────────────────────────────────────────────────────────────┘

Key Takeaways

  1. Content hashing is essential: Changed content = changed filename = fresh download.

  2. Generate manifests: Automate mapping between logical names and hashed filenames.

  3. Shopify CDN helps: Theme assets are automatically cached on Shopify’s edge network.

  4. Service workers add control: Implement custom caching strategies when needed.

  5. Handle updates gracefully: Notify users when new versions are available.

  6. Debug with tools: Use browser DevTools and custom helpers to diagnose cache issues.

  7. Different strategies for different assets: Code needs hashing; images can use query strings.

  8. Automate version injection: Don’t rely on manual version bumps.

In the next lesson, we’ll set up CI/CD with GitHub Actions to automate building, testing, and deploying your theme.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...