| |

Dart 81 🎯 Profiling Dart Applications with DevTools: CPU Profiler and Memory Snapshots

⏱️ Estimated read: 19 min | |

Dart DevTools is a suite of debugging and performance tools that ships with the Dart SDK. When your application feels sluggish, consumes too much memory, or exhibits behavior that cannot be explained by reading source code alone, DevTools provides the observational instruments you need to see what the Dart VM is actually doing at runtime. The two most important instruments for performance work are the CPU profiler and the memory snapshot tool.

The CPU profiler samples the Dart VM’s call stack at regular intervals, aggregating thousands of snapshots into a statistical picture of where your code spends its time. The memory tool captures heap snapshots—complete records of every object alive at a moment in time—allowing you to compare states, identify leaks, and understand allocation patterns. Together, these tools transform performance debugging from guesswork into measurement.

This chapter covers how the CPU profiler works, how to read its four views, how to capture and diff heap snapshots, and how to use both tools together to diagnose the two most common Dart performance problems: slow code paths and memory leaks. It assumes you can launch DevTools against a running Dart or Flutter application and have basic familiarity with the Dart VM’s execution model.

Key point: Profiling is measurement, not intuition. The CPU profiler tells you which methods consumed CPU time; memory snapshots tell you which objects survived when they should have been collected. Neither tool guesses—they report.


Why profiling tools exist

The blind spot of static analysis. Reading code tells you what should happen. It does not tell you which method is called ten thousand times per second, which collection grows unbounded, or which closure captures a reference that prevents garbage collection. Performance problems live in runtime behavior, not syntax. DevTools exists because the Dart VM already collects the data—CPU samples and heap states—but that data is invisible without an interface to visualize it.

The sampling problem. Instrumenting every method call would destroy performance and produce unusable results. Instead, the Dart VM samples the call stack at a fixed rate (by default, one sample every 25 microseconds in DevTools settings). Each sample is a snapshot: the current method, its caller, its caller’s caller, all the way down. Aggregating many samples produces a statistical approximation of where time is spent. The profiler does not measure exact durations; it reveals proportions. A method appearing in 40% of samples consumed roughly 40% of sampled CPU time.

The memory lifecycle problem. Dart’s garbage collector reclaims unreachable objects automatically. But objects remain reachable if anything still references them—a global variable, a listener that was never removed, a closure capturing a large scope. These objects are leaks in the practical sense: they consume memory indefinitely despite being useless. Heap snapshots make reachability visible. By capturing the heap at two points in time and comparing, you see which object types grew, which specific instances survived, and what retains them.

The production gap. Debug builds include assertions and instrumentation that change performance characteristics. Profile builds preserve near-release performance while retaining the VM service hooks DevTools needs. Profiling a debug build of a Flutter app produces misleading CPU data because debug-mode overhead dominates the samples. The tools exist, but they demand the right build mode to produce trustworthy results.

The trade-off. Profiling adds overhead. CPU sampling steals cycles from the application; heap snapshots pause execution briefly while the VM walks the object graph. You cannot profile continuously in production. The tools are diagnostic instruments for development and staging, not runtime monitoring solutions. Understanding when to reach for them—and when to rely on simpler metrics like frame timing—is part of using them effectively.


a. The CPU profiler: sampling and recording

The CPU profiler works by recording samples from the Dart VM’s sample buffer. You click Record, exercise the code path of interest, then click Stop. DevTools pulls the accumulated samples and presents them in four complementary views. Alternatively, Load all CPU samples retrieves whatever the VM has already buffered without a manual recording session.

The VM’s sample buffer is a ring buffer by default: once full, new samples overwrite the oldest. If you need to preserve startup samples, pass --profile-startup to dart run or flutter run so the VM discards samples after the buffer fills rather than overwriting. This matters when profiling initialization code that runs before you can manually click Record.

// Launch a Dart CLI application with a larger sample buffer
// and startup preservation enabled.
// The VM will collect samples from the first instruction.
// Run this from your terminal, not from within Dart code.
//
// dart run --profile-startup my_app.dart
//
// For Flutter, the equivalent is:
// flutter run --profile --profile-startup

