Classic Performance Debugging Problems in Rails Apps 🔬 – Part 3: Advanced Techniques: Query Plans, Indexing, Profiling & Production Diagnostics

Overview – what we’ll cover

  • How to read and act on EXPLAIN ANALYZE output (Postgres) — with exact commands and examples.
  • Index strategy: b-tree, composite, INCLUDE, covering indexes, partials, GIN/GIN_TRGM where relevant.
  • Practical before/after for the Flipper join query.
  • Database-level tooling: pg_stat_statements, slow query logging, ANALYZE, vacuum, stats targets.
  • Advanced Rails-side profiling: CPU sampling (rbspy), Ruby-level profilers (stackprof, ruby-prof), flamegraphs, allocation profiling.
  • Memory profiling & leak hunting: derailed_benchmarks, memory_profiler, allocation tracing.
  • Production-safe profiling and APMs: Skylight, New Relic, Datadog, and guidelines for low-risk sampling.
  • Other advanced optimizations: connection pool sizing, backgrounding heavy work, keyset pagination, materialized views, denormalization, and caching patterns.
  • A checklist & playbook you can run when a high-traffic route is slow.

1) Deep dive: EXPLAIN ANALYZE (Postgres)

Why use it

`EXPLAIN` shows the planner’s chosen plan. `EXPLAIN ANALYZE` runs the query and shows *actual* times and row counts. This is the single most powerful tool to understand why a query is slow. <h3>Run it from psql</h3>

sql EXPLAIN ANALYZE SELECT flipper_features.key AS feature_key, flipper_gates.key, flipper_gates.value FROM flipper_features LEFT OUTER JOIN flipper_gates ON flipper_features.key = flipper_gates.feature_key; 

Or add verbosity, buffers and JSON output:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON)
SELECT ...;

Then pipe JSON to jq for readability:

psql -c "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ..." | jq .

Run it from Rails console

res = ActiveRecord::Base.connection.execute(<<~SQL) EXPLAIN ANALYZE SELECT ... SQL puts res.values.flatten.join("\n") 

`res.values.flatten` will give the lines of the textual plan.

How to read the plan (key fields)

A typical node line: `Nested Loop (cost=0.00..123.45 rows=100 width=48) (actual time=0.123..5.678 rows=100 loops=1) ` – **Plan node**: e.g., Seq Scan, Index Scan, Nested Loop, Hash Join, Merge Join. – **cost=** planner estimates (startup..total). Not actual time. – **actual time=** real measured times: start..end. The end value for the top node is total time. – **rows=** estimated rows; **actual rows** follow in `actual time` block. If estimates are very different from actuals → bad statistics or wrong assumptions. – **loops=** how many times the node ran (outer loop counts). Multiply loops × actual time to know total work. – **Buffers** (if `BUFFERS` requested) show disk vs shared buffer I/O — important for I/O-bound queries. <h3>Interpretation checklist</h3> – Is Postgres doing a `Seq Scan` on a table that should use an index? → candidate for index. – Are `actual rows` much larger than `estimated rows`? → statistics outdated (`ANALYZE`) or stats target insufficient. – Is the planner using `Nested Loop` with a large inner table and many outer loops? → might need a different join strategy or indexes to support index scans, or to rewrite query. – High `buffers` read from disk → cold cache or I/O pressure. Consider tuning or adding indexes to reduce full scans, or faster disks/IO.


2) Indexing strategies – practical rules

B-tree indexes (default)

– Good for equality (`=`) and range (`<`, `>`) queries and joins on scalar columns. – Add a single-column index when you join on that column often.

Migration example:

class AddIndexToFlipperGatesFeatureKey < ActiveRecord::Migration[7.0]
  def change
    add_index :flipper_gates, :feature_key, name: 'index_flipper_gates_on_feature_key'
  end
end

Composite index

– Useful when WHERE or JOIN uses multiple columns together in order. – The left-most prefix rule: index `(a,b,c)` supports lookups on `a`, `a,b`, `a,b,c` — not `b` alone. <h3>`INCLUDE` for covering indexes (Postgres)</h3> – Use `INCLUDE` to add non-key columns to the index payload so the planner can do an index-only scan.

`add_index :orders, [:user_id, :created_at], include: [:total_amount] ` This avoids heap lookup for those included columns. <h3>Partial indexes</h3> – Index only a subset of rows where conditions often match:

add_index :users, :email, unique: true, where: "email IS NOT NULL" 

GIN / GIST indexes

– For full-text search or array/JSONB: use GIN (or trigram GIN for `ILIKE` fuzzy matches).

– Example: `CREATE INDEX ON table USING GIN (jsonb_col);`

Index maintenance

– Run `ANALYZE` after large data load to keep statistics fresh. – Consider `REINDEX` if index bloat occurs. – Use `pg_stat_user_indexes` to check index usage.


<h2>3) Example: Flipper join query — BEFORE & AFTER</h2> <h3>Problem query (recap)</h3

“`sql SELECT flipper_features.key AS feature_key, flipper_gates.key, flipper_gates.value FROM flipper_features LEFT OUTER JOIN flipper_gates ON flipper_features.key = flipper_gates.feature_key; “`

This was running repeatedly and slow (60–200ms) in many requests. <h3>Diagnosis</h3>

– The `flipper_gates` table had a composite index `(feature_key, key, value)`. Because your join only used `feature_key`, Postgres sometimes didn’t pick the composite index effectively, or the planner preferred seq scan due to small table size or outdated stats. – Repetition (many calls to `Flipper.enabled?`) magnified cost.

<h3>Fix 1 — Add a direct index on `feature_key`</h3>

Migration: “`ruby class AddIndexFlipperGatesOnFeatureKey < ActiveRecord::Migration[7.0] def change add_index :flipper_gates, :feature_key, name: ‘index_flipper_gates_on_feature_key’ end end “`

<h3>Fix 2 — Optionally make it a covering index (if you select `key, value` often)</h3>

“`ruby add_index :flipper_gates, :feature_key, name: ‘index_flipper_gates_on_feature_key_include’, using: :btree, include: [:key, :value] “` This lets Postgres perform an index-only scan without touching the heap for `key` and `value`.

<h3>EXPLAIN ANALYZE before vs after (expected)</h3

BEFORE (hypothetical):

Nested Loop
  -> Seq Scan on flipper_features (cost=...)
  -> Seq Scan on flipper_gates (cost=...)  <-- heavy
Actual Total Time: 120ms

AFTER:

Nested Loop
  -> Seq Scan on flipper_features (small)
  -> Index Scan using index_flipper_gates_on_feature_key on flipper_gates (cost=... actual time=0.2ms)
Actual Total Time: 1.5ms

Add EXPLAIN ANALYZE to your pipeline and confirm the plan uses Index Scan rather than Seq Scan.

<h3>Important note</h3>

On tiny tables, sometimes Postgres still chooses Seq Scan (cheap), but when repeated or run many times per request, even small scans add up. Index ensures stable, predictable behaviour when usage grows.


<h2>4) Database-level tools & monitoring</h2>

<h3>`pg_stat_statements` (must be enabled)</h3>

Aggregate query statistics (calls, total time). Great to find heavy queries across the whole DB. Query example: “`sql SELECT query, calls, total_time, mean_time FROM pg_stat_statements ORDER BY total_time DESC LIMIT 20; “` This points to the most expensive queries over time (not just single slow execution).

<h3>Slow query logging</h3>

Enable `log_min_duration_statement` in `postgresql.conf` (e.g., 200ms) to log slow queries. Then analyze logs with `pgbadger` or `pg_activity`.

<h3>`ANALYZE`, `VACUUM`</h3>

`ANALYZE` updates table statistics — helps the planner choose better plans. Run after bulk loads. – `VACUUM` frees up space and maintains visibility map; `VACUUM FULL` locks table — use carefully.

<h3>Lock and activity checks</h3>

See long-running queries and blocking:

“`sql SELECT pid, query, state, age(now(), query_start) AS runtime FROM pg_stat_activity WHERE state <> ‘idle’ AND now() – query_start > interval ‘5 seconds’; “`


<h2>5) Ruby / Rails advanced profiling</h2>

You already use rack-mini-profiler. For CPU & allocation deep dives, combine sampling profilers and Ruby-level profilers.

<h3>Sampling profilers (production-safe-ish)</h3>

rbspy (native sampling for Ruby processes) — low overhead, no code changes:

rbspy record --pid <PID> -- ruby bin/rails server
rbspy flamegraph --output flame.svg

rbspy collects native stack samples and generates a flamegraph. Good for CPU hotspots in production.

rbspy notes

  • Does not modify code; low overhead.
  • Requires installing rbspy on the host.

<h3>stackprof + flamegraph (Ruby-level)</h3>

Add to Gemfile (in safe envs):

gem 'stackprof'
gem 'flamegraph'

Run a block you want to profile:

require 'stackprof'

StackProf.run(mode: :wall, out: 'tmp/stackprof.dump', raw: true) do
  # run code you want to profile (a request, a job, etc)
end

# to read
stackprof tmp/stackprof.dump --text
# or generate flamegraph with stackprof or use flamegraph gem:
require 'flamegraph'
Flamegraph.generate('tmp/fg.svg') { your_code_here }

<h3>ruby-prof (detailed callgraphs)</h3>

Much higher overhead; generates call-graphs. Use in QA or staging, not production.

“`ruby require ‘ruby-prof’ RubyProf.start # run code result = RubyProf.stop printer = RubyProf::GraphHtmlPrinter.new(result) printer.print(File.open(“tmp/ruby_prof.html”, “w”), {}) “`

