Speeding Up Slow Jekyll Builds

Jekyll has a reputation for slow builds, and for large sites it is partly deserved: Ruby and Liquid are slower than Go templates or a compiled JavaScript pipeline. But most slow Jekyll sites are not slow because of Jekyll. They are slow because of a handful of template patterns — a sidebar that loops over every post on every page, a "related posts" include that compares every post with every other, a plugin that rewrites every page after rendering — whose cost grows with the square of the site's size. Those are fixable without leaving Jekyll.

This guide profiles a 3,200-post Jekyll blog and documentation site that took 6 minutes 40 seconds to build, fixes the four hot spots, and gets it to under two minutes. It is part of Jekyll Plugin Ecosystem; if the fixes are not enough, Replacing Jekyll Plugins When Migrating to Eleventy covers the way out.

Prerequisites

  • Jekyll 4.x (Jekyll 4 added caching that 3.x lacks — upgrading alone often halves build time; see Upgrading Jekyll and Ruby Versions Safely).
  • Bundler and a Gemfile.lock so every run uses the same gem versions.
  • A way to diff build output before and after, to make sure optimisations change nothing readers see.

Step 1: Profile

bundle exec jekyll build --profile | head -25
| Filename                          | Count |    Bytes |    Time |
|-----------------------------------+-------+----------+---------|
| _includes/related.html            |  3200 |  9.12MB  | 171.402 |
| _includes/sidebar-archive.html    |  3406 | 41.33MB  |  98.119 |
| _layouts/default.html             |  3406 | 212.4MB  |  41.866 |
| _includes/tag-cloud.html          |  3406 |  7.81MB  |  38.205 |
| _layouts/post.html                |  3200 | 118.7MB  |  11.940 |
| feed.xml                          |     1 |  2.44MB  |   4.117 |

Two includes — related posts and the archive sidebar — accounted for 270 of 400 seconds. Neither did anything unusual; both looped over site.posts on every page.

Where a 400-second Jekyll build spends its time Bars from jekyll build --profile: related posts include 171 seconds, archive sidebar 98 seconds, default layout 42 seconds, tag cloud 38 seconds, post layout 12 seconds, feed 4 seconds. Seconds per template, 3,200 posts (jekyll --profile) related.html 171 sidebar-archive.html 98 default.html 42 tag-cloud.html 38 post.html 12 feed.xml 4 Two includes that loop over site.posts on every page: 270 of 400 seconds
Per-page loops over the whole site are quadratic: double the posts and those two bars quadruple.

Step 2: Fix Quadratic Includes

Related posts. The include compared every post's tags with every other post's tags on every post page — about 10 million comparisons. Jekyll's built-in site.related_posts (with lsi: false) is cheap but crude; the better fix computes related posts once in a small generator plugin and stores the result on each post:

# _plugins/related.rb
Jekyll::Hooks.register :site, :pre_render do |site|
  by_tag = Hash.new { |h, k| h[k] = [] }
  site.posts.docs.each { |p| p.data['tags'].to_a.each { |t| by_tag[t] << p } }
  site.posts.docs.each do |post|
    scores = Hash.new(0)
    post.data['tags'].to_a.each { |t| by_tag[t].each { |o| scores[o] += 1 unless o == post } }
    post.data['related'] = scores.sort_by { |o, s| [-s, -o.date.to_i] }.first(4).map(&:first)
  end
end

The include then renders page.related directly. An index by tag turns the quadratic comparison into roughly linear work: 171 s became 2.3 s.

Archive sidebar. Every page rendered a month-by-month archive of all 3,200 posts — the same 12 KB of HTML each time. Rendering it once with jekyll-include-cache ({% include_cached sidebar-archive.html %}) made it render once per build: 98 s became 0.4 s.

Tag cloud. Same pattern, same fix with include_cached: 38 s became 0.2 s.

Quadratic versus indexed related-posts work as the site grows A line chart of related-posts time against number of posts. The per-page loop grows quadratically from about 2 seconds at 400 posts to 171 seconds at 3,200 posts. The tag-indexed hook grows roughly linearly and stays under 3 seconds. Related-posts time vs number of posts 0 s 180 s 400 1,200 2,000 3,200 posts per-page loop: 171 s tag index: 2.3 s
A quadratic include feels fine at a few hundred posts and dominates the build a few years later.

Checking the Plugins Too

The profile table covers templates, but plugin generators and hooks run outside it. Time them by building with each plugin disabled in turn (comment it out of _config.yml and the Gemfile), or wrap hook bodies in Benchmark.realtime and log the result. On this site jekyll-seo-tag, jekyll-sitemap and jekyll-feed together cost under three seconds — cheap for what they do — while a custom "reading time" plugin that re-parsed each post's HTML with Nokogiri cost 14 seconds. Computing reading time from the word count of the raw Markdown instead, which Jekyll already has in memory, reduced it to under half a second with results within one minute of the old values on every post.