The recording session itself should target a specific operation. Do not record a five-minute general session and hope the problem reveals itself. Isolate the slow operation—a network request handler, a list rendering routine, a parsing function—and record only while that code runs. The profiler aggregates samples across the entire recording; unrelated code dilutes the signal.

Once recording stops, DevTools displays four views: Call Tree, Bottom Up, Method Table, and Flame Chart. Each answers a different question about the same sample data.


b. Reading the four profiler views

Bottom Up answers: which methods are the most expensive leaves? Each root node in this view is a method that appeared at the top of the call stack in at least one sample—meaning the CPU was executing that method’s own code when the sample was taken. High self time at the root level identifies hot methods that do work directly rather than delegating to callees. Expand a node to see its callers: who invoked this expensive method.

Call Tree answers: which paths lead to expensive work? Each root is a method where samples began at the bottom of the stack. High total time at a root means many samples started from that entry point and flowed through its descendants. Expand to see callees. This view is essential for understanding entry points: if main() or handleRequest() shows high total time, the work beneath it is where CPU is consumed.

Method Table answers: what are the aggregate statistics per method? Every method in the profile appears with its total time and self time. Selecting a method reveals its call graph: callers above, callees below, with percentages. This is the fastest way to locate a specific method by name and see its context.

Flame Chart answers: what does the call tree look like as a timeline? It is the call tree rendered graphically, with width proportional to time on the stack. Wide bars are expensive; stacked bars show depth. The flame chart makes pathological patterns visible at a glance—a single enormous bar for one method, or a deep tower of calls that suggests recursion or unnecessary indirection.

// A deliberately slow function to demonstrate profiler views.
// The outer loop runs 1000 times; the inner loop does
// expensive string concatenation on each iteration.
// In the profiler, buildReport will show high "self time"
// because the string work happens directly in its frame,
// not delegated to a callee.
String buildReport(List<int> values) {
  var result = '';
  for (var i = 0; i < values.length; i++) {
    // String concatenation in a loop is O(n²) for large lists.
    // The profiler will attribute this cost to buildReport.
    result += 'Value ${values[i]}: ${values[i] * 2}\n';
  }
  return result;
}

void main() {
  final data = List.generate(10000, (i) => i);
  // Record CPU profile while this executes.
  // Call Tree will show main -> buildReport with high total time.
  // Bottom Up will show buildReport at the root with high self time.
  print(buildReport(data).length);
}

In the example above, buildReport appears at the root of the Bottom Up view with high self time because the string concatenation executes in its own frame. The Call Tree shows main at the root with high total time, with buildReport as its child. The Flame Chart shows a wide bar for buildReport. All three views describe the same reality from different angles.


c. Memory snapshots: capture, diff, and retain

A heap snapshot is a complete record of every object in the Dart heap at the moment of capture. DevTools’ Memory view lets you take snapshots, compare them, and inspect the retaining paths that keep objects alive.

The workflow for leak detection is always the same: capture a baseline, exercise the suspect code, force garbage collection, capture a second snapshot, diff them. Objects that appear in the second snapshot but not the first—or that grew in count—are suspects. The retaining path of a suspect object tells you why it survived: which other object holds the reference that prevented collection.

// A class that registers a listener but never removes it.
// Each created instance will survive every garbage collection
// because the global _listeners list holds a reference.
// A memory snapshot diff will show MyLeakyWidget instances
// growing without bound.
final _listeners = <void Function()>[];

class MyLeakyWidget {
  void Function()? _callback;

  void startListening() {
    _callback = () {
      // Callback captures this instance implicitly.
      // The global list holds the closure, which holds this.
    };
    _listeners.add(_callback!); // Reference added, never removed.
  }

  // Missing: void dispose() { _listeners.remove(_callback); }
}

void main() {
  // Simulate creating widgets without disposing them.
  for (var i = 0; i < 100; i++) {
    MyLeakyWidget().startListening();
  }
  // After GC, all 100 MyLeakyWidget instances remain reachable
  // through _listeners. A snapshot diff reveals 100 instances
  // of MyLeakyWidget where 0 should exist after disposal.
}

