Slow sass-loader Build Times with Webpack
Asked Answered
S

2

37

Summary

When we switched to using Webpack for handling our SASS files, we noticed that our build times in some cases became really slow. After measuring the performance of different parts of the build using the SpeedMeasurePlugin, it seems that sass-loader is the culprit…it can easily take 10s to build (it used to take 20s before we made some fixes), which is longer than we’d like.

I’m curious if people have additional strategies for optimizing building Sass assets that I didn’t cover. I’ve gone through a fair number of them at this point (multiple times for some of them), and still can’t seem to get the build times low enough. In terms of goals, currently a large rebuild (such as making a change for a component used in many files), can easily take 10-12 seconds, I’m hoping to get it down closer to 5s if possible.

Solutions Tried

We tried a number of different solutions, some worked, others did not help much.

  • HardSourcePlugin - This one worked reasonably well. Depending on the caching for that build, it was able to reduce build times by several seconds.
  • Removing duplicated imports (like importing the same ‘variables.sass’ file into multiple places) - This also reduced build time by a few seconds
  • Changing our mix of SASS and SCSS to just SCSS - I’m not sure why this helped, but it did seem to shave a bit off our build times. Maybe because everything was the same file type, it was easier to compile? (It’s possible that something else was happening here to confound the results, but it did seem to consistently help).
  • Replace sass-loader with fast-sass-loader - Many people recommended this, but when I got it to work, it didn’t seem to change the build time at all. I’m not sure why…maybe there was a configuration issue.
  • Utilizing cache-loader - This also didn’t seem to have any improvement.
  • Disabled source maps for Sass - This seems to have had a big effect, reducing the build times in half (from when the change was applied)
  • Tried using includePaths for SASS loaded from node_modules - This was suggested on a git issue I found, where sass-loader had issues with something they called ’custom importers’. My understanding was that by using includePaths, SASS was able to rely on those provided absolute paths, instead of using an inefficient algorithm for resolving paths to places like node_modules

From some brief stats, we appear to have about 16k lines of SASS code spread across 150 SASS files. Some have a fair amount of code, while others have less, with a simple average of the LOC across these files of about 107 LOC / file.

Below is the configuration that is being used. The application is a Rails application, and so much of the Webpack configuration is handled through the Webpacker gem.

