# FBRetainCycleDetector: runtime retain cycle detection for Objective-C apps

> FBRetainCycleDetector walks the object graph at runtime and returns the cycles it finds as arrays of wrapped objects. It is a debug-time tool for Objective-C codebases, not a general memory profiler.

**facebook/FBRetainCycleDetector** — iOS library to help detecting retain cycles in runtime.

- Repository: https://github.com/facebook/FBRetainCycleDetector
- Stars: 4,226 · Forks: 601
- Language: Objective-C++
- License: NOASSERTION
- Published: 2026-09-23 · Updated: 2026-09-23 · Language: en
- Canonical page: https://hysenlabs.com/projects/facebook-fbretaincycledetector

## What FBRetainCycleDetector is for, and who should reach for it

A retain cycle is a group of objects that keep each other alive through strong references, so none of them is ever deallocated. The README is blunt about why this matters: retain cycles are "one of the most common ways of creating memory leaks" and they "tend to be hard to spot it." Static reading of a large Objective-C codebase is a poor way to find them, because the reference that closes the loop is often an associated object, a timer target, or a property on a class you did not think to open.

This library answers a narrower question than a memory profiler does. Given one object you suspect, it asks: is this object part of a reference cycle, and what is the chain? The audience is an iOS engineer working in Objective-C or Objective-C++ who already has a leak hypothesis and wants the exact path. The README credits Circle by Mike Ash as an influence on the feature set, so the approach has precedent outside Facebook.

The scope is deliberately runtime. There is no static analyzer here, no CI gate, and no reporting dashboard. You add candidates, you call a method, you get back a set. Everything else, including how you collect candidates, is left to you or to the companion projects the README names.

## How the graph walk works and what findRetainCycles returns

The entry point is an FBRetainCycleDetector instance. You register objects with addCandidate: and then call findRetainCycles, which returns NSSet<NSArray<FBObjectiveCGraphElement *> *>. The shape is the key detail: the outer set holds one entry per detected cycle, and each inner array holds the objects in that cycle, wrapped in FBObjectiveCGraphElement rather than handed to you raw.

By default the search stops at cycles no longer than 10 objects. The README says this can be raised with findRetainCyclesWithMaxCycleLength: and adds the trade-off in parentheses: a larger limit "is going to be slower". That is the central cost model of the library. Detection is a graph traversal, so the bound on cycle length is what keeps a run finite.

The printed example in the README shows what a result looks like once logged:

```objc
{(
    (
        "-> MyObject ",
        "-> _someObject -> __NSArrayI "
    )
)}
```

The README's reading of that output is that MyObject retained an NSArray through a someObject property, and that array was part of the cycle back to MyObject. The arrow notation is a path description, not a stack trace, so a cycle of five objects produces five entries in one inner array. The wrapping is why the README warns the result is "pretty hard to look at at first": you are reading graph elements, and the human-readable class names come from how those elements describe themselves.

## Filters, NSTimer targets and associated objects

Not every cycle is a leak, and the README says so directly. Some objects are expected to hold each other, and reporting them buries the real finding. FBObjectGraphConfiguration carries filter blocks and options, and each filter is a block that takes two FBObjectiveCGraphElement objects and decides whether their relation is valid. The README points at FBStandardGraphEdgeFilters for the standard set.

The README's own example filters the relation between UIView and its _subviewCache ivar. That is a useful illustration of the granularity: you are not excluding a class, you are excluding an edge in the graph. Get this wrong in either direction and the output changes character. Too few filters and every run is noise. Too many and the cycle you are looking for is silently absent, with no warning that a filter removed it.

NSTimer gets its own treatment because a timer retains its target, which the README calls out as a frequent source of cycles. The detector can report these, and shouldInspectTimers:NO in the configuration tells it to skip them. That flag is a blunt instrument: it suppresses the whole category rather than a specific timer.

Associated objects need setup before they can be caught at all. The README says to call [FBAssociationManager hook] early in the application's lifetime, preferably in main.m. That hook uses fishhook to interpose objc_setAssociatedObject and objc_resetAssociatedObjects so associations are tracked as they are made. The repository confirms the dependency is vendored: there is an rcd_fishhook directory and an update_fishhook.sh script at the top level. The practical consequence is that associations created before the hook runs are invisible to the detector.

## Installing FBRetainCycleDetector and running a first detection

Two package managers are documented. With CocoaPods you add one line to your podspec. The README states plainly that the library works fully only in Debug builds, and that this is controlled by a compilation flag in FBRetainCycleDetector.h which can be supplied to the build to make it work in other configurations. Treat that as the first thing to check when detection returns nothing.

```ruby
pod 'FBRetainCycleDetector'
```

With Carthage you add the GitHub coordinate to your Cartfile, and because the library is built out from non-debug builds, the README instructs you to pull it in Debug configuration when you intend to test it.

```bash
# Cartfile
github "facebook/FBRetainCycleDetector"

carthage update --configuration Debug
```

Once the framework is linked, the minimal flow is three statements: create a detector, hand it a candidate, ask for cycles. The candidate is the object you already suspect, which is why candidate collection is a separate problem from detection.

```objc
#import <FBRetainCycleDetector/FBRetainCycleDetector.h>

FBRetainCycleDetector *detector = [FBRetainCycleDetector new];
[detector addCandidate:myObject];
NSSet *retainCycles = [detector findRetainCycles];
NSLog(@"%@", retainCycles);
```

If you also want associated-object cycles, add the hook before UIApplicationMain runs. This must happen early, because the interposition only sees associations made after it is installed.