When you diff snapshots in DevTools, the Memory view shows allocation counts per class. A class that should have zero live instances after cleanup but shows a positive count is a leak. Select an instance to see its retaining path: the chain of references from a GC root to the object. In the example above, the retaining path for a MyLeakyWidget instance runs through the closure in _listeners, which is held by the global list, which is a GC root. The fix is removing the closure in a dispose method.

DevTools also provides a Diff Snapshots mode that compares two captures explicitly, showing classes that gained or lost instances. This is more precise than eyeballing two separate snapshots: the diff shows exact deltas. The allocation profile, a related tool, shows which code allocated objects recently, useful for finding churn even when objects are eventually collected.


Complete Example Session

// ============================================
// PART 1: THE LEAKY APPLICATION
// ============================================
import 'dart:async';

// A service that maintains a list of listeners but
// never cleans them up when consumers are destroyed.
class EventBus {
  final _listeners = <void Function(String)>[];

  void addListener(void Function(String) listener) {
    _listeners.add(listener); // Listeners accumulate forever.
  }

  void emit(String event) {
    for (final listener in _listeners) {
      listener(event);
    }
  }
}

// ============================================
// PART 2: A CONSUMER THAT FORGETS TO UNSUBSCRIBE
// ============================================
class Consumer {
  final String name;
  Consumer(this.name);

  void connect(EventBus bus) {
    // The closure captures this Consumer instance.
    // EventBus holds the closure; the closure holds this.
    bus.addListener((event) {
      // Using name proves the closure retains this instance.
      print('$name received: $event');
    });
  }
  // No disconnect method. No way to remove the listener.
}

// ============================================
// PART 3: SIMULATING A LIFECYCLE WITH CLEANUP
// ============================================
void runSimulation() {
  final bus = EventBus();

  // Create and connect 500 consumers.
  // Each one becomes permanently retained by the bus.
  for (var i = 0; i < 500; i++) {
    final consumer = Consumer('consumer_$i');
    consumer.connect(bus);
    // consumer goes out of scope here, but the closure
    // in EventBus keeps it reachable.
  }

  bus.emit('tick'); // All 500 listeners fire.
}

// ============================================
// PART 4: THE MAIN ENTRY POINT
// ============================================
void main() async {
  print('Starting leak simulation...');
  runSimulation();
  print('Simulation complete. 500 Consumer instances remain reachable.');

  // At this point, if you take a memory snapshot in DevTools,
  // you will find 500 Consumer instances. They should have been
  // garbage collected after runSimulation returned, but the
  // EventBus (local to runSimulation) is also retained because
  // it holds the listeners. Actually the bus itself is collected,
  // but the closures form a cycle: bus -> listener -> consumer.
  // The bus is unreachable, but the consumer is reachable only
  // through the bus. Dart's GC handles cycles, so both are
  // collectable in theory. The leak appears when the bus is
  // global or long-lived, which is the realistic case.
  // For a demonstrable snapshot diff, see Part 8.
  await Future.delayed(Duration(seconds: 1));
}

// ============================================
// PART 5: A GLOBAL BUS (THE REAL LEAK)
// ============================================
// Replace the local bus with a global one to make the leak
// persist and visible in snapshots across the app lifetime.
final globalBus = EventBus();

void leakWithGlobalBus() {
  for (var i = 0; i < 200; i++) {
    Consumer('leaked_$i').connect(globalBus);
  }
  // After this function returns, globalBus (a GC root)
  // retains 200 closures, each retaining a Consumer.
  // Snapshot diff: Consumer count grows by 200.
}

// ============================================
// PART 6: THE FIX: DISPOSE AND UNSUBSCRIBE
// ============================================
class FixedConsumer {
  final String name;
  void Function(String)? _listener;
  FixedConsumer(this.name);

  void connect(EventBus bus) {
    _listener = (event) => print('$name: $event');
    bus.addListener(_listener!);
  }

  void disconnect(EventBus bus) {
    // Remove the exact closure reference from the bus.
    bus._listeners.remove(_listener);
    _listener = null;
  }
}

// ============================================
// PART 7: VERIFYING THE FIX
// ============================================
void verifyFix() {
  final bus = EventBus();
  for (var i = 0; i < 200; i++) {
    final c = FixedConsumer('fixed_$i');
    c.connect(bus);
    c.disconnect(bus); // Immediately clean up.
  }
  // After disconnect, the bus holds zero listeners.
  // Snapshot after GC: FixedConsumer count returns to baseline.
}

