๐Ÿšฆ 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).


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.


The Complete Guide to Rails Database Commands: From Basics to Production

Managing databases in Rails can seem overwhelming with all the available commands. This comprehensive guide will walk you through every essential Rails database command, from basic operations to complex real-world scenarios.

Basic Database Commands

Core Database Operations

# Create the database
rails db:create

# Drop (delete) the database
rails db:drop

# Run pending migrations
rails db:migrate

# Rollback the last migration
rails db:rollback

# Rollback multiple migrations
rails db:rollback STEP=3

Schema Management

# Load current schema into database
rails db:schema:load

# Dump current database structure to schema.rb
rails db:schema:dump

# Load structure from structure.sql (for complex databases)
rails db:structure:load

# Dump database structure to structure.sql
rails db:structure:dump

Seed Data

# Run the seed file (db/seeds.rb)
rails db:seed

Combined Commands: The Powerhouses

rails db:setup

What it does: Sets up database from scratch

rails db:setup

Equivalent to:

rails db:create
rails db:schema:load  # Loads from schema.rb
rails db:seed

When to use:

  • First time setting up project on new machine
  • Fresh development environment
  • CI/CD pipeline setup

rails db:reset

What it does: Nuclear option – completely rebuilds database

rails db:reset

Equivalent to:

rails db:drop
rails db:create
rails db:schema:load
rails db:seed

When to use:

  • Development when you want clean slate
  • After major schema changes
  • When your database is corrupted

โš ๏ธ Warning: Destroys all data!

rails db:migrate:reset

What it does: Rebuilds database using migrations

rails db:migrate:reset

Equivalent to:

rails db:drop
rails db:create
rails db:migrate  # Runs all migrations from scratch

When to use:

  • Testing that migrations run cleanly
  • Debugging migration issues
  • Ensuring migration sequence works

Advanced Database Commands

Migration Management

# Rollback to specific migration
rails db:migrate:down VERSION=20240115123456

# Re-run specific migration
rails db:migrate:up VERSION=20240115123456

# Get current migration version
rails db:version

# Check migration status
rails db:migrate:status

Database Information

# Show database configuration
rails db:environment

# Validate database and pending migrations
rails db:abort_if_pending_migrations

# Check if database exists
rails db:check_protected_environments

Environment-Specific Commands

# Run commands on specific environment
rails db:create RAILS_ENV=production
rails db:migrate RAILS_ENV=staging
rails db:seed RAILS_ENV=test

Real-World Usage Scenarios

Scenario 1: New Developer Onboarding

# New developer joins the team
git clone project-repo
cd project
bundle install

# Set up database
rails db:setup

# Or if you prefer running migrations
rails db:create
rails db:migrate
rails db:seed

Scenario 2: Production Deployment

# Safe production deployment
rails db:migrate RAILS_ENV=production

# Never run these in production:
# rails db:reset        โŒ Will destroy data!
# rails db:schema:load  โŒ Will overwrite everything!

Scenario 3: Development Workflow

# Daily development cycle
git pull origin main
rails db:migrate          # Run any new migrations

# If you have conflicts or issues
rails db:rollback         # Undo last migration
# Fix migration file
rails db:migrate          # Re-run

# Major cleanup during development
rails db:reset           # Nuclear option

Scenario 4: Testing Environment

# Fast test database setup
rails db:schema:load RAILS_ENV=test

# Or use the test-specific command
rails db:test:prepare

Environment-Specific Best Practices

Development Environment

# Liberal use of reset commands
rails db:reset              # โœ… Safe to use
rails db:migrate:reset      # โœ… Safe to use
rails db:setup              # โœ… Safe for fresh start

Staging Environment

# Mirror production behavior
rails db:migrate RAILS_ENV=staging  # โœ… Recommended
rails db:seed RAILS_ENV=staging     # โœ… If needed

# Avoid
rails db:reset RAILS_ENV=staging    # โš ๏ธ Use with caution

Production Environment

# Only safe commands
rails db:migrate RAILS_ENV=production     # โœ… Safe
rails db:rollback RAILS_ENV=production    # โš ๏ธ With backup

# Never use in production
rails db:reset RAILS_ENV=production       # โŒ NEVER!
rails db:drop RAILS_ENV=production        # โŒ NEVER!
rails db:schema:load RAILS_ENV=production # โŒ NEVER!

Pro Tips and Gotchas

Migration vs Schema Loading

# For existing databases with data
rails db:migrate          # โœ… Incremental, safe

# For fresh databases
rails db:schema:load      # โœ… Faster, clean slate

Data vs Schema

Remember that some operations preserve data differently:

  • db:migrate: Preserves existing data, applies incremental changes
  • db:schema:load: Loads clean schema, no existing data
  • db:reset: Destroys everything, starts fresh

Common Workflow Commands

# The "fix everything" development combo
rails db:reset && rails db:migrate

# The "fresh start" combo  
rails db:drop db:create db:migrate db:seed

# The "production-safe" combo
rails db:migrate db:seed

Quick Reference Cheat Sheet

CommandUse CaseData SafetySpeed
db:migrateIncremental updatesโœ… SafeMedium
db:setupInitial setupโœ… Safe (new DB)Fast
db:resetClean slateโŒ Destroys allFast
db:migrate:resetTest migrationsโŒ Destroys allSlow
db:schema:loadFresh schemaโŒ No data migrationFast
db:seedAdd sample dataโœ… AdditiveFast

Conclusion

Understanding Rails database commands is crucial for efficient development and safe production deployments. Start with the basics (db:create, db:migrate, db:seed), get comfortable with the combined commands (db:setup, db:reset), and always remember the golden rule: be very careful with production databases!

