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.4model: sonnet5allowed-tools: Read, Grep, Glob, Bash6---78# Performance Profiler910Investigate and resolve performance issues systematically across our stack: Rails + PostgreSQL/PostGIS + Redis + Sidekiq + React Native + Centrifugo.1112## Performance Investigation Workflow1314### Step 1: Identify the Bottleneck Type1516Before optimizing, determine what is slow and why. The bottleneck categories for our stack:1718| Type | Symptoms | Investigation Tool |19|---|---|---|20| **Rails** | Slow API responses, high CPU on ECS | rack-mini-profiler, bullet gem, Rails logs |21| **PostgreSQL** | Slow queries, lock waits, sequential scans | `EXPLAIN ANALYZE`, pg_stat_statements |22| **PostGIS** | Slow spatial queries, missing GiST indexes | `EXPLAIN ANALYZE` with ST_ functions |23| **Redis** | Cache misses, high memory, slow Sidekiq | redis-cli INFO, Sidekiq dashboard |24| **React Native** | Slow renders, jank, memory leaks | Flipper, React DevTools Profiler |25| **Centrifugo** | Connection drops, message delays | Centrifugo admin UI, connection metrics |2627Key rule: **Measure first, optimize second.** Never optimize based on assumptions.2829### Step 2: Gather Metrics3031#### Backend Metrics32- **Response time**: p50, p95, p99 latency for each endpoint.33- **Throughput**: Requests per second the service handles.34- **Error rate**: Percentage of failed requests.35- **Database**: Query execution time, number of queries per request, slow query log.36- **Memory**: Heap usage, GC frequency and duration.37- **CPU**: Average utilization, spikes.3839#### Frontend Metrics40- **Core Web Vitals**: LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift).41- **TTFB**: Time to First Byte.42- **Bundle size**: Total JavaScript, CSS, and asset sizes.43- **Render performance**: Frame rate, long tasks.4445### Step 3: Profile the Issue4647#### Database Query Profiling481. Enable slow query logging (threshold: 50ms).492. Run `EXPLAIN ANALYZE` on slow queries.503. Check for:51 - Full table scans (missing indexes).52 - N+1 patterns (many small queries instead of one batch).53 - Inefficient joins (wrong join type, missing index on join column).54 - Unnecessary columns fetched (`SELECT *` instead of specific columns).55 - Large result sets without pagination.5657#### Application Code Profiling581. Use a CPU profiler to capture a flame graph during the slow operation.592. Identify hot functions — functions that consume the most time.603. Check for:61 - Synchronous operations blocking the event loop.62 - Redundant computations (recalculating the same value).63 - Inefficient algorithms (O(n^2) where O(n log n) is possible).64 - Excessive object creation causing GC pressure.65 - Large string concatenation in loops.6667#### Memory Profiling681. Take heap snapshots before and after the suspected leak.692. Compare snapshots to identify objects that grow but are never released.703. Check for:71 - Event listeners not removed.72 - Closures retaining large objects.73 - Global caches without size limits or expiration.74 - Buffers not freed after use.7576#### Frontend Bundle Analysis771. Run the bundle analyzer to visualize module sizes.782. Check for:79 - Large dependencies with smaller alternatives.80 - Duplicate dependencies (same library in multiple versions).81 - Unused code (tree-shaking not working).82 - Unminified or uncompressed assets.83 - Images without optimization or lazy loading.8485### Step 4: Common Anti-Patterns and Fixes8687#### N+1 Queries88```89// BAD: N+1 — one query per user90const users = await db.query('SELECT * FROM users');91for (const user of users) {92 user.orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [user.id]);93}9495// GOOD: Batch query96const users = await db.query('SELECT * FROM users');97const userIds = users.map(u => u.id);98const orders = await db.query('SELECT * FROM orders WHERE user_id IN (?)', [userIds]);99// Map orders to users in application code100```101102#### Missing Database Indexes103```sql104-- Find slow queries105SELECT query, calls, mean_time, total_time106FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 20;107108-- Check if a query uses an index109EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 'abc-123';110-- If "Seq Scan" appears, add an index:111CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);112```113114#### Unnecessary Re-Renders (React)115```jsx116// BAD: New object reference on every render117<Component style={{ color: 'red' }} />118119// GOOD: Stable reference120const style = useMemo(() => ({ color: 'red' }), []);121<Component style={style} />122123// BAD: Inline function creates new reference124<Button onClick={() => handleClick(id)} />125126// GOOD: Stable callback127const handleClickMemo = useCallback(() => handleClick(id), [id]);128<Button onClick={handleClickMemo} />129```130131#### Blocking the Event Loop132```javascript133// BAD: Synchronous file read in request handler134app.get('/data', (req, res) => {135 const data = fs.readFileSync('/path/to/large-file.json');136 res.json(JSON.parse(data));137});138139// GOOD: Async file read140app.get('/data', async (req, res) => {141 const data = await fs.promises.readFile('/path/to/large-file.json');142 res.json(JSON.parse(data));143});144```145146#### Unbounded Caches147```javascript148// BAD: Cache grows without limit149const cache = new Map();150function getData(key) {151 if (cache.has(key)) return cache.get(key);152 const value = computeExpensive(key);153 cache.set(key, value);154 return value;155}156157// GOOD: LRU cache with max size158const cache = new LRUCache({ max: 1000, ttl: 60000 });159```160161#### Large Payloads162```javascript163// BAD: Return everything164app.get('/users', async (req, res) => {165 const users = await db.query('SELECT * FROM users');166 res.json(users);167});168169// GOOD: Paginate and select fields170app.get('/users', async (req, res) => {171 const { page = 1, limit = 20 } = req.query;172 const users = await db.query(173 'SELECT id, name, email FROM users LIMIT ? OFFSET ?',174 [Math.min(limit, 100), (page - 1) * limit]175 );176 res.json({ data: users, pagination: { page, limit } });177});178```179180### Step 5: Optimization Strategies181182#### Caching Layers1831. **Application cache**: In-memory (LRU) for frequently accessed, rarely changed data.1842. **Distributed cache**: Redis/Memcached for data shared across instances.1853. **HTTP cache**: `Cache-Control` headers for static and semi-static responses.1864. **CDN**: Static assets and cacheable API responses at the edge.187188Cache invalidation strategy must be defined for every cached item.189190#### Database Optimization1911. Add indexes for query patterns (check with `EXPLAIN`).1922. Use connection pooling — do not open a new connection per request.1933. Denormalize read-heavy data where appropriate.1944. Use read replicas for reporting and analytics queries.1955. Archive old data to keep active tables small.196197#### Frontend Optimization1981. Code splitting — load only the JavaScript needed for the current page.1992. Lazy load images and below-the-fold content.2003. Compress assets with gzip or brotli.2014. Preload critical resources, prefetch likely next pages.2025. Use a CDN for static assets.203204### Step 6: Benchmark and Verify205206After optimizing:2072081. Run the same benchmark that revealed the issue.2092. Compare before/after metrics.2103. Verify no regressions in other areas.2114. Document the optimization with before/after numbers.212213Benchmark methodology:214- Use consistent test data and load patterns.215- Run multiple iterations (minimum 10) and report p50/p95/p99.216- Test under realistic load, not just single-request timing.217- Include warm-up period to fill caches.218219### Web Core Web Vitals220221For web frontends (Vite SPA + Next.js), measure and optimize Core Web Vitals:222223| Metric | Target | Tool |224|--------|--------|------|225| LCP (Largest Contentful Paint) | < 2.5s | Lighthouse, web-vitals |226| INP (Interaction to Next Paint) | < 200ms | Lighthouse, web-vitals |227| CLS (Cumulative Layout Shift) | < 0.1 | Lighthouse, web-vitals |228| TTFB (Time to First Byte) | < 800ms | Lighthouse |229230#### Web Bundle Analysis231- **Vite SPA**: Use `vite-bundle-visualizer` (`npx vite-bundle-visualizer`) to audit chunk sizes.232 Target **< 300KB initial JS, minified and uncompressed** — owned by233 `@skills/std-reactjs/references/routing-and-code-split.md`, and wired into the build as234 `chunkSizeWarningLimit: 300`, so this is a number the toolchain already checks rather than an235 aspiration. Note the unit: the gzipped budgets in `references/performance-benchmarks.md` are a236 **different measure**, not a stricter version of this one.237- **Next.js**: Use `@next/bundle-analyzer` to inspect client and server bundles. Target < 200KB client JS per route.238- Check for: large dependencies, duplicate modules, missing tree-shaking, unoptimized images.239240#### Hydration Performance (Next.js)241- Minimize Client Component boundaries — each `'use client'` component adds to hydration cost.242- Use `<Suspense>` to stream Server Component content progressively.243- Pass serialized data from Server Components to Client Components to avoid double-fetching.244245#### Lighthouse CI246- Run Lighthouse CI in the deployment pipeline.247- Set performance budget: performance > 90, accessibility > 95, best practices > 90.248- Fail CI if scores drop below thresholds.249250## Output Format251252When reporting performance findings:253254| Issue | Impact | Location | Current | Target | Fix |255|---|---|---|---|---|---|256| N+1 query on user orders | 200ms per request | api/users.ts:45 | 150 queries/req | 2 queries/req | Batch fetch with IN clause |257| Missing index on orders.status | Slow order listing | orders table | 850ms seq scan | <10ms index scan | Add index on status column |258| Unoptimized hero image | 3s LCP | public/hero.png | 2.4MB PNG | <200KB WebP | Convert and resize |259260## Deep guides (read on demand, do not preload)261262- The actual numbers: API response times, query limits, bundle budgets, Core Web Vitals, memory thresholds, throughput targets → `references/performance-benchmarks.md`