<h3>Allocation profiling</h3>

Use `derailed_benchmarks` gem for bundle and memory allocations:

“`bash bundle exec derailed bundle:mem bundle exec derailed exec perf:objects # or memory “` – `memory_profiler` gem gives detailed allocations:

“`ruby require ‘memory_profiler’ report = MemoryProfiler.report { run_code } report.pretty_print(to_file: ‘tmp/memory_report.txt’) “`

<h3>Flamegraphs for request lifecycles</h3>

You can capture a request lifecycle and render a flamegraph using stackprof or rbspy, then open SVG.


<h2>6) Memory & leak investigations</h2>

<h3>Symptoms</h3>

Memory grows over time in production processes. – Frequent GC pauses. – OOM kills.

<h3>Tools</h3> – `derailed_benchmarks` (hotspots and gem bloat). – `memory_profiler` for allocation snapshots (see above). – `objspace` built-in inspector (`ObjectSpace.each_object(Class)` helps count objects). – Heap dumps with `rbtrace` or `memory_profiler` for object graphs. <h3>Common causes & fixes</h3> – Caching big objects in-process (use Redis instead). – Retaining references in global arrays or singletons. – Large temporary arrays in request lifecycle — memoize or stream responses. <h3>Example patterns to avoid</h3> – Avoid storing large AR model sets in global constants. – Use `find_each` to iterate large result sets. – Use streaming responses for very large JSON/XML.


<h2>7) Production profiling — safe practices & APMs</h2> <h3>APMs</h3> – **Skylight / NewRelic / Datadog / Scout** — they give per-endpoint timings, slow traces, and SQL breakdowns in production with low overhead. Use them to find hotspots without heavy manual profiling. <h3>Sampling vs continuous profiling</h3> – Use *sampling* profilers (rbspy, production profilers) in short windows to avoid high overhead. – Continuous APM tracing (like New Relic) integrates naturally and is production-friendly. <h3>Instrument carefully</h3> – Only enable heavy profiling when you have a plan; capture for short durations. – Prefer off-peak hours or blue/green deployments to avoid affecting users.


<h2>8) Other advanced DB & Rails optimizations</h2> <h3>Connection pool tuning</h3> – Puma workers & threads must match DB pool size. Example `database.yml`: “`yaml production: pool: <%= ENV.fetch(“DB_POOL”, 5) %> “` – If Puma threads > DB pool, requests will block waiting for DB connection — can appear as slow requests. <h3>Background jobs</h3> – Anything non-critical to request latency (e.g., sending emails, analytics, resizing images) should be moved to background jobs (Sidekiq, ActiveJob). – Synchronous mailers or external API calls are common causes of slow requests. <h3>Keyset pagination (avoid OFFSET)</h3> – For large result sets use keyset pagination: “`sql SELECT * FROM posts WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20 “` This is far faster than `OFFSET` for deep pages. <h3>Materialized views for heavy aggregations</h3> – Pre-compute heavy joins/aggregates into materialized views and refresh periodically or via triggers. <h3>Denormalization & caching</h3> – Counter caches: store counts in a column and update via callbacks to avoid COUNT(*) queries. – Cache pre-rendered fragments or computed JSON blobs for heavy pages (with care about invalidation).


<h2>9) Serialization & JSON performance</h2> <h3>Problems</h3> – Serializing huge AR objects or many associations can be expensive. <h3>Solutions</h3> – Use serializers that only include necessary fields: `fast_jsonapi` (jsonapi-serializer) or `JBuilder` with simple `as_json(only: …)`. – Return minimal payloads and paginate. – Use `pluck` when you only need a few columns.


<h2>10) Playbook: step-by-step when a route is slow (quick reference)</h2>

  1. Reproduce the slow request locally or in staging if possible.
  2. Tail the logs (tail -f log/production.log) and check SQL statements and controller timings.
  3. Run EXPLAIN (ANALYZE, BUFFERS) for suspect queries.
  4. If Seq Scan appears where you expect an index, add or adjust indexes. Run ANALYZE.
  5. Check for N+1 queries with Bullet or rack-mini-profiler and fix with includes.
  6. If many repeated small DB queries (Flipper-like), add caching (Redis or adapter-specific cache) or preloading once per request.
  7. If CPU-bound, collect a sampling profile (rbspy) for 30–60s and generate a flamegraph — find hot Ruby methods. Use stackprof for deeper dive.
  8. If memory-bound, run memory_profiler or derailed, find object retainers.
  9. If urgent and unknown, turn on APM traces for a short window to capture slow traces in production.
  10. After changes, run load test (k6, wrk) if at scale, and monitor pg_stat_statements to confirm improvement.

<h2>11) Example commands and snippets (cheat-sheet)</h2>

EXPLAIN ANALYZE psql

psql -d mydb -c "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ...;" | jq .

EXPLAIN from Rails console

res = ActiveRecord::Base.connection.execute("EXPLAIN ANALYZE SELECT ...")
puts res.values.flatten.join("\n")

Add index migration

class AddIndexFlipperGatesOnFeatureKey < ActiveRecord::Migration[7.0]
  def change
    add_index :flipper_gates, :feature_key, name: 'index_flipper_gates_on_feature_key'
  end
end

ANALYZE

ANALYZE flipper_gates;
ANALYZE flipper_features;

pg_stat_statements

SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY total_time DESC LIMIT 10;

rbspy recording (system-level sampling)

rbspy record --pid <PID> --output /tmp/rbspy.dump
rbspy flamegraph --input /tmp/rbspy.dump --output /tmp/flame.svg

stackprof example

StackProf.run(mode: :cpu, out: 'tmp/stackprof.dump') do
  # exercise the app code, e.g. issue a request
end
stackprof tmp/stackprof.dump --text

memory_profiler

report = MemoryProfiler.report { run_heavy_code }
report.pretty_print
report.write_html('/tmp/memory_report.html') if report.respond_to?(:write_html)

Check DB connections

SELECT count(*) FROM pg_stat_activity WHERE state='active';


<h2>12) Final notes & best practices</h2>

  • Measure before you change. Always capture a baseline (EXPLAIN ANALYZE, APM traces).
  • Small fixes add up — one index + reducing a few N+1s often reduces response times dramatically.
  • Cache with thought — caching solves many repeated-read problems (Flipper example), but invalidation must be correct. Use namespaced keys and hook invalidation to mutations.
  • Use the right tool for the job: sampling profilers for CPU hotspots; EXPLAIN ANALYZE for DB; memory tools for leaks.
  • Automate monitoringpg_stat_statements, APMs, dashboards, and alerts let you spot regressions early.

If you want, I’ll now:

  • Generate a ready-to-run EXPLAIN ANALYZE before/after script for your Flipper query and the index migration, or
  • Provide the exact commands to run in staging to capture a stackprof or rbspy flamegraph for a slow request (and a sample SVG), or
  • Draft a one-page playbook you can paste in a team wiki for on-call performance steps.

Which of those would you like me to produce next?

Sidekiq Testing Gotchas: When Your Tests Pass Locally But Fail in CI

A deep dive into race conditions, testing modes, and the mysterious world of background job testing


The Mystery: “But It Works On My Machine!” 🤔

Picture this: You’ve just refactored some code to improve performance by moving slow operations to background workers. Your tests pass locally with flying colors. You push to CI, feeling confident… and then:

X expected: 3, got: 2
X expected: 4, got: 0

Welcome to the wonderful world of Sidekiq testing race conditions – one of the most frustrating debugging experiences in Rails development.

The Setup: A Real-World Example

Let’s examine a real scenario that recently bit us. We had a OrdersWorker that creates orders for new customers:

# app/workers/signup_create_upcoming_orders_worker.rb
class OrdersWorker
  include Sidekiq::Worker

  def perform(client_id, reason)
    client = Client.find(client_id)
    # Create orders - this is slow!
    client.orders.create
    # ... more setup logic
  end
end

The worker gets triggered during customer activation:

# lib/settings/update_status.rb
def setup(prev)
  # NEW: Move slow operation to background
  OrdersWorker.perform_async(@user.client.id, @reason)
  # ... other logic
end

And our test helper innocently calls this during setup:

# spec/helper.rb
def init_client(tags = [], sub_menus = nil)
  client = FactoryBot.create(:client, ...)
  # This triggers the worker! 
  Settings::Status.new(client, { status: 'active', reason: 'test'}).save
  client
end

Understanding Sidekiq Testing Modes

Sidekiq provides three testing modes that behave very differently:

1. Default Mode (Production-like)

# Workers run asynchronously in separate processes
OrdersWorker.perform_async(client.id, 'signup')
# Test continues immediately - worker runs "sometime later"

2. Fake Mode

Sidekiq::Testing.fake!
# Jobs are queued but NOT executed
expect(OrdersWorker.jobs.size).to eq(1)

3. Inline Mode

Sidekiq::Testing.inline!
# Jobs execute immediately and synchronously
OrdersWorker.perform_async(client.id, 'signup')
# ^ This blocks until the job completes

The Environment Plot Twist

Here’s where it gets interesting. The rspec-sidekiq gem can completely override these modes:

Local Development

# Your test output
[rspec-sidekiq] WARNING! Sidekiq will *NOT* process jobs in this environment.

Translation: “I don’t care what Sidekiq::Testing mode you set – workers aren’t running, period.”

CI/Staging

# No warning - workers run normally
Sidekiq 7.3.5 connecting to Redis with options {:url=>"redis://redis:6379/0"}