The key is knowing when to use each command:

  • Development: Feel free to experiment with db:reset and friends
  • Production: Stick to db:migrate and always have backups
  • Team collaboration: Use migrations to keep everyone in sync

Remember: migrations tell the story of how your database evolved, while schema files show where you ended up. Both are important, and now you know how to use all the tools Rails gives you to manage them effectively.


โšก Understanding Vue.js Composition API

Vue 3 introduced the Composition API โ€” a modern, function-based approach to building components. If you’ve been using the Options API (data, methods, computed, etc.), this might feel like a big shift. But the Composition API gives you more flexibility, reusability, and scalability.

In this post, we’ll explore what it is, how it works, why it matters, and we’ll finish with a real-world API fetching example.

๐Ÿงฉ What is the Composition API?

The Composition API is a collection of functions (like ref, reactive, watch, computed) that you use inside a setup() function (or <script setup>). Instead of organizing code into option blocks, you compose logic directly.

๐Ÿ‘‰ In short:
It lets you group related logic together in one place, making your components more readable and reusable.


๐Ÿ”‘ Core Features

Here are the most important building blocks:

  • ref() โ†’ create reactive primitive values (like numbers, strings, booleans).
  • reactive() โ†’ create reactive objects or arrays.
  • computed() โ†’ define derived values based on reactive state.
  • watch() โ†’ run side effects when values change.
  • Lifecycle hooks (onMounted, onUnmounted, etc.) โ†’ usable inside setup().
  • Composables โ†’ reusable functions built with Composition API logic.

โš–๏ธ Options API vs Composition API

Options API (Vue 2 style)

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>


Composition API (Vue 3 style)

<script setup>
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

โœจ Notice the difference:

  • With Options API, logic is split across data and methods.
  • With Composition API, everything (state + methods) is grouped together.

๐Ÿš€ Why Use Composition API?

  1. Better logic organization โ†’ Group related logic in one place.
  2. Reusability โ†’ Extract shared code into composables (useAuth, useFetch, etc.).
  3. TypeScript-friendly โ†’ Works smoothly with static typing.
  4. Scalable โ†’ Easier to manage large and complex components.

๐ŸŒ Real-World Example: Fetching API Data

Let’s say we want to fetch user data from an API.

Step 1: Create a composable useFetch.js

// composables/useFetch.js
import { ref, onMounted } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)

  onMounted(async () => {
    try {
      const res = await fetch(url)
      data.value = await res.json()
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  })

  return { data, error, loading }
}


Step 2: Use it inside a component

<script setup>
import { useFetch } from '@/composables/useFetch'

const { data, error, loading } = useFetch('https://jsonplaceholder.typicode.com/users')
</script>

<template>
  <div>
    <p v-if="loading">Loading...</p>
    <p v-if="error">Error: {{ error.message }}</p>
    <ul v-if="data">
      <li v-for="user in data" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

โœจ What happened?

  • The composable useFetch handles logic for fetching.
  • The component only takes care of rendering.
  • Now, you can reuse useFetch anywhere in your app.

๐ŸŽฏ Final Thoughts

The Composition API makes Vue components cleaner, reusable, and scalable. It might look different at first, but once you start grouping related logic together, youโ€™ll see how powerful it is compared to the Options API.

If you’re building modern Vue 3 apps, learning the Composition API is a must.


๐Ÿ”„ Vue.js: Composition API vs Mixins vs Composables

When working with Vue, developers often ask:

  • What’s the difference between the Composition API and Composables?
  • Do Mixins still matter in Vue 3?
  • When should I use one over the other?

Letโ€™s break it down with clear explanations and examples.

๐Ÿงฉ Mixins (Vue 2 era)

๐Ÿ”‘ What are Mixins?

Mixins are objects that contain reusable logic (data, methods, lifecycle hooks) which can be merged into components.

โšก Example: Counter with a mixin

// mixins/counterMixin.js
export const counterMixin = {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}

<script>
import { counterMixin } from '@/mixins/counterMixin'

export default {
  mixins: [counterMixin]
}
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

โœ… Pros

  • Easy to reuse logic.
  • Simple syntax.

โŒ Cons

  • Name conflicts โ†’ two mixins or component methods can override each other.
  • Hard to track where logic comes from in large apps.
  • Doesn’t scale well.

๐Ÿ‘‰ That’s why Vue 3 encourages Composition API + Composables instead.


โš™๏ธ Composition API

๐Ÿ”‘ What is it?

The Composition API is a set of functions (ref, reactive, watch, computed, lifecycle hooks) that let you write components in a function-based style.

โšก Example: Counter with Composition API

<script setup>
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

๐Ÿ‘‰ Unlike Mixins, all logic lives inside the component โ€” no magic merging.

โœ… Pros

  • Explicit and predictable.
  • Works great with TypeScript.
  • Organizes related logic together instead of scattering across options.

๐Ÿ”„ Composables

๐Ÿ”‘ What are Composables?

Composables are just functions that use the Composition API to encapsulate and reuse logic.

Theyโ€™re often named with a use prefix (useAuth, useCounter, useFetch).

โšก Example: Reusable counter composable

// composables/useCounter.js
import { ref } from 'vue'

export function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

Usage in a component:

<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, increment } = useCounter()
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
</template>

โœ… Pros

  • Clear and explicit (unlike Mixins).
  • Reusable across multiple components.
  • Easy to test (plain functions).
  • Scales beautifully in large apps.

๐Ÿ†š Side-by-Side Comparison