// ============================================
// PART 8: PROFILING THE CPU COST OF THE LEAK
// ============================================
// Run this in a profile session. The emit call iterates
// over every accumulated listener, so as listeners grow,
// emit becomes slower. The CPU profiler shows emit with
// increasing self time relative to listener count.
void profileEmit() {
  for (var i = 0; i < 1000; i++) {
    Consumer('c$i').connect(globalBus);
  }
  // emit now loops over 1000+ listeners.
  // Record CPU profile during this call.
  // Bottom Up: emit has high self time (the loop itself).
  // Call Tree: profileEmit -> emit with high total time.
  globalBus.emit('profile_test');
}

// ============================================
// PART 9: CAPTURING SNAPSHOTS IN DEVTOOLS
// ============================================
// Manual steps (cannot be coded):
// 1. Launch app with `dart run --observe` or `flutter run --profile`.
// 2. Open DevTools from the Observatory URL.
// 3. Navigate to Memory view.
// 4. Click "Take Heap Snapshot" — baseline.
// 5. Execute leakWithGlobalBus().
// 6. Click GC button, then "Take Heap Snapshot" again.
// 7. Switch to "Diff Snapshots" mode.
// 8. Observe Consumer count delta: +200.
// 9. Select a Consumer instance, inspect Retaining Path.

// ============================================
// PART 10: SUMMARY OF THE WORKFLOW
// ============================================
// The complete diagnostic loop:
// CPU: Record -> exercise code -> Stop -> read Bottom Up for hot methods
// Memory: Snapshot baseline -> exercise suspect code -> GC -> Snapshot
//         -> Diff -> find growing classes -> inspect retaining paths
// Fix: add dispose/unsubscribe, then repeat both measurements
//       to confirm the profile improved and the leak closed.

This ten-part session demonstrates the full diagnostic cycle: creating a leak, observing it via CPU profiler (expensive emit with accumulated listeners) and memory snapshot diff (growing Consumer count), then fixing it with explicit unsubscription and verifying the fix. The code is intentionally minimal—the real work happens in DevTools, where the retaining paths and flame chart reveal what the source alone cannot.


Quick Reference

CPU Profiler Views

ViewQuestion AnsweredKey Metric
Bottom UpWhich methods are expensive leaves?Self time at root
Call TreeWhich paths lead to expensive work?Total time at root
Method TableWhat are per-method aggregates?Total and self time
Flame ChartWhat does the call tree look like?Bar width = time

Memory Workflow

StepActionPurpose
1Capture baseline snapshotRecord initial object counts
2Exercise suspect codeCreate/destroy objects
3Force GCCollect unreachable objects
4Capture second snapshotRecord post-exercise state
5Diff snapshotsFind growing classes
6Inspect retaining pathIdentify what holds references

Build Modes for Profiling

ModeCommandProfiling Validity
Debugdart run / flutter runMisleading overhead
Profileflutter run --profileValid for CPU
Releaseflutter run --releaseNo VM service

Sample Buffer Flags

FlagEffect
(default)Ring buffer, overwrites oldest
--profile-startupDiscards new samples when full

Snapshot Diff Signals

SignalMeaning
Class count grewPotential leak
Count unchangedLikely fine
Retaining path through globalLeak suspect

Best Practices

✅ Do This:

// Record only during the suspect operation.                // ✅
devToolsProfiler.record(); slowOperation(); stop();

// Use profile mode for Flutter CPU measurements.           // ✅
flutter run --profile

// Dispose controllers and cancel subscriptions.            // ✅
@override void dispose() { _controller.dispose(); super.dispose(); }

// Capture baseline before exercising suspect code.         // ✅
final baseline = await takeSnapshot();

// Inspect retaining paths, not just instance counts.       // ✅
final path = instance.retainingPath; // Find the holder.

// Force GC before comparison snapshots.                    // ✅
await forceGC(); final after = await takeSnapshot();

❌ Don’t Do This:

// Record a five-minute general session.                    // ❌
profiler.record(); /* everything */ await Future.delayed(minutes:5);

