Symfony Asset Versioning and Cache-Busting in Production
AI generated
SF
{ }
Symfony · Performance · Deployment
Asset Versioning and Cache-Busting
in Symfony Production

Long cache lifetimes for CSS and JavaScript are great for performance, but dangerous once a file's content changes while its URL stays the same. This article explains why content-hash-based versioning through json_manifest_path or AssetMapper beats the query-string approach, how to configure Nginx and a CDN correctly, and which pitfalls appear in multi-server deployments without a shared manifest.

17 min read AssetMapper json_manifest_path

1. Why cache-busting is necessary for assets at all

Browsers and CDNs cache static assets such as CSS and JavaScript files aggressively by default once the server sends a long max-age value in the Cache-Control header, because avoiding repeated downloads saves bandwidth and load time. The problem appears the moment a deployment changes the content of such a file while the URL in the browser cache stays the same: the browser happily keeps serving the stale version from cache even though the server already has a new version ready, and in the worst case users see a broken page because old CSS collides with new HTML. Setting a hard Cache-Control: no-cache on every asset would be safe but would destroy every benefit of browser caching, so it is not a real option in practice.

Cache-busting solves this dilemma by changing a file's URL whenever its content changes, which lets unchanged files stay cached indefinitely while changed files are automatically fetched under a new address. Symfony's Asset component offers several versioning strategies for this, from a simple global version number to a manifest that assigns each file an individual hash derived from its content. Which strategy is used in production largely decides whether cache-busting works reliably or silently fails in certain setups, for example behind a CDN or across multiple application servers.

2. json_manifest_path vs. query-string versioning

The simplest option is query-string versioning through framework.assets.version, where Symfony appends a parameter like ?v1 to every asset URL and that number is bumped manually or via an environment variable whenever something changes. The advantage is simplicity: no build step, no manifest file, just a single configuration line. The drawback shows up in production behind a CDN or multiple reverse proxies, because not every caching layer treats query strings as part of the cache key. Some configurations deliberately ignore query parameters to raise the cache hit rate, which in this case means the old cached response keeps being served even though the version number changed. On top of that, this approach usually bumps the version globally for all assets at once, forcing even unchanged files to be re-downloaded on the next deployment.

json_manifest_path takes a more precise approach: a build tool such as Webpack Encore generates a manifest.json during compilation that maps every logical filename to a physical, content-hashed file, for example app.css to app.3f5e9c2b.css. The Twig asset() function reads this manifest at runtime and automatically returns the correct hashed path. If a file's content does not change, its hash and therefore its URL stay identical, so only genuinely changed files get invalidated while unchanged files can remain cached indefinitely. This mechanism is more robust against caching layers that ignore query strings, because the path itself, not just a trailing parameter, carries the change.


# config/packages/framework.yaml
framework:
    assets:
        # Option A: simple, global query-string versioning
        # version: 'v%env(APP_VERSION)%'
        # version_format: '%%s?v=%%s'

        # Option B: manifest-based per-file versioning (recommended)
        json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'

3. How AssetMapper uses content hashes for cache-busting

AssetMapper is Symfony's native asset pipeline without a Node-based build process, stable since Symfony 6.4 and aimed at projects that do not need a full JavaScript bundling chain like Webpack. Assets live in a directory such as assets/, are referenced through an importmap.php, and get written to the public public/assets/ directory when bin/console asset-map:compile runs for production, with each file receiving a content-derived hash in its filename, for example app-3f5e9c2b8a1d.js. Internally AssetMapper maintains its own mapping from logical to physical path, so the Twig asset() function uses the same mechanism as with json_manifest_path, just without an external build tool having to generate a manifest.json.

In development mode AssetMapper instead serves files unchanged and unhashed directly, so code changes are visible immediately without a compile step, which noticeably speeds up the developer workflow. Only the explicit compile step before a production deployment produces the versioned, cacheable files. It matters that asset-map:compile produces the same hash deterministically for identical content, regardless of which server or at what time the command runs, which is exactly what allows multiple application servers to end up with the same result when they build in sync.

4. Webpack Encore as an alternative for complex frontends

For projects with substantial JavaScript logic, such as embedded Vue or React components, npm dependencies with their own build chain, or SCSS preprocessing, Webpack Encore remains the better fit compared to AssetMapper, because it brings real bundling, tree-shaking and code-splitting. Encore generates the same kind of manifest.json through Encore.enableVersioning(), which is then wired into Symfony via json_manifest_path, and can additionally generate subresource-integrity hashes with enableIntegrityHashes() for extra protection against tampered CDN delivery. For projects with multiple entry points, Encore also writes an entrypoints.json, which Symfony uses to automatically include all CSS and JS files belonging to an entry point in the correct order.