FeatureMixins ๐ŸงฉComposition API โš™๏ธComposables ๐Ÿ”„
Introduced inVue 2Vue 3Vue 3
ReusabilityYes, but limitedMostly inside componentsYes, very flexible
Code OrganizationScattered across mixinsGrouped inside setup()Encapsulated in functions
ConflictsPossible (naming issues)NoneNone
TestabilityHarderGoodExcellent
TypeScript SupportPoorStrongStrong
Recommended in Vue 3?โŒ Not preferredโœ… Yesโœ… Yes

๐ŸŽฏ Final Thoughts

  • Mixins were useful in Vue 2, but they can cause naming conflicts and make code hard to trace.
  • Composition API solves these issues by letting you organize logic in setup() with functions like ref, reactive, watch.
  • Composables build on the Composition API โ€” theyโ€™re just functions that encapsulate and reuse logic across components.

๐Ÿ‘‰ In Vue 3, the recommended pattern is:

  • Use Composition API inside components.
  • Extract reusable logic into Composables.
  • Avoid Mixins unless maintaining legacy Vue 2 code.

๐Ÿš€ Optimizing Vue 3 Page Rendering with for Async Components

When building a homepage in Vue, it’s common to split the UI into multiple components. Some of them are purely presentational, while others fetch data from APIs.

Here’s the problem: if one of those components uses an await during its setup, Vue will wait for it before rendering the parent. That means a single API call can block the entire page from appearing to the user.

That’s not what we want. A modern web app should feel snappy and responsive, even when waiting for data.

Vue 3 gives us the perfect tool for this: <Suspense>.


๐Ÿ— The Starting Point

Let’s look at a simplified index.vue homepage:

<template>
  <div>
    <Component1 :perServingPrice="data.perServingPrice" />

    <Component2 :perServingPrice="data.perServingPrice" />
    <Component3 :landingContentKey="landingContentKey" />
    <Component4 :perServingPrice="data.perServingPrice" />
    <Component5 :landingContentKey="landingContentKey" />
    <Component6 :recipes="data.recipes" />
    <Component7 :landingContentKey="landingContentKey" />
    <Component8 :recipes="data.recipes" />
  </div>
</template>

<script setup lang="ts">
import Component1 from '@/components/HomePage/Component1.vue'
import Component2 from '@/components/HomePage/Component2.vue'
import Component3 from '@/components/HomePage/Component3.vue'
import Component4 from '@/components/HomePage/Component4.vue'
import Component5 from '@/components/HomePage/Component5.vue'
import Component6 from '@/components/HomePage/Component6.vue'
import Component7 from '@/components/HomePage/Component7.vue'
import Component8 from '@/components/HomePage/Component8.vue'

const data = {
  perServingPrice: 10,
  recipes: [],
}
const landingContentKey = 'homepage'
</script>

Now imagine:

  • Component2 fetches special offers.
  • Component6 fetches recipe data.
  • Component8 fetches trending dishes.

If those API calls are written like this inside a child component:

<script setup lang="ts">
const response = await fetch('/api/recipes')
const recipes = await response.json()
</script>

โžก๏ธ Vue will not render the parent index.vue until this await is finished. That means the entire page waits, even though other components (like Component1 and Component3) donโ€™t need that data at all.


โณ Why Blocking Is a Problem

Letโ€™s simulate the render timeline without <Suspense>:

  • At t=0s: Page requested.
  • At t=0.3s: HTML + JS bundles load.
  • At t=0.4s: Component2 makes an API request.
  • At t=0.8s: Component6 makes an API request.
  • At t=1.2s: Component8 makes an API request.
  • At t=2.0s: All API responses return โ†’ finally the page renders.

The user stares at a blank page until everything resolves. ๐Ÿ˜ฉ


๐ŸŽฏ Enter <Suspense>

The <Suspense> component lets you wrap child components that might suspend (pause) while awaiting data. Instead of blocking the whole page, Vue shows:

  • The rest of the parent page (immediately).
  • A fallback placeholder for the async child until it’s ready.

๐Ÿ“ Example: Wrapping Component6

<Suspense>
  <template #default>
    <Component6 :recipes="data.recipes" />
  </template>
  <template #fallback>
    <div class="skeleton">Loading recipes...</div>
  </template>
</Suspense>

Here’s what happens now:

  • At t=0.4s: The page renders.
  • <Component6> isnโ€™t ready yet, so Vue shows the fallback (Loading recipes...).
  • At t=2.0s: Recipes arrive โ†’ Vue automatically replaces the fallback with the actual component.

Result: The page is usable instantly. โœ…


๐Ÿ”„ Applying Suspense to Multiple Components

We can selectively wrap only the async components:

<template>
  <div>
    <Component1 :perServingPrice="data.perServingPrice" />

    <Suspense>
      <template #default>
        <Component2 :perServingPrice="data.perServingPrice" />
      </template>
      <template #fallback>
        <div class="skeleton">Loading deals...</div>
      </template>
    </Suspense>

    <Component3 :landingContentKey="landingContentKey" />

    <Suspense>
      <template #default>
        <Component6 :recipes="data.recipes" />
      </template>
      <template #fallback>
        <div class="skeleton">Loading recipes...</div>
      </template>
    </Suspense>

    <Component7 :landingContentKey="landingContentKey" />

    <Suspense>
      <template #default>
        <Component8 :recipes="data.recipes" />
      </template>
      <template #fallback>
        <div class="skeleton">Loading trending dishes...</div>
      </template>
    </Suspense>
  </div>
</template>


๐Ÿ“ฆ Combining with Async Imports

Vue also allows lazy-loading the component itself, not just the data.

<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

const Component6 = defineAsyncComponent(() =>
  import('@/components/HomePage/Component6.vue')
)
</script>

