Speeding Up Assets Precompilation

Speeding Up Assets Precompilation

There has been a lot of conversations on social media about the “NoBuild opens a new window ” approach: using native browser features and plain CSS+JavaScript to avoid a precompilation step for our assets.

In many cases, it’s not easy to move to a “NoBuild” setup (and in some cases it’s not even possible depending on the application’s needs), and we can still aim to make the assets:precompile task as fast as possible if we can’t eliminate it.

In this article we’ll explore some areas for optimization using one of our applications.

The Starting Point

We’ll be using a Rails 7.0 app, with Sprockets opens a new window to handle the CSS and Webpacker opens a new window to handle the JavaScript. We are using the rails assets:precompile command as this is the command we run during deployment in Heroku.

For every iteration, we have to clear the already compiled assets with the rails assets:clobber task before running the precompile task. This is essential because the different bundlers are smart enough to avoid extra work when possible if files are already compiled and that can skew the results. We use Heroku to host this app, and the assets are compiled from scratch when deploying.

When talking about improving performance, the first step we must always do is to profile and measure what we are trying to improve.

This has 2 main benefits:

  1. We have a base number to compare with (to know when something is improving or not)
  2. With a detailed profiling result we can identify which parts of the process are slow to focus on those areas (to not waste time on changing what’s already fast).

The first idea was to use the time command to wrap the task (time rails assets:precompile). This provides information about how much time it took to run the command but it doesn’t provide enough information to know where the time is going.

In addition to the time command, we decided to also print different timestamps in some parts of the process to have more granular data.

Boot sequence for a Rails task

It is important to understand the boot sequence of the app to identify what we want to measure and to find opportunities. Running the rails assets:precompile command follows this sequence:

  • The rails command is executed with the assets:precompile argument. This internally calls the Rails::Command.invoke method with assets:precompile as the command to execute. (source opens a new window )

  • Eventually, Rake is used to load our application using the Rakefile

This is what a Rakefile typically looks like (and this is our case):

require_relative "config/application"
Rails.application.load_tasks

Note that, in this step, we are loading our app, up to the point of running all the initializers too.

After the application and the tasks are loaded, Rake will continue processing the actual assets:precompile task

Multiple tasks can be specified, so Rake will loop through them invoking them one after the other. (source opens a new window )

Measuring

We want to start measuring 4 parts initially (then we can go deeper in each area if we need to):

  1. How long it takes from pressing Enter on the terminal to start processing the Rakefile file
  2. How long it takes for the application source to be loaded
  3. How long it takes for tasks to be loaded
  4. How long it takes after loading the tasks to be back to the terminal (the actual assets compilation)

We picked these first 4 areas because they are the boundaries of our application code and the first lines from our source that are executed.

Once we decide what we need to optimize we can go deeper patching Rails’ and Rake’s source code or measuring other files to have more details, but this is good as a starting point.

Start and End Times

The first thing we do is to print the timestamps before and after the command:

time (echo $(date '+%s.%N') && rails assets:precompile && echo $(date '+%s.%N'))

This will first print the current timestamp, then execute the task, and then print the timestamp at the end. We are also adding a time call wrapping these calls for extra information to double check our results, but the timestamps are enough.

Rake Reading the Rakefile

Next, we are printing the current timestamp at the beginning of the Rakefile with puts "#{Time.now.to_f} - loading Rakefile". With this information and the initial timestamp we can get our first number.

Rakefile Lines

To measure how long it takes to load the application and the tasks, we have two options:

  • We can use something like the Benchmark#measure opens a new window method (or any similar tool)
  • Or we can print the current timestamp after each line and then compare with the previous number.

Processing the Task

Since the final timestamp is printed in the terminal with the last echo $(date '+%s.%N') command, we need the timestamp at the end of parsing the Rakefile file. If we used timestamp printing for the previous measures we already have this.

Final Code

This is how our Rakefile looks like:

puts "#{Time.now.to_f} - loading Rakefile"

require_relative "config/application"
puts "#{Time.now.to_f} - config/application loaded"

Rails.application.load_tasks
puts "#{Time.now.to_f} - tasks loaded"

And now we are ready to get some numbers.

Webpacker

After running the command we get this output:

1705326728.2913189
1705326729.4916348 - loading Rakefile
1705326732.9132965 - config/application loaded
1705326732.9966373 - tasks loaded
1705326737.6213546

real  0m9,333s
user  0m8,855s
sys   0m2,520s

We then ran the command multiple times and calculated the averages.

From this we can see that:

  • It takes 1.16s to start loading the application
  • It takes 3.38s to load the application
  • It takes 0.08s to load the tasks
  • It takes 4.83s to compile the assets