```objc
#import <FBRetainCycleDetector/FBAssociationManager.h>

int main(int argc, char * argv[]) {
  @autoreleasepool {
    [FBAssociationManager hook];
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
  }
}
```

For candidates at scale, the README recommends FBAllocationTracker, described there as a small tool for tracking objects with a simple API to query all instances of a given class or all currently tracked class names. FBMemoryProfiler is the drop-in example project that combines both and offers a basic UI for tracking allocations and forcing detection.

## Where FBRetainCycleDetector is the wrong tool

The library inspects the Objective-C runtime. Swift classes that are not exposed to Objective-C, and the reference graph the Swift runtime maintains, are not what this walker is built to read. If your leak is in pure Swift code, this is not the instrument.

The 10-object default is a real boundary, not a footnote. Cycles longer than that are missed unless you raise the limit, and raising it costs time in a way the README acknowledges without quantifying. There is no documented guidance on what limit is safe for a given app size, so that number is yours to find.

The Debug-only constraint shapes where the tool can live. Detection in a shipped build requires the compilation flag from FBRetainCycleDetector.h, and the README does not describe the runtime cost of leaving it on. There is also no rollback story documented: the README does not describe how to uninstall the association hook once it is in main.m, and interposing objc_setAssociatedObject is not something to leave in place casually.

Finally, the output format is an obstacle in itself. A set of arrays of graph element wrappers, logged with NSLog, is not a report. Turning it into something a team can triage is work the library does not do for you. And a report is only as good as the candidates you fed it: if the leaked object is never added as a candidate, no amount of filter tuning will surface it.

## How it compares with Instruments and the MLeaksFinder approach

Xcode's Instruments Leaks template and the Allocations instrument observe memory from outside the process. They do not need you to name a candidate, and they can show growth over time across the whole app. The trade-off is that they tell you memory is being retained, and leave you to reconstruct which reference closed the loop. FBRetainCycleDetector inverts that: you must supply the suspect, but the answer comes back as a chain of objects.

MLeaksFinder takes a third position. It watches for view controllers and views that are expected to be deallocated and reports when they are not, which means it needs no candidate list at all. It also only covers objects with a predictable lifetime. FBRetainCycleDetector is indifferent to lifetime expectations: any object you can name can be a candidate, including model objects and associated objects that no view-controller heuristic would catch. The cost is that you have to name it.

The README's own suggested combination is the closest thing to a recommended stack: FBAllocationTracker supplies candidates by class, FBRetainCycleDetector does the graph walk, and FBMemoryProfiler puts a UI in front of both. That is a heavier integration than either Instruments or MLeaksFinder, and it is the price of getting a named cycle rather than a growth curve.

## Maintenance, licence and what a fork inherits

The repository is not archived, and the last push was on 2026-08-19. The most recent tagged release listed is 0.1.4 from 2017-11-17, with 0.1.2 and 0.1 before it. That gap between release tags and recent commits is worth noting when you plan upgrades: there is no published changelog in the repository, so pinning a version means pinning a commit range rather than tracking documented releases.

The licence is BSD, per the README's licence section, and the repository carries a LICENSE file at the top level. The metadata for the project reports the licence as NOASSERTION, which means the automated classifier did not resolve it; read the LICENSE file itself rather than trusting either label. This is not legal advice, and if you redistribute the framework you should have someone confirm the terms.

One maintenance detail a fork inherits: fishhook is vendored in the rcd_fishhook directory, with update_fishhook.sh to refresh it. That script is how the project keeps its copy current, and it is the thing to run if you fork and the interposition stops working on a newer OS. The podspec, the Xcode project and the build.sh script are all at the top level, so the build path is not hidden behind a generator.

## Conclusion

Adopt FBRetainCycleDetector if you have an Objective-C codebase where reference cycles are suspected and you can run detection in Debug builds, ideally through FBMemoryProfiler with FBAllocationTracker supplying candidates. Do not adopt it as a substitute for Instruments or for Swift-only code, and do not expect it to run in release builds unless you supply the compilation flag the header documents. Before committing, verify three things yourself: that your build configuration actually compiles the detector in, that your filter blocks do not hide the cycle you are hunting, and that the returned array wrapping is readable enough for your team to act on.

## FAQ

### How do I use FBRetainCycleDetector in an iOS app?

Create an FBRetainCycleDetector, call addCandidate: with the object you suspect, then call findRetainCycles and log the resulting set. If you also want associated-object cycles, call [FBAssociationManager hook] early in main.m before UIApplicationMain runs.

### Does FBRetainCycleDetector work in release builds?

The README states the library works fully only in Debug builds, and that this is controlled by a compilation flag in FBRetainCycleDetector.h which can be supplied to the build to make it work in other configurations. Carthage users are told to update with --configuration Debug when testing.

### Why does FBRetainCycleDetector return no retain cycles?

The default search covers cycles no longer than 10 objects, so longer cycles need findRetainCyclesWithMaxCycleLength:. Filter blocks can also remove a relation before it is reported, and associated-object cycles are only visible if FBAssociationManager hook ran before the association was created.

## Sources

- [facebook/FBRetainCycleDetector on GitHub](https://github.com/facebook/FBRetainCycleDetector)
- [Issues](https://github.com/facebook/FBRetainCycleDetector/issues)
- [README](https://github.com/facebook/FBRetainCycleDetector/blob/main/README.md)
- [Releases](https://github.com/facebook/FBRetainCycleDetector/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/facebook-fbretaincycledetector