Now:

  • If the component file is heavy, Vue won’t even load it until needed.
  • Suspense covers both the network request and the async component loading.

๐Ÿ“Š Before vs After

Without Suspense:

  • Whole page waits until all API calls resolve.
  • User sees blank โ†’ page suddenly appears.

With Suspense:

  • Page renders instantly with placeholders.
  • Components hydrate individually as data arrives.
  • User perceives speed even if data is slow.

๐Ÿ† Best Practices

  1. Wrap only async components. Don’t spam <Suspense> everywhere.
  2. Always provide a meaningful fallback. Use skeleton loaders, not just “Loadingโ€ฆ”.
  3. Lift state when appropriate. If multiple components need the same data, fetch once in the parent and pass it down as props.
  4. Combine Suspense with code-splitting. Async imports keep your initial bundle small.
  5. Group related components. You can wrap multiple components in a single <Suspense> if they depend on the same async source.

โœ… Conclusion

With Vue 3 <Suspense>, you can make sure that your homepage never blocks while waiting for data. Each component becomes non-blocking and self-contained, showing a loader until it’s ready.

This is the same direction React and Angular have taken:

  • React โ†’ Suspense + Concurrent Rendering.
  • Angular โ†’ Route Resolvers + AsyncPipe.
  • Vue โ†’ <Suspense> + async setup.

๐Ÿ‘‰ If you want your Vue pages to feel fast and modern, adopt <Suspense> for async components.


Happy Vue Coding!

Working with docker on Mac: Core Docker Concepts | Docker Desktop

Docker is an openโ€‘source platform for packaging applications and their dependencies into lightweight, portable units called containers, enabling consistent behavior across development, testing, and production environments (Docker, Wikipedia). Its core component, the Docker Engine, powers container execution, while Docker Desktopโ€”a userโ€‘friendly application tailored for macOSโ€”bundles the Docker Engine, Docker CLI, Docker Compose, Kubernetes integration, and a visual Dashboard into a seamless package (Docker Documentation).

On macOS, Docker Desktop simplifies container workflows by leveraging native virtualization (HyperKit on Intel Macs, Apple’s Hypervisor.framework on Apple Silicon), eliminating the need for cumbersome VMs like VirtualBox (The Mac Observer, Medium).

Installation is straightforward: simply download the appropriate .dmg installer (Intel or Apple Silicon), drag Docker into the Applications folder, and proceed through setupโ€”granting permissions and accepting licensing as needed (LogicCore Digital Blog). Once up and running, you can verify your setup via commands like:

docker --version
docker run hello-world
docker compose version

These commands confirm successful installation and provide instant access to Docker’s ecosystem on your Mac.


Commands Executed in Local System:

โžœ docker --version
zsh: command not found: docker

โžœ docker ps
# check the exact container name:
โžœ docker ps --format "table {{.Names}}\t{{.Image}}"

# rebuild the containers
โžœ docker-compose down
โžœ docker-compose up --build

# Error: target webpacker: failed to solve: error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH, out: ``

โžœ cat ~/.docker/config.json # remove "credsStore": "desktop"

# Remove all containers and images
โžœ docker container prune -f
โžœ docker image prune -f
โžœ docker ps -a # check containers
โžœ docker images # check images
โžœ docker-compose up --build -d # build again

# postgres
โžœ docker exec -it image-name psql -U username -d database_name

# rails console
โžœ docker exec -it image-name rails c

# get into the docker container shell
โžœ docker exec -it image-name bash

๐Ÿณ 1. Difference between docker compose up --build and docker compose up

docker compose up

  • Just starts the containers using the existing images.
  • If the image for a service doesnโ€™t exist locally, Docker will pull it from the registry (e.g., Docker Hub).
  • It will not rebuild your image unless you explicitly tell it to.

docker compose up --build

  • Forces Docker to rebuild the images from the Dockerfile before starting the containers.
  • Useful when youโ€™ve changed:
    • The Dockerfile
    • Files copied into the image
    • Dependencies
  • This ensures your running containers reflect your latest code and build instructions.

๐Ÿ“Œ Example:

docker compose up         # Use existing images (fast startup)
docker compose up --build # Rebuild images before starting

If you changed your app code and your Docker setup uses bind mounts (volumes), you usually don’t need --build unless the image itself changed.
If you changed the Dockerfile, then you need --build.


๐Ÿ–ฅ 2. Why we use Docker Desktop & can we use Docker without it?

Docker Desktop is basically a GUI + background service that makes Docker easy to run on macOS and Windows.
It includes:

  • Docker Engine (runs containers)
  • Docker CLI
  • Docker Compose
  • Kubernetes (optional)
  • Settings & resource controls (CPU, RAM)
  • Networking setup
  • A UI to view containers, images, logs, etc.

Why needed on macOS & Windows?

  • Docker requires Linux kernel features like cgroups & namespaces.
  • macOS and Windows don’t have these natively, so Docker Desktop runs a lightweight Linux VM behind the scenes (using HyperKit, WSL2, etc.).
  • Without Docker Desktop, you’d need to set up that Linux VM manually, install Docker inside it, and configure networking โ€” which is more complex.

Can you use Docker without Docker Desktop?
Yes, but:

  • On macOS/Windows โ€” you’d have to:
    • Install a Linux VM manually (VirtualBox, VMware, UTM, etc.)
    • SSH into it
    • Install Docker Engine
    • Expose ports and share files manually
  • On Linux โ€” you don’t need Docker Desktop at all, you can install Docker Engine directly via: sudo apt install docker.io
  • For Windows, Microsoft has Docker on WSL2 which can run without the Docker Desktop GUI, but requires WSL2 setup.