Translation: “Sidekiq testing modes work as expected.”

The Race Condition Emerges

Now we can see the perfect storm:

RSpec.describe 'OrderBuilder' do
  it "calculates order quantities correctly" do
    client = init_client([],[])  # * Triggers worker async in CI
    client.update!(order_count: 5)  # * Sets expected value

    order = OrderBuilder.new(client).create(week)  # * Reads client state

    expect(order.products.first.quantity).to eq(3)  # >> Fails in CI
  end
end

What happens in CI:

  1. init_client triggers OrdersWorker.perform_async
  2. Test sets order_count = 5
  3. Worker runs asynchronously, potentially resetting client state
  4. OrderBuilder reads modified/stale client data
  5. Calculations use wrong values → test fails

What happens locally:

  1. init_client triggers worker (but rspec-sidekiq blocks it)
  2. Test sets order_count = 5
  3. No worker interference
  4. OrderBuilder reads correct client data
  5. Test passes ✅

Debugging Strategies

1. Look for the Warning

# Local: Workers disabled
[rspec-sidekiq] WARNING! Sidekiq will *NOT* process jobs in this environment.

# CI: Workers enabled (no warning)

2. Trace Worker Triggers

Look for these patterns in your test setup:

# Direct calls
SomeWorker.perform_async(...)

# Indirect calls through model callbacks, service objects
client.setup!  # May trigger workers internally
Settings::Status.new(...).save  # May trigger workers

3. Check for State Mutations

Workers that modify the same data your tests depend on:

# Test expects this value
client.update!(important_field: 'expected_value')

# But worker might reset it
class ProblematicWorker
  def perform(client_id)
    client = Client.find(client_id)
    client.update!(important_field: 'default_value')  # 💥 Race condition
  end
end

Solutions & Best Practices

Solution 1: File-Level Inline Mode

For specs heavily dependent on worker behavior:

RSpec.describe 'OrderBuilder' do
  before(:each) do
    # Force all workers to run synchronously
    Sidekiq::Testing.inline!
    # ... other setup
  end

  # All tests now have consistent worker behavior
end

Solution 2: Context-Specific Inline Mode

For isolated problematic tests:

context "with background jobs" do
  before { Sidekiq::Testing.inline! }

  it "works with synchronous workers" do
    # Test that needs worker execution
  end
end

Solution 3: Stub the Workers

When you don’t need the worker logic:

before do
  allow(ProblematicWorker).to receive(:perform_async)
end

Solution 4: Test the Worker Separately

Isolate worker testing from business logic testing:

# Test the worker in isolation
RSpec.describe OrdersWorker do
  it "creates orders correctly" do
    Sidekiq::Testing.inline!
    worker.perform(client.id, 'signup')
    expect(client.orders.count).to eq(4)
  end
end

# Test business logic without worker interference
RSpec.describe OrderBuilder do
  before { allow(OrdersWorker).to receive(:perform_async) }

  it "calculates quantities correctly" do
    # Pure business logic test
  end
end

The Golden Rules

1. Be Explicit About Worker Behavior

Don’t rely on global configuration – be explicit in your tests:

# ✅ Good: Clear intent
context "with synchronous jobs" do
  before { Sidekiq::Testing.inline! }
  # ...
end

# ❌ Bad: Relies on global config
context "testing orders" do
  # Assumes some global Sidekiq setting
end

2. Understand Your Test Environment

Know how rspec-sidekiq is configured in each environment:

# config/environments/test.rb
if ENV['CI']
  # Allow workers in CI for realistic testing
  Sidekiq::Testing.fake!
else
  # Disable workers locally for speed
  require 'rspec-sidekiq'
end

3. Separate Concerns

  • Test business logic without worker dependencies
  • Test worker behavior in isolation
  • Test integration with controlled worker execution

Real-World Fix

Here’s how we actually solved our issue:

RSpec.describe 'OrderBuilder' do
  before(:each) do |example|
    # CRITICAL: Ensure Sidekiq workers run synchronously to prevent race conditions
    # The init_client helper triggers OrdersWorker via Settings::Status,
    # which can modify client state (rte_meal_count) asynchronously in CI, causing test failures.
    Sidekiq::Testing.inline!

    unless example.metadata[:skip_before]
      create_diet_restrictions
      create_recipes
      assign_recipe_tags
    end
  end

  # All tests now pass consistently in both local and CI! ✅
end

Takeaways

  1. Environment Parity Matters: Your local and CI environments may handle Sidekiq differently
  2. Workers Create Race Conditions: Background jobs can interfere with test state
  3. Be Explicit: Don’t rely on global Sidekiq test configuration
  4. Debug Systematically: Look for worker triggers in your test setup
  5. Choose the Right Solution: Inline, fake, or stubbing – pick what fits your test needs

The next time you see tests passing locally but failing in CI, ask yourself: “Are there any background jobs involved?” You might just save yourself hours of debugging! 🎯


Have you encountered similar Sidekiq testing issues? Share your war stories and solutions in the comments below!

Configuring CSP Nonce in Ruby on Rails 7/8

Content Security Policy (CSP) adds a powerful security layer to prevent Cross Site Scripting (XSS) attacks. Rails makes CSP easy by generating a nonce (random token per request) that you attach to inline scripts. This ensures only scripts generated by your server can run — attackers can’t inject HTML/JS and execute it.

A nonce is a number used once — a unique per-request token (e.g. nonce-faf83af82392). Add it to inline <script> tags:

<script nonce="<%= content_security_policy_nonce %>">
  // safe inline script
</script>

The browser only runs the script if the nonce matches the value advertised in the Content-Security-Policy response header.

Rails ships with a CSP initializer. Edit or create config/initializers/content_security_policy.rb and configure script_src to allow nonces:

# config/initializers/content_security_policy.rb
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.font_src    :self, :https, :data
    policy.img_src     :self, :https, :data
    policy.object_src  :none
    policy.style_src   :self, :https
    policy.script_src  :self, :https, :nonce # allow scripts with nonce
  end

  # Optional: report violations
  # policy.report_uri "/csp-violation-report-endpoint"
end

Restart your server after changing initializers.

Example app/views/layouts/application.html.erb using csp_meta_tag and per-request nonce:

<!DOCTYPE html>
<html>
<head>
  <title>MyApp</title>
  <%= csp_meta_tag %>
  <%= csrf_meta_tags %>
  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
  <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
</head>
<body>
  <%= yield %>

  <!-- inline script with nonce -->
  <script nonce="<%= content_security_policy_nonce %>">
    console.log("CSP Nonce example script");
  </script>
</body>
</html>

Only this inline script will run; any injected script without the nonce will be blocked by the browser.

Turbo works fine with CSP nonces because Turbo prefers external script modules and does not require inline JavaScript. If you must return inline JS in Turbo responses (e.g., Turbo Streams that embed inline <script>), include the nonce on those scripts.

Stimulus controllers are loaded from external JavaScript (via import maps, webpack, or esbuild) — so they do not require inline scripts and thus are fully compatible with strict CSP policies. If you initialize Stimulus inline (not recommended), add the nonce:

<script nonce="<%= content_security_policy_nonce %>">
  // initialize application controllers
</script>

Best practice: keep all JS in app/javascript/controllers and avoid inline JS.

If you need to do a redirect from an HTML fragment returned to a Turbo frame, you might inline a small script. Add nonce like this:

<!-- returned inside a turbo frame response -->
<script nonce="<%= content_security_policy_nonce %>">
  Turbo.visit("<%= dashboard_path %>");
</script>

Open browser DevTools → Network → select a request → Response Headers. You should see something like:

Content-Security-Policy: script-src 'self' https: 'nonce-<value>'

You can also read the header in Rails tests or logs.

Verify forms include authenticity token (CSRF):

require "rails_helper"

RSpec.describe "CSRF token", type: :system do
  it "includes authenticity token in forms" do
    visit new_user_registration_path
    expect(page.html).to match(/name="authenticity_token"/)
  end
end

You can assert that the response includes a nonce and that an inline script was rendered with that nonce:

require "rails_helper"