// Profile debug-mode Flutter for CPU data.                 // ❌
flutter run; // Debug overhead dominates samples.

// Assume GC will collect listeners automatically.          // ❌
bus.addListener(callback); // No corresponding remove.

// Take one snapshot and call it a leak.                    // ❌
final snapshot = takeSnapshot(); // No baseline to compare.

// Look only at total instance counts.                      // ❌
print('Consumers: 500'); // Without retaining paths, no cause.

// Skip GC before snapshot comparison.                      // ❌
final after = takeSnapshot(); // Garbage inflates counts.

Common Pitfalls

PitfallWhy It HappensFix
Profiling debug Flutter buildsDebug overhead dominates samplesUse --profile mode
Trusting single snapshot as leak proofNormal allocation looks like growthAlways diff against baseline
Ignoring self time vs total timeTotal includes callees, self does notUse Bottom Up for hot methods
Missing --profile-startup for init codeRing buffer overwrites early samplesPass flag or record early
Forgetting to force GC before diffUncollected garbage inflates countsClick GC button in DevTools
Assuming Dart GC handles all cyclesCycles are collected, but roots are notCheck retaining paths to roots

Real-World Examples

1. Identifying an O(n²) String Builder

String badBuild(List<String> items) {
  var s = '';                                    // Allocates new string each iteration.
  for (final i in items) s += i;                 // Profiler: high self time in badBuild.
  return s;
}

2. Detecting a Growing Listener List

class Bus {
  final _subs = <void Function()>[];
  void on(void Function() f) => _subs.add(f);    // Snapshot diff: _subs length grows.
  // Missing: void off(void Function() f) => _subs.remove(f);
}

3. Finding a Hot Deserialization Method

Map<String, dynamic> decode(String json) {
  return jsonDecode(json) as Map<String, dynamic>; // Flame chart: wide bar under request handler.
}

4. Leak via Timer Not Canceled

class Poller {
  Timer? _timer;
  void start() { _timer = Timer.periodic(Duration(seconds:1), (_) => _poll()); }
  // Missing: void stop() => _timer?.cancel();
}

5. Retaining Path Through a Closure

final _callbacks = <void Function()>[];
void register() { _callbacks.add(() => print('alive')); } // Path: _callbacks -> closure -> captured state.

6. Profiling an Isolate

// DevTools can attach to isolates independently.
// Use `--observe` and select the isolate in the CPU profiler dropdown.
await Isolate.spawn(heavyComputation, data);

7. Snapshot Diff After Navigation

// Push a screen, pop it, then diff snapshots.
// State/Element instances should return to baseline.
// If they do not, a GlobalKey or static reference retains them.

8. CPU Profile of JSON Parsing

// Record while parsing a large response.
final data = jsonDecode(largeJson); // Bottom Up: _parseJson with high self time.

9. Leak from Static Cache

class Cache {
  static final _store = <String, Object>{};        // GC root; never cleared.
  static void put(String k, Object v) => _store[k] = v;
}

10. Verifying Dispose Fix

// Before fix: Consumer count = 500 after GC.
// After fix: Consumer count = 0 after GC.
// Diff snapshots to confirm the delta is zero.

Visual

The Sampling Loop

┌──────────────────────────────────────────────────┐
│  DART VM SAMPLE BUFFER                           │
│                                                  │
│  Every 25μs: snapshot the call stack.            │
│                                                  │
│  Sample 1: main → handle → parse → _scan         │
│  Sample 2: main → handle → parse → _scan         │
│  Sample 3: main → handle → emit → listener       │
│  Sample 4: main → handle → parse → _scan         │
│  ...                                             │
│                                                  │
│  Aggregate: parse appears in 3/4 samples.        │
│  Conclusion: parse dominates this window.        │
│                                                  │
└──────────────────────────────────────────────────┘

Profiler View Relationship

┌──────────────────────────────────────────────────┐
│  SAME DATA, FOUR PERSPECTIVES                    │
│                                                  │
│  Call Tree (top-down)                            │
│    main ──► handleRequest ──► parse ──► _scan    │
│    Total time high at main.                      │
│                                                  │
│  Bottom Up (leaf-first)                          │
│    _scan (self: 80%) ◄── parse ◄── handleRequest │
│    Self time high at _scan.                      │
│                                                  │
│  Flame Chart: wide bar for _scan under parse.    │
│  Method Table: _scan row shows self/total.       │
│                                                  │
└──────────────────────────────────────────────────┘

