Performance Profiler
Investigate and resolve performance issues systematically across our stack: Rails + PostgreSQL/PostGIS + Redis + Sidekiq + React Native + Centrifugo.
Performance Investigation Workflow
Step 1: Identify the Bottleneck Type
Before optimizing, determine what is slow and why. The bottleneck categories for our stack:
| Type |
Symptoms |
Investigation Tool |
| Rails |
Slow API responses, high CPU on ECS |
rack-mini-profiler, bullet gem, Rails logs |
| PostgreSQL |
Slow queries, lock waits, sequential scans |
EXPLAIN ANALYZE, pg_stat_statements |
| PostGIS |
Slow spatial queries, missing GiST indexes |
EXPLAIN ANALYZE with ST_ functions |
| Redis |
Cache misses, high memory, slow Sidekiq |
redis-cli INFO, Sidekiq dashboard |
| React Native |
Slow renders, jank, memory leaks |
Flipper, React DevTools Profiler |
| Centrifugo |
Connection drops, message delays |
Centrifugo admin UI, connection metrics |
Key rule: Measure first, optimize second. Never optimize based on assumptions.
Step 2: Gather Metrics
Backend Metrics
- Response time: p50, p95, p99 latency for each endpoint.
- Throughput: Requests per second the service handles.
- Error rate: Percentage of failed requests.
- Database: Query execution time, number of queries per request, slow query log.
- Memory: Heap usage, GC frequency and duration.
- CPU: Average utilization, spikes.
Frontend Metrics
- Core Web Vitals: LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift).
- TTFB: Time to First Byte.
- Bundle size: Total JavaScript, CSS, and asset sizes.
- Render performance: Frame rate, long tasks.
Step 3: Profile the Issue
Database Query Profiling
- Enable slow query logging (threshold: 50ms).
- Run
EXPLAIN ANALYZE on slow queries.
- Check for:
- Full table scans (missing indexes).
- N+1 patterns (many small queries instead of one batch).
- Inefficient joins (wrong join type, missing index on join column).
- Unnecessary columns fetched (
SELECT * instead of specific columns).
- Large result sets without pagination.
Application Code Profiling
- Use a CPU profiler to capture a flame graph during the slow operation.
- Identify hot functions — functions that consume the most time.
- Check for:
- Synchronous operations blocking the event loop.
- Redundant computations (recalculating the same value).
- Inefficient algorithms (O(n^2) where O(n log n) is possible).
- Excessive object creation causing GC pressure.
- Large string concatenation in loops.
Memory Profiling
- Take heap snapshots before and after the suspected leak.
- Compare snapshots to identify objects that grow but are never released.
- Check for:
- Event listeners not removed.
- Closures retaining large objects.
- Global caches without size limits or expiration.
- Buffers not freed after use.
Frontend Bundle Analysis
- Run the bundle analyzer to visualize module sizes.
- Check for:
- Large dependencies with smaller alternatives.
- Duplicate dependencies (same library in multiple versions).
- Unused code (tree-shaking not working).
- Unminified or uncompressed assets.
- Images without optimization or lazy loading.
Step 4: Common Anti-Patterns and Fixes
N+1 Queries
// BAD: N+1 — one query per user
const users = await db.query('SELECT * FROM users');
for (const user of users) {
user.orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [user.id]);
}
// GOOD: Batch query
const users = await db.query('SELECT * FROM users');
const userIds = users.map(u => u.id);
const orders = await db.query('SELECT * FROM orders WHERE user_id IN (?)', [userIds]);
// Map orders to users in application code
Missing Database Indexes
-- Find slow queries
SELECT query, calls, mean_time, total_time
FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 20;
-- Check if a query uses an index
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 'abc-123';
-- If "Seq Scan" appears, add an index:
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
Unnecessary Re-Renders (React)
// BAD: New object reference on every render
<Component style={{ color: 'red' }} />
// GOOD: Stable reference
const style = useMemo(() => ({ color: 'red' }), []);
<Component style={style} />
// BAD: Inline function creates new reference
<Button => handleClick(id)} />
// GOOD: Stable callback
const handleClickMemo = useCallback(() => handleClick(id), [id]);
<Button />
Blocking the Event Loop
// BAD: Synchronous file read in request handler
app.get('/data', (req, res) => {
const data = fs.readFileSync('/path/to/large-file.json');
res.json(JSON.parse(data));
});
// GOOD: Async file read
app.get('/data', async (req, res) => {
const data = await fs.promises.readFile('/path/to/large-file.json');
res.json(JSON.parse(data));
});
Unbounded Caches
// BAD: Cache grows without limit
const cache = new Map();
function getData(key) {
if (cache.has(key)) return cache.get(key);
const value = computeExpensive(key);
cache.set(key, value);
return value;
}
// GOOD: LRU cache with max size
const cache = new LRUCache({ max: 1000, ttl: 60000 });
Large Payloads
// BAD: Return everything
app.get('/users', async (req, res) => {
const users = await db.query('SELECT * FROM users');
res.json(users);
});
// GOOD: Paginate and select fields
app.get('/users', async (req, res) => {
const { page = 1, limit = 20 } = req.query;
const users = await db.query(
'SELECT id, name, email FROM users LIMIT ? OFFSET ?',
[Math.min(limit, 100), (page - 1) * limit]
);
res.json({ data: users, pagination: { page, limit } });
});
Step 5: Optimization Strategies
Caching Layers
- Application cache: In-memory (LRU) for frequently accessed, rarely changed data.
- Distributed cache: Redis/Memcached for data shared across instances.
- HTTP cache:
Cache-Control headers for static and semi-static responses.
- CDN: Static assets and cacheable API responses at the edge.
Cache invalidation strategy must be defined for every cached item.
Database Optimization
- Add indexes for query patterns (check with
EXPLAIN).
- Use connection pooling — do not open a new connection per request.
- Denormalize read-heavy data where appropriate.
- Use read replicas for reporting and analytics queries.
- Archive old data to keep active tables small.
Frontend Optimization
- Code splitting — load only the JavaScript needed for the current page.
- Lazy load images and below-the-fold content.
- Compress assets with gzip or brotli.
- Preload critical resources, prefetch likely next pages.
- Use a CDN for static assets.
Step 6: Benchmark and Verify
After optimizing:
- Run the same benchmark that revealed the issue.
- Compare before/after metrics.
- Verify no regressions in other areas.
- Document the optimization with before/after numbers.
Benchmark methodology:
- Use consistent test data and load patterns.
- Run multiple iterations (minimum 10) and report p50/p95/p99.
- Test under realistic load, not just single-request timing.
- Include warm-up period to fill caches.
Web Core Web Vitals
For web frontends (Vite SPA + Next.js), measure and optimize Core Web Vitals:
| Metric |
Target |
Tool |
| LCP (Largest Contentful Paint) |
< 2.5s |
Lighthouse, web-vitals |
| INP (Interaction to Next Paint) |
< 200ms |
Lighthouse, web-vitals |
| CLS (Cumulative Layout Shift) |
< 0.1 |
Lighthouse, web-vitals |
| TTFB (Time to First Byte) |
< 800ms |
Lighthouse |
Web Bundle Analysis
- Vite SPA: Use
vite-bundle-visualizer (npx vite-bundle-visualizer) to audit chunk sizes.
Target < 300KB initial JS, minified and uncompressed — owned by
@skills/std-reactjs/references/routing-and-code-split.md, and wired into the build as
chunkSizeWarningLimit: 300, so this is a number the toolchain already checks rather than an
aspiration. Note the unit: the gzipped budgets in references/performance-benchmarks.md are a
different measure, not a stricter version of this one.
- Next.js: Use
@next/bundle-analyzer to inspect client and server bundles. Target < 200KB client JS per route.
- Check for: large dependencies, duplicate modules, missing tree-shaking, unoptimized images.
Hydration Performance (Next.js)
- Minimize Client Component boundaries — each
'use client' component adds to hydration cost.
- Use
<Suspense> to stream Server Component content progressively.
- Pass serialized data from Server Components to Client Components to avoid double-fetching.
Lighthouse CI
- Run Lighthouse CI in the deployment pipeline.
- Set performance budget: performance > 90, accessibility > 95, best practices > 90.
- Fail CI if scores drop below thresholds.
Output Format
When reporting performance findings:
| Issue |
Impact |
Location |
Current |
Target |
Fix |
| N+1 query on user orders |
200ms per request |
api/users.ts:45 |
150 queries/req |
2 queries/req |
Batch fetch with IN clause |
| Missing index on orders.status |
Slow order listing |
orders table |
850ms seq scan |
<10ms index scan |
Add index on status column |
| Unoptimized hero image |
3s LCP |
public/hero.png |
2.4MB PNG |
<200KB WebP |
Convert and resize |
Deep guides (read on demand, do not preload)
- The actual numbers: API response times, query limits, bundle budgets, Core Web Vitals, memory thresholds, throughput targets →
references/performance-benchmarks.md
1---2name: performance-profiler3description: Profile and optimize application performance including Rails query optimization, React Native rendering, Redis cache strategy, PostgreSQL/PostGIS query tuning, and Sidekiq job performance. Use this skill whenever someone asks to investigate slowness, profile performance, optimize queries, reduce latency, or says things like "why is this slow", "profile this endpoint", "optimize this query", "find the bottleneck", "improve performance of X", or "this page takes too long to load". Also trigger when someone mentions N+1 queries, EXPLAIN ANALYZE, memory leaks, bundle size analysis, or cache hit rate optimization.4---56# Performance Profiler78Investigate and resolve performance issues systematically across our stack: Rails + PostgreSQL/PostGIS + Redis + Sidekiq + React Native + Centrifugo.910## Performance Investigation Workflow1112### Step 1: Identify the Bottleneck Type1314Before optimizing, determine what is slow and why. The bottleneck categories for our stack:1516| Type | Symptoms | Investigation Tool |17|---|---|---|18| **Rails** | Slow API responses, high CPU on ECS | rack-mini-profiler, bullet gem, Rails logs |19| **PostgreSQL** | Slow queries, lock waits, sequential scans | `EXPLAIN ANALYZE`, pg_stat_statements |20| **PostGIS** | Slow spatial queries, missing GiST indexes | `EXPLAIN ANALYZE` with ST_ functions |21| **Redis** | Cache misses, high memory, slow Sidekiq | redis-cli INFO, Sidekiq dashboard |22| **React Native** | Slow renders, jank, memory leaks | Flipper, React DevTools Profiler |23| **Centrifugo** | Connection drops, message delays | Centrifugo admin UI, connection metrics |2425Key rule: **Measure first, optimize second.** Never optimize based on assumptions.2627### Step 2: Gather Metrics2829#### Backend Metrics30- **Response time**: p50, p95, p99 latency for each endpoint.31- **Throughput**: Requests per second the service handles.32- **Error rate**: Percentage of failed requests.33- **Database**: Query execution time, number of queries per request, slow query log.34- **Memory**: Heap usage, GC frequency and duration.35- **CPU**: Average utilization, spikes.3637#### Frontend Metrics38- **Core Web Vitals**: LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift).39- **TTFB**: Time to First Byte.40- **Bundle size**: Total JavaScript, CSS, and asset sizes.41- **Render performance**: Frame rate, long tasks.4243### Step 3: Profile the Issue4445#### Database Query Profiling461. Enable slow query logging (threshold: 50ms).472. Run `EXPLAIN ANALYZE` on slow queries.483. Check for:49 - Full table scans (missing indexes).50 - N+1 patterns (many small queries instead of one batch).51 - Inefficient joins (wrong join type, missing index on join column).52 - Unnecessary columns fetched (`SELECT *` instead of specific columns).53 - Large result sets without pagination.5455#### Application Code Profiling561. Use a CPU profiler to capture a flame graph during the slow operation.572. Identify hot functions — functions that consume the most time.583. Check for:59 - Synchronous operations blocking the event loop.60 - Redundant computations (recalculating the same value).61 - Inefficient algorithms (O(n^2) where O(n log n) is possible).62 - Excessive object creation causing GC pressure.63 - Large string concatenation in loops.6465#### Memory Profiling661. Take heap snapshots before and after the suspected leak.672. Compare snapshots to identify objects that grow but are never released.683. Check for:69 - Event listeners not removed.70 - Closures retaining large objects.71 - Global caches without size limits or expiration.72 - Buffers not freed after use.7374#### Frontend Bundle Analysis751. Run the bundle analyzer to visualize module sizes.762. Check for:77 - Large dependencies with smaller alternatives.78 - Duplicate dependencies (same library in multiple versions).79 - Unused code (tree-shaking not working).80 - Unminified or uncompressed assets.81 - Images without optimization or lazy loading.8283### Step 4: Common Anti-Patterns and Fixes8485#### N+1 Queries86```87// BAD: N+1 — one query per user88const users = await db.query('SELECT * FROM users');89for (const user of users) {90 user.orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [user.id]);91}9293// GOOD: Batch query94const users = await db.query('SELECT * FROM users');95const userIds = users.map(u => u.id);96const orders = await db.query('SELECT * FROM orders WHERE user_id IN (?)', [userIds]);97// Map orders to users in application code98```99100#### Missing Database Indexes101```sql102-- Find slow queries103SELECT query, calls, mean_time, total_time104FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 20;105106-- Check if a query uses an index107EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 'abc-123';108-- If "Seq Scan" appears, add an index:109CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);110```111112#### Unnecessary Re-Renders (React)113```jsx114// BAD: New object reference on every render115<Component style={{ color: 'red' }} />116117// GOOD: Stable reference118const style = useMemo(() => ({ color: 'red' }), []);119<Component style={style} />120121// BAD: Inline function creates new reference122<Button onClick={() => handleClick(id)} />123124// GOOD: Stable callback125const handleClickMemo = useCallback(() => handleClick(id), [id]);126<Button onClick={handleClickMemo} />127```128129#### Blocking the Event Loop130```javascript131// BAD: Synchronous file read in request handler132app.get('/data', (req, res) => {133 const data = fs.readFileSync('/path/to/large-file.json');134 res.json(JSON.parse(data));135});136137// GOOD: Async file read138app.get('/data', async (req, res) => {139 const data = await fs.promises.readFile('/path/to/large-file.json');140 res.json(JSON.parse(data));141});142```143144#### Unbounded Caches145```javascript146// BAD: Cache grows without limit147const cache = new Map();148function getData(key) {149 if (cache.has(key)) return cache.get(key);150 const value = computeExpensive(key);151 cache.set(key, value);152 return value;153}154155// GOOD: LRU cache with max size156const cache = new LRUCache({ max: 1000, ttl: 60000 });157```158159#### Large Payloads160```javascript161// BAD: Return everything162app.get('/users', async (req, res) => {163 const users = await db.query('SELECT * FROM users');164 res.json(users);165});166167// GOOD: Paginate and select fields168app.get('/users', async (req, res) => {169 const { page = 1, limit = 20 } = req.query;170 const users = await db.query(171 'SELECT id, name, email FROM users LIMIT ? OFFSET ?',172 [Math.min(limit, 100), (page - 1) * limit]173 );174 res.json({ data: users, pagination: { page, limit } });175});176```177178### Step 5: Optimization Strategies179180#### Caching Layers1811. **Application cache**: In-memory (LRU) for frequently accessed, rarely changed data.1822. **Distributed cache**: Redis/Memcached for data shared across instances.1833. **HTTP cache**: `Cache-Control` headers for static and semi-static responses.1844. **CDN**: Static assets and cacheable API responses at the edge.185186Cache invalidation strategy must be defined for every cached item.187188#### Database Optimization1891. Add indexes for query patterns (check with `EXPLAIN`).1902. Use connection pooling — do not open a new connection per request.1913. Denormalize read-heavy data where appropriate.1924. Use read replicas for reporting and analytics queries.1935. Archive old data to keep active tables small.194195#### Frontend Optimization1961. Code splitting — load only the JavaScript needed for the current page.1972. Lazy load images and below-the-fold content.1983. Compress assets with gzip or brotli.1994. Preload critical resources, prefetch likely next pages.2005. Use a CDN for static assets.201202### Step 6: Benchmark and Verify203204After optimizing:2052061. Run the same benchmark that revealed the issue.2072. Compare before/after metrics.2083. Verify no regressions in other areas.2094. Document the optimization with before/after numbers.210211Benchmark methodology:212- Use consistent test data and load patterns.213- Run multiple iterations (minimum 10) and report p50/p95/p99.214- Test under realistic load, not just single-request timing.215- Include warm-up period to fill caches.216217### Web Core Web Vitals218219For web frontends (Vite SPA + Next.js), measure and optimize Core Web Vitals:220221| Metric | Target | Tool |222|--------|--------|------|223| LCP (Largest Contentful Paint) | < 2.5s | Lighthouse, web-vitals |224| INP (Interaction to Next Paint) | < 200ms | Lighthouse, web-vitals |225| CLS (Cumulative Layout Shift) | < 0.1 | Lighthouse, web-vitals |226| TTFB (Time to First Byte) | < 800ms | Lighthouse |227228#### Web Bundle Analysis229- **Vite SPA**: Use `vite-bundle-visualizer` (`npx vite-bundle-visualizer`) to audit chunk sizes.230 Target **< 300KB initial JS, minified and uncompressed** — owned by231 `@skills/std-reactjs/references/routing-and-code-split.md`, and wired into the build as232 `chunkSizeWarningLimit: 300`, so this is a number the toolchain already checks rather than an233 aspiration. Note the unit: the gzipped budgets in `references/performance-benchmarks.md` are a234 **different measure**, not a stricter version of this one.235- **Next.js**: Use `@next/bundle-analyzer` to inspect client and server bundles. Target < 200KB client JS per route.236- Check for: large dependencies, duplicate modules, missing tree-shaking, unoptimized images.237238#### Hydration Performance (Next.js)239- Minimize Client Component boundaries — each `'use client'` component adds to hydration cost.240- Use `<Suspense>` to stream Server Component content progressively.241- Pass serialized data from Server Components to Client Components to avoid double-fetching.242243#### Lighthouse CI244- Run Lighthouse CI in the deployment pipeline.245- Set performance budget: performance > 90, accessibility > 95, best practices > 90.246- Fail CI if scores drop below thresholds.247248## Output Format249250When reporting performance findings:251252| Issue | Impact | Location | Current | Target | Fix |253|---|---|---|---|---|---|254| N+1 query on user orders | 200ms per request | api/users.ts:45 | 150 queries/req | 2 queries/req | Batch fetch with IN clause |255| Missing index on orders.status | Slow order listing | orders table | 850ms seq scan | <10ms index scan | Add index on status column |256| Unoptimized hero image | 3s LCP | public/hero.png | 2.4MB PNG | <200KB WebP | Convert and resize |257258## Deep guides (read on demand, do not preload)259260- The actual numbers: API response times, query limits, bundle budgets, Core Web Vitals, memory thresholds, throughput targets → `references/performance-benchmarks.md`