{
  "mode": "production",
  "output": {
    "filename": "js/[name].js",
    "chunkFilename": "js/[name].js",
    "hotUpdateChunkFilename": "js/[id]-[hash].hot-update.js",
    "path": "myApp/public/packs",
    "publicPath": "/packs/"
  },
  "resolve": {
    "extensions": [".mjs", ".js", ".sass", ".scss", ".css", ".module.sass", ".module.scss", ".module.css", ".png", ".svg", ".gif", ".jpeg", ".jpg"],
    "plugins": [{
      "topLevelLoader": {}
    }],
    "modules": ["myApp/app/assets/javascript", "myApp/app/assets/css", "node_modules"]
  },
  "resolveLoader": {
    "modules": ["node_modules"],
    "plugins": [{}]
  },
  "node": {
    "dgram": "empty",
    "fs": "empty",
    "net": "empty",
    "tls": "empty",
    "child_process": "empty"
  },
  "devtool": "source-map",
  "stats": "normal",
  "bail": true,
  "optimization": {
    "minimizer": [{
      "options": {
        "test": {},
        "extractComments": false,
        "sourceMap": true,
        "cache": true,
        "parallel": true,
        "terserOptions": {
          "output": {
            "ecma": 5,
            "comments": false,
            "ascii_only": true
          },
          "parse": {
            "ecma": 8
          },
          "compress": {
            "ecma": 5,
            "warnings": false,
            "comparisons": false
          },
          "mangle": {
            "safari10": true
          }
        }
      }
    }],
    "splitChunks": {
      "chunks": "all",
      "name": false
    },
    "runtimeChunk": true
  },
  "externals": {
    "moment": "moment"
  },
  "entry": {
    "entry1": "myApp/app/assets/javascript/packs/entry1.js",
    "entry2": "myApp/app/assets/javascript/packs/entry2.js",
    "entry3": "myApp/app/assets/javascript/packs/entry3.js",
    "entry4": "myApp/app/assets/javascript/packs/entry4.js",
    "entry5": "myApp/app/assets/javascript/packs/entry5.js",
    "entry6": "myApp/app/assets/javascript/packs/entry6.js",
    "entry7": "myApp/app/assets/javascript/packs/entry7.js",
    "entry8": "myApp/app/assets/javascript/packs/entry8.js",
    "landing": "myApp/app/assets/javascript/packs/landing.js",
    "entry9": "myApp/app/assets/javascript/packs/entry9.js",
    "entry10": "myApp/app/assets/javascript/packs/entry10.js",
    "entry11": "myApp/app/assets/javascript/packs/entry11.js",
    "entry12": "myApp/app/assets/javascript/packs/entry12.js",
    "entry13": "myApp/app/assets/javascript/packs/entry13.js",
    "entry14": "myApp/app/assets/javascript/packs/entry14.js",
    "entry15": "myApp/app/assets/javascript/packs/entry15.js"
  },
  "module": {
    "strictExportPresence": true,
    "rules": [{
      "parser": {
        "requireEnsure": false
      }
    }, {
      "test": {},
      "use": [{
        "loader": "file-loader",
        "options": {
          "context": "app/assets/javascript"
        }
      }]
    }, {
      "test": {},
      "use": ["myApp/node_modules/mini-css-extract-plugin/dist/loader.js", {
        "loader": "css-loader",
        "options": {
          "sourceMap": true,
          "importLoaders": 2,
          "localIdentName": "[name]__[local]___[hash:base64:5]",
          "modules": false
        }
      }, {
        "loader": "postcss-loader",
        "options": {
          "config": {
            "path": "myApp"
          },
          "sourceMap": true
        }
      }],
      "sideEffects": true,
      "exclude": {}
    }, {
      "test": {},
      "use": ["myApp/node_modules/mini-css-extract-plugin/dist/loader.js", {
        "loader": "css-loader",
        "options": {
          "sourceMap": true,
          "importLoaders": 2,
          "localIdentName": "[name]__[local]___[hash:base64:5]",
          "modules": false
        }
      }, {
        "loader": "postcss-loader",
        "options": {
          "config": {
            "path": "myApp"
          },
          "sourceMap": false,
          "plugins": [null, null]
        }
      }, {
        "loader": "sass-loader",
        "options": {
          "sourceMap": false,
          "sourceComments": true
        }
      }],
      "sideEffects": true,
      "exclude": {}
    }, {
      "test": {},
      "use": ["myApp/node_modules/mini-css-extract-plugin/dist/loader.js", {
        "loader": "css-loader",
        "options": {
          "sourceMap": true,
          "importLoaders": 2,
          "localIdentName": "[name]__[local]___[hash:base64:5]",
          "modules": true
        }
      }, {
        "loader": "postcss-loader",
        "options": {
          "config": {
            "path": "myApp"
          },
          "sourceMap": true
        }
      }],
      "sideEffects": false,
      "include": {}
    }, {
      "test": {},
      "use": ["myApp/node_modules/mini-css-extract-plugin/dist/loader.js", {
        "loader": "css-loader",
        "options": {
          "sourceMap": true,
          "importLoaders": 2,
          "localIdentName": "[name]__[local]___[hash:base64:5]",
          "modules": true
        }
      }, {
        "loader": "postcss-loader",
        "options": {
          "config": {
            "path": "myApp"
          },
          "sourceMap": true
        }
      }, {
        "loader": "sass-loader",
        "options": {
          "sourceMap": true
        }
      }],
      "sideEffects": false,
      "include": {}
    }, {
      "test": {},
      "include": {},
      "exclude": {},
      "use": [{
        "loader": "babel-loader",
        "options": {
          "babelrc": false,
          "presets": [
            ["@babel/preset-env", {
              "modules": false
            }]
          ],
          "cacheDirectory": "tmp/cache/webpacker/babel-loader-node-modules",
          "cacheCompression": true,
          "compact": false,
          "sourceMaps": false
        }
      }]
    }, {
      "test": {},
      "include": ["myApp/app/assets/javascript", "myApp/app/assets/css"],
      "exclude": {},
      "use": [{
        "loader": "babel-loader",
        "options": {
          "cacheDirectory": "tmp/cache/webpacker/babel-loader-node-modules",
          "cacheCompression": true,
          "compact": true
        }
      }]
    }, {
      "test": "myApp/node_modules/jquery/dist/jquery.js",
      "use": [{
        "loader": "expose-loader",
        "options": "jQuery"
      }, {
        "loader": "expose-loader",
        "options": "$"
      }]
    }, {
      "test": "myApp/node_modules/popper.js/dist/umd/popper.js",
      "use": [{
        "loader": "expose-loader",
        "options": "Popper"
      }]
    }, {
      "test": "myApp/node_modules/scroll-depth/jquery.scrolldepth.js",
      "use": [{
        "loader": "expose-loader",
        "options": "scrollDepth"
      }]
    }]
  },
  "plugins": [{
    "environment_variables_plugin": "values don't really matter in this case I think"
  }, {
    "options": {},
    "pathCache": {},
    "fsOperations": 0,
    "primed": false
  }, {
    "options": {
      "filename": "css/[name]-[contenthash:8].css",
      "chunkFilename": "css/[name]-[contenthash:8].chunk.css"
    }
  }, {}, {
    "options": {
      "test": {},
      "cache": true,
      "compressionOptions": {
        "level": 9
      },
      "filename": "[path].gz[query]",
      "threshold": 0,
      "minRatio": 0.8,
      "deleteOriginalAssets": false
    }
  }, {
    "pluginDescriptor": {
      "name": "OptimizeCssAssetsWebpackPlugin"
    },
    "options": {
      "assetProcessors": [{
        "phase": "compilation.optimize-chunk-assets",
        "regExp": {}
      }],
      "assetNameRegExp": {},
      "cssProcessorOptions": {},
      "cssProcessorPluginOptions": {}
    },
    "phaseAssetProcessors": {
      "compilation.optimize-chunk-assets": [{
        "phase": "compilation.optimize-chunk-assets",
        "regExp": {}
      }],
      "compilation.optimize-assets": [],
      "emit": []
    },
    "deleteAssetsMap": {}
  }, {
    "definitions": {
      "$": "jquery",
      "jQuery": "jquery",
      "jquery": "jquery",
      "window.$": "jquery",
      "window.jQuery": "jquery",
      "window.jquery": "jquery",
      "Popper": ["popper.js", "default"]
    }
  }, {
    "definitions": {
      "process.env": {
        "MY_DEFINED_ENV_VARS": "my defined env var values"
      }
    }
  }, {
    "options": {}
  }]
}
Supernova answered 22/5, 2019 at 17:32 Comment(8)
why are all the "test": {}, emptySeptuplet
Good question. Those loaders I think were part of the auto-generated config from Rails' Webpacker gem. I suspect it was because those loaders were added but not used in development...but I'm not positive about that. For example, the mini-css-extract-plugin is not utilized to extract the CSS in development. My understand is that having an empty test like that would just not match against any files, and so would likely not affect build times.Supernova
Did you ever figure this out? test: {} is due to JSON.stringify not serializing regex objects.Marquez
Makes sense about the test bit. I never really solved it. Some changes were made that shaved off a few seconds here or there, but it's still slow. I think some of it was also architectural, which isn't something that sass-loader will really address much.Supernova
The empty test is definitely due to JSON.stringify - try defining RegExp.prototype.toJSON = RegExp.prototype.toString; above where you stringify the Webpacker environment config (useful for debugging)Fribble
For a more accurate picture of the bottleneck, you could try using Webpack's ProfilingPlugin, webpack.js.org/plugins/profiling-plugin, and then look at the stats in Chrome profiler. You probably want to know which functions are taking a long time.Exsiccate
It's possible that this has to do with the underlying sass package rather than sass-loader itself. For future readers, check if you're using the sass package or the node-sass package. From my testing, I've found node-sass to be incredibly slow for complex projects, whereas sass is significantly faster.Passing
Instead of using Webpack, try switching to ESBuild. I have documented an easy setup here: #70326315Conlon
H
0