Snapshot Diff Workflow

┌──────────────────────────────────────────────────┐
│  MEMORY DIAGNOSIS CYCLE                          │
│                                                  │
│  ┌─────────┐    exercise    ┌─────────┐         │
│  │ Baseline │ ────────────► │ Suspect │         │
│  │ Snapshot │    code        │  Code   │         │
│  └────┬────┘                └────┬────┘         │
│       │                          │              │
│       │       Force GC           │              │
│       │          │               │              │
│       ▼          ▼               ▼              │
│  ┌─────────────────────────────────────┐        │
│  │  Diff: Consumer +200                │        │
│  │  Retaining path: globalBus → _subs  │        │
│  │                  → closure → this   │        │
│  └─────────────────────────────────────┘        │
│                                                  │
│  Fix: remove from _subs in dispose.              │
│  Verify: diff again, delta = 0.                  │
│                                                  │
└──────────────────────────────────────────────────┘

Retaining Path Anatomy

┌──────────────────────────────────────────────────┐
│  RETAINING PATH (why an object survives GC)      │
│                                                  │
│  GC Root (globalBus)                             │
│       │                                          │
│       ▼                                          │
│  _subs (List)                                    │
│       │                                          │
│       ▼                                          │
│  Closure (captures Consumer)                     │
│       │                                          │
│       ▼                                          │
│  Consumer instance (should be dead)              │
│                                                  │
│  Each arrow is a strong reference.               │
│  Break any arrow → object becomes collectable.   │
│  The fix targets the arrow at the closure.       │
│                                                  │
└──────────────────────────────────────────────────┘

Summary

ItemValue
CPU ProfilerSamples call stack at fixed intervals
Sample Rate~1 sample per 25μs (DevTools default)
Bottom UpRoot = expensive leaf method (high self time)
Call TreeRoot = entry point (high total time)
Flame ChartGraphical call tree, width = time
Memory SnapshotComplete heap object graph at one moment
Snapshot DiffCompare two captures to find growth
Retaining PathReference chain from GC root to object
Profile Buildflutter run --profile for valid CPU data
Ring BufferDefault sample storage, overwrites oldest
--profile-startupPreserves early samples, discards new when full

Key takeaways:

  • Self time identifies hot methods. A method with high self time executes expensive code directly; total time includes work delegated to callees.
  • Bottom Up answers “what is expensive,” Call Tree answers “where did it start.” Use both: Bottom Up finds the leaf, Call Tree traces the path.
  • Snapshot diff is the leak-detection primitive. A single snapshot shows state; two snapshots with GC between them show growth.
  • Retaining paths explain survival. An object survives because something references it. The path from GC root to object reveals the holder to fix.
  • Profile builds matter for Flutter. Debug overhead distorts CPU samples; --profile mode preserves near-release behavior.
  • The sample buffer is a ring. Startup code requires --profile-startup or early recording to capture.
  • Fix leaks by removing references, not by forcing GC. GC collects unreachable objects; leaks are reachable objects that should not be.
  • Verify fixes with the same tool that found the problem. Re-profile after the fix and confirm the hot method cooled or the diff delta is zero.
  • DevTools instruments the Dart VM, not the operating system. Native memory, plugin allocations, and platform channel overhead are outside its scope.
  • Profiling is diagnostic, not continuous. Run it during suspect operations in development, not in production.

Remember: The CPU profiler and memory snapshots are complementary instruments for the same underlying question: what is the Dart VM doing that the source code does not reveal? The profiler aggregates call-stack samples into a statistical map of CPU consumption, with Bottom Up and Call Tree offering opposite entry points into the same data. Memory snapshots capture the heap object graph; diffing two snapshots with garbage collection between them exposes objects that survive when they should not, and retaining paths explain why. Neither tool fixes anything. They measure. The fix—removing a listener, disposing a controller, breaking a reference—comes from reading the retaining path or the flame chart correctly. Profile in the right build mode, record only the suspect operation, and always verify the fix by repeating the measurement.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!