Performance Monitoring
Java
August 6, 202520 min read

Complete Guide to Java Application Profiling: Tools, Techniques & Best Practices 2025

Author:

Niyati ThakkarNiyati Thakkar

Java applications slow down for countless reasons: memory leaks, inefficient algorithms, database bottlenecks, thread contention. The frustrating part? These issues often stay hidden until production traffic hits and users start complaining. Even experienced developers can spend days hunting performance problems without the right approach.

Profiling changes that. Instead of guessing where problems might be, profiling shows exactly what's happening inside your running application. This guide covers the practical aspects of Java profiling: which tools actually work, how to interpret the data without drowning in details, and what fixes make real differences in production.

What is Java Application Profiling?

Profiling means measuring what your Java application actually does when it runs, not what the code suggests it should do. While debugging fixes broken functionality, profiling fixes working code that's just too slow, uses too much memory, or randomly freezes under load.

How Profiling Differs from Infrastructure Monitoring and Tracing

Infrastructure monitoring tells you the server is using 90% CPU. Profiling tells you which Java method is causing it.

Distributed tracing shows a request took 5 seconds across services. Profiling shows the exact line of code where those 5 seconds were spent.

APM tools alert when response times spike. Profiling reveals it's because someone added a synchronous remote call inside a loop.

Think of it this way: monitoring and tracing show symptoms at the system level, while profiling diagnoses the root cause at the code level. You need both,monitoring to know when something's wrong, profiling to fix it.

Common Performance Problems

Most performance issues fall into predictable patterns:

CPU bottlenecks: One method taking 80% of processing time, nested loops processing large datasets inefficiently, or algorithms with exponential complexity hiding in seemingly simple code.

Memory issues: Objects accumulating faster than garbage collection can handle, static collections growing indefinitely, or heap fragmentation causing long GC pauses.

Concurrency problems: Threads waiting on locks, deadlocks between services, or thread pools configured wrong for the actual workload.

I/O delays: Database queries without proper indexes, N+1 query problems, or network calls in tight loops.

The key insight: performance bugs are just as critical as functional bugs. They just take longer to manifest and harder to reproduce.

Why Profiling Matters More Than Ever

Modern applications face unique challenges:

  • Distributed complexity: One slow method can cascade delays across 20 microservices
  • Cloud costs: Inefficient code directly translates to higher AWS/GCP bills
  • User expectations: Response times over 100ms feel noticeable; each 100ms delay can reduce e-commerce conversions by ~1%
  • Scale challenges: Code that works for 100 users might fail at 10,000

The JVM can't fix bad algorithms or architectural problems. Only profiling reveals where your code actually struggles under real load.

Essential Java Profiling Tools for 2025

The Java ecosystem has dozens of profiling tools, but most teams use the same handful that actually work. Here's what matters:

Built-in JVM Tools

These ship with Java and cost nothing. Start here before buying anything else.

Java Flight Recorder (JFR)
Free and open-source in OpenJDK since Java 11 (previously commercial in Oracle JDK 7+). Runs in production with <2% overhead. Records everything: CPU, memory, threads, I/O.

# Start recording on running app
java -XX:+FlightRecorder -XX:StartFlightRecording=duration=60s,filename=myapp.jfr MyApplication

# Or attach to running process
jcmd <pid> JFR.start duration=60s filename=recording.jfr

JFR shines for production issues because it's always available and barely impacts performance. The catch: interpreting the data takes practice.

Quick command-line tools

jconsole                # GUI for basic monitoring
jmap -histo <pid>       # See what's eating memory RIGHT NOW
jstack <pid>            # Find deadlocks and blocked threads
jcmd <pid> GC.heap_info # Quick heap status without dumps

These work anywhere Java runs. No setup needed.

Open-Source Profilers

VisualVM
Still the easiest way to profile local applications. Connect, click profile, see results. Great for development, struggles with remote production apps.

Profiling in VisualVM
Profiling in VisualVM

Best for: Finding memory leaks during development, CPU hotspot analysis, thread deadlock detection.

Async Profiler
The go-to for production CPU profiling. Creates flame graphs that actually make sense.

./profiler.sh -e cpu -d 30 -f flamegraph.html <pid>

