Blog › ICP guides
Objective-C developer on retainer: Cocoa, UIKit, ARC, and legacy iOS systems on monthly retainer
September 27, 2026 · ~20 min read
A healthcare startup had a 400,000-line Objective-C iOS app — built for iOS 11 compliance, still receiving feature requests from an enterprise client base that had not yet migrated to modern devices — that was exhibiting memory spikes on the iPad version. The app consumed 40 MB on launch, which was expected. After navigating through twelve patient record screens, it consumed 340 MB. The device began terminating background processes. On low-memory devices, the OS terminated the app itself. The client had seen the memory growth in Instruments but attributed it to UIImage caching; they had already added [NSCache setCountLimit:50] on the image cache, which had no measurable effect. The actual cause was a retain cycle between every instance of PatientDetailViewController and its NSURLSession: the session’s delegate property was declared strong in the header, the controller held a strong reference to the session, and the session held a strong reference back to the controller via the delegate — a cycle that ARC could not break because neither direction was a zeroing weak reference. Every navigation to a patient detail screen created a new controller that was never released.
The Objective-C developer on retainer diagnosed it using Instruments > Allocations with the “Track live allocations” option, which showed 12 live PatientDetailViewController instances after navigating to and from 12 screens — zero had been deallocated. They confirmed the cycle by adding - (void)dealloc { NSLog(@"PatientDetailViewController dealloc"); } to the class, navigating away from a screen, and observing that the log line never appeared. The fix was two changes: the delegate property was changed from @property (nonatomic, strong) id<NSURLSessionDelegate> delegate to @property (nonatomic, weak) id<NSURLSessionDelegate> delegate, and all completion block captures of self were wrapped in the __weak typeof(self) weakSelf = self; __strong typeof(weakSelf) strongSelf = weakSelf; pattern to prevent implicit strong captures. After the change, dealloc logged on every navigation away from a patient screen. Memory stabilized at 42 MB regardless of navigation history.
Objective-C language fundamentals: message passing, properties, and ARC
Objective-C is a thin layer of Smalltalk-style message passing on top of C, compiled by the same LLVM toolchain as Swift. The fundamental syntax is the message expression: [receiver message] for zero-argument messages, [receiver message:argument] for single-argument messages, and [receiver method:arg1 with:arg2] for multi-argument messages where each label is part of the method name (the selector). Unlike function calls, message sends are resolved at runtime via the Objective-C runtime’s objc_msgSend function — the receiver is looked up in its class’s method dispatch table, and if not found, the runtime walks the superclass chain and eventually reaches -[NSObject doesNotRecognizeSelector:] if no implementation is found. This dynamic dispatch is what makes Objective-C introspectable (every object can respond to respondsToSelector:, isKindOfClass:, and performSelector:) and what makes method swizzling possible — replacing a method’s implementation at runtime by manipulating the dispatch table directly via method_exchangeImplementations.
Properties are declared in the @interface block with attributes that specify memory management semantics and thread safety: @property (nonatomic, strong) NSString *title declares a property that uses ARC strong ownership (the object is retained as long as this property holds a reference to it), @property (nonatomic, weak) id<UITableViewDelegate> delegate declares a zeroing weak reference (the property is automatically set to nil when the referenced object is deallocated — the key mechanism for safe delegate patterns), @property (nonatomic, copy) NSString *name copies the assigned value (preventing mutations to a mutable string from affecting the property’s value), and @property (nonatomic, assign) CGFloat alpha uses primitive assignment semantics for scalar types. The nonatomic attribute omits the lock that atomic (the default) would acquire on getter and setter access — atomic is rarely used in practice because it prevents data races only on the property access itself, not on the pointed-to mutable object, and the lock overhead is significant in high-frequency property access patterns. ARC (Automatic Reference Counting) inserts retain and release calls at compile time — the programmer does not write them manually — but ARC cannot break retain cycles because it manages reference counts deterministically, not via tracing garbage collection. Every retain cycle requires manual intervention: identifying the cycle, deciding which direction should be weak, and annotating the property or block capture accordingly.
Block syntax in Objective-C — ^(NSString *input) { return [input uppercaseString]; } — defines an anonymous closure that captures variables from its enclosing scope. The capture semantics depend on the variable’s type and declaration: variables declared with __block are captured by reference (mutations inside the block affect the outer variable), variables without __block are captured by value at block creation time (the value is frozen), and Objective-C object pointers are captured with a strong reference by default under ARC — meaning that ^{ [self doSomething]; } captures self with a strong reference, extending the object’s lifetime for as long as the block is alive. When a block is stored as a property or passed to an asynchronous API (NSURLSession completion handler, dispatch_async), the strong capture of self creates the potential for a retain cycle: if the object that owns the block (or a queue that holds the block) is also referenced strongly by self, neither can be released. The canonical solution is the weak-strong dance: __weak typeof(self) weakSelf = self; [session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { return; } [strongSelf handleResponse:data]; }];. The __weak capture prevents the cycle; the __strong re-capture inside the block prevents the object from being released mid-completion if the only remaining strong reference was released between the time the block started executing and the point where strongSelf is used.
Core Data threading, GCD concurrency, and Swift interoperability
Core Data’s threading model is strict: every NSManagedObjectContext and every NSManagedObject must be accessed only on the thread (or queue) on which the context was created or on which its performBlock block is executing. The two canonical context configurations are the main queue context ([NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]), which is safe to access from the main thread and is the context used for UIKit data binding, and the private queue context (NSPrivateQueueConcurrencyType), which runs operations on its own private serial queue via performBlock: or performBlockAndWait:. For background import operations, the standard architecture is a private queue context that shares the same NSPersistentStoreCoordinator as the main context (or is a child of the main context in a parent-child hierarchy), performs the import inside a performBlock:^{ [self importRecords:data]; [context save:&error]; } block, and notifies the main context of changes via NSManagedObjectContextDidSaveNotification and mergeChangesFromContextDidSaveNotification:. Accessing an NSManagedObject property directly from a thread other than the context’s queue — even a read — is undefined behavior that causes EXC_BAD_ACCESS crashes under concurrent access, intermittent data corruption, and faults that trigger on the wrong thread. The retainer-level architecture audit identifies every cross-thread access point by searching for NSManagedObject property accesses outside performBlock wrappers and replaces them with explicit thread marshaling.
Grand Central Dispatch (GCD) is the underlying concurrency mechanism for all of Cocoa’s asynchronous APIs, and Objective-C retainer work frequently involves auditing GCD usage patterns. The most common category of bugs is UIKit access from background queues: NSURLSession completion handlers execute on a background queue by default, and any UIKit update (setting a label’s text, reloading a table view, presenting an alert) inside the completion handler without wrapping in dispatch_async(dispatch_get_main_queue(), ^{ /* UIKit work */ }) is a threading violation that causes EXC_BAD_ACCESS crashes during concurrent access. The second category is serial queue design for shared mutable state: instead of @synchronized(self) locks (which carry overhead and can cause priority inversion), a private serial dispatch queue (dispatch_queue_create("com.example.resourceQueue", DISPATCH_QUEUE_SERIAL)) serves as an exclusive access mutex for a resource — reads and writes dispatch onto the serial queue, and because it is serial, only one operation executes at a time. Concurrent queues with dispatch_barrier_async implement reader-writer locks: reads use dispatch_async(concurrentQueue, ^{ /* read */ }) and can execute in parallel, while writes use dispatch_barrier_async(concurrentQueue, ^{ /* write */ }) which drains all in-flight reads before the write executes and blocks subsequent reads until the write completes.
Swift interoperability is a retainer engagement category that has grown in importance as organizations adopt phased Swift introduction into large Objective-C codebases. The core mechanism is the bridging header: a file named TargetName-Bridging-Header.h that lists Objective-C headers that should be importable from Swift files in the same target. From the Swift side, Objective-C classes appear as Swift classes; Objective-C methods appear as Swift methods with automatically-converted names. The conversion is improved substantially by nullability annotations: adding NS_ASSUME_NONNULL_BEGIN and NS_ASSUME_NONNULL_END around Objective-C interface declarations tells the Swift compiler that all pointer parameters and return types are non-optional (non-null) unless explicitly annotated with _Nullable. Without these annotations, all Objective-C object pointers appear in Swift as implicitly unwrapped optionals (SomeClass!), which are correct for pointer-can-be-null APIs but unsafe when used without checking. The NS_SWIFT_NAME(swiftName) attribute provides Swift-idiomatic method names: an Objective-C method - (NSArray *)fetchUsersWithPredicate:(NSPredicate *)predicate error:(NSError **)error NS_SWIFT_NAME(fetchUsers(predicate:)) appears in Swift as func fetchUsers(predicate: NSPredicate) throws -> [Any] (the NSError ** parameter is automatically bridged to a Swift throwing function when annotated). The @objc attribute in the reverse direction — exposing Swift declarations to Objective-C — is required for any Swift protocol, class, property, or method that needs to be referenced from Objective-C code.
How HourTab tracks Objective-C developer retainer hours
Objective-C retainers produce the same invisibility problem as all platform-engineering retainers, amplified by the fact that memory management in ARC is completely invisible to end users and largely invisible to non-specialist engineers. A client who hires an Objective-C developer on retainer sees their iOS app running without crashes, without memory spikes, without the data corruption that was occurring when Core Data objects were being accessed across thread boundaries — and has no way to connect that stability to the 16-hour retain cycle audit that moved the app from “consumes 340 MB after 12 navigation operations and gets terminated on low-memory iPads” to “stable at 42 MB regardless of navigation history.” The work log entry “fixed memory leak, 16h” leaves the client unable to explain the mechanism or evaluate the proportionality of the billing. The gap between what was done (changed one property attribute from strong to weak and added weak-strong dance patterns to nine block captures) and what was prevented (OS terminations of the app on 12 low-memory enterprise iPad devices during patient care workflows) requires a structured explanation that connects ARC semantics, the specific cycle, the detection method, and the measurable before-and-after outcome — context that takes five minutes to write once, per log entry.
HourTab gives Objective-C developers a public retainer-hours URL they paste into the first message of every client engagement. The client opens the URL and sees the current burn-down: hours purchased, hours used, hours remaining, and a work log of every session. For Objective-C retainers specifically, the work log entries carry more information than the burn-down chart alone can convey. Each entry should name the ARC or Cocoa mechanism involved (NSURLSession delegate strong/weak cycle, __weak block capture, Core Data performBlock threading, GCD main queue dispatch, NSFetchedResultsController section configuration, NS_ASSUME_NONNULL Swift annotation), the diagnostic tool and its output (Instruments > Allocations showing N unreleased instances; dealloc NSLog never triggered; EXC_BAD_ACCESS crash log pointing to Core Data property access on background thread), the specific class and property or method changed, and the before-and-after metric (memory per navigation: 1.2 MB per push/pop → zero; crash rate per 100 background imports: 3–5 → zero; low-memory app terminations on enterprise iPads: daily → zero). Entries at that level of specificity turn each retainer session into a documented platform improvement that the client can reference in their own internal stability reporting and that provides the evidence base for retainer renewal.
Track Objective-C developer retainer hours without the status emails
HourTab gives Objective-C developers and iOS engineers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.
See HourTab pricing →FAQ: Objective-C developer retainers
What does an Objective-C developer on retainer typically do?
An Objective-C developer on monthly retainer provides ongoing ARC memory management (retain cycle audit and elimination using Instruments Allocations, __weak delegate property qualification, __weak/__strong block capture patterns), NSURLSession architecture (configuration selection between default/ephemeral/background, delegate method implementation for authentication and certificate pinning, session invalidation design), Core Data threading (multi-context architecture with private/main queue context pairs, performBlock threading wrappers, NSFetchedResultsController configuration, mergeChangesFromContextDidSaveNotification cross-context sync), GCD concurrency design (dispatch_async main queue UI dispatch, serial queue resource mutex design, dispatch_barrier_async reader-writer patterns), and Swift interoperability (NS_ASSUME_NONNULL nullability annotation, NS_SWIFT_NAME renaming, @objc protocol declaration, bridging header management). The retainer covers the continuous Cocoa platform engineering between visible feature releases.
What Objective-C work is most underlogged in a retainer?
Retain cycle elimination (auditing strong NSURLSession delegate properties and __strong self block captures that prevent controller deallocation; converting to weak delegate and __weak/__strong block patterns; 8–18 hours invisible in eliminated memory growth and OS app terminations on low-memory devices), Core Data threading fixes (adding performBlock wrappers around all NSManagedObject cross-thread access; redesigning with private/main queue context pairs and mergeChangesFromContextDidSaveNotification; 12–24 hours invisible in eliminated EXC_BAD_ACCESS crashes during background import), and GCD main queue dispatch audits (identifying UIKit updates in NSURLSession completion handlers that execute on background queues; wrapping in dispatch_async(dispatch_get_main_queue(), ^{}); 6–14 hours invisible in eliminated intermittent UI crash reports) are the three most systematically underlogged categories in Objective-C retainers.
What are typical Objective-C developer retainer rates?
Entry-level Objective-C developers (1–3 years, @property semantics, basic UIViewController, NSURLSession block API, NSError ** handling) bill at $75–$130/hr. Mid-level Objective-C engineers (3–6 years, ARC retain cycle diagnosis with Instruments, NSURLSession delegate with certificate pinning, Core Data performBlock threading, GCD design, NSFetchedResultsController, Swift interop with _Nullable/_Nonnull) bill at $120–$215/hr. Senior Objective-C architects (6–12 years, Core Data migration, NSURLProtocol subclassing, runtime method swizzling, NSInvocation dynamic dispatch, phased Swift introduction architecture, bridging header management) bill at $175–$315/hr. Monthly retainer ranges: $3,000–$7,000/mo for advisory retainers (15–30 hrs), $10,000–$25,000/mo for full legacy iOS platform engagements.
What should an Objective-C developer retainer agreement include?
An Objective-C developer retainer agreement should specify: ARC memory scope (retain cycle audit with Instruments Allocations, __weak delegate property qualification, __weak/__strong block capture design, dealloc verification, NSTimer invalidation before dealloc), NSURLSession scope (configuration selection, delegate authentication implementation, certificate pinning via SecCertificateRef, session invalidation patterns), Core Data scope (multi-context architecture, performBlock threading wrappers, NSFetchedResultsController with sectionNameKeyPath and cacheName, NSPredicate authorship, cross-context merge notification), GCD scope (main queue UI dispatch, serial queue resource mutex, dispatch_barrier_async reader-writer, dispatch_group multi-task coordination), Swift interop scope (NS_ASSUME_NONNULL annotation, NS_SWIFT_NAME renaming, NS_ENUM/NS_OPTIONS bridging, @objc protocol declaration, bridging header management), and hour logging format (ARC or Cocoa mechanism named, Instruments tool and measurement cited, specific class and property or method, before/after observable metric).
How should Objective-C developer retainer hours be logged?
Log each Objective-C retainer session with: advisory category (ARC retain cycle, __weak delegate property, __weak/__strong block capture, NSURLSession configuration, certificate pinning, Core Data performBlock threading, NSFetchedResultsController, NSPredicate, mergeChangesFromContextDidSaveNotification, GCD main queue dispatch, dispatch_barrier_async reader-writer, Swift interop nullability annotation, NS_SWIFT_NAME renaming, @objc protocol), specific class and property/method, diagnostic output (Instruments > Allocations: 12 PatientDetailViewController instances alive after 12 push/pop navigations; dealloc NSLog never triggered; EXC_BAD_ACCESS in Thread 4 at NSManagedObject property access without performBlock wrapper), the fix applied and why (changed delegate property from strong to weak — NSURLSession retains its delegate with a strong reference, so a strong delegate property creates a cycle ARC cannot break; __weak typeof(self) weakSelf capture in all completion blocks prevents implicit strong self capture extending controller lifetime), and before/after metric (memory per 12 navigation operations: 300 MB growth → zero; OS app terminations per day on low-memory iPads: 3–5 → zero; EXC_BAD_ACCESS crash rate per 100 background imports: 3–5 → zero over 30-day observation). Include Xcode version, deployment target iOS version, and which Instruments tool was used.