RSpec.describe "CSP Nonce", type: :request do
  it "adds nonce to inline scripts" do
    get root_path

    csp_header = response.headers["Content-Security-Policy"]
    # extract nonce value from header (pattern depends on how you configured policy)
    nonce = csp_header[/nonce-(.*?)'/, 1]

    # ensure the body includes a script tag with the nonce
    expect(response.body).to include("nonce=\"#{nonce}\"")
  end
end

Note: header parsing may require adjusting the regex depending on quoting in your CSP header.

  • Avoid inline JS where possible — favors well-structured JS bundles.
  • Use nonces only when necessary (e.g., third-party scripts that are injected inline, small inline initializers returned in Turbo responses).
  • Test in production-like environment because browsers enforce CSP differently; dev tooling can be permissive.
  • Report-only mode during rollout: set policy.report_only = true to collect violations without blocking.
  • If inline scripts still blocked: confirm csp_meta_tag is present and that the inline <script> uses content_security_policy_nonce.
  • For external scripts blocked: ensure script_src includes the allowed host or https:.
  • If using secure_headers gem or a reverse proxy adding headers, ensure they don’t conflict.
  • Enable CSP with nonces for inline scripts when necessary.
  • Prefer Stimulus/Turbo with external JS modules — avoid inline code.
  • Test CSP and CSRF behavior in request/system specs.
  • Use report-only mode when rolling out strict CSP.

Quick checklist

  • Add policy.script_src :self, :https, :nonce in content_security_policy.rb
  • Use <%= csp_meta_tag %> in layout
  • Add nonce="<%= content_security_policy_nonce %>" to inline scripts
  • Move JS into app/javascript/controllers where possible
  • Add request/system specs to validate nonce and CSRF

Optimizing Third-Party Script Loading in a Rails + Vue Hybrid Architecture

Part 1: The Problem – When Legacy Meets Modern Frontend

Our Architecture: A Common Evolution Story

Many web applications today follow a similar evolutionary path. What started as a traditional Rails monolith gradually transforms into a modern hybrid architecture. Our application, let’s call it “MealCorp,” followed this exact journey:

Phase 1: Traditional Rails Monolith

# Traditional Rails serving HTML + embedded JavaScript
class HomeController < ApplicationController
  def index
    # Rails renders ERB templates with inline scripts
    render 'home/index'
  end
end

Phase 2: Hybrid Rails + Vue Architecture (Current State)

# Modern hybrid: Rails API + Vue frontend
class AppController < ApplicationController
  INDEX_PATH = Rails.root.join('public', 'app.html')
  INDEX_CONTENT = File.exist?(INDEX_PATH) && File.open(INDEX_PATH, &:read).html_safe

  def index
    if Rails.env.development?
      redirect_to request.url.gsub(':3000', ':5173') # Vite dev server
    else
      render html: INDEX_CONTENT # Serve built Vue app
    end
  end
end

The routes configuration looked like this:

# config/routes.rb
Rails.application.routes.draw do
  root 'home#index'
  get '/dashboard' => 'app#index'
  get '/settings' => 'app#index'
  get '/profile' => 'app#index'
  # Most routes serve the Vue SPA
end

The Hidden Performance Killer

While our frontend was modern and fast, we discovered a critical performance issue that’s common in hybrid architectures. Our Google PageSpeed scores were suffering, showing this alarming breakdown:

JavaScript Execution Time Analysis:

Reduce JavaScript execution time: 1.7s
┌─────────────────────────────────────────────────────────────┐
│ Script                           │ Total │ Evaluation │ Parse │
├─────────────────────────────────────────────────────────────┤
│ Google Tag Manager              │ 615ms │    431ms   │ 171ms │
│ Rollbar Error Tracking          │ 258ms │    218ms   │  40ms │
│ Facebook SDK                    │ 226ms │    155ms   │  71ms │
│ Main Application Bundle         │ 190ms │    138ms   │  52ms │
└─────────────────────────────────────────────────────────────┘

The smoking gun? Third-party monitoring scripts were consuming more execution time than our actual application!

Investigating the Mystery

The puzzle deepened when we compared our source files:

Vue Frontend Source (index.html):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>MealCorp Dashboard</title>
    <!-- Clean, minimal head section -->
    https://js.stripe.com/v3
    https://kit.fontawesome.com/abc123.js
  </head>
  <body>
    <div id="app"></div>
    /src/main.ts
  </body>
</html>

Built Static File (public/app.html):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>MealCorp Dashboard</title>
    <!-- Same clean content, no third-party scripts -->
    /assets/index-xyz123.js
    <link rel="stylesheet" crossorigin href="/assets/index-abc456.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

But Browser “View Source” Showed:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>MealCorp Dashboard</title>

    <!-- Mystery scripts appearing from nowhere! -->
    <script>var _rollbarConfig = {"accessToken":"token123...","captureUncaught":true...}</script>
    <script>!function(r){var e={}; /* Minified Rollbar library */ }</script>

    <script>(function(w,d,s,l,i){w[l]=w[l]||[]; /* GTM script */ })(window,document,'script','dataLayer','GTM-ABC123');</script>

    <!-- Our clean application code -->
    /assets/index-xyz123.js
  </body>
</html>

The Root Cause Discovery

After investigation, we discovered that Rails was automatically injecting third-party scripts at runtime, despite serving static files!

Here’s what was happening in our Rails configuration:

Google Tag Manager Configuration:

# config/initializers/analytics.rb (Old problematic approach)
# This was loading synchronously in the Rails asset pipeline

Rollbar Configuration:

# config/initializers/rollbar.rb
Rollbar.configure do |config|
  config.access_token = 'server_side_token_123'

  # The culprit: Automatic JavaScript injection!
  config.js_enabled = true  # X This caused performance issues
  config.js_options = {
    accessToken: Rails.application.credentials[Rails.env.to_sym][:rollbar_client_token],
    captureUncaught: true,
    payload: { environment: Rails.env },
    hostSafeList: ['example.com', 'staging.example.com']
  }
end

The Request Flow That Caused Our Performance Issues:

  1. Browser requests /dashboard
  2. Rails routes to AppController#index
  3. Rails renders static public/app.html content
  4. Rollbar gem automatically injects JavaScript into the HTML response
  5. GTM configuration adds synchronous tracking scripts
  6. Browser receives HTML with blocking third-party scripts
  7. Performance suffers due to synchronous execution

Part 2: The Solution – Modern Deferred Loading

Understanding the Performance Impact

The core issue was synchronous script execution during page load. Each third-party service was blocking the main thread:

// What was happening (blocking):
<script>
  var _rollbarConfig = {...}; // Immediate execution - blocks rendering
</script>
<script>
  (function(w,d,s,l,i){ // GTM immediate execution - blocks rendering
    // Heavy synchronous operations
  })(window,document,'script','dataLayer','GTM-ABC123');
</script>

The Modern Solution: Deferred Loading Architecture

We implemented a Vue-based deferred loading system that maintains identical functionality while dramatically improving performance.

Step 1: Disable Rails Auto-Injection

# config/initializers/rollbar.rb
Rollbar.configure do |config|
  config.access_token = 'server_side_token_123'

  # Disable automatic JavaScript injection for better performance
  config.js_enabled = false  # Good - Stop Rails from injecting scripts

  # Server-side error tracking remains unchanged
  config.person_method = "current_user"
  # ... other server-side config
end

Step 2: Implement Vue-Based Deferred Loading

// src/App.vue
<script setup lang="ts">
import { onMounted } from 'vue';

// Load third-party scripts after Vue app mounts for better performance  
onMounted(() => {
  loadGoogleTagManager();
  loadRollbarDeferred();
});

function loadGoogleTagManager() {
  const script = document.createElement('script');
  script.async = true;
  script.src = `https://www.googletagmanager.com/gtm.js?id=${import.meta.env.VITE_GTM_ID}`;

  // Track initial pageview once GTM loads
  script.onload = () => {
    trackEvent({
      event: 'page_view',
      page_title: document.title,
      page_location: window.location.href,
      page_path: window.location.pathname
    });
  };

  document.head.appendChild(script);
}

function loadRollbarDeferred() {
  const rollbarToken = import.meta.env.VITE_ROLLBAR_CLIENT_TOKEN;
  if (!rollbarToken) return;

  // Load after all other resources are complete
  window.addEventListener('load', () => {
    // Initialize Rollbar configuration
    (window as any)._rollbarConfig = {
      accessToken: rollbarToken,
      captureUncaught: true,
      payload: {
        environment: import.meta.env.MODE // 'production', 'staging', etc.
      },
      hostSafeList: ['example.com', 'staging.example.com']
    };

    // Load Rollbar script asynchronously
    const rollbarScript = document.createElement('script');
    rollbarScript.async = true;
    rollbarScript.src = 'https://cdn.rollbar.com/rollbarjs/refs/tags/v2.26.1/rollbar.min.js';
    document.head.appendChild(rollbarScript);
  });
}
</script>

Step 3: TypeScript Support

// src/types/global.d.ts
declare global {
  interface Window {
    _rollbarConfig?: {
      accessToken: string;
      captureUncaught: boolean;
      payload: {
        environment: string;
      };
      hostSafeList: string[];
    };
    dataLayer?: any[];
  }
}

export {};

Environment Configuration

# .env.production
VITE_GTM_ID=GTM-PROD123
VITE_ROLLBAR_CLIENT_TOKEN=client_token_prod_456

# .env.staging  
VITE_GTM_ID=GTM-STAGING789
VITE_ROLLBAR_CLIENT_TOKEN=client_token_staging_789

Testing the Implementation

Comprehensive Testing Script:

// Browser console testing function
function testTrackingImplementation() {
  console.log('=== TRACKING SYSTEM TEST ===');

  // Test 1: GTM Integration
  console.log('GTM dataLayer exists:', !!window.dataLayer);
  console.log('GTM script loaded:', !!document.querySelector('script[src*="googletagmanager.com"]'));
  console.log('Recent GTM events:', window.dataLayer?.slice(-3));

  // Test 2: Rollbar Integration  
  console.log('Rollbar loaded:', typeof Rollbar !== 'undefined');
  console.log('Rollbar config:', window._rollbarConfig);
  console.log('Rollbar script loaded:', !!document.querySelector('script[src*="rollbar"]'));

  // Test 3: Send Test Events
  // GTM Test Event
  window.dataLayer?.push({
    event: 'test_tracking',
    test_source: 'manual_verification',
    timestamp: new Date().toISOString()
  });

  // Rollbar Test Error
  if (typeof Rollbar !== 'undefined') {
    Rollbar.error('Test error for verification - please ignore', {
      test_context: 'performance_optimization_verification'
    });
  }

  console.log('✅ Test events sent - check dashboards in 1-2 minutes');
}

// Run the test
testTrackingImplementation();

Expected Console Output:

=== TRACKING SYSTEM TEST ===
GTM dataLayer exists: true
GTM script loaded: true
Recent GTM events: [
  {event: "page_view", page_title: "Dashboard", ...},
  {event: "gtm.dom", ...}, 
  {event: "gtm.load", ...}
]
Rollbar loaded: true
Rollbar config: {accessToken: "...", captureUncaught: true, ...}
Rollbar script loaded: true
✅ Test events sent - check dashboards in 1-2 minutes

Performance Results

Before Optimization:

JavaScript Execution Time: 1.7s
├── Google Tag Manager: 615ms (synchronous)
├── Rollbar: 258ms (synchronous)  
├── Facebook SDK: 226ms (synchronous)
└── Application Code: 190ms

After Optimization:

JavaScript Execution Time: 0.4s
├── Application Code: 190ms (immediate)
├── Deferred Scripts: ~300ms (non-blocking, post-load)
└── Performance Improvement: ~1.3s (76% reduction)

Key Benefits Achieved

  1. Performance Gains:
  • 76% reduction in blocking JavaScript execution time
  • Improved Core Web Vitals scores
  • Better user experience with faster perceived load times
  1. Maintained Functionality:
  • Identical error tracking capabilities
  • Same analytics data collection
  • All monitoring dashboards continue working
  1. Better Architecture:
  • Modern Vue-based script management
  • Environment-specific configuration
  • TypeScript support for better maintainability
  1. Security Improvements:
  • Proper separation of server vs. client tokens
  • Environment variable management
  • No sensitive data in version control

Common Pitfalls and Solutions

Issue 1: Token Confusion

Error: post_client_item scope required but token has post_server_item

Solution: Use separate client-side tokens for browser JavaScript.

Issue 2: Missing Initial Pageviews
Solution: Implement manual pageview tracking in script.onload callback.

Issue 3: TypeScript Errors

// Fix: Add proper type declarations
(window as any)._rollbarConfig = { ... }; // Type assertion approach
// OR declare global types for better type safety

This hybrid architecture optimization demonstrates how modern frontend practices can be retroactively applied to existing applications, achieving significant performance improvements while maintaining full functionality. The key is identifying where legacy server-side patterns conflict with modern client-side performance optimization and implementing targeted solutions.


Happy Optimization! 🚀

Classic Performance Debugging Problems in Rails Apps – Part 2: Tools & Fixes

In Part 1, we learned how to spot bottlenecks using Rails logs.
Now let’s go deeper – using profiling tools, custom logging, and real-world fixes (including our Flipper case).

🧰 Profiling Tools for Rails Developers

1. Rack Mini Profiler 📊

Rack Mini Profiler is the go-to gem for spotting slow DB queries and views.

Add it to your Gemfile (development & staging only):

group :development do
  gem 'rack-mini-profiler'
end

Then run:

bundle install

When you load a page, you’ll see a little timing panel in the top-left corner:

  • Total time per request.
  • SQL queries count & time.
  • Which queries are repeated.
  • View rendering breakdown.

This makes N+1 queries immediately visible.


2. Bullet (for N+1 Queries)

Add to Gemfile:

group :development do
  gem 'bullet'
end

Config in config/environments/development.rb:

config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.rails_logger = true
end

Now, if you forget to eager load (includes), Bullet will warn:

N+1 query detected: Review => Product
Add to your query: .includes(:product)


3. Custom Logging for SQL Queries

Sometimes you need to trace where in your code a slow query is triggered.
Rails lets you hook into ActiveRecord logging:

# config/initializers/query_tracer.rb
ActiveSupport::Notifications.subscribe("sql.active_record") do |_, start, finish, _, payload|
  duration = (finish - start) * 1000
  if duration > 100 # log queries > 100ms
    Rails.logger.warn "SLOW QUERY (#{duration.round(1)}ms): #{payload[:sql]}"
    Rails.logger.warn caller.select { |line| line.include?(Rails.root.to_s) }.first(5).join("\n")
  end
end

This logs:

  • The query.
  • Execution time.
  • Stack trace (where in Rails code it was triggered).

Real Example: Fixing Flipper Performance

In Part 1, we saw Flipper queries running 38 times per page:

SELECT "flipper_features"."key" AS feature_key,
       "flipper_gates"."key",
       "flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"

Problem

Each Flipper.enabled?(:feature_name, user) call hit the DB.
With dozens of flags per request → repeated queries → 6s page loads.

Solution: Redis Caching

Flipper supports caching with Redis.

# config/initializers/flipper.rb
require 'flipper/adapters/active_record'
require 'flipper/adapters/redis'
require 'flipper/adapters/operation_logger'

flipper_db_adapter = Flipper::Adapters::ActiveRecord.new
flipper_redis_adapter = Flipper::Adapters::Redis.new(Redis.new)

# wrap with memory cache to avoid repeat queries
flipper_caching_adapter = Flipper::Adapters::Cache.new(
  flipper_db_adapter,
  cache: flipper_redis_adapter,
  expires_in: 5.minutes
)

Flipper.configure do |config|
  config.default = Flipper.new(flipper_caching_adapter)
end

Now:

  • First request → fetches from DB, writes to Redis.
  • Next requests → served from Redis/memory.
  • Page load dropped from 6s → <500ms 🎉.

Extra: Inspecting Redis Keys 🔍

Check what’s being cached:

Rails.cache.redis.keys("*flipper*")

Or, if using connection pooling:

Rails.cache.redis.with { |r| r.keys("*flipper*") }


Other Common Fixes

1. Add Missing Indexes

If you see slow queries with filters:

SELECT * FROM orders WHERE user_id = 123

Fix:

rails generate migration add_index_to_orders_user_id
rails db:migrate

2. Eager Load Associations

Instead of:

@orders = current_user.orders
@orders.each do |order|
  puts order.user.email
end

Fix N+1:

@orders = current_user.orders.includes(:user)

3. Memoization / Request Caching

If you call the same method multiple times per request:

def expensive_query
  User.where(active: true).to_a
end

Fix:

def expensive_query
  @expensive_query ||= User.where(active: true).to_a
end

📌 Summary of Part 2

In this part, we covered:

  • Using tools: Rack Mini Profiler, Bullet, and custom logging.
  • A real-world Flipper caching fix (DB → Redis).
  • Other fixes: indexes, eager loading, memoization.

Together, these tools + fixes turn performance debugging from guesswork into a repeatable workflow.

📌 In Part 3, we’ll go even deeper into advanced techniques:

  • EXPLAIN ANALYZE in Postgres.
  • Profiling memory allocations with stackprof.
  • Using Skylight or Datadog in production.

Classic Performance Debugging Problems in Rails Apps – Part 1: Finding the Bottlenecks

Rails makes building apps fast and joyful – but sooner or later, every team runs into the same dreaded complaint:

“Why is this page so slow?”

Performance debugging is tricky because Rails abstracts so much for us. Underneath every User.where(...).first or current_user.orders.includes(:products), there’s real SQL, database indexes, network calls, caching layers, and Ruby code running.

This post (Part 1) focuses on how to find the bottlenecks in a Rails app using logs and manual inspection. In Part 2, we’ll explore tools like Rack Mini Profiler and real-world fixes.


Symptoms of a Slow Rails Page

Before diving into logs, it’s important to recognize what “slow” might mean:

  • Page loads take several seconds.
  • CPU usage spikes during requests.
  • The database log shows queries running longer than expected.
  • Repeated queries (e.g. the same SELECT firing 30 times).
  • Memory bloat or high GC (garbage collection) activity.

Example symptom we hit:

SELECT "flipper_features"."key" AS feature_key,
       "flipper_gates"."key",
       "flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"

This query was executed 38 times when loading a product page (/product/adidas-shoe). That’s a red flag .


Understanding Rails Logs

Every Rails request is logged in log/development.log (or production.log). A typical request looks like:

Started GET "/products/123" for 127.0.0.1 at 2025-09-25 12:45:01 +0530
Processing by ProductsController#show as HTML
  Parameters: {"id"=>"123"}
  Product Load (1.2ms)  SELECT "products".* FROM "products" WHERE "products"."id" = $1 LIMIT $2  [["id", 123], ["LIMIT", 1]]
  Review Load (10.4ms)  SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = $1  [["product_id", 123]]
Completed 200 OK in 120ms (Views: 80.0ms | ActiveRecord: 20.0ms | Allocations: 3456)

Key things to notice:

  • Controller actionProductsController#show.
  • Individual SQL timings → each query shows how long it took.
  • Overall timeCompleted 200 OK in 120ms.
  • BreakdownViews: 80.0ms | ActiveRecord: 20.0ms.

If the DB time is small but Views are big → it’s a rendering problem.
If ActiveRecord dominates → the DB queries are the bottleneck.


🕵️ Debugging a Slow Page Step by Step

1. Watch your logs in real time

tail -f log/development.log | grep -i "SELECT"

This shows you every SQL query as it executes.

2. Look for repeated queries (N+1)

If you see the same SELECT firing dozens of times:

SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 123
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 124
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 125

That’s the classic N+1 query problem.

3. Look for expensive joins

Queries with multiple JOINs can be slow without proper indexing. Example:

SELECT "orders"."id", "users"."email"
FROM "orders"
INNER JOIN "users" ON "users"."id" = "orders"."user_id"
WHERE "users"."status" = 'active'

If there’s no index on users.status, this can cause sequential scans.

4. Look for long-running queries

Rails logs include timings:

User Load (105.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = 123

If a query consistently takes >100ms on small tables, it probably needs an index or query rewrite.


⚡ Real Example: Debugging the Flipper Feature Flag Queries

In our case, the Rails logs showed:

SELECT "flipper_features"."key" AS feature_key,
       "flipper_gates"."key",
       "flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"

  • It executed 38 times on one page.
  • Each execution took between 60–200ms.
  • Together, that added ~6 seconds to page load time.

The query itself wasn’t huge (tables had <150 rows). The problem was repetition – every feature flag check was hitting the DB fresh.

This pointed us toward caching (covered in Part 2).

Workflow for Performance Debugging in Rails

  1. Reproduce the slow page locally or in staging.
  2. Tail the logs and isolate the slow request.
  3. Categorize: rendering slow? DB queries slow? external API calls?
  4. Identify repeated or long queries.
  5. Ask “why“:
    • Missing index?
    • Bad join?
    • N+1 query?
    • Repeated lookups that could be cached?
  6. Confirm with SQL tools (EXPLAIN ANALYZE in Postgres).

Summary of Part 1

In this first part, we covered:

  • Recognizing symptoms of slow pages.
  • Reading Rails logs effectively.
  • Debugging step by step with queries and timings.
  • A real-world case of repeated Flipper queries slowing down a page.

In Part 2, we’ll go deeper into tools and solutions:

  • Setting up Rack Mini Profiler.
  • Capturing queries + stack traces in custom logs.
  • Applying fixes: indexes, eager loading, and caching (with Flipper as a worked example).

to be continued …

Part 4 – Redis, cache invalidation, testing, pitfalls, and checklist

10) Optional: Redis caching in a Rails API app (why and how)

Even in API-only apps, application-level caching is useful to reduce DB load for expensive queries or aggregated endpoints.

Common patterns

  • Low-level caching: Rails.cache.fetch
  • Fragment caching: less relevant for API-only (used for views), but you can cache JSON blobs
  • Keyed caching with expiration for computed results

Example — caching an expensive query

class Api::V1::ReportsController < ApplicationController
  def sales_summary
    key = "sales_summary:#{Time.current.utc.strftime('%Y-%m-%d-%H')}"
    data = Rails.cache.fetch(key, expires_in: 5.minutes) do
      # expensive computation
      compute_sales_summary
    end
    render json: data
  end
end

Why Redis?

  • Redis is fast, supports expirations, and can be used as Rails.cache store (config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }).
  • Works well for ephemeral caches that you want to expire automatically.