The choice between AssetMapper and Webpack Encore is not purely a matter of taste but depends on actual frontend needs: teams that work mostly with Stimulus controllers and little external npm code usually get by more simply and quickly with AssetMapper, since no Node toolchain has to be maintained. Teams relying on a large JavaScript ecosystem with many dependencies benefit from Encore's more mature bundling. Both approaches ultimately share the same core principle: a content hash in the filename instead of a query string, so cache-busting works reliably.

5. Configuring Nginx and caching headers for versioned assets correctly

Once assets are uniquely identified by a content hash in their filename, Nginx can apply the most aggressive sensible caching policy to those paths: Cache-Control: public, max-age=31536000, immutable. The immutable directive explicitly tells browsers to skip conditional requests entirely, even on a manual reload, because content under that URL will by definition never change, which noticeably improves load times on slow mobile connections. It matters to apply this rule only to genuinely hashed paths and not accidentally to the HTML document itself, which must reference the current asset hashes on every deployment.

The manifest.json, or AssetMapper's internal manifest endpoint, must not be cached for long, because it contains exactly the current mapping from logical to physical path and gets updated on every deployment. For this file, no-cache with must-revalidate is the right choice, so every request actually reaches the origin server instead of accidentally serving a stale mapping. In practice a two-block Nginx configuration works well: one location block for hashed asset paths with a long cache lifetime, and one for everything else with a short or absent cache lifetime.


# /etc/nginx/conf.d/symfony-assets.conf
location ~* ^/(build|assets)/.+\.[0-9a-f]{8,}\.(css|js|woff2?|png|jpe?g|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
    try_files $uri =404;
}

location = /build/manifest.json {
    add_header Cache-Control "no-cache, must-revalidate";
    try_files $uri =404;
}

6. CDN configuration and invalidation

In front of a CDN such as Cloudflare or Fastly, the same principle applies as with Nginx: hashed asset paths can safely be cached at the edge for the maximum lifetime, because a new version automatically appears under a new URL and never needs to be purged manually. This is the real advantage over query-string versioning, where many CDN configurations with query-string-ignoring enabled effectively never revalidate, requiring a manual purge after every deployment, which is easy to forget in automated pipelines and can leave stale assets live for days.

For manifest.json and all HTML responses, the CDN should apply the same short or disabled caching rule as the origin server, so users get the new HTML document with correctly referenced, new asset hashes immediately after a deployment. A common mistake is setting a blanket page rule for the entire /build/ directory at the CDN that accidentally includes manifest.json as well. It therefore pays off to define cache rules explicitly by file extension or hash pattern in the path rather than by a blanket directory prefix.

7. Pitfalls in multi-server deployments without a shared manifest

Once an application runs on multiple application servers behind a load balancer and each server builds its assets locally and independently, a subtle but serious problem appears: if Server A is updated first during a rolling deployment and already serves HTML referencing new asset hashes, a subsequent request for exactly that file may be routed by the load balancer to Server B, which still has the old build and simply does not know the new file. The result is a 404 error for CSS or JavaScript in the middle of live operation, even though each individual server is configured correctly on its own.

The opposite problem occurs when deployment scripts delete old asset directories immediately after switching over to save disk space: browser tabs opened before the deployment, or whose HTML page still comes from the browser cache, then reference hashes that no longer exist on the server, which also produces 404 errors, this time for already active user sessions. Both scenarios share the same root cause: a missing, centrally shared manifest, or a missing centrally shared asset store, across all servers.

8. A central build artifact and atomic deployment strategies

The robust solution is to build assets exactly once in the CI pipeline and distribute the resulting artifact, including manifest.json or the AssetMapper output directory, identically to every application server, or directly into a central object store such as S3 behind a CDN, instead of letting every server build independently and potentially at different times. An atomic deployment pattern with a releases/ directory and a current symlink, as implemented by Capistrano-style tools, further ensures that the switch to a new version happens practically simultaneously across all servers instead of gradually over several minutes.

It also helps to keep the last two or three releases and their assets around instead of deleting them immediately, so users with an older HTML page still sitting in the browser cache can keep accessing the matching, still-present asset hashes. Only once it is certain that no active session still references an old release, for example after the maximum HTML cache lifetime has passed, can the old build be safely removed. This small delay in cleanup reliably prevents most of the asset 404 errors observed in practice after deployments.