๐Ÿ’ก In short:

  • Use --build when you change something in the image definition.
  • Docker Desktop = easiest way to run Docker on macOS/Windows.
  • You can skip Docker Desktop, but then you must manually set up a Linux VM with Docker.

๐Ÿงฉ 1. Core Docker Concepts

TermWhat it isKey analogy
ImageA read-only blueprint (template) that defines what your app needs to run (OS base, packages, configs, your code). Built from a Dockerfile.Like a recipe for a dish
ContainerA running instance of an image. Containers are isolated processes, not full OSes.Like a meal prepared from the recipe
VolumePersistent storage for containers. Survives container restarts or deletions.Like a pantry/fridge where food stays even after cooking is done
Docker ComposeA YAML-based tool to define & run multi-container apps. Lets you describe services, networks, and volumes in one file and start them all at once.Like a restaurant order sheet for multiple dishes at once
NetworkVirtual network that containers use to talk to each other or the outside world.Like a kitchen intercom system

โ˜ธ 2. Kubernetes in simple words

Kubernetes (K8s) is a container orchestration system. Itโ€™s what you use when you have many containers across many machines and you need to manage them automatically.

What it does:

  • Deploy containers on a cluster of machines
  • Restart them if they crash
  • Scale up/down automatically
  • Load balance traffic between them
  • Handle configuration and secrets
  • Do rolling updates with zero downtime

๐Ÿ“Œ Analogy
If Docker Compose is like cooking multiple dishes at home, Kubernetes is like running a huge automated kitchen in a restaurant chain โ€” you don’t manually turn on each stove; the system manages resources and staff.


๐Ÿ 3. How Docker Works on macOS

Your assumption is right โ€” Docker needs Linux kernel features (cgroups, namespaces, etc.), and macOS doesnโ€™t have them.

So on macOS:

  • Docker Desktop runs a lightweight Linux virtual machine under the hood using Appleโ€™s HyperKit (before) or Apple Virtualization Framework (newer versions).
  • That VM runs the Docker Engine.
  • Your docker CLI in macOS talks to that VM over a socket.
  • All containers run inside that Linux VM, not directly on macOS.

Workflow:

Mac Terminal โ†’ Docker CLI โ†’ Linux VM in background โ†’ Container runs inside VM


โš™ 4. Hardware Needs for Docker on macOS

Yes, VMs can be heavy, but Docker’s VM for macOS is minimal โ€” not like a full Windows or Ubuntu desktop VM.

Typical Docker Desktop VM:

  • Base OS: Tiny Linux distro (Alpine or LinuxKit)
  • Memory: Usually 2โ€“4 GB (configurable)
  • CPU: 2โ€“4 virtual cores (configurable)
  • Disk: ~1โ€“2 GB base, plus images & volumes you pull

Recommended host machine for smooth Docker use on macOS:

  • RAM: At least 8 GB (16 GB is comfy)
  • CPU: Modern dual-core or quad-core
  • Disk: SSD (fast read/write for images & volumes)

๐Ÿ’ก Reason it’s lighter than “normal” VMs:
Docker doesn’t need a full OS with GUI in its VM โ€” just the kernel & minimal services to run containers.


โœ… Quick Recap Table:

TermPurposePersistent?
ImageApp blueprintYes (stored on disk)
ContainerRunning app from imageNo (dies when stopped unless data in volume)
VolumeData storage for containersYes
ComposeMulti-container managementYes (config file)
KubernetesCluster-level orchestrationN/A

Quickest way to see per-request Rails logs in Docker

  • Run app logs:
docker compose logs -f --tail=200 main-app
  • Run Sidekiq logs:
docker compose logs -f --tail=200 sidekiq
  • Filter for a single request by its request ID (see below):
docker compose logs -f main-app | rg 'request_id=YOUR_ID'

Ensure logs are emitted to STDOUT (so Docker can collect them)

Your images already set RAILS_LOG_TO_STDOUT=true and the app routes logs to STDOUT:

if ENV["RAILS_LOG_TO_STDOUT"].present?
  logger           = ActiveSupport::Logger.new(STDOUT)
  logger.formatter = config.log_formatter
  config.log_tags  = [:subdomain, :uuid]
  config.logger    = ActiveSupport::TaggedLogging.new(logger)
end

So the docker compose logs commands above are the right way. Tailing log files inside the container usually isnโ€™t needed, but if you want to:

docker compose exec main-app bash -lc 'tail -f log/development.log'

Trace a single request end-to-end

  • Get the request ID from the response (Rails sets X-Request-Id):