Invalidation strategies

  • Time-based (TTL) — simplest.
  • Key-based — when related data changes, evict related keys.
    • Example: after a product update, call Rails.cache.delete("top_offers").
  • Versioned keys — embed a version or timestamp in the key (e.g., products:v3:top) and bump the version on deploy/major change.
  • Tagging / Key sets — maintain a set of keys per resource to delete them on change (more manual).

Caveat: Don’t rely solely on Redis caching for user-specific sensitive data. Use private caches when needed.

11) Purging caches and CDN invalidation

When hashed assets are used you rarely need to purge because new filenames mean new URLs. For non-hashed assets or CDN caches you may need purge:

  • CDN invalidation: Cloudflare / Fastly / CloudFront offer purge by URL or cache key. Use CDN APIs or surrogate-key headers to do group purges.
  • Surrogate-Control / Surrogate-Key (Fastly): set headers that help map objects to tags for efficient purging.
  • Nginx cache purge: if you configure proxy_cache, you must implement purge endpoints or TTLs.

Recommended approach:

  • Prefer hashed filenames for assets so you rarely need invalidation.
  • For dynamic content, prefer short TTLs or implement programmatic CDN purges as part of deploy/administration flows.

12) Testing and verifying caching behavior (practical commands)

Check response headers

curl -I https://www.mydomain.com/vite/index-B34XebCm.js
# expect: Cache-Control: public, max-age=31536000, immutable