Why it works: samples stack traces using OS-level APIs, avoiding JVM safepoint bias. Translation: more accurate results with less overhead.

Eclipse MAT
When you have a 10GB heap dump and need to find the leak, MAT finds it. Automatically identifies leak suspects and shows exactly what's holding references.

Commercial Profilers

JProfiler and YourKit dominate this space. Both excellent, both expensive. They excel at:

  • Database query profiling (see actual SQL with timings)
  • Memory allocation tracking down to line numbers
  • IDE integration that actually works
  • Support when things go wrong

Worth it? For teams doing serious performance work, yes. For occasional profiling, stick with free tools.

The Four Pillars of Java Application Profiling

1. CPU Profiling: Finding Processing Bottlenecks

CPU profiling answers one question: where does the time go? Start here when applications feel slow or CPU stays pegged at 100%.

Common discoveries:

  • That innocent-looking regex in a loop processing millions of times
  • JSON serialization taking 40% of request time
  • Logging statements doing expensive string concatenation even when disabled
  • Database drivers spinning on connection pool locks

Real example: An e-commerce site's recommendation engine ate 80% CPU. Profiling showed a sort() called inside nested loops: O(n³) complexity hidden in clean-looking code. Adding a cache dropped CPU to 20%.

Reading CPU Profiles

Flame Graphs show the whole picture at once. Wide bars = time hogs. Tall stacks = deep call chains.

Flame Graph to view CPU consumption for different methods
Flame Graph to view CPU consumption for different methods

In this flame graph, Structure.read() burns 14,443 µs across 419 calls. That's 34µs per call,not terrible individually, but those calls add up.

What to look for:

  • Wide bars at any level (time sinks)
  • Repeated patterns (inefficient loops)
  • Deep stacks under simple operations (overengineering)
  • Unexpected methods taking time (surprises = bugs)

CPU Profiling Gotchas

JIT compilation skews results The JVM optimizes hot code paths while profiling runs. Early measurements show interpreted code, later ones show optimized code. Solution: warm up the JVM before profiling, or use JFR which accounts for compilation.

You can watch the JIT compiler at work to understand when your code is being optimized:

# See what's getting compiled
java -XX:+PrintCompilation MyApp | grep "made not entrant"

Methods marked "made not entrant" were deoptimized—usually because the JIT's assumptions proved wrong. This is normal but can affect profiling results.

Sampling vs. Instrumentation

  • Sampling: Takes snapshots periodically (configurable, often 10-20ms). Misses short methods but low overhead.
  • Instrumentation: Tracks every call. Accurate but can 10x execution time.

Production = always sampling. Development debugging = instrumentation okay.

Fixing CPU Bottlenecks

Algorithm fixes usually give the biggest wins:

// Classic N² problem hiding in "clean" code
for (Order order : orders) {
    for (Product product : allProducts) {
        if (order.containsProduct(product.getId())) {
            // Process
        }
    }
}

// After profiling shows this takes 90% CPU:
Map<String, Product> productLookup = products.stream()
    .collect(Collectors.toMap(Product::getId, p -> p));

for (Order order : orders) {
    order.getProductIds().stream()
        .map(productLookup::get)
        .forEach(this::process);
}

Caching works when profiling shows repeated calculations:

@Cacheable("expensive-calculations")
public Result calculate(String input) {
    // Only runs on cache miss
    return doExpensiveWork(input);
}

But beware: caches can become memory leaks. Profile memory after adding caches.

2. Memory Profiling: Optimizing Heap Usage

Memory problems manifest as OutOfMemoryErrors, long GC pauses, or gradually degrading performance. Memory profiling finds the cause.

Typical culprits:

  • Collections that only grow (forgotten cache eviction)
  • Listeners that never unregister (classic GUI leak)
  • ThreadLocals in thread pools (threads live forever)
  • String intern() abuse (permanent heap pollution)
  • Closed-over variables in lambdas (surprise references)

Finding Memory Leaks

Java memory leaks happen when objects can't be garbage collected. The classic patterns:

Static collections without bounds:

public class MetricsCollector {
    // Keeps every metric forever
    private static final List<Metric> ALL_METRICS = new ArrayList<>();
    
    public static void record(Metric m) {
        ALL_METRICS.add(m);  // Memory leak
    }
}

Forgotten listeners:

// Component adds listener but never removes
EventBus.register(this);
// When 'this' should die, EventBus still holds reference

ThreadLocal in shared threads:

private static final ThreadLocal<ExpensiveObject> CACHE = new ThreadLocal<>();
// In thread pool, threads never die = objects never collected

Memory Profiling in Practice

Getting heap dumps when you need them:

The most important heap dump is the one you don't have to trigger manually. Always run production apps with:

# Automatic dump on OutOfMemoryError
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/ MyApp

When the app runs out of memory, you'll have a dump waiting for analysis instead of just a stack trace.

For investigating memory issues before they cause OOM:

# Manual dump of running app
jcmd <pid> GC.heap_dump /tmp/heap.hprof

Reading the signs:

Graph demonstrating memory leak
Graph demonstrating memory leak

This sawtooth pattern climbing over time = memory leak. Each GC recovers less memory. Eventually: OOM.

Eclipse MAT finds leaks fast:

  1. Open heap dump
  2. Run Leak Suspects report
  3. See biggest objects and what holds them
  4. Fix the reference chain

Memory Optimization Patterns

Stop creating garbage:

// Allocation hotspot: String concatenation in loops
log.debug("Processing item " + item.getId() + " for user " + user.getName());
// Creates multiple temporary strings even if debug disabled

// Better: Lazy evaluation
log.debug("Processing item {} for user {}", item.getId(), user.getName());
// No string creation unless actually logging

Object pooling (when profiling shows high allocation rates):

public class BufferPool {
    private final Queue<ByteBuffer> pool = new ConcurrentLinkedQueue<>();
    
    public ByteBuffer acquire() {
        ByteBuffer buffer = pool.poll();
        if (buffer == null) {
            buffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
        }
        return buffer.clear();
    }
    
    public void release(ByteBuffer buffer) {
        pool.offer(buffer);
    }
}

Only pool objects that are:

  1. Expensive to create
  2. Created frequently
  3. Have bounded lifetime

Garbage Collection Reality Check

Pick the right GC for your workload:

Choosing a garbage collector is like choosing a car—depends on what you're optimizing for:

# Most apps: G1GC balances throughput and latency
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 MyApp

# Need <10ms pauses? ZGC (trades memory overhead for low latency)
java -XX:+UseZGC MyApp

# Batch processing? ParallelGC maximizes throughput
java -XX:+UseParallelGC MyApp

GC tuning truth: Most GC problems are actually memory leaks or excessive allocation. Fix those first. GC tuning is the last resort, not the first response.

3. Thread Profiling: Resolving Concurrency Issues

Thread problems are the worst. The app works fine in dev, then production load hits and everything locks up. Thread profiling shows why.

What goes wrong:

  • Synchronized blocks creating bottlenecks
  • Deadlocks between services
  • Thread pools too small (or too large)
  • Race conditions corrupting data

Thread Issues That Kill Performance

Contention (threads waiting for locks):

// Every thread waits here
public synchronized void updateStats(Stats s) {
    globalStats.merge(s);  // 50ms operation
}

// Better: reduce lock scope
public void updateStats(Stats s) {
    Stats merged = s.calculate();  // Do work outside lock
    synchronized(this) {
        globalStats.quickMerge(merged);  // 1ms under lock
    }
}

Deadlocks (circular waiting):

// Thread 1: locks A, then B
// Thread 2: locks B, then A
// Result: Both stuck forever

// Fix: Always lock in same order
private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();

// All code must acquire LOCK_A before LOCK_B

Finding Thread Problems

Thread dumps tell the story of what your threads are actually doing:

When the app feels stuck, capture the current state:

jstack <pid> | grep -A 10 "BLOCKED\|WAITING"

Look for:

  • Many threads BLOCKED on same lock = contention
  • Threads WAITING forever = likely deadlock
  • 500 threads for 10 concurrent users = pool misconfigured
Analyzing threads for BLOCKED or WAITING state.
Analyzing threads for BLOCKED or WAITING state.

VisualVM shows blocked threads in red/yellow. If you see many threads blocked on the same monitor, you found your bottleneck.

Thread pool sizing is more art than science, but these formulas give you a starting point:

// CPU-bound work: one thread per core maximizes throughput
int threads = Runtime.getRuntime().availableProcessors();