Step 3: Cache Across Builds and Trim Work

  • Jekyll 4's cache (.jekyll-cache/) stores rendered Markdown and Sass; persist it between CI runs, as with the Hugo cache in Caching Hugo Builds in GitHub Actions.
  • Exclude what does not need processing: exclude: for node_modules, vendor, source images and drafts. The site was processing 1,900 files in an assets/src directory that were never output.
  • Move asset pipelines out of Jekyll: Sass through jekyll-sass-converter is fine, but JavaScript bundling and image resizing belong in a separate step before jekyll build.
  • Use --incremental for writers, not deploys: locally, jekyll serve --incremental rebuilt an edited post in 4 seconds; for production it can miss pages that list other pages, so deploys stay full builds.

Measured Impact

GitHub Actions ubuntu-latest, Ruby 3.3, Jekyll 4.3, median of three runs, output diffed against the original to confirm it was byte-identical apart from the related-posts ordering (which changed from arbitrary to score-sorted, deliberately).

ChangeBuild time
Baseline400 s
Related posts via indexed hook231 s
Archive sidebar + tag cloud via include_cached94 s
Exclude unused source dirs83 s
Persist .jekyll-cache in CI (warm)61 s
Local incremental rebuild, one post edited4 s
Build time after each change Descending bars: baseline 400 seconds, related posts fixed 231, cached includes 94, excluded directories 83, warm cache 61. From 400 s to 61 s in four changes 400 231 94 83 61 baseline related index include_cached exclude warm cache seconds, GitHub Actions ubuntu-latest, Jekyll 4.3, median of 3
The two template fixes delivered most of the gain; caching and exclusions finished the job.

Keeping It Fast

Build time crept from 90 seconds to 400 over three years on this site without anyone deciding to make it slow; each include was reasonable when written. Two habits keep it from happening again. First, run jekyll build --profile in CI on the main branch and store the top ten rows as a build artifact, alerting when total time grows more than 20% over the previous month. Second, review new includes for loops over site.posts, site.pages or collections, and ask whether the result depends on the current page. If it does not, it belongs in include_cached; if it does but only through a small key (tag, category, year), it belongs in a hook that computes it once, like the related-posts index above.

GitHub Pages users should note that jekyll-include-cache and custom plugins are not on the Pages allow-list; building in GitHub Actions and deploying the output, as in Deploying to GitHub Pages with Actions, removes that restriction and was a precondition for every fix here.

When Jekyll Is Still Too Slow

After these fixes, the remaining 61 seconds were mostly Markdown rendering and Liquid execution for 3,400 pages — genuine per-page work. A few more percent is available from kramdown options (disabling unused extensions) or switching the Markdown engine to CommonMark via jekyll-commonmark, which rendered about 30% faster here. Beyond that, Jekyll's per-page cost is simply higher than Hugo's or Eleventy's; the same content built in 9 seconds with Hugo after a migration test. If builds must stay under ten seconds for previews, that is when a migration is justified, and Eleventy vs Jekyll for Markdown-Heavy Blogs compares the most common destination.

Pitfalls & Rollback

  • Optimising without profiling. Guessing usually targets the wrong template; --profile takes one build to answer.
  • include_cached on page-dependent includes. It caches by include parameters only; an include that reads page.url returns the first page's output everywhere. Pass page-specific values as parameters or do not cache.
  • Trusting --incremental for deploys. It can miss listing pages; keep production builds full.
  • Skipping the output diff. Always compare built output before and after to catch caching mistakes.
  • Rollback: every change is a template or config edit; reverting one restores its behaviour on the next build.

Conclusion

Slow Jekyll builds are usually quadratic templates, not a slow generator. Profiling a 3,200-post site found two includes looping over every post on every page; indexing related posts in a hook and caching the invariant includes took the build from 400 to 94 seconds, and exclusions plus a persisted cache brought it to 61. Fix the templates first; migrate only if per-page cost is still the bottleneck.

FAQ

How do I find out why a Jekyll build is slow?

Run jekyll build --profile. It prints a table of every template and include with the number of times it was rendered and the total time spent, which usually shows one or two includes or layouts responsible for most of the build.

Does jekyll build --incremental work in production?

It is marked experimental and can miss dependencies, such as a page that lists other pages. Use it for local writing and previews, and run full builds for production deploys.

Which plugins slow Jekyll down most?

Plugins that touch every page on every build: related-posts calculations, full-site search index generators, SEO and sitemap plugins with expensive per-page logic, and image processing without a cache. jekyll-seo-tag and jekyll-sitemap are cheap; custom generators often are not.

Is it worth migrating away from Jekyll for build speed?

Sometimes, but first fix the templates. Most slow Jekyll sites are slow because of a few Liquid loops over site.pages or site.posts on every page, which are fixable in an afternoon and often cut build time by more than half.