Conditional request test (ETag)

  1. Get ETag:
curl -I https://api.mydomain.com/api/v1/products/123
# Look for ETag: "..."

  1. Re-request with that ETag:
curl -I -H 'If-None-Match: "the-etag-value"' https://api.mydomain.com/api/v1/products/123
# expect: 304 Not Modified (if unchanged)

Check s-maxage / CDN effect

  • Use curl -I against the CDN domain (if applicable) to inspect Age header (shows time cached at edge) and X-Cache headers from CDN.

Chrome DevTools

  • Open Network tab, reload page, inspect a cached resource:
    • Status might show (from disk cache) or (from memory cache) if cached.
    • For resources with max-age but no immutable, you might see 200 with from disk cache or network requests with 304.

13) Common pitfalls and how to avoid them

  1. Caching HTML
    • Problem: If your index.html or vite.html is cached, users can get old asset references and see broken UI.
    • Avoid: Always no-cache your HTML entry file.
  2. Caching non-hashed assets long-term
    • Problem: Logo or content images may not update for users.
    • Avoid: Short TTL or rename files when updating (versioning).
  3. Not using ETag/Last-Modified
    • Problem: Clients re-download entire payloads when unchanged — wasted bandwidth.
    • Avoid: Use ETag or Last-Modified so clients can get 304.
  4. Caching user-specific responses publicly
    • Problem: Data leak (private data served to other users).
    • Avoid: Use private/no-store for user-specific responses.
  5. Relying solely on Nginx for dynamic caching
    • Problem: Hard to maintain invalidations and complex to configure with Passenger.
    • Avoid: Use Rails headers + CDN; or a caching reverse proxy only if necessary and you know how to invalidate.

14) Commands and operational notes for Passenger

Test nginx config

sudo nginx -t

Reload nginx gracefully

sudo systemctl reload nginx

Restart nginx if reload fails

sudo systemctl restart nginx

Restart Passenger (app-level)

  • Passenger allows restarting an app without touching the systemd service:
# restart specific app by path
sudo passenger-config restart-app /apps/mydomain/current

  • Or restart all apps (be careful):
sudo passenger-config restart-app --ignore-app-not-running

Check Passenger status

passenger-status

Always run nginx -t before reload. Make sure to test caching headers after a reload (curl) before rolling out.

15) Final checklist before/after deploy (practical)

Before deploy:

  • Ensure build pipeline fingerprints Vite output (hash in filenames).
  • Ensure /apps/mydomain/current/public/vite/ contains hashed assets.
  • Confirm vite.html is correct and references the hashed file names.
  • Confirm Nginx snippet for /vite/ long cache is present and not overridden.

After deploy:

  • Run sudo nginx -t and sudo systemctl reload nginx.
  • Test hashed asset: curl -I https://www.mydomain.com/vite/index-...jsCache-Control: public, max-age=31536000
  • Test HTML: curl -I https://www.mydomain.com/vite.htmlCache-Control: no-cache
  • Test sample API endpoint headers: curl -I https://api.mydomain.com/api/v1/products → verify Cache-Control and ETag/Last-Modified where applicable.
  • Run smoke tests in browser (Chrome DevTools) to verify resources are cached as expected.

16) Appendix — example Rails snippets (summary)

Set header manually

response.set_header('Cache-Control', 'public, max-age=60, s-maxage=300')

Return 304 using conditional GET

def show
  resource = Resource.find(params[:id])
  if stale?(etag: resource, last_modified: resource.updated_at)
    render json: resource
  end
end

Redis caching (Rails.cache)

data = Rails.cache.fetch("top_products_page_#{params[:page]}", expires_in: 5.minutes) do
  Product.top.limit(20).to_a
end
render json: data

Conclusion (Part 4)

This part explained where caching belongs in an API-only Rails + Vue application, how Passenger fits into the stack, how to set cache headers for safe API caching, optional Redis caching strategies, and practical testing/operational steps.


Part 3 – Passenger, Nginx and the Request Lifecycle (deep dive)

Overview / Goal

In Part 3 we explain how Passenger sits behind Nginx in an API-only Rails app, where caching belongs in the stack, how to safely cache API responses (or avoid caching sensitive ones), and how to verify behavior. This part covers Passenger role, request lifecycle, and API response caching strategy.


1) Passenger’s role in a Rails API app — what it is and how it plugs into Nginx

What is Passenger?
Passenger (Phusion Passenger) is an application server that runs Ruby apps (Rails) and integrates tightly with web servers such as Nginx. It manages application processes, handles spawning, lifecycle, zero-downtime restarts, and serves Rack apps directly without a separate reverse-proxy layer.

Why using Passenger in your stack matters:

  • Nginx serves static files directly (fast).
  • If a request cannot be served as a static file, Nginx hands it to Passenger, which invokes your Rails app (API).
  • Passenger takes care of Ruby processes, workers, memory limits, restarts, etc., reducing operational complexity compared to orchestrating your own Puma cluster + systemd.

Typical nginx + passenger snippet (conceptual)

server {
  listen 443 ssl http2;
  server_name api.mydomain.com www.mydomain.com;
  root /apps/mydomain/current/public;

  passenger_enabled on;
  passenger_ruby /apps/mydomain/shared/ruby;
  passenger_min_instances 2;
  passenger_max_pool_size 6;
  passenger_preload_bundler on;

  # static + caching rules here ...
  location / {
    # fallback: handover to passenger (Rails)
    passenger_enabled on;
  }
}

Passenger is enabled per-server or per-location. Static files under root are resolved by nginx first — Passenger only gets requests that do not map to files (or that you explicitly route to Passenger).


2) Request lifecycle (browser → Nginx → Passenger → Rails API)

