Speeding Up Assets Precompilation
There has been a lot of conversations on social media about the “NoBuild ” 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 to handle the CSS and Webpacker 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:
- We have a base number to compare with (to know when something is improving or not)
- 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 theassets:precompile
argument. This internally calls theRails::Command.invoke
method withassets:precompile
as the command to execute. (source ) -
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 )
Measuring
We want to start measuring 4 parts initially (then we can go deeper in each area if we need to):
- How long it takes from pressing
Enter
on the terminal to start processing theRakefile
file - How long it takes for the application source to be loaded
- How long it takes for tasks to be loaded
- 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
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
withrake
always, to be safe we usebundle exec rake
here. You can learn why here .
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 whenRake.application.top_level_tasks == ["assets:precompile"]
. -
We can use Bumbler 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 theGemfile
putting each gem in the right group. -
We can measure each part of the boot process with
Benchmark.measure
orputs
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 notrake
for these tests to compare with the initial metrics.
Vite
Our first test was to migrate to Vite using the
vite_rails
gem .
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 is known to be fast, and faster than both Rollup (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 .
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 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!