I think there are a few issues:

1: It may be because you haven't specified which files to test against (so webpack may be searching everything):

...
"test": {},
  "use": ["myApp/node_modules/mini-css-extract-plugin/dist/loader.js", {
   "loader": "css-loader"
}
...

try replacing test: {} with test: /\.(sa|sc|c)ss$/ or test: /\.module.(sa|sc|c)ss$/ if using css modules

1b: try modifying the test key in other loaders too, e.g. babel usually only needs to look for js/ts(x) files so if that's the case specify that.

1c: play around with the include/exclude properties too

2: You have 4 instances of css-loaders in that config - unless you are server side rendering you should only need two (one for sc/sa/css modules, one for normal sa/sc/ss)

here is a sample css loader config that I hope is helpful (hint: the way css wants to be loaded often differs per project so be sure to check the module options in webpack/css-loader documentation)

{
  test: /\.(sa|sc|c)ss$/,
  use: [
    {
      loader: MiniCssExtractPlugin.loader,
      options: {
        esModule: true,
        modules: {
          namedExport: true,
        },
      },
    },
    {
      loader: 'css-loader',
      options: {
        sourceMap: !isProd,
        importLoaders: 2,
        esModule: true,
        modules: {
          auto: true,
          namedExport: true,
        },
      },
    },
    {
      loader: 'postcss-loader',
      options: {
        sourceMap: !isProd,
      },
    },
    {
      loader: 'sass-loader',
      options: {
        sourceMap: !isProd,
      },
    },
  ]
}

gotchas: webpack loaders are processed in reverse, so for this sample config the order of processing is sass -> postcss -> css -> minicss

Unfortunately there is rarely a straightforward/direct answer for css issues with webpack, it takes a bit of digging into the docs and figuring out which options are needed for your project.

Hild answered 27/5, 2022 at 14:5 Comment(0)
I
0

Slow sass compiling times means you did wrong code splitting stuff.

Its globally and theoretical problem too.

If you create entrypoint for all the website - you add tonns of files there, so once you touch at least one small file - all css will be rebuild from scratch.

So you need to split your css by layouts/pages, pages usually edited more often, layouts - rarely. So your libraries could be in layout, and page will compile faster.

Correct modern way is dont use sass-files with different entrypoints and load sass files directly with modules in javascript (it will be transformed to ajax queries that you can see in DevTools Network tab later)

Btw, on simple project adding fully configured js compiler not always a solution so you can split your code for pages and layouts.

Insomniac answered 21/1 at 14:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.