HTTP 103 Early Hints - Manual API Guide
📚 Main Documentation: This guide covers the manual
send_pack_early_hintsAPI for advanced use cases. For the recommended controller-based API (configure_pack_early_hints,skip_send_pack_early_hints) and comprehensive setup instructions, see early_hints.md.
This guide focuses on manual control of early hints for advanced scenarios where you need to send hints before expensive controller work or customize hints per-pack in layouts.
Automatic vs Manual API
By default, Shakapacker automatically sends early hints when javascript_pack_tag and stylesheet_pack_tag are called (after views render). The manual API allows you to:
- Send hints earlier - Before controller work starts, maximizing parallelism
- Customize per-pack - Different strategies for different packs in the same layout
- Override automatic behavior - When you need fine-grained control
⚠️ IMPORTANT: Performance Testing Required
Early hints can improve OR hurt performance depending on your application:
- ✅ May help: Large JS bundles (>500KB), slow controllers (>300ms), fast CDN
- ❌ May hurt: Large images as LCP (Largest Contentful Paint), small JS bundles
- ⚠️ Test before deploying: Measure LCP and Time to Interactive before/after enabling
How to test:
- Enable early hints in production for 10% of traffic
- Measure Core Web Vitals (LCP, FCP, TTI) for both groups
- Only keep enabled if metrics improve
See the Feature Testing Guide for testing instructions and the main documentation for comprehensive troubleshooting.
When to Use the Manual API
Use send_pack_early_hints when you need:
- Maximum parallelism - Send hints BEFORE expensive controller work (database queries, API calls)
- Per-pack customization - Different hint strategies for different packs in layouts
- Dynamic control - Runtime decisions about which packs to hint
For most applications, use the controller-based API instead (configure_pack_early_hints, skip_send_pack_early_hints).
Manual API Patterns
Pattern 1: Per-Pack Customization in Layout
Customize hint handling per pack using a hash:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%# Mixed handling: preload application, prefetch vendor %>
<%= stylesheet_pack_tag 'application', 'vendor',
early_hints: { 'application' => 'preload', 'vendor' => 'prefetch' } %>
</head>
<body>
<%= yield %>
<%# Disable early hints for this tag %>
<%= javascript_pack_tag 'application', early_hints: false %>
</body>
</html>
Options:
"preload"- High priority (default)"prefetch"- Low priorityfalseor"none"- Disabled
Pattern 2: Controller Override (Before Expensive Work)
Send hints manually in controller BEFORE expensive work to maximize parallelism:
class PostsController < ApplicationController
def show
# Send hints BEFORE expensive work
send_pack_early_hints({
"application" => { js: "preload", css: "preload" },
"admin" => { js: "prefetch", css: "none" }
})
# Browser now downloading assets while we do expensive work
@post = Post.includes(:comments, :author, :tags).find(params[:id])
@related = @post.find_related_posts(limit: 10) # Expensive query
# ... more work ...
end
end
Timeline:
- Request arrives
send_pack_early_hintscalled → HTTP 103 sent immediately- Browser starts downloading assets
- Rails continues with expensive queries (IN PARALLEL with browser downloads)
- View renders
- HTTP 200 sent with full HTML
- Assets already downloaded = faster page load
Benefits:
- ✅ Parallelizes browser downloads with server processing
- ✅ Can save 200-500ms on pages with slow controllers
- ✅ Most valuable for pages with expensive queries/API calls
Pattern 3: View Override
Views can use append_*_pack_tag to add packs dynamically:
<%# app/views/posts/edit.html.erb %>
<% append_javascript_pack_tag 'admin_tools' %>
<div class="post-editor">
<%# ... editor UI ... %>
</div>
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%= stylesheet_pack_tag 'application' %>
</head>
<body>
<%= yield %> <%# View has run, admin_tools added to queue %>
<%# Sends hints for BOTH application + admin_tools %>
<%= javascript_pack_tag 'application' %>
</body>
</html>
How it works:
- Views call
append_javascript_pack_tag('admin_tools') - Layout calls
javascript_pack_tag('application') - Helper combines:
['application', 'admin_tools'] - Sends hints for ALL packs automatically
Configuration
📚 Configuration: See the main documentation for all configuration options including global settings, priority levels (preload/prefetch/none), and per-controller configuration.
Duplicate Prevention
Hints are automatically prevented from being sent twice:
# Controller
def show
send_pack_early_hints({ "application" => { js: "preload", css: "preload" } })
# ... expensive work ...
end
<%# Layout %>
<%= javascript_pack_tag 'application' %>
<%# Won't send duplicate hint - already sent in controller %>
How it works:
- Tracks which packs have sent JS hints:
@early_hints_javascript = {} - Tracks which packs have sent CSS hints:
@early_hints_stylesheets = {} - Skips sending hints for packs already sent
When to Use Each Pattern
Pattern 1 (Per-Pack) - Best for:
- Mixed vendor bundles (preload critical, prefetch non-critical)
- Different handling for different packs
- Layout-specific optimizations
Pattern 2 (Controller) - Best for:
- Slow controllers with expensive queries (>300ms)
- Large JS bundles (>500KB)
- APIs calls in controller
- Maximum parallelism needed
Pattern 3 (View Override) - Best for:
- Admin sections with extra packs
- Feature flags determining packs
- Page-specific bundles
Full Example: Mixed Patterns
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
# Fast controller, automatic hints work fine (Pattern 1)
end
def show
# Slow controller, send hints early for parallelism (Pattern 2)
send_pack_early_hints({
"application" => { js: "preload", css: "preload" }
})
# Expensive work happens in parallel with browser downloads
@post = Post.includes(:comments, :author).find(params[:id])
end
end
<%# app/views/posts/show.html.erb %>
<% if current_user&.admin? %>
<%# Pattern 3: Dynamic pack loading based on user role %>
<% append_javascript_pack_tag 'admin_tools' %>
<% end %>
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%= stylesheet_pack_tag 'application' %>
</head>
<body>
<%= yield %>
<%# Sends hints for application + admin_tools (if appended) %>
<%# Won't duplicate hints already sent in controller %>
<%= javascript_pack_tag 'application' %>
</body>
</html>
Preloading Non-Pack Assets (Images, Videos, Fonts)
Shakapacker's early hints are for pack assets (JS/CSS bundles). For non-pack assets like hero images, videos, and fonts, you have two options.
Note: The main documentation covers using Rails' built-in
preload_link_tagfor images and videos, which is simpler than the manual approach below.
Option 1: Manual Early Hints (For LCP/Critical Assets)
IMPORTANT: Browsers only process the FIRST HTTP 103 response. If you need both pack assets AND images/videos in early hints, you must send them together in ONE call.
class PostsController < ApplicationController
before_action :send_critical_early_hints, only: [:show]
private
def send_critical_early_hints
# Build all early hints in ONE call (packs + images)
links = []
# Pack assets (using Shakapacker manifest)
js_path = "/packs/#{Shakapacker.manifest.lookup!('application.js')}"
css_path = "/packs/#{Shakapacker.manifest.lookup!('application.css')}"
links << "<#{js_path}>; rel=preload; as=script"
links << "<#{css_path}>; rel=preload; as=style"
# Critical images (for LCP - Largest Contentful Paint)
links << "<#{view_context.asset_path('hero.jpg')}>; rel=preload; as=image"
# Send ONE HTTP 103 response with all hints
request.send_early_hints("Link" => links.join(", "))
end
def show
# Early hints already sent, browser downloading assets in parallel
@post = Post.find(params[:id])
end
end
When to use:
- Pages with hero images affecting LCP (Largest Contentful Paint)
- Videos that must load quickly
- Critical fonts not in pack bundles
Option 2: HTML Preload Links (Simpler, No Early Hints)
Use Rails' preload_link_tag to add <link rel="preload"> in the HTML:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%# Shakapacker sends early hints for packs %>
<%= stylesheet_pack_tag 'application' %>
<%# Preload link in HTML (no HTTP 103, but still speeds up loading) %>
<%= preload_link_tag asset_path('hero.jpg'), as: 'image' %>
</head>
<body>
<%= yield %>
<%= javascript_pack_tag 'application' %>
</body>
</html>
When to use:
- Images that don't affect LCP
- Less critical assets
- Simpler implementation preferred
Note: preload_link_tag only adds HTML <link> tags - it does NOT send HTTP 103 Early Hints.
Requirements & Limitations
📚 Full Requirements: See the main documentation for complete browser and server requirements. This section covers limitations specific to the manual API.
IMPORTANT: Understand these limitations when using the manual API:
Architecture: Proxy Required for HTTP/2
Standard production architecture for Early Hints:
Browser (HTTP/2)
↓
Proxy (Thruster ✅, nginx ✅, Cloudflare ✅)
├─ Receives HTTP/2
├─ Translates to HTTP/1.1
↓
Puma (HTTP/1.1 with --early-hints flag)
├─ Sends HTTP/1.1 103 Early Hints ✅
├─ Sends HTTP/1.1 200 OK
↓
Proxy
├─ Translates to HTTP/2
↓
Browser (HTTP/2 103) ✅
Key insights:
- Puma always runs HTTP/1.1 and requires
--early-hintsflag - The proxy handles HTTP/2 for external clients
- NOT all proxies support early hints (Control Plane ❌, AWS ALB ❌)
Puma Limitation: HTTP/1.1 Only
Puma ONLY supports HTTP/1.1 Early Hints (not HTTP/2). This is a Rack/Puma limitation, and there are no plans to add HTTP/2 support to Puma.
- ✅ Works: Puma 5+ with HTTP/1.1
- ❌ Doesn't work: Puma with HTTP/2 (h2)
- ✅ Solution: Use a proxy in front of Puma (Thruster, nginx, etc.)
This is the expected architecture - there's always something in front of Puma to handle HTTP/2 translation in production.
Browser Behavior
Browsers only process the FIRST HTTP/1.1 103 response.
- Shakapacker sends ONE 103 response with ALL hints (JS + CSS combined)
- Subsequent 103 responses are ignored by browsers
- This is by design per the HTTP 103 spec
Testing Locally
📚 Full Testing Guide: See the Feature Testing Guide for comprehensive testing instructions with browser DevTools and curl.
Step 1: Enable early hints in your test environment
# config/shakapacker.yml
development: # or production
early_hints:
enabled: true
debug: true # Shows hints in HTML comments
Step 2: Start Rails with Puma's --early-hints flag
# Option 1: Test in development (if enabled above)
bundle exec puma --early-hints
# Option 2: Test in production mode locally (more realistic)
RAILS_ENV=production bundle exec rake assets:precompile # Compile assets first
RAILS_ENV=production bundle exec puma --early-hints -e production
Step 3: Test with curl
# Use HTTP/1.1 (NOT HTTP/2)
curl -v http://localhost:3000/
# Look for this in output:
< HTTP/1.1 103 Early Hints
< link: </packs/application-abc123.js>; rel=preload; as=script
< link: </packs/application-abc123.css>; rel=preload; as=style
<
< HTTP/1.1 200 OK
Important notes:
- Use
http://(nothttps://) for local testing - Puma dev mode uses HTTP/1.1 (not HTTP/2)
- Test in production mode for realistic asset paths with content hashes
- Early hints must be
enabled: truefor the environment you're testing
Production Setup
📚 Production Setup: See the main documentation for complete production setup instructions including Puma configuration, proxy setup (Thruster, nginx, Cloudflare), and troubleshooting proxy issues.
Quick checklist:
- Puma 5+ with
--early-hintsflag (REQUIRED) - HTTP/2-capable proxy (Thruster ✅, nginx ✅, Cloudflare ✅, Control Plane ❌, AWS ALB ❌)
- Rails 5.2+
Troubleshooting
📚 Complete Troubleshooting: See the main documentation for comprehensive troubleshooting including debug mode, proxy configuration, and performance optimization.
Quick debugging steps:
- Enable
debug: truein shakapacker.yml to see hints in HTML comments - Verify Puma started with
--early-hintsflag - Test with
curl -v http://localhost:3000/to see if Puma sends 103 responses - Check if your proxy strips 103 responses (Control Plane ❌, AWS ALB ❌)