A canonical sequence for a request to your site:

  1. Browser requests https://www.mydomain.com/some/path (or /vite/index-ABC.js, or /api/v1/products).
  2. Nginx checks if the request maps to a static file under root /apps/mydomain/current/public.
    • If file exists → serve it directly and attach headers (Cache-Control, etc.).
    • If not → pass the request to Passenger.
  3. Passenger receives the request and dispatches it to a Rails process.
  4. Rails API processes the request (controllers -> models -> DB) and produces a response JSON or status.
  5. Rails returns the response to Passenger → Passenger returns it to Nginx → Nginx returns it to the browser.

Key layers where caching can occur:

  • Client-side (browser) — controlled by Cache-Control returned from server.
  • Reverse-proxy or CDN — e.g., Cloudflare, Fastly, CloudFront; caching behavior influenced by s-maxage and surrogate headers.
  • Application caching (Rails/Redis) — memoization or precomputed JSON payloads to reduce DB cost.
  • Nginx (edge) caching — possible for static assets; less common for dynamic Rails responses when using Passenger (but possible with proxying setups).

3) Where API caching should sit (principles)

Because your Rails app is API-only, you should carefully control caching:

  • Static assets (JS/CSS/fonts/images) = Nginx (1-year for hashed assets).
  • API responses (JSON) = usually short-lived or uncached unless content is highly cacheable and non-sensitive. If cached:
    • Prefer caching at CDN layer (s-maxage) or using application-level caching (Rails + Redis).
    • Use cache validation (ETag, Last-Modified) to enable conditional requests and 304 responses.
  • Sensitive endpoints (auth, user-specific data) = never cached publicly. Use Cache-Control: no-store, private.

4) Cache-Control and related headers for APIs — recommended practices

Important response headers and their recommended usage

  • Cache-Control:
    • no-store — do not store response anywhere (safest for sensitive data).
    • no-cache — caches may store but must revalidate with origin before use (useful if you want caching but require revalidation).
    • private — response intended for a single user; shared caches (CDNs) must not store it.
    • public — response may be stored by browsers and CDNs.
    • max-age=SECONDS — TTL in seconds.
    • s-maxage=SECONDS — TTL for shared caches (CDNs); supersedes max-age for shared caches.
    • must-revalidate / proxy-revalidate — force revalidation after expiration.
    • stale-while-revalidate / stale-if-error — allow stale responses while revalidation or in case of errors (good for resilience).
  • ETag:
    • Strong validator; server generates a value representing resource state. Client includes If-None-Match on subsequent requests. Server returns 304 Not Modified if ETag matches.
  • Last-Modified and If-Modified-Since:
    • Based on timestamp; less precise than ETag but simple.
  • Vary:
    • Tells caches that responses vary by certain request headers (e.g., Vary: Accept-Encoding or Vary: Authorization).

Example header patterns

  • Public, CDN cacheable API (e.g., public product listings): Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30 ETag: "abc123" Vary: Accept-Encoding
    • Browser caches for 60s. CDN caches for 300s. Meanwhile allow stale while revalidate.
  • User-specific / sensitive responses: Cache-Control: private, no-store, no-cache, must-revalidate
    • Prevents sharing.
  • No caching (strict): Cache-Control: no-store

5) How to add caching headers in a Rails API controller (practical examples)

Because you run an API-only app, prefer setting headers in controllers selectively for GET endpoints you consider safe to cache.

Basic manual header (safe and explicit):

class Api::V1::ProductsController < ApplicationController
  def index
    @products = Product.popular.limit(20)
    # set short-lived cache for 60 seconds for browsers
    response.set_header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30')
    render json: @products
  end
end

Using conditional GET with ETag / Last-Modified:

class Api::V1::ProductsController < ApplicationController
  def show
    product = Product.find(params[:id])
    # This helps return 304 Not Modified if product hasn't changed
    if stale?(etag: product, last_modified: product.updated_at)
      render json: product
    end
  end
end

Notes: stale? and fresh_when are provided by ActionController::ConditionalGet. In an API-only app these helper methods are normally available, but confirm by checking your ApplicationController inheritance; if not, you can use response.set_header('ETag', ...) directly.

Setting ETag manually:

etag_value = Digest::SHA1.hexdigest(product.updated_at.to_i.to_s + product.id.to_s)
response.set_header('ETag', "\"#{etag_value}\"")
# then Rails will respond with 304 if If-None-Match matches


6) Important rules for API caching

  • Only cache GET responses. Never cache responses to POST, PUT, PATCH, DELETE.
  • Do not cache user-specific or sensitive info in shared caches. Use private or no-store.
  • Prefer CDN caching (s-maxage) for public endpoints. Use s-maxage to instruct CDNs to keep content longer than browsers.
  • Use ETags or Last-Modified for validation to reduce bandwidth and get 304 responses.
  • Consider short TTLs and stale-while-revalidate to reduce origin load while keeping content fresh.
  • Version your API (e.g., /api/v1/) so you can change caching behavior on new releases without conflicting with old clients.

7) Nginx + Passenger and caching for API endpoints — what to do (and what to avoid)

  • Avoid using Nginx proxy cache with Passenger by default. Passenger is not a reverse proxy; it’s an app server. Nginx can use proxy_cache for caching upstream responses, but that pattern is more common when you proxy to a separate Puma/Unicorn backend via proxy_pass. With Passenger, it’s simpler and safer to set cache headers in Rails and let CDNs or clients respect them.
  • If you want edge caching in Nginx, it is technically possible to enable fastcgi_cache/proxy_cache patterns if you have an upstream; use caution — caching dynamic JSON responses at the web server is tricky and must be carefully invalidated.

Recommended: set caching headers in Rails (as shown), then let a CDN (Cloudflare/Fastly/CloudFront) apply caching and invalidation; Passenger remains the process manager for Rails.


8) Example: making a public, cacheable endpoint safe and CDN-friendly

class Api::V1::PublicController < ApplicationController
  def top_offers
    data = Offer.top(10) # expensive query
    response.set_header('Cache-Control', 'public, max-age=120, s-maxage=600, stale-while-revalidate=30')
    # Optionally set ETag
    fresh_when(etag: Digest::SHA1.hexdigest(data.map(&:updated_at).join(',')))
    render json: data
  end
end

  • max-age=120 → browsers cache for 2 minutes
  • s-maxage=600 → CDN caches for 10 minutes
  • stale-while-revalidate=30 → CDN/browsers may serve stale for 30s while origin revalidates

9) Passenger vs Puma — quick comparison (for API deployments)

Passenger

  • Pros:
    • Tight nginx integration (simpler config).
    • Auto-manages application processes; zero-downtime restarts are straightforward (passenger-config restart-app).
    • Good defaults for concurrency and memory management.
  • Cons:
    • Less flexible for custom proxy patterns (compared to running Puma behind nginx).
    • Some advanced caching/proxy setups are easier with a dedicated reverse-proxy architecture.

Puma (common alternative)

  • Pros:
    • Lightweight, highly configurable; often used behind nginx as reverse proxy.
    • Works well in containerized environments (Docker/Kubernetes).
    • Easy to pair with systemd or process managers and to horizontally scale workers.
  • Cons:
    • Requires extra process management & reverse proxying (nginx proxy_pass) configuration.
    • Slightly more operational overhead vs Passenger.

For an API-only Rails app with static assets served by nginx, Passenger is a great choice when you want fewer moving pieces. Puma + nginx gives more flexibility if you need advanced proxy caching or plan to run in a container orchestration platform.

I’ll continue with Part 4 covering Redis caching (optional), invalidation strategies, testing, debugging, commands, examples of common pitfalls and a final checklist.


Part 2: Caching Strategy for Vue + Rails API with Nginx

In Part 1, we explored the request flow between Nginx, Vue (frontend), and Rails (API backend). We also covered how Nginx routes traffic and why caching matters in such a setup.

Now in Part 2, we’ll go deeper into asset caching strategies — specifically tailored for a Rails API-only backend + Vue frontend deployed with Nginx.

🔑 The Core Idea

  • HTML files (like vite.html) should never be cached. They are the entry point of the SPA and change frequently.
  • Hashed assets (like /vite/index-G34XebCm.js) can be cached for 1 year safely, because the hash ensures cache-busting.
  • Non-hashed assets (images, fonts, legacy JS/CSS) should get short-term caching (e.g., 1 hour).

This split ensures fast repeat visits while avoiding stale deploys.

📂 Example: Files in public/vite/

Your build pipeline (via Vite) outputs hashed assets like:

vite/
  index-G34XebCm.js
  DuckType-CommonsRegular-CSozX1Vl.otf
  Allergens-D48ns5vN.css
  LoginModal-DR9oLFAS.js

Notice the random-looking suffixes (G34XebCm, D48ns5vN) — these are hashes. They change whenever the file content changes.

➡️ That’s why they’re safe to cache for 1 year: a new deploy creates new filenames, so the browser will fetch fresh assets.

By contrast, files like:

assets/
  15_minutes.png
  Sky_background.png

do not have hashes. If you update them, the filename doesn’t change, so the browser might keep showing stale content if cached too long. These need shorter cache lifetimes.


🛠️ Final Nginx Caching Configuration

Here’s the Nginx cache snippet tuned for your setup:

# =====================
# HTML (always no-cache)
# =====================
location = /vite.html {
    add_header Cache-Control "no-cache";
}

location ~* \.html$ {
    add_header Cache-Control "no-cache";
}