// I/O-bound work: account for waiting time
// If threads wait 50ms (database) and compute 10ms:
int threads = cores * (1 + 50/10);  // cores * 6

These are starting points. Profile under real load to find where throughput peaks without excessive context switching.

4. I/O Profiling: Optimizing External Operations

I/O is usually the real performance killer. Your code runs in microseconds, then waits milliseconds (or seconds) for the database.

Common I/O disasters:

  • N+1 queries (load user, then load each order separately)
  • Missing database indexes
  • Synchronous HTTP calls in loops
  • Reading huge files into memory

Database Performance

Finding slow queries:

// Use P6Spy or datasource-proxy for automatic SQL logging
@Bean
public DataSource dataSource() {
    return ProxyDataSourceBuilder.create(originalDataSource)
        .logQueryBySlf4j(SLF4JLogLevel.INFO)
        .multiline()
        .build();
}
// Logs: "Query took 523ms: SELECT * FROM orders WHERE..."

Connection pool health:

// HikariCP exposes key metrics
HikariPoolMXBean poolMXBean = pool.getHikariPoolMXBean();
int active = poolMXBean.getActiveConnections();
int waiting = poolMXBean.getThreadsAwaitingConnection();

if (waiting > 0) {
    log.warn("Threads waiting for connections: {}", waiting);
    // Pool too small or queries too slow
}

The N+1 query trap:

// Terrible: 1 + N queries
List<Order> orders = loadOrders();
for (Order order : orders) {
    order.setCustomer(loadCustomer(order.getCustomerId()));
}

// Better: 2 queries total
List<Order> orders = loadOrdersWithCustomers();  // JOIN

Network and File I/O

HTTP client mistakes:

// Wrong: Creating new client per request
for (String url : urls) {
    HttpClient client = HttpClient.newHttpClient();  // Expensive!
    client.send(...);
}

// Right: Reuse client with proper timeouts
private static final HttpClient CLIENT = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(5))
    .executor(Executors.newFixedThreadPool(10))
    .build();

File I/O traps:

// Memory bomb:
List<String> lines = Files.readAllLines(huge10GBFile);

// Stream instead:
Files.lines(huge10GBFile)
    .filter(line -> line.contains("ERROR"))
    .forEach(this::processError);

Batch operations:

// Instead of 1000 individual inserts:
List<String> batch = new ArrayList<>();
for (Record r : records) {
    batch.add(r.toSql());
    if (batch.size() >= 1000) {
        executeBatch(batch);
        batch.clear();
    }
}

Modern Profiling for Cloud-Native Applications

Microservices and Containers

Profiling distributed systems is hard. A slow endpoint might involve 10 services. Traditional profilers only see one service at a time.

Distributed tracing connects the dots by adding trace IDs that follow requests across services:

@GetMapping("/order/{id}")
public Order getOrder(@PathVariable String id) {
    // OpenTelemetry automatically propagates trace context
    // When this calls inventory, payment, and shipping services,
    // you can follow the entire request flow
    return orderService.findById(id);
}

Without tracing, you'd see service A is slow. With tracing, you see service A is slow because it's waiting for service B, which is stuck calling database C.

Container gotchas can make Java apps misbehave in Kubernetes:

# Kubernetes sets limits your JVM needs to respect
resources:
  limits:
    memory: "1Gi"    # Container gets killed if exceeded
    cpu: "1000m"     # 1 CPU
    
# Tell JVM to respect container memory limits
env:
- name: JAVA_OPTS
  value: "-XX:MaxRAMPercentage=75.0"  # Leave room for non-heap memory

Since JDK 10, the JVM is container-aware and automatically detects cgroup limits. MaxRAMPercentage gives you explicit control over heap sizing within those limits.

Continuous Profiling in Production

Always-On Profiling

The old way: Wait for problems, then scramble to profile. The better way: Profile continuously with minimal overhead.

Set up Java Flight Recorder to always capture the last hour of activity:

# Continuous JFR with automatic rotation
java -XX:StartFlightRecording=maxsize=100m,maxage=1h,disk=true MyApp

This creates a rolling window of profiling data. When users report "it was slow 30 minutes ago," you have the exact data from that time—not a reproduction attempt hours later.

