Differential serving with webpack encore

Recently we’ve been looking into differential serving. With our set up there were a few complications, so in this post i’ll share how we overcame those, and how to set up differential serving with webpack encore.

If you want an explanation of what differential serving is, or how to do with with a normal webpack setup, i suggest reading this post.

In our project we use webpack encore. We use a babel.config.json to manage our babel settings, as jest can’t read that info from the webpack encore file. We also have a .browserslistrc file, in which we configure what browsers we support.

In order to make this work with webpack encore we need to do a few things. First we need to split up our webpack into 3 configs. We need our ‘base’ config. I’ll call this webpack.base.config.js. This contains all our webpack configuration, except for setOutputPath and setPublicPath. We’ll export the Encore object, instead of the getWebpackConfig. So rename your webpack.config.js and remove those lines.

I’ve taken the symfony demo project as an example, so the webpack.base.config.js will look like this:

const Encore = require('@symfony/webpack-encore');
    .addEntry('app', './assets/app.js')
    .addEntry('login', './assets/login.js')
    .addEntry('admin', './assets/admin.js')
    .addEntry('search', './assets/search.js')
        "window.Bloodhound": require.resolve('bloodhound-js'),
        "jQuery.tagsinput": "bootstrap-tagsinput"

module.exports = Encore;

Now, we need the ‘legacy’ and the ‘modern’ builds which will support all the older browsers. For that we create a webpack.legacy.config.js and a webpack.modern.config.js with the following content. The name is set so we can refer to that later in our config file.

// webpack.legacy.config.js
const Encore = require('./webpack.base.config');

process.env.BROWSERSLIST_ENV = 'legacy';

const config = Encore.getWebpackConfig();
config.name = 'legacy';

module.exports = config;
// webpack.modern.config.js
const Encore = require('./webpack.base.config');

process.env.BROWSERSLIST_ENV = 'modern';

const config = Encore.getWebpackConfig();
config.name = 'modern';

module.exports = config;

The key here is the BROWSERSLIST_ENV that is being set. As now we’ll edit our .browserslistrc file. Instead of having the config in a top level in this file, we have 2 versions, the legacy and the modern. By setting the BROWSERSLIST_ENV environment variable to either, we tell browserlist which one to use. This is also the reason we need to have these 2 in separate files. Otherwise the env variables will override each other.

# .browserslistrc
Last 2 versions
IE >= 11
not IE 10
not ie_mob 10

chrome >= 80
firefox >= 80
safari >= 12

Now we’ll need to edit our package.json file to build both at the same time. So we change our scripts part to the following:

"scripts": {
  "dev-server": "encore dev-server -c webpack.modern.config.js  & encore dev-server -c webpack.legacy.config.js",
  "dev": "encore dev -c webpack.modern.config.js & encore dev -c webpack.legacy.config.js & wait",
  "watch": "encore dev --watch -c webpack.modern.config.js  & encore dev --watch -c webpack.legacy.config.js",
  "build": "encore production --progress -c webpack.modern.config.js & encore production --progress -c webpack.legacy.config.js & wait"

By using & and wait we run both processes at the same time, instead of one by one. You can also use npm-run-all or something similar.

Next up we’ll change our config/packages/webpack_encore.yaml to tell it about these two builds. Now we can refer to those builds when rendering our tags.

  output_path: false
    legacy: '%kernel.project_dir%/public/legacy-build'
    modern: '%kernel.project_dir%/public/modern-build'

However, it may be cumbersome to remember to call encore_entry_script_tags twice. Once for legacy, and once for modern. And then also remember to give it the correct attributes. (nomodule and type=module). So instead lets create some twig functions to always render both.

Now, instead of calling encore_entry_script_tags we call script_tags, which will render both script tags. And after we’ve replaced the encore_entry_script_tags, we have differential serving.

namespace App\Twig;

use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

final class EncoreExtension extends AbstractExtension
    public function __construct(
        private TagRenderer $tagRenderer
    ) {}

    public function getFunctions(): array
        return [
            new TwigFunction('script_tags', [$this, 'legacyEntry'], ['is_safe' => ['html']]),
            new TwigFunction('link_tags', [$this, 'linkEntry'], ['is_safe' => ['html']]),

    public function legacyEntry($name): string
        return $this->tagRenderer->renderWebpackScriptTags($name, null, 'legacy', ['nomodule' => null]) .
            $this->tagRenderer->renderWebpackScriptTags($name, null, 'modern', ['type' => 'module']);

    public function linkEntry(string $name): string
        return $this->tagRenderer->renderWebpackLinkTags($name, null, 'legacy');

If you want to see all the changes, you can check this commit

Gert de Pagter
Software Engineer

My interests include software development, math and magic.