# ==============================
# Hashed Vue/Vite assets (1 year)
# ==============================
location ^~ /vite/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# ==================================================
# Other static assets (non-hashed) - 1 hour caching
# ==================================================
location ~* \.(?:js|css|woff2?|ttf|otf|eot|jpg|jpeg|png|gif|svg|ico)$ {
    add_header Cache-Control "public, max-age=3600";
}

🔍 Explanation

  • location = /vite.html → explicitly disables caching for the SPA entry file.
  • location ~* \.html$ → covers other .html files just in case.
  • location ^~ /vite/ → everything inside /vite/ (all hashed JS/CSS/images/fonts) gets 1 year caching.
  • Final block → fallback for other static assets like /assets/*.png, with only 1-hour cache.

⚠️ What Happens If We Misconfigure?

  • If you cache .html → new deploys won’t show up, users may stay stuck on the old app shell.
  • If you cache non-hashed images for 1 year → product images may stay stale even after updates.
  • If you don’t use immutable on hashed assets → browsers may still revalidate unnecessarily.

🏗️ Real-World Examples

  • GitLab uses a similar strategy with hashed Webpack assets, caching them long-term via Nginx and Cloudflare.
  • Discourse does long-term caching of fingerprinted JS/CSS, but keeps HTML dynamic with no-cache.
  • Basecamp (Rails + Hotwire) fingerprints all assets, leveraging 1-year immutable caching.

These projects rely heavily on content hashing + Nginx headers — exactly what we’re setting up here.

✅ Best Practices Recap

  1. Always fingerprint (hash) assets in production builds.
  2. Cache HTML for 0 seconds, JS/CSS hashed files for 1 year.
  3. Use immutable for hashed assets.
  4. Keep non-hashed assets on short lifetimes or rename them when updated.

This ensures smooth deploys, lightning-fast repeat visits, and no stale content issues.

📌 In Part 3, we’ll go deeper into Rails + Passenger integration, showing how Rails API responses fit into this caching strategy (and what not to cache at the API layer).


Part 1: Understanding Request Flow and Caching in a Rails + Vue + Nginx Setup

Introduction

When building modern web applications, performance is a critical factor for user experience and SEO. In setups that combine Rails (for backend logic) with Vue 3 (for the frontend), and Nginx + Passenger as the web server layer, developers must understand how requests flow through the system and how caching strategies can maximize efficiency. Without a clear understanding, issues such as stale content, redundant downloads, or poor Google PageSpeed scores can creep in.

In this series, we will break down the architecture into three detailed parts. In this first part, we’ll look at the basic request flow, why caching is needed, and the specific caching strategies applied for different types of assets (HTML, hashed Vue assets, images, fonts, and SEO files).

🔹 1. Basic Request Flow

Let’s first understand how a browser request travels through our stack. In a Rails + Vue + Nginx setup, the flow is layered so that Nginx acts as the gatekeeper, serving static files directly and passing dynamic requests to Rails via Passenger. This ensures maximum efficiency.

Browser Request (user opens https://mydomain.com)
      |
      v
+-------------------------+
|        Nginx            |
| - Serves static files   |
| - Adds cache headers    |
| - Redirects HTTP → HTTPS|
+-------------------------+
      |
      |---> /public/vite/*   (hashed Vue assets: JS, CSS, images)
      |---> /public/assets/* (general static files, fonts, images)
      |---> /public/*.html   (entry files, e.g. vite.html)
      |---> /sitemap.xml, robots.txt
      |
      v
+-------------------------+
| Passenger + Rails       |
| - Handles API requests  |
| - Renders dynamic views |
| - Business logic        |
+-------------------------+
      |
      v
Browser receives response

Key takeaways:

  • Nginx is optimized for serving static files and does this without invoking Rails.
  • Hashed Vue assets live in /public/vite/ and are safe for long-term caching.
  • HTML entry files like vite.html should never be cached aggressively, as they bootstrap the application.
  • Rails only handles requests that cannot be resolved by static files (APIs, dynamic content, authentication, etc.).

🔹 2. Why Caching Matters

Every time a user visits your site, the browser requests resources such as JavaScript, CSS, images, and fonts. Without caching, the browser re-downloads these assets on every visit, leading to:

  • Slower page load times
  • Higher bandwidth usage
  • Poorer SEO scores (Google PageSpeed penalizes missing caching headers)
  • Increased server load

Caching helps by instructing browsers to reuse resources when possible. However, caching needs to be carefully tuned:

  • Static, versioned assets (like hashed JS files) should be cached for a long time.
  • Dynamic or frequently changing files (like HTML, sitemap.xml) should bypass cache.
  • Non-hashed assets (like assets/*.png) can be cached for a shorter duration.

🔹 3. Caching Strategy in Detail

1. Hashed Vue Assets (/vite/ folder)

Files built by Vite include a content hash in their filenames (e.g., index-B34XebCm.js). This ensures that when the file content changes, the filename changes as well. Browsers see this as a new resource and download it fresh. This makes it safe to cache these files aggressively:

location /vite/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

This tells browsers to cache these files for a year, and the immutable directive prevents unnecessary revalidation.

2. HTML Files (vite.html and others)

HTML files should always be fresh because they reference the latest asset filenames. If an old HTML file is cached, it might point to outdated JS or CSS, breaking the app. Therefore, HTML must always be served with no-cache:

location ~* \.html$ {
    add_header Cache-Control "no-cache";
}

This forces browsers to check the server every time before using the file.

3. Other Static Assets (images, fonts, non-hashed JS/CSS)

Some assets in /public/assets/ do not have hashed filenames (e.g., logo.png). Caching these too aggressively could cause stale content issues. A shorter cache period (like 1 hour) is a safe balance:

location ~* \.(?:js|css|woff2?|ttf|otf|eot|jpg|jpeg|png|gif|svg|ico)$ {
    expires 1h;
    add_header Cache-Control "public";
}

4. SEO Files (sitemap.xml, robots.txt)

Search engines like Google frequently re-fetch sitemap.xml and robots.txt to keep their index up-to-date. If these files are cached, crawlers may miss recent updates. To avoid this, they should always bypass cache:

location = /sitemap.xml {
    add_header Cache-Control "no-cache";
}
location = /robots.txt {
    add_header Cache-Control "no-cache";
}

🔹 4. Summary Diagram

The diagram below illustrates the request flow and caching rules:

Browser Request
      |
      v
+------------------+          +-------------------+
|      Nginx       |          | Passenger + Rails |
|------------------|          |-------------------|
| - Serves /vite/* |          | - Dynamic APIs    |
|   (1y immutable) |          | - Auth flows      |
| - Serves .html   |          | - Business logic  |
|   (no-cache)     |          +-------------------+
| - Serves assets/*|
|   (1h cache)     |
| - Serves SEO     |
|   (no-cache)     |
+------------------+
      |
      v
Response to Browser

Let’s bring in some real-world examples from well-known Rails projects so you can see how this fits into practice:

🔹 Example 1: Discourse (Rails + Ember frontend, served via Nginx + Passenger)

  • Request flow:
    • Nginx serves all static JS/CSS files that are fingerprinted (application-9f2c01f2b3f.js).
    • Rails generates these during asset precompilation.
    • Fingerprinting ensures cache-busting (like our vite/index-B34XebCm.js).
  • Caching:
    • In their Nginx config, Discourse sets: location ~ ^/assets/ { expires 1y; add_header Cache-Control "public, immutable"; }
    • All .html responses (Rails views) are marked no-cache.
    • This is exactly the same principle we applied for our /vite/ folder.

🔹 Example 2: GitLab (Rails + Vue frontend, Nginx load balancer)

  • Request flow:
    • GitLab has Vue components bundled by Webpack (similar to Vite in our case).
    • Nginx first checks /public/assets/ for compiled frontend assets.
    • If not found → request is passed to Rails via Passenger.
  • Caching:
    • GitLab sets very aggressive caching for hashed assets, because they change only when a new release is deployed: location ~ ^/assets/.*-[a-f0-9]{32}\.(js|css|png|jpg|svg)$ { expires max; add_header Cache-Control "public, immutable"; }
    • Non-hashed files (like /uploads/ user content) get shorter caching (1 hour or 1 day).
    • HTML pages rendered by Rails = no-cache.

🔹 Example 3: Basecamp (Rails + Hotwire, Nginx + Passenger)

  • Request flow:
    • Their entrypoint is still HTML (application.html.erb) served via Rails.
    • Static assets (CSS/JS/images) precompiled into /public/assets.
    • Nginx serves these directly, without touching Rails.
  • Caching:
    • Rails generates digest-based file names (like style-4f8d9d7.css).
    • Nginx rule: location /assets { expires 1y; add_header Cache-Control "public, immutable"; }
    • Same idea: hashed = long cache, HTML = no cache.

👉 What this shows:

  • All large Rails projects (Discourse, GitLab, Basecamp) follow the same caching pattern we’re doing:
    • HTML → no-cache
    • Hashed assets (fingerprinted by build tool) → 1 year, immutable
    • Non-hashed assets → shorter cache (1h–1d)

So what we’re implementing in our setup is the industry standard. ✅

Conclusion

In this part, we established the foundation for how requests move through Nginx, Vue, and Rails, and why caching plays such an essential role in performance and reliability. The key principles are:

  • Hashed files = cache long term
  • HTML and SEO files = never cache
  • Non-hashed static assets = short cache
  • Rails/Passenger handles only dynamic requests

In Part 2, we’ll dive deeper into writing a complete Nginx configuration for Rails + Vue, covering gzip compression, HTTP/2 optimizations, cache busting, and optional Vue Router history mode support.