Smart profiling triggers reduce overhead while catching problems:

Instead of profiling constantly, monitor key metrics and trigger detailed profiling when things go wrong:

// Watch response times
if (responseTime.percentile(0.99) > Duration.ofSeconds(2)) {
    startDetailedProfiling("p99-exceeded");
}

// Watch error rates
if (errorRate.rate() > 0.05) {  // 5% errors
    startDetailedProfiling("high-error-rate");
}

This adaptive approach keeps overhead near zero during normal operation but captures detailed data exactly when you need it.

Best Practices: Making Profiling Actually Work

Here's what works in real teams, not just in theory:

Profile Early, Not Just When Things Break

The best time to profile? Before anyone complains. Add basic performance tests to your CI pipeline:

# Simple but effective CI check
- name: Performance Smoke Test
  run: |
    java -XX:StartFlightRecording=duration=30s,filename=ci.jfr -jar app.jar &
    sleep 5  # Let it warm up
    ab -n 1000 -c 10 http://localhost:8080/health
    jfr print ci.jfr | grep "Hot Methods" -A 10

If a PR suddenly makes your top method 10x slower, you'll know before merge.

Set Realistic Performance Goals

Forget arbitrary numbers. Base goals on what actually matters:

// Real goals based on user impact
@Test
public void checkoutShouldBeSnappy() {
    // Users abandon carts after 3 seconds
    assertThat(checkoutTime).isLessThan(Duration.ofSeconds(2));
}

@Test 
public void searchShouldFeelInstant() {
    // Search needs to feel responsive
    assertThat(searchP95).isLessThan(Duration.ofMillis(300));
}

Make Performance Visible

Performance problems hide when nobody's looking. Make them obvious:

Weekly 5-minute check:

  1. Open your APM dashboard (SigNoz, New Relic, whatever)
  2. Sort endpoints by p99 latency
  3. Compare to last week
  4. If something doubled, investigate

Share war stories: When you find a performance bug, share it. "Hey team, found why login was slow—we were bcrypt hashing passwords twice. Fixed it, 500ms → 50ms." Others learn from your pain.

Know Your Tools Before You Need Them

Don't learn profiling during an outage. Practice on real code:

# Friday afternoon exercise:
# 1. Pick a slow endpoint from your APM
# 2. Profile it locally
# 3. Find one thing to improve
# 4. Measure the difference

Most teams find 20-50% improvements just by looking.

The Right Tool at the Right Time

Stop overthinking tool choice:

Something's slow? Start with your APM (SigNoz shows you which endpoint/query) Need details? JFR for general profiling, async-profiler for CPU Memory issues? Heap dump + Eclipse MAT Can't reproduce locally? Add temporary detailed JFR in production

Don't profile everything. Profile what's actually slow.

Troubleshooting Common Issues

Profiler Won't Connect

The most common profiling problem? Connection issues. Here's the fix:

# First, check if the debug port is actually open
netstat -an | grep 5005

# Wrong: Missing address binding
java -agentlib:jdwp=transport=dt_socket,server=y MyApp

# Right: Explicitly bind to all interfaces
java -agentlib:jdwp=transport=dt_socket,server=y,address=*:5005 MyApp

The address=*:5005 is crucial—without it, the JVM might only listen on localhost, blocking remote connections.

Profiling Overhead Too High

Wrong approach: Full instrumentation in production Right approach:

# Use sampling with longer intervals
java -XX:StartFlightRecording=settings=profile.jfc,samplethreads=true,interval=100ms MyApp

Heap Dumps Too Large

Modern apps can have 10-50GB heaps. Here's how to handle massive dumps:

# Compress while dumping (Java 11+) - reduces size by 80%
jcmd <pid> GC.heap_dump -gz /tmp/heap.hprof.gz

# Or analyze without loading entire dump into memory
java -jar mat.jar -application org.eclipse.mat.api.parse heap.hprof \
     org.eclipse.mat.api:suspects > leak-suspects.txt

The command-line analysis extracts just the leak suspects without needing 50GB of RAM to open the dump.

SigNoz: Application Performance Monitoring for Java

While SigNoz doesn't provide traditional profiling capabilities like CPU flame graphs or heap dumps, it excels at application performance monitoring, tracing, and logging that complements profiling tools. Think of it as the layer that tells you when and where to profile.