REQ=$(curl -s -D - http://localhost:3001/your/path -o /dev/null | awk '/X-Request-Id/ {print $2}' | tr -d '\r')
docker compose logs -f main-app | rg "$REQ"
  • Your app tags logs with a UUID per request; switching to the canonical tag makes grepping easier. Optionally change tags to:
# in `website-v1/config/application.rb`
config.log_tags = [:request_id]

Make logs easier to read (optional, but highly recommended)

  • Add concise, per-request logging (1 line per request) with JSON output for easy grep/parse:
# Gemfile
gem 'lograge'

# config/initializers/lograge.rb
Rails.application.configure do
  config.lograge.enabled = true
  config.lograge.formatter = Lograge::Formatters::Json.new
  config.lograge.custom_options = lambda do |event|
    {
      request_id: event.payload[:request_id],
      params: event.payload[:params].except('controller','action'),
      user_id: event.payload[:user_id],
      time: Time.now.utc
    }
  end
end

Then:

bundle install
docker compose restart main-app
docker compose logs -f main-app

Now you can grep easily:

docker compose logs -f main-app | jq -r 'select(.request_id=="'"$REQ"'")'

Tuning

  • In development, you already have config.log_level = :debug. For production, consider :info instead of :warn to get request lines:
# config/environments/production.rb
config.log_level = :info
  • Sidekiq logs are a separate stream (service sidekiq); use its logs for background job tracing.
  • Noise reduction: you already have config.assets.quiet = true in development.

If you want, I can switch to :request_id tagging and add lograge with JSON in the repo now.

  • Made no code edits; provided commands to stream logs from main-app and sidekiq.
  • Confirmed logs are routed to STDOUT via RAILS_LOG_TO_STDOUT and TaggedLogging in application.rb.
  • Offered optional improvements (switch to :request_id, add lograge JSON) and how to grep a single request by ID.

Complete Guide to RSpec with Rails 7+: From Basics to Advanced Testing

RSpec is the most popular testing framework for Ruby and Rails applications. This comprehensive guide covers everything from basic RSpec syntax to advanced Rails 7+ testing patterns, with real-world examples and scenarios.

Table of Contents

  1. RSpec Basics
  2. Rails 7+ Integration
  3. Core RSpec Methods
  4. Testing Scenarios
  5. Advanced Features
  6. Best Practices

RSpec Basics

Basic Structure

require "rails_helper"

RSpec.describe Session::AppliedDiscount do
  # Test content goes here
end

Key Components:

  • require "rails_helper" – Loads Rails testing environment
  • RSpec.describe – Groups related tests
  • describe can take a class, string, or symbol

The Building Blocks

describe and context

RSpec.describe User do
  describe "#full_name" do
    context "when first and last name are present" do
      # tests here
    end

    context "when only first name is present" do
      # tests here
    end
  end

  describe ".active_users" do
    context "with active users in database" do
      # tests here
    end
  end
end

it – Individual Test Cases

it "returns the user's full name" do
  user = User.new(first_name: "John", last_name: "Doe")
  expect(user.full_name).to eq("John Doe")
end

it "handles missing last name gracefully" do
  user = User.new(first_name: "John")
  expect(user.full_name).to eq("John")
end

Core RSpec Methods

let and let!

Lazy Evaluation with let
RSpec.describe Session::Discount do
  let(:cookies) { CookiesStub.new }
  let(:code) { create_code(10) }
  let(:customer) { init_customer }
  let(:customer_code) { create_customer_code(customer) }

  it "uses lazy evaluation" do
    # code is only created when first accessed
    expect(code.amount).to eq(10)
  end
end
Immediate Evaluation with let!
let!(:user) { User.create(name: "John") }  # Created immediately
let(:profile) { user.profile }             # Created when accessed

it "has user already created" do
  expect(User.count).to eq(1)  # user already exists
end

subject

Implicit Subject
RSpec.describe User do
  let(:user_params) { { name: "John", email: "john@example.com" } }

  subject { User.new(user_params) }

  it { is_expected.to be_valid }
  it { is_expected.to respond_to(:full_name) }
end
Named Subject
describe '#initial_discount' do
  subject(:initial_discount_in_rupee) { 
    described_class.new(cookies: cookies).initial_discount_in_rupee 
  }

  it 'returns initial discount for customer' do
    accessor.set_customer_code(customer_code: customer_code)
    expect(initial_discount_in_rupee).to eq(expected_amount)
  end
end

expect and Matchers

Basic Matchers
# Equality
expect(user.name).to eq("John")
expect(user.age).to be > 18
expect(user.email).to include("@")

# Boolean checks
expect(user).to be_valid
expect(user.active?).to be true
expect(user.admin?).to be_falsy

# Type checks
expect(user.created_at).to be_a(Time)
expect(user.tags).to be_an(Array)
Collection Matchers
expect(users).to include(john_user)
expect(user.roles).to contain_exactly("admin", "user")
expect(shopping_cart.items).to be_empty
expect(search_results).to have(3).items
String Matchers
expect(user.email).to match(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
expect(response.body).to include("Welcome")
expect(error_message).to start_with("Error:")
expect(success_message).to end_with("successfully!")

Rails 7+ Integration

Rails Helper Setup

# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'

abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'

RSpec.configure do |config|
  config.fixture_path = "#{::Rails.root}/spec/fixtures"
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!
end

Testing Controllers

RSpec.describe Api::V1::SessionsController, type: :controller do
  let(:user) { create(:user) }
  let(:valid_params) { { email: user.email, password: "password" } }

  describe "POST #create" do
    context "with valid credentials" do
      it "returns success response" do
        post :create, params: valid_params
        expect(response).to have_http_status(:success)
        expect(JSON.parse(response.body)["success"]).to be true
      end

      it "sets authentication token" do
        post :create, params: valid_params
        expect(response.cookies["auth_token"]).to be_present
      end
    end

    context "with invalid credentials" do
      it "returns unauthorized status" do
        post :create, params: { email: user.email, password: "wrong" }
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
end

Testing Models

RSpec.describe User, type: :model do
  describe "validations" do
    it { is_expected.to validate_presence_of(:email) }
    it { is_expected.to validate_uniqueness_of(:email) }
    it { is_expected.to validate_length_of(:password).is_at_least(8) }
  end

  describe "associations" do
    it { is_expected.to have_many(:orders) }
    it { is_expected.to belong_to(:organization) }
    it { is_expected.to have_one(:profile) }
  end

  describe "scopes" do
    let!(:active_user) { create(:user, :active) }
    let!(:inactive_user) { create(:user, :inactive) }

    it "returns only active users" do
      expect(User.active).to include(active_user)
      expect(User.active).not_to include(inactive_user)
    end
  end
end

Testing Scenarios

Testing Service Objects

RSpec.describe Session::Discount do
  let(:cookies) { CookiesStub.new }
  let(:accessor) { Session::CookieDiscount.new(cookies) }

  describe '#initialize' do
    it 'calls ClearDiscountCode' do
      expect_any_instance_of(Session::ClearDiscountCode).to receive(:run)
      described_class.new(cookies: cookies)
    end

    it 'removes discount_code if referral_code presented' do
      accessor.set_code(discount)
      accessor.set_referral_code(referral_code: code)

      described_class.new(cookies: cookies)
      expect(accessor.discount).to be nil
    end
  end
end

Testing API Endpoints

RSpec.describe "API V1 Sessions", type: :request do
  let(:headers) { { "Content-Type" => "application/json" } }

  describe "POST /api/v1/sessions" do
    let(:user) { create(:user) }
    let(:params) do
      {
        session: {
          email: user.email,
          password: "password"
        }
      }
    end

    it "creates a new session" do
      post "/api/v1/sessions", params: params.to_json, headers: headers

      expect(response).to have_http_status(:created)
      expect(json_response["user"]["id"]).to eq(user.id)
      expect(json_response["token"]).to be_present
    end

    context "with invalid credentials" do
      before { params[:session][:password] = "wrong_password" }

      it "returns error" do
        post "/api/v1/sessions", params: params.to_json, headers: headers

        expect(response).to have_http_status(:unauthorized)
        expect(json_response["error"]).to eq("Invalid credentials")
      end
    end
  end
end

Testing Background Jobs

RSpec.describe EmailNotificationJob, type: :job do
  include ActiveJob::TestHelper

  let(:user) { create(:user) }

  describe "#perform" do
    it "sends welcome email" do
      expect {
        EmailNotificationJob.perform_now(user.id, "welcome")
      }.to change { ActionMailer::Base.deliveries.count }.by(1)
    end

    it "enqueues job" do
      expect {
        EmailNotificationJob.perform_later(user.id, "welcome")
      }.to have_enqueued_job(EmailNotificationJob)
    end
  end
end

Testing with Database Transactions

RSpec.describe OrderProcessor do
  describe "#process" do
    let(:order) { create(:order, :pending) }
    let(:payment_method) { create(:payment_method) }

    it "processes order successfully" do
      expect {
        OrderProcessor.new(order).process(payment_method)
      }.to change { order.reload.status }.from("pending").to("completed")
    end

    it "handles payment failures" do
      allow(payment_method).to receive(:charge).and_raise(PaymentError)

      expect {
        OrderProcessor.new(order).process(payment_method)
      }.to raise_error(PaymentError)

      expect(order.reload.status).to eq("failed")
    end
  end
end

Advanced Features

Shared Examples

# spec/support/shared_examples/auditable.rb
RSpec.shared_examples "auditable" do
  it "tracks creation" do
    expect(subject.created_at).to be_present
    expect(subject.created_by).to eq(current_user)
  end

  it "tracks updates" do
    subject.update(name: "Updated Name")
    expect(subject.updated_by).to eq(current_user)
  end
end

# Usage in specs
RSpec.describe User do
  let(:current_user) { create(:user) }
  subject { create(:user) }

  it_behaves_like "auditable"
end

Custom Matchers

# spec/support/matchers/be_valid_email.rb
RSpec::Matchers.define :be_valid_email do
  match do |actual|
    actual =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  end

  failure_message do |actual|
    "expected #{actual} to be a valid email address"
  end
end

# Usage
expect(user.email).to be_valid_email

Hooks and Callbacks

RSpec.describe User do
  before(:each) do
    @original_time = Time.current
    travel_to Time.zone.parse("2023-01-01 12:00:00")
  end

  after(:each) do
    travel_back
  end

  before(:all) do
    # Runs once before all tests in this describe block
    @test_data = create_test_data
  end

  around(:each) do |example|
    Rails.logger.silence do
      example.run
    end
  end
end

Stubbing and Mocking

describe "external API integration" do
  let(:api_client) { instance_double("APIClient") }

  before do
    allow(APIClient).to receive(:new).and_return(api_client)
  end

  it "calls external service" do
    expect(api_client).to receive(:get_user_data).with(user.id)
      .and_return({ name: "John", email: "john@example.com" })

    result = UserDataService.fetch(user.id)
    expect(result[:name]).to eq("John")
  end

  it "handles API errors gracefully" do
    allow(api_client).to receive(:get_user_data).and_raise(Net::TimeoutError)

    expect {
      UserDataService.fetch(user.id)
    }.to raise_error(ServiceUnavailableError)
  end
end

Testing Time-dependent Code

describe "subscription expiry" do
  let(:subscription) { create(:subscription, expires_at: 2.days.from_now) }

  it "is not expired when current" do
    expect(subscription).not_to be_expired
  end

  it "is expired when past expiry date" do
    travel_to 3.days.from_now do
      expect(subscription).to be_expired
    end
  end
end

Factory Bot Integration

Basic Factory Setup

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    first_name { "John" }
    last_name { "Doe" }
    password { "password123" }

    trait :admin do
      role { "admin" }
    end

    trait :with_profile do
      after(:create) do |user|
        create(:profile, user: user)
      end
    end

    factory :admin_user, traits: [:admin]
  end
end

# Usage in tests
let(:user) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:user_with_profile) { create(:user, :with_profile) }

Advanced Factory Patterns

# spec/factories/orders.rb
FactoryBot.define do
  factory :order do
    user
    total_amount { 100.00 }
    status { "pending" }

    factory :completed_order do
      status { "completed" }
      completed_at { Time.current }

      after(:create) do |order|
        create_list(:order_item, 3, order: order)
      end
    end
  end
end

Testing Different Types

Feature Tests (System Tests)

RSpec.describe "User Registration", type: :system do
  it "allows user to register" do
    visit "/signup"

    fill_in "Email", with: "test@example.com"
    fill_in "Password", with: "password123"
    fill_in "Confirm Password", with: "password123"

    click_button "Sign Up"

    expect(page).to have_content("Welcome!")
    expect(page).to have_current_path("/dashboard")
  end
end

Mailer Tests

RSpec.describe UserMailer, type: :mailer do
  describe "#welcome_email" do
    let(:user) { create(:user) }
    let(:mail) { UserMailer.welcome_email(user) }

    it "sends to correct recipient" do
      expect(mail.to).to eq([user.email])
    end

    it "has correct subject" do
      expect(mail.subject).to eq("Welcome to Our App!")
    end

    it "includes user name in body" do
      expect(mail.body.encoded).to include(user.first_name)
    end
  end
end

Helper Tests

RSpec.describe ApplicationHelper, type: :helper do
  describe "#format_currency" do
    it "formats positive amounts" do
      expect(helper.format_currency(100.50)).to eq("$100.50")
    end

    it "handles zero amounts" do
      expect(helper.format_currency(0)).to eq("$0.00")
    end

    it "formats negative amounts" do
      expect(helper.format_currency(-50.25)).to eq("-$50.25")
    end
  end
end

Best Practices

1. Clear Test Structure

# Good: Clear, descriptive names
describe User do
  describe "#full_name" do
    context "when both names are present" do
      it "returns concatenated first and last name" do
        # test implementation
      end
    end
  end
end

# Bad: Unclear names
describe User do
  it "works" do
    # test implementation
  end
end

2. One Assertion Per Test

# Good: Single responsibility
it "validates email presence" do
  user = User.new(email: nil)
  expect(user).not_to be_valid
end

it "validates email format" do
  user = User.new(email: "invalid-email")
  expect(user).not_to be_valid
end

# Bad: Multiple assertions
it "validates email" do
  user = User.new(email: nil)
  expect(user).not_to be_valid

  user.email = "invalid-email"
  expect(user).not_to be_valid

  user.email = "valid@email.com"
  expect(user).to be_valid
end

3. Use let for Test Data

# Good: Reusable and lazy-loaded
let(:user) { create(:user, email: "test@example.com") }
let(:order) { create(:order, user: user, total: 100) }

it "calculates tax correctly" do
  expect(order.tax_amount).to eq(8.50)
end

# Bad: Repeated setup
it "calculates tax correctly" do
  user = create(:user, email: "test@example.com")
  order = create(:order, user: user, total: 100)
  expect(order.tax_amount).to eq(8.50)
end

4. Meaningful Error Messages

# Good: Custom error messages
expect(discount.amount).to eq(50), 
  "Expected discount amount to be $50 for premium users"

# Good: Descriptive matchers
expect(user.subscription).to be_active,
  "User subscription should be active after successful payment"

5. Test Edge Cases

describe "#divide" do
  it "divides positive numbers" do
    expect(calculator.divide(10, 2)).to eq(5)
  end

  it "handles division by zero" do
    expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
  end

  it "handles negative numbers" do
    expect(calculator.divide(-10, 2)).to eq(-5)
  end

  it "handles float precision" do
    expect(calculator.divide(1, 3)).to be_within(0.001).of(0.333)
  end
end

Rails 7+ Specific Features

Testing with ActionText

RSpec.describe Post, type: :model do
  describe "rich text content" do
    let(:post) { create(:post) }

    it "can store rich text content" do
      post.content = "<p>Hello <strong>world</strong></p>"
      expect(post.content.to_s).to include("Hello")
      expect(post.content.to_s).to include("<strong>world</strong>")
    end
  end
end

Testing with Active Storage

RSpec.describe User, type: :model do
  describe "avatar attachment" do
    let(:user) { create(:user) }
    let(:image) { fixture_file_upload("spec/fixtures/avatar.jpg", "image/jpeg") }

    it "can attach avatar" do
      user.avatar.attach(image)
      expect(user.avatar).to be_attached
      expect(user.avatar.content_type).to eq("image/jpeg")
    end
  end
end

Testing Hotwire/Turbo

RSpec.describe "Todo Management", type: :system do
  it "updates todo via turbo stream" do
    todo = create(:todo, title: "Original Title")

    visit todos_path
    click_link "Edit"
    fill_in "Title", with: "Updated Title"
    click_button "Update"

    expect(page).to have_content("Updated Title")
    expect(page).not_to have_content("Original Title")
    # Verify it was updated via AJAX, not full page reload
    expect(page).not_to have_selector(".flash-message")
  end
end

Configuration and Setup

RSpec Configuration

# spec/rails_helper.rb
RSpec.configure do |config|
  # Database cleaner
  config.use_transactional_fixtures = true

  # Factory Bot
  config.include FactoryBot::Syntax::Methods

  # Custom helpers
  config.include AuthenticationHelpers, type: :request
  config.include ControllerHelpers, type: :controller

  # Filtering
  config.filter_run_when_matching :focus
  config.example_status_persistence_file_path = "spec/examples.txt"

  # Parallel execution
  config.order = :random
  Kernel.srand config.seed
end

Database Cleaner Setup

# spec/rails_helper.rb
require 'database_cleaner/active_record'

RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end
end

This comprehensive guide covers the essential RSpec patterns you’ll use in Rails 7+ applications. The examples shown are based on real-world scenarios and follow current best practices for maintainable, reliable test suites.

Remember: Good tests are documentation for your code – they should clearly express what your application does and how it should behave under different conditions.