๐Ÿ” 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):

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

Built Static File (public/app.html):

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

But Browser “View Source” Showed:

<pre class="wp-block-syntaxhighlighter-code"><!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 -->
    <a href="/assets/index-xyz123.js">/assets/index-xyz123.js</a>
  </body>
</html></pre>

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 action โ†’ ProductsController#show.
  • Individual SQL timings โ†’ each query shows how long it took.
  • Overall time โ†’ Completed 200 OK in 120ms.
  • Breakdown โ†’ Views: 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).

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-...js โ†’ Cache-Control: public, max-age=31536000
  • Test HTML: curl -I https://www.mydomain.com/vite.html โ†’ Cache-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).