Some important things to note:

  • Our first idea was to optimize the assets compilation, but we found out that this phase accounts for ~51% of the total time, so we have other areas to focus too. The other ~49% of the time affects all other commands, not just the assets compilation, so improving the time for that can also impact the time it takes for the other commands (like starting the console, the server, or any other task).

  • We can see that loading the tasks is really quick compared to the other numbers (less than 1% of the total time) and it’s not worth analyzing at this point for us, so we’ll skip it in the rest of the article.

Rails vs Rake

We’ll first focus in the initial phase of the process. There is not much we can do there since it’s internal code by Rails and Rake, but what happens if we use rake assets:precompile instead of rails assets:precompile? Let’s see the numbers:

1705327358.4954120
1705327359.1051338 - loading Rakefile
1705327362.9320574 - config/application loaded
1705327363.0320442 - tasks loaded
1705327367.6751084

real  0m9,183s
user  0m8,236s
sys   0m2,511s

Note that we can’t simply replace rails with rake always, to be safe we use bundle exec rake here. You can learn why here opens a new window .

By replacing rails with bundle exec rake, we can see a reduction from 1.16s to 0.63s (using averages from multiple runs). This difference doesn’t look big, but compared to the original 9.3s total this is a 6/7% reduction of the total time.

Loading the Application

We will cover optimization for this phase in another article in more depth with more measures, but here are some tips on how to optimize this:

  • We can use the Rake.application.top_level_tasks variable to know which tasks are going to be executed. With this information, we can conditionally initialize parts of the application to not load code or don’t do actions that won’t be needed for the assets. For example: if we have an initializer that performs a request to an external API, we could make a decision to skip that call when Rake.application.top_level_tasks == ["assets:precompile"].

  • We can use Bumbler opens a new window to know the time it takes to require each of our dependencies for a given environment: RAILS_ENV=development bumbler --all. Then we can analyze the results and see which gems are not needed for a given environment and rearrange the Gemfile putting each gem in the right group.

  • We can measure each part of the boot process with Benchmark.measure or puts statements to identify the slowest parts to focus.

Assets Precompilation

We finally got to the actual phase we wanted to optimize initially. We have 2 areas to focus here: how much work needs to be done (how many assets need to be compiled using different compilation steps) and what tool does the work (which bundler we use)

Bundlers

The first idea we decided to try was to replace Webpacker with another bundler. We decided to try with Vite, Esbuild, and Bun to compile our JavaScript.

We’ll use the rails command and not rake for these tests to compare with the initial metrics.

Vite

Our first test was to migrate to Vite opens a new window using the vite_rails gem opens a new window . We won’t go over the steps of the migration since that is out of the scope of this article.

These are the measurements while using Vite to compile the assets:

  • 4.52s for the assets precompilation phase
  • 9.12s for the whole process to execute

The number is slightly better, only around 300ms faster. This change is not that significant, and based on other reports online we were expecting a better number.

Esbuild

esbuild opens a new window is known to be fast, and faster than both Rollup opens a new window (used by Vite) and Webpack. For this, we migrated the assets pipeline from the webpacker gem to the jsbundling-rails gem with esbuild as the bundler opens a new window .

After the setup was done, we ran the rails assets:precompile task and the average of the phase that compiles the assets is now 1.99s, and the total time for the command is now 6.53s.

This means a reduction of more than 50% compared to the original 4.82s with Webpacker for that specific phase of the process and a reduction of over 30% for the complete command.

Bun

We couldn’t leave Bun opens a new window out of this, known for being blazingly fast. We also used the jsbundling-rails gem to set this up.

After a few runs and calculating averages, the assets compilation step of the process took an average of 1.95s, slightly faster than esbuild by 40ms, but not really significant (each run varies more than that).

Both Bun and Esbuild provide a similar improvement in speed.

Reducing the Assets

It is expected that, the more work the bundlers have to do, the longer it will take for this process to finish. So the next optimization we can work on is removing unused JavaScript and CSS code, replacing big dependencies with smaller ones (or eliminate them), removing unused images, fonts, and any other type of assets that is no longer needed, replacing JS code and images with modern CSS features (when possible), replacing steps in the pipeline, etc.

For example: instead of using SASS for our CSS, we can try to move to native CSS nesting and custom properties if that’s what we need, removing this step during the build process. Instead of using a plugin like PurgeCSS to remove unused CSS, we can remove the CSS from the source itself manually.

Conclusion

By applying only a few of these ideas (using Esbuild/Bun and the rake command), we reduced the time from 9.5s to 6.5s without modifying the assets, and this is a reduction of 35%. There are always more opportunities for improvements and more advanced and involved changes we can do to get the numbers even lower, and we’ll explore them in future articles.

Is your app slow to start? We can take a look! opens a new window

Get the book