The go-to resource for upgrading Ruby, Rails, and your dependencies.

Reducing Largest Contentful Paint (LCP) Times in Server-Rendered Rails Views


Learn practical techniques to diagnose and fix poor Largest Contentful Paint (LCP) times in server-rendered Ruby on Rails applications.

Diagnosing and Fixing High LCP Times in Rails Applications

For years, web developers measured application performance primarily through Server Response Time, or Time to First Byte (TTFB). If the server was fast, the site was considered fast. Over time, though, it became apparent that this metric didn’t capture the actual user experience. The user, after all, doesn’t see the first byte of HTML — they see the rendered page.

This disconnect led to the development of Core Web Vitals, and specifically, the Largest Contentful Paint (LCP) metric. LCP, strictly speaking, measures the time it takes for the browser to render the largest visible element within the user’s viewport. When your application fails to load this primary content element quickly, users perceive the site as slow, and search engines penalize the page ranking accordingly.

In server-rendered Ruby on Rails applications, LCP bottlenecks frequently diverge from those found in Single Page Applications (SPAs). Rather than waiting for complex JavaScript bundles to execute, your LCP score depends heavily on two distinct phases: Server Response Time and unoptimized asset delivery for the main content block.

Before we get into that, though, let’s take a step back and examine how we identify the problem in the first place.

Identifying the LCP Element on the Frontend

Before writing any new code or optimizing queries, we must pinpoint exactly which element the browser considers the Largest Contentful Paint. In most e-commerce or content applications, this element typically manifests as a product photo, an article hero image, or a prominent text block introducing the page.

You can inspect the exact LCP element using the Performance tab in browser developer tools — like Chrome DevTools — or by running a Lighthouse audit.

If the LCP element is a hero image, the browser must first parse the HTML document, discover the image tag, open a network connection, download the asset, and finally decode and render it on the screen. If the element is a text block, the browser must parse the HTML, evaluate blocking stylesheets, construct the render tree, load the required web fonts, and then paint the text.

Understanding whether the bottleneck stems from backend data retrieval or frontend asset loading dictates your remediation strategy.

Reducing Server Response Time (TTFB)

In a traditional Rails architecture, the server must query the database, process the logic, and render the ERB, Haml, or Slim views before sending the first byte of HTML to the browser. A slow TTFB directly delays the start of the LCP render. If your server takes 1.5 seconds to respond, your LCP will invariably exceed the recommended 2.5-second threshold.

Resolving N+1 Database Queries

The most frequent cause of slow backend rendering in complex Rails views is the N+1 query problem. When rendering a collection of records, the application may inadvertently execute a new database query for each associated record, rather than loading the entire association in a single query.

Consider a view that renders a list of products alongside their categories:

# Inefficient Controller
def index
  @products = Product.all
end
<!-- Inefficient View -->
<% @products.each do |product| %>
  <div class="product-card">
    <h2><%= product.name %></h2>
    <span>Category: <%= product.category.name %></span>
  </div>
<% end %>

If we were to look at our development logs while rendering this page, we would see something like this:

  Product Load (1.2ms)  SELECT "products".* FROM "products"
  Category Load (0.4ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  Category Load (0.5ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
  ...snip...

You also may notice that the database latency adds up quickly. While a 0.3ms query seems fast in isolation, executing fifty of them will noticeably degrade the response time. We can eliminate this overhead by using includes to eager load the associated categories:

# Optimized Controller
def index
  # Eager loads the associated category records in a single query
  @products = Product.includes(:category)
end

By resolving N+1 queries along the critical rendering path, you reduce the time spent waiting on database latency, directly lowering your TTFB.

Implementing Fragment Caching

Even with optimized database queries, generating complex HTML structures in Ruby consumes CPU cycles. When views are computationally expensive, you should cache the rendered HTML fragments.

Rails, of course, provides robust fragment caching out of the box. For example, if we are rendering complex Product partials that do not change frequently, we can cache them based on their cache key — which updates automatically when the record is modified:

<% @products.each do |product| %>
  <!-- Caches the HTML fragment based on the product's updated_at timestamp -->
  <% cache product do %>
    <%= render partial: "product_card", locals: { product: product } %>
  <% end %>
<% end %>

While fragment caching dramatically improves server response times, we must acknowledge the inherent trade-offs. Aggressive caching introduces complexity in cache invalidation and can lead to users seeing stale data if not carefully managed. You should reserve fragment caching for elements that require heavy computation or database access and change infrequently.

Optimizing LCP Asset Delivery

Once the server sends the HTML document promptly, we must ensure the browser can load the LCP element without unnecessary delays.

Preloading Critical Images

If your LCP element is a hero image or banner, the browser will not begin downloading it until the HTML parser encounters the <img> tag. You can accelerate this discovery by instructing the browser to preload the image immediately using a <link rel="preload"> tag in the document <head>.

In Rails, you can utilize the preload_link_tag helper to inject this tag dynamically:

<!-- In your application.html.erb or view template -->
<head>
  <!-- Instructs the browser to prioritize fetching this asset -->
  <%= preload_link_tag 'hero-banner.jpg' %>
</head>

This ensures the image download begins concurrently with parsing the CSS and JavaScript, shaving crucial milliseconds off the final LCP metric.

Serving Modern Image Formats via Active Storage

Serving unoptimized, massive JPEGs or PNGs guarantees poor LCP scores. You should process and serve images in modern formats like WebP, which offer superior compression ratios without discernible quality loss.

If your application utilizes Active Storage and the image_processing gem, you can dynamically generate highly optimized variants.

<!-- In your view -->
<!-- Generates a WebP variant scaled to the maximum required dimensions -->
<%= image_tag @product.hero_image.variant(format: :webp, resize_to_limit: [1200, 800]), width: 1200, height: 800 %>

By resizing the image on the server and explicitly setting width and height attributes in the image_tag, you prevent layout shifts, improving your Cumulative Layout Shift (CLS) metric alongside your LCP.

Deferring Render-Blocking Assets

When the LCP element is a text node, the browser must process all blocking CSS and JavaScript in the <head> before rendering the text. Ensure that any non-critical JavaScript is deferred.

When using javascript_include_tag or javascript_pack_tag, you can apply the defer attribute to allow the HTML parser to continue uninterrupted while the script downloads in the background:

<!-- In your layout -->
<%= javascript_include_tag 'application', 'data-turbo-track': 'reload', defer: true %>

Measuring the Impact Incrementally

After deploying these optimizations, you must measure the results using field data, not isolated lab tests. Tools like Google Search Console provide aggregated LCP metrics reflecting your actual users’ experiences across varying devices and network conditions.

Improving Largest Contentful Paint in Rails applications requires a holistic approach. By systematically addressing backend query inefficiencies, leveraging fragment caching, preloading critical assets, and deferring non-essential scripts, you build a resilient, high-performance architecture capable of delivering optimal user experiences.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming