Profiling in development feels safe but often misses what matters. Your laptop has no network latency, a different CPU profile, warm caches, and a fraction of the concurrent connections your production system handles. The slow code your users experience may not even be measurable in development.
Profiling in production feels risky but surfaces the problems that actually affect users. With the right tools and techniques, you can profile safely without impacting performance.
Why Production Profiling Is Different
The bottlenecks that matter exist in production because:
Production realities that don't exist locally:
- Database under concurrent load (lock contention, connection pool exhaustion)
- Cache hit rates at real scale
- Network latency to downstream services
- Memory pressure and GC pauses
- CPU throttling in containerized environments
- Distributed tracing across many services
- The actual queries your users run (not your test data)
A query that takes 5ms against your 100-row development database might take 800ms against a 50-million-row production table, especially without the right indexes.
Continuous Profiling
Continuous profiling runs sampling-based profilers permanently in production at very low overhead (typically < 1% CPU). Unlike traditional profiling that profiles everything at high overhead, continuous profiling samples stack traces every millisecond or so.
Pyroscope (Open Source)
// Go: embed Pyroscope profiler
import "github.com/grafana/pyroscope-go"
func main() {
pyroscope.Start(pyroscope.Config{
ApplicationName: "api-service",
ServerAddress: "http://pyroscope:4040",
Logger: pyroscope.StandardLogger,
Tags: map[string]string{
"environment": os.Getenv("ENV"),
"version": os.Getenv("VERSION"),
},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
},
})
// Normal application startup
startServer()
}
Pyroscope gives you flame graphs showing exactly where CPU time is spent, continuously, without a triggered profiling session. You can go back and look at what was happening during a past performance incident.
Python: py-spy
# Profile a running Python process by PID without modifying code
py-spy top --pid 12345
# Generate a flame graph from a live process
py-spy record -o profile.svg --pid 12345 --duration 30
# In Docker container
py-spy top --pid 1 # PID 1 is usually the main process
py-spy attaches to a running Python process without requiring code changes. The top command is like htop but shows Python function-level breakdown instead of process-level.
PHP: Blackfire in Production
// Trigger profile for specific request
// Use Blackfire browser extension or curl
// For automated profiling in production:
$blackfire = new \Blackfire\Client(new \Blackfire\ClientConfiguration(
config('services.blackfire.client_id'),
config('services.blackfire.client_token')
));
// Profile a specific code path on demand
$probe = $blackfire->createProbe();
runExpensiveOperation();
$profile = $blackfire->endProbe($probe);
Log::info('Blackfire profile URL: ' . $profile->getUrl());
Application Performance Monitoring (APM)
APM tools provide transaction-level tracing — you can see exactly what happened in a specific request: which functions were called, how long each database query took, what downstream service calls were made.
Distributed Tracing with OpenTelemetry
OpenTelemetry is the vendor-neutral standard for distributed tracing. Instrument once, send to any backend (Jaeger, Tempo, Datadog, Honeycomb):
# Python: OpenTelemetry setup
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
# Configure tracer
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
# Auto-instrument framework and libraries
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
RedisInstrumentor().instrument()
tracer = trace.get_tracer(__name__)
# Manual spans for important code paths
async def process_order(order_id: int):
with tracer.start_as_current_span("process_order") as span:
span.set_attribute("order.id", order_id)
with tracer.start_as_current_span("validate_inventory"):
result = await validate_inventory(order_id)
span.set_attribute("inventory.available", result.available)
with tracer.start_as_current_span("charge_payment"):
charge = await charge_payment(order_id)
With auto-instrumentation, every database query, Redis call, and outgoing HTTP request is automatically traced. You get a waterfall view of every request without manually adding spans everywhere.
Reading a Trace
A trace for a slow API request might look like:
GET /api/orders/checkout [820ms total]
├── middleware.auth [8ms]
├── middleware.rate_limit [2ms]
└── OrderController.checkout [810ms]
├── DB: SELECT * FROM carts WHERE user_id=? [3ms]
├── DB: SELECT * FROM products WHERE id IN(?) [6ms]
├── HTTP: POST api.stripe.com/charges [650ms] ← bottleneck
├── DB: INSERT INTO orders [4ms]
└── DB: UPDATE cart SET status='converted' [3ms]
The bottleneck is obvious: Stripe's API takes 650ms. This might prompt investigation into whether payments can be made asynchronous or whether Stripe's regional endpoint closer to your servers is faster.
Sampling vs. Always-On
Profiling everything in production at full resolution adds overhead. Solutions:
Tail-Based Sampling
Sample 100% of slow requests, 1% of fast ones:
# OpenTelemetry Collector: tail-based sampling
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
policies:
# Always sample slow requests
- name: latency-policy
type: latency
latency:
threshold_ms: 500
# Always sample errors
- name: error-policy
type: status_code
status_code:
status_codes: [ERROR]
# Sample 1% of everything else
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 1
This gives you full detail on the requests that actually matter (slow or erroring) while minimizing overhead for normal traffic.
Database Query Profiling
MySQL Slow Query Log
-- Enable slow query log (can do at runtime)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.1; -- Log queries > 100ms
SET GLOBAL log_queries_not_using_indexes = ON;
-- Show current status
SHOW VARIABLES LIKE 'slow_query%';
-- Analyze with pt-query-digest
-- (run on server, not in MySQL)
-- pt-query-digest /var/log/mysql/slow.log | head -100
pt-query-digest groups similar queries, shows total time spent, average duration, and call count. This answers "which query class is consuming the most database time?" rather than "which individual query was slowest?" — a much more actionable view.
PostgreSQL pg_stat_statements
-- Enable the extension
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Find most time-consuming queries
SELECT
query,
calls,
total_exec_time / 1000 AS total_seconds,
mean_exec_time AS avg_ms,
rows,
100.0 * total_exec_time /
SUM(total_exec_time) OVER () AS percentage_of_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
This query shows which SQL statements (normalized, so WHERE id = 1 and WHERE id = 2 are grouped) consume the most cumulative time. The calls column is important — a query that takes 1 second but runs 100,000 times per hour is a better optimization target than one that takes 5 seconds but runs once a day.
Profiling Memory
Memory issues often show up as gradual performance degradation as memory fills and GC pressure increases.
Node.js Memory Profiling
// Trigger heap snapshot on demand via HTTP endpoint
// (only expose this endpoint internally!)
app.get('/debug/heap-snapshot', (req, res) => {
const v8 = require('v8');
const heap = v8.writeHeapSnapshot();
res.json({ file: heap, size: require('fs').statSync(heap).size });
});
// Monitor memory continuously
const MEMORY_THRESHOLD = 500 * 1024 * 1024; // 500MB
setInterval(() => {
const { heapUsed, heapTotal, rss, external } = process.memoryUsage();
if (heapUsed > MEMORY_THRESHOLD) {
logger.warn('High memory usage detected', {
heapUsed: Math.round(heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(heapTotal / 1024 / 1024) + 'MB',
rss: Math.round(rss / 1024 / 1024) + 'MB'
});
}
}, 30000);
Go Memory Profiling
import (
"net/http"
_ "net/http/pprof" // Import for side effect: registers /debug/pprof/ routes
)
func main() {
// Start pprof server on separate port (never expose publicly!)
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()
// Your application...
}
# Capture 30-second CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Capture heap profile
go tool pprof http://localhost:6060/debug/pprof/heap
# In pprof interactive mode:
(pprof) top20 # Top 20 functions by CPU time
(pprof) web # Open flame graph in browser
(pprof) list myFunc # Annotated source view
Making Profiling Actionable
Establish Baselines
Before optimizing anything, capture baseline metrics:
Baseline to capture per service:
P50, P95, P99 latency per endpoint
CPU usage under normal load
Memory usage over 24 hours
Database query time distribution
Cache hit rates
Error rates
Without baselines, you can't measure improvements. And without proving improvement, optimizations don't get prioritized.
Profile Before Optimizing
The classic mistake: assume you know where the bottleneck is without measuring.
Common wrong assumptions:
"It must be the database" → Actually it's N+1 queries in the ORM
"JSON serialization is slow" → Actually it's an unindexed column
"The external API is slow" → Actually it's called 20 times per request
"Memory is the problem" → Actually it's CPU throttling in the container
Profile first. The data tells you where to look. Your intuition is a starting hypothesis, not the answer.
Prioritize by Impact
Prioritization formula:
Priority = (requests_per_day × avg_time_saved_per_request)
Example:
Endpoint A: 1M requests/day, can save 100ms → 100,000 seconds/day saved
Endpoint B: 1K requests/day, can save 2s → 2,000 seconds/day saved
Endpoint A is 50x more valuable to optimize despite saving less per request.
Profiling in production is about finding what's actually slow for actual users, not what looks inefficient in code review. The flame graph that points to your real bottleneck is worth more than any amount of speculative optimization.
Building something that needs to scale? We help teams architect systems that grow with their business. scopeforged.com