9. Monitoring and summary

A simple but effective safeguard is monitoring the 404 rate for paths under /build/ or /assets/ right after every deployment, for example through a dashboard built on Nginx or CDN access logs. A sudden spike immediately after a rollout is a reliable early warning sign for exactly the multi-server or cleanup problems described above, and it lets a rollback be triggered much earlier than waiting for user complaints.

In summary, the clear recommendation for production is to use content-hash-based versioning through json_manifest_path or AssetMapper instead of a global query-string version, because it cooperates more reliably with CDNs and reverse proxies and enables precise, per-file cache-busting. This versioning only reaches its full potential, though, in combination with correctly separated caching headers for hashed assets versus the manifest, and a deployment strategy that builds assets centrally and updates servers in sync instead of letting every node build independently.

Aspect Query-String Versioning Manifest/Content Hash AssetMapper (native)
Cache-busting mechanism ?v=... parameter on the URL Hash in the filename Hash in the filename under public/assets/
CDN compatibility Error-prone with ignored query strings Robust, since the path itself is unique Robust, since the path itself is unique
immutable caching sensible Only recommended with caveats Yes, unconditionally Yes, unconditionally
Build tool required No Yes, e.g. Webpack Encore No, native since Symfony 6.4
Risk in multi-server deployments Low High without a shared manifest High without shared output

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

Asset Versioning: The Essentials at a Glance

Core problem

Static assets stay in the browser cache even though their content changed after a deployment, as long as the URL stays the same.

Recommended solution

Content-hash-based versioning via json_manifest_path or AssetMapper combined with immutable Cache-Control.

Multi-server pitfall

Out-of-sync manifests or builds deleted too early cause 404 errors during the rollout.

Deployment recommendation

Build assets centrally once, distribute identically to all servers, keep old releases briefly, switch over atomically.

11. FAQ: Asset Versioning: The Essentials at a Glance

1What is the difference between json_manifest_path and AssetMapper?
json_manifest_path reads a manifest.json produced by an external build tool such as Webpack Encore, while AssetMapper is a self-contained mechanism built into Symfony without a Node build process. Both end up achieving the same effect: unique, content-hash-based filenames resolved by the Twig asset() function.
2Is query-string versioning fundamentally bad?
No, for small projects without a CDN and without multiple application servers it is a practical, simple solution. Once a CDN or several reverse proxies with their own caching logic are involved, though, the risk grows that query strings get ignored during caching and stale versions keep being served.
3What does Cache-Control: immutable actually do in the browser?
It tells the browser to skip conditional requests with If-None-Match or If-Modified-Since entirely, even on a manual reload, as long as the local entry is still within max-age. That saves a full round-trip per asset and is especially noticeable on mobile connections.
4Do I need to keep using Webpack Encore if I switch to AssetMapper?
No, both systems are alternatives and generally cannot be run usefully in parallel within the same project. For projects with complex JavaScript bundling, Encore often remains the more practical choice, while AssetMapper is sufficient for leaner Stimulus-based frontends.
5How do I reliably avoid 404 errors in multi-server deployments?
The most reliable approach is building assets exactly once centrally in the CI pipeline and distributing the identical artifact to all servers, or into a shared object store behind a CDN, instead of letting every server build independently. The switch to the new version should also happen atomically.
6How long should I keep old asset builds around after a deployment?
A reasonable rule of thumb is two to three previous releases, or at least the maximum cache lifetime of the HTML document, so users with an older cached page can still reach the matching asset hashes before the old build gets removed.
7Is it fine to cache manifest.json itself for a long time?
No, it must be current on every deployment because it holds the mapping from logical to physical, hashed filenames. For this file, no-cache with must-revalidate is the correct configuration, both at the origin server and at the CDN.
8Does CDN caching work correctly with AssetMapper automatically?
Generally yes, as long as the CDN's cache rules explicitly target hashed paths under public/assets/ and do not accidentally include the entire directory along with any manifest or debug endpoints.
9How do I test cache-busting locally before going live?
The simplest way is to run bin/console asset-map:compile, or Encore's production build, locally, inspect the response headers of the generated files with curl -I, and then deliberately change a file to verify that its hash, and therefore its URL, actually changes.
10Is AssetMapper worthwhile for larger, JavaScript-heavy applications too?
It depends on the complexity of the dependencies. For projects relying mainly on ES modules without elaborate bundling, AssetMapper works well. Once many npm packages with complex build steps such as TypeScript compilation or JSX transformation are needed, Webpack Encore is usually the more robust choice.