How SigNoz Complements Java Profiling

Performance Monitoring: SigNoz tracks p50/p95/p99 latencies, error rates, and throughput. When these metrics spike, you know it's time to break out the profiler.

Distributed Tracing: See exactly which service and endpoint is slow across your entire system. This narrows down where to focus your profiling efforts.

Database Query Insights: Automatically captures slow queries with full SQL and execution time. Often, you won't even need to profile,the slow query is right there.

Root Cause Analysis: Correlate metrics, traces, and logs in one place. When users report issues, quickly identify if it's a code problem (needs profiling) or infrastructure issue.

Zero-code Setup: OpenTelemetry auto-instrumentation for Spring Boot, JDBC, Redis, Kafka, and more. No code changes required.

Using SigNoz with Profiling Tools

Typical workflow:

  1. SigNoz alerts you to performance degradation (p99 latency spike)
  2. Use distributed tracing to identify the slow service and endpoint
  3. Check if it's a database query issue (often visible in SigNoz)
  4. If not, use profiling tools on that specific service to dig deeper
  5. After fixing, monitor the improvement in SigNoz

What SigNoz shows:

  • Service-level performance metrics and trends
  • Request flow across microservices with timing
  • Database query performance without profiling overhead
  • Infrastructure metrics correlated with application performance
  • Real user impact of performance issues

Best practice: Use SigNoz for continuous monitoring and alerting, then profile specific services when SigNoz identifies performance anomalies. This targeted approach is more efficient than continuous profiling everywhere.

Getting Started with SigNoz

You can choose between various deployment options in SigNoz. The easiest way to get started with SigNoz is SigNoz cloud. We offer a 30-day free trial account with access to all features.

Those who have data privacy concerns and can't send their data outside their infrastructure can sign up for either enterprise self-hosted or BYOC offering.

Those who have the expertise to manage SigNoz themselves or just want to start with a free self-hosted option can use our community edition.

Key Takeaways

  1. Start with built-in tools: JFR, jstack, jmap are free and powerful. Learn them first.

  2. Profile the right thing: CPU for slowness, memory for leaks/OOMs, threads for deadlocks, I/O for external delays.

  3. Production profiling is different: Always use sampling, keep overhead under 3%, profile continuously not reactively.

  4. Most performance problems are obvious: That O(n²) algorithm, the missing database index, the synchronization bottleneck. Profiling just helps you find them.

  5. Modern Java needs modern tools: Distributed tracing for microservices, container-aware profilers for Kubernetes, APM tools for observability.

  6. Make it routine: Profile during development, in CI/CD, and continuously in production. Performance regressions caught early are easier to fix.

Frequently Asked Questions

What's the difference between sampling and instrumentation profiling?

Sampling takes snapshots of your app every few milliseconds,like taking photos of a race. Low overhead (1-3%) but might miss short-lived methods. Instrumentation tracks every method call,like recording video of the entire race. Accurate but adds 10-50% overhead. Use sampling in production, instrumentation for debugging.

How often should I profile my Java application?

Continuously in production with tools like JFR (low overhead). During development whenever you add significant features. Set up weekly automated performance reports. Profile immediately when users report slowness.

Can profiling hurt production performance?

Yes, if done wrong. Bad: instrumentation profiling, profiling all classes, writing huge files to disk. Good: sampling profilers, JFR with 1-2% overhead, async-profiler for CPU. Always test overhead first.

Which profiling tool should I start with?

For beginners: VisualVM (free, GUI, works everywhere). For production: JFR + SigNoz or similar APM. For specific issues: async-profiler (CPU), Eclipse MAT (memory), thread dumps (deadlocks).

What metrics matter most?

Depends on your problem:

  • Slow responses? Check p95/p99 latency and CPU flame graphs
  • OOM errors? Monitor heap usage and allocation rate
  • System hanging? Look at thread states and lock contention
  • High cloud bills? Track CPU usage and memory efficiency

Hope we answered all your questions regarding Java application profiling. If you have more questions, feel free to join and ask on our slack community.

You can also subscribe to our newsletter for insights from observability nerds at SigNoz — get open source, OpenTelemetry, and devtool-building stories straight to your inbox.

Was this page helpful?