Blog › ICP guides
Nim developer on retainer: ARC/ORC memory management, macro design, async pipelines, and systems programming on monthly retainer
September 16, 2026 · ~19 min read
A backend service written in Nim with ORC memory management had been in production for eight months without memory issues. After a feature release that added an async request handler with a callback closure, the heap started growing at 4 megabytes per minute under sustained load. The service would be restarted by the supervisor every few hours when the RSS hit the configured limit. Valgrind massif confirmed the leak; the flame graph showed Session objects accumulating without collection. ORC was supposed to handle cycle collection — but the cycle detector was silent.
The Nim developer on retainer diagnosed it by reading the generated C code. Nim compiles to C before invoking a C compiler, and the generated C is the ground truth for what the runtime actually does. The Session type held a reference to an async callback closure; the closure captured a ref Session to call methods on it when the callback fired. This formed a reference cycle: Session → closure → Session. ORC should detect and collect this cycle. It did not, because Nim's ORC assumes types are {.acyclic.} unless explicitly annotated with {.cyclic.} — and Session had no annotation. The generated C code confirmed it: the session type was declared without the cycle tracking metadata that ORC uses to mark it as a candidate for cycle collection. Adding {.cyclic.} to the Session pragma was one line. The subsequent audit of 23 ref object types in the codebase found three more with the same gap. Total work: 18 hours across three sessions of generated C reading, pragma auditing, and valgrind massif verification.
No feature shipped. Four pragmas added. The invisible artifact was a heap growth rate that went from 4MB per minute to zero, and the elimination of a class of cycle detection gaps across the entire codebase's object graph. A Nim developer on monthly retainer does this category of work continuously: auditing ARC/ORC pragma annotations before they produce production memory leaks, designing macro DSLs with proper hygiene before they produce wrong-value bugs in caller scope, and architecting async cancellation paths before they leave open file descriptors under high-timeout load.
ARC/ORC memory management, ownership semantics, and custom hooks
Nim's memory management evolution from a stop-the-world garbage collector to ARC (atomic reference counting) and ORC (ARC plus cycle detection) is the most consequential change in the language's recent history, and the source of the most invisible retainer work in Nim codebases. ARC is deterministic: objects are freed when their reference count drops to zero, at the scope exit where the last reference goes out of scope. No pauses, no GC thread, predictable destructor timing. ORC adds a tricolor mark-and-sweep cycle collector that runs periodically to collect reference cycles that ARC's count-based approach cannot free. Selecting between --gc:arc and --gc:orc at build time determines whether cycles are detected — and applications that form reference cycles and use --gc:arc will leak those cycles forever without a build-time error or runtime warning.
The {.acyclic.} and {.cyclic.} pragmas are the ORC performance and correctness interface. {.acyclic.} tells ORC that a type can never form a reference cycle — skip the per-object cycle tracking overhead for instances of this type. This is an optimization annotation that eliminates ORC's bookkeeping cost for the majority of types in a well-designed codebase where most objects form trees rather than graphs. {.cyclic.} tells ORC that a type may form cycles — track instances and include them in cycle collection sweeps. Unannotated types are treated as {.acyclic.} by default in current Nim versions, which means any ref object type that forms a cycle without a {.cyclic.} annotation will not be collected by ORC's cycle detector. A retainer engagement covering ORC annotation audit maps the object graph for every ref type in the codebase, identifies which types participate in potential cycles (typically: types that hold callbacks or closures that capture a reference to the type itself, or types in bidirectional reference structures like parent-child relationships), applies {.cyclic.} to those types, applies {.acyclic.} to types that are verifiably acyclic (leaf types with only value fields and no ref fields), and re-runs valgrind massif or the production memory profiler to verify that the annotated cycle types are now collected.
Custom {.destructor.}, {.copy.}, and {.move.} hooks are the mechanism for wrapping C resources in Nim types with ARC/ORC lifetime management. A Nim type that wraps a C sqlite3* database handle needs a custom destructor that calls sqlite3_close() when the Nim object goes out of scope, a copy hook that either increments a reference count or raises a compile-time error to prevent accidental copying, and a move hook that transfers the C pointer to the new location and nulls the source to prevent double-close. Writing these hooks correctly requires understanding the calling conventions Nim ARC uses: the destructor receives a var T parameter and must handle the case where the field it is releasing is already nil (the object may have been moved from); the move hook must leave the source in a valid state (typically zeroing the C pointer) because the source's destructor will still run when the source goes out of scope after the move. Getting the destructor-move interaction wrong produces double-close bugs that are silent in normal operation and crash under sanitizer builds or when the underlying C library detects the double-close.
Nim macros, template hygiene, and compile-time code generation
Nim's macro system provides full AST access at compile time: a macro receives its arguments as NimNode trees, manipulates them using the NimNode API, and returns a NimNode tree that the compiler substitutes at the call site. The power is broad: macros can generate types, define DSLs with custom syntax, analyze existing code with getImpl, and produce compile-time errors with structured diagnostics. The risk is equally broad: a macro that introduces identifiers in the expanded code can shadow caller-scope variables, producing wrong-value bugs that are silent and position-dependent.
Nim macro hygiene operates through two mechanisms: by default, identifiers introduced in a macro are hygienic — the compiler generates unique names to prevent caller-scope shadowing — but this hygiene can be defeated by using newIdentNode("result") explicitly to introduce an identifier with a specific name that matches a caller-scope variable. A macro that introduces a variable named result in its expansion will shadow Nim's implicit return variable in any proc where the macro is called at a position that uses the implicit return. The bug only manifests when the macro is used in a proc that returns its value through the implicit result variable rather than an explicit return statement — which is common Nim style. Finding this in a codebase requires knowing what the macro generates, which requires reading the expansion with expandMacros or dumpTree, recognizing that newIdentNode("result") is a hygiene violation, and replacing it with gensym(nskVar, "result") to generate a unique identifier for the internal variable. A retainer engagement covering macro hygiene audit reads every newIdentNode call in every macro in the codebase, identifies which ones could shadow caller-scope names (result, it, common loop variables), and replaces them with gensym-generated identifiers or renames them to names unlikely to appear in caller scope.
quote do is the primary mechanism for writing complex macros with readable syntax. Instead of constructing a NimNode tree with explicit API calls (newCall(newIdentNode("+"), arg1, arg2)), quote do lets you write Nim syntax directly and interpolate NimNode values with the backtick notation: result = quote do: `lhs` + `rhs`. The resulting AST is identical to what explicit construction would produce, but readable as Nim code. The discipline: quote do generates hygienic identifiers for names in the quoted block that are not interpolated with backticks — if you write quote do: let x = `value`; x * 2, the x is hygienic and will not conflict with caller-scope x. The retainer work in quote do macros is ensuring that the interpolated values (`lhs`, `rhs`) are NimNode values rather than values of the wrong type — a common error is interpolating a string where a NimNode is expected, which produces a confusing type error at macro compilation time rather than at call site.
Nim async/await, chronos, and C FFI design
Nim's async/await model compiles {.async.}-annotated procedures to coroutine-style state machines using either the asyncdispatch stdlib event loop or the chronos library developed by the Ethereum foundation for production async workloads. The two runtimes are not compatible — async procs written for asyncdispatch cannot be awaited in chronos contexts and vice versa — so selecting the runtime is an early architectural decision that the retainer enforces rather than reverses. asyncdispatch is appropriate for simple services with a single-threaded event loop and no cancellation requirements. chronos is appropriate for services where operations must be cancellable (because a cancelled async proc must be able to clean up resources — close sockets, release file handles — before the coroutine frame is released), where deadline propagation is needed (chronos provides withTimeout that propagates the deadline into nested await calls), and where the {.raises: [].} effect system enforcement is required.
Async resource cleanup under cancellation is the most common source of resource leaks in Nim async codebases. When a Future[T] is cancelled in chronos, a CancelledError is injected at the current await suspension point. If the async proc does not handle CancelledError, the error propagates up, and any resources the proc had acquired (an open socket via connect, an open file handle via open, a database connection via acquire) are not released — their cleanup code is in the path after the await that was cancelled, and that path never executes. The correct pattern: wrap the resource-acquisition and resource-usage path in a try...except CancelledError block with cleanup in the except branch, or use defer statements after each resource acquisition so the cleanup runs regardless of whether the proc completes normally or is cancelled. A retainer engagement covering async cancellation audit reviews every {.async.} proc that acquires a resource, verifies that the resource is released on the cancellation path, and writes the missing cleanup for the procs that don't handle it.
Nim's C FFI uses pragma annotations to bind C functions and types to Nim names. {.importc: "sqlite3_open", header: "sqlite3.h".} declares a Nim proc that maps directly to the C function — no wrapper layer, no overhead. {.bycopy.} on a struct type tells Nim to pass it by value using C's struct-by-value ABI rather than Nim's default by-reference behavior. {.packed.} on a struct type removes padding fields to match a C-packed struct layout. A retainer engagement covering C FFI design wraps the raw importc declarations in a Nim module that presents an idiomatic Nim API — error codes become Nim error types with raise, output-pointer return patterns become Nim return values, and C string cstring parameters are converted to Nim string at the boundary so callers never need to manage cstring lifetime directly. The wrapper module is the safety perimeter: inside, C semantics; outside, Nim semantics. A retainer developer maintains that perimeter as the underlying C library's API evolves.
How HourTab tracks Nim developer retainer hours
Nim developer retainers produce some of the most invisible work-to-visible-output ratios of any language retainer. A session that identified a missing {.cyclic.} pragma on four ref object types and added it produced four pragma annotations across four files. The session involved reading generated C code to understand why ORC's cycle detector was silent, tracing the reference cycle from the session type through the closure to the captured ref, mapping 23 ref object types against the ORC annotation requirements, and verifying with valgrind massif that the annotated cycle types were now collected. The log entry “fixed ORC cycle detection, 18h” accurately captures the duration and leaves the client with no way to connect 18 hours to the elimination of a 4MB-per-minute memory leak, because nothing in four pragma annotations communicates the generated-C reading and object graph analysis that went into them.
HourTab gives Nim 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. For Nim retainers specifically, the work log format carries more information than the burn-down: each entry should name the ref type involved and its cycle structure (Session → async callback closure → ref Session), the diagnostic used (valgrind massif: 4MB/min heap growth; generated C: session type declared without ORC cycle tracking metadata), the fix applied and the rationale ({.cyclic.} added to Session — the ref-to-closure-to-ref cycle cannot be broken without a redesign; three other types redesigned to capture an integer ID rather than a ref, breaking the cycle at the cost of a registry lookup), the scope of the audit (23 types reviewed; 4 corrected), and the before/after metric (heap growth: 0MB/min after fix; service restart frequency: 0 restarts in 72-hour soak test vs. every 2–3 hours before). That entry takes five minutes to write and makes the client check-in a two-sentence confirmation rather than a twenty-minute explanation of what ORC's tricolor mark algorithm is and why reading generated C was necessary to diagnose it.
The retainer model fits Nim platform engineering because the language's tooling and memory model continue to evolve — Nim 2.0 changed the default memory manager to ORC, tightened {.raises.} effect tracking, and adjusted cstring coercion rules in ways that require ongoing advisory as codebases upgrade. Macro APIs change between minor versions; chronos's cancellation semantics have evolved across releases. A project contract closes when the current memory audit milestone is completed. A Nim retainer stays open for the next ORC annotation review triggered by a new async feature, the next macro hygiene bug surfaced by a new engineer using the DSL in an unexpected position, and the next C FFI wrapper update required by an upstream library version bump.
Track Nim developer retainer hours without the status emails
HourTab gives systems 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: Nim developer retainers
What does a Nim developer on retainer typically do?
A Nim developer on monthly retainer provides ongoing ARC/ORC memory management advisory ({.acyclic.}/{.cyclic.} pragma audits, custom {.destructor.}/{.copy.}/{.move.} hook design for C resource wrappers, sink/lent ownership parameter design), hygienic macro authorship with quote do and NimNode AST manipulation, gensym collision-free identifier design, getImpl compile-time analysis, asyncdispatch vs chronos selection and async pipeline design with CancelledError cancellation handling, {.gcsafe.} effect verification, C FFI {.importc.}/{.header.} binding with idiomatic Nim wrapper design, and nimble task configuration with cross-compilation flags. The retainer covers the systems engineering between visible feature releases: ORC cycle annotations, macro hygiene audits, async cancellation path hardening, and C wrapper updates that produce no new feature but eliminate a class of memory leaks or wrong-value bugs.
What Nim work is most underlogged in a retainer?
ORC cycle detection audit (identifying missing {.cyclic.} pragmas by reading generated C; 18 hours invisible in 4MB/min heap leak elimination), macro hygiene audit (finding newIdentNode("result") shadowing caller-scope implicit return variables; 6–14 hours invisible in wrong-value bug elimination), and async cancellation design (adding CancelledError handling in chronos async procs for resource cleanup under high-timeout load; 12–24 hours invisible in socket and file descriptor leak elimination) are the three most systematically underlogged categories. Each produces a small diff — four pragmas, a gensym change, or a try/except block — representing large behavioral corrections that only surface in memory profilers, wrong-value test failures, or resource exhaustion under load.
What are typical Nim developer retainer rates?
Entry-level Nim developers (1–3 years, basic ref objects, seq/string, asyncdispatch, nimble) bill at $75–$130/hr. Mid-level Nim engineers (3–7 years, ARC/ORC {.acyclic.}/{.cyclic.} pragma design, quote do macros, NimNode manipulation, chronos async with CancelledError handling, {.importc.} C FFI, nimble task configuration) bill at $120–$210/hr. Senior Nim architects (7+ years, ORC tricolor mark internals, custom {.destructor.}/{.copy.}/{.move.} hooks for C wrappers, advanced macro DSL with getImpl, {.gcsafe.} effect system design, chronos cancellation semantics, Nim 1.x → 2.0 migration advisory, compiler internals knowledge) bill at $175–$315/hr. Firm rates run $145–$255/hr. Monthly retainer ranges: $3,000–$6,500/mo for advisory (15–30 hrs), $9,000–$20,000/mo for full-engagement.
What should a Nim developer retainer agreement include?
A Nim developer retainer agreement should specify Nim version scope (1.6.x vs 2.0+ — ORC defaults, exception semantics, and cstring coercion changed; material to migration advisory scope), memory management advisory scope (ARC vs ORC selection, {.acyclic.}/{.cyclic.} pragma audit, custom destructor/copy/move hooks for C wrappers, sink/lent ownership design), macro advisory scope (hygienic quote do authorship, gensym design, getImpl analysis, template vs macro trade-offs), async advisory scope (asyncdispatch vs chronos selection, CancelledError handling, {.gcsafe.} verification, withTimeout design), C FFI scope ({.importc.}/{.header.} binding, {.bycopy.}/{.packed.} structs, Nim wrapper API design), and hour logging specifics (ref type and cycle structure, ORC diagnostic from generated C, NimNode kind of incorrectly generated AST, before/after metric in heap growth or async cancellation latency).
How should Nim developer retainer hours be logged?
Log each Nim retainer session with: advisory category (ORC cycle detection audit, {.acyclic.}/{.cyclic.} pragma annotation, ARC deterministic RC design, custom {.destructor.}/{.copy.}/{.move.} hook authorship, sink/lent ownership design, hygienic macro authorship with quote do, NimNode AST inspection, gensym identifier design, getImpl compile-time analysis, asyncdispatch Future[T] pipeline, chronos async with CancelledError handling, {.gcsafe.} verification, withTimeout deadline design, {.importc.}/{.header.} C FFI binding, {.bycopy.}/{.packed.} struct wrapper, nimble task configuration, cross-compilation flags), specific module and proc, diagnostic output (valgrind massif: 4MB/min heap growth; generated C: Session type without ORC cycle metadata; expandMacros: macro introduces newIdentNode("result") shadowing implicit return), fix and rationale ({.cyclic.} on Session — ref-closure-ref cycle; gensym replaces newIdentNode("result") — prevents implicit return shadowing), scope of audit (23 ref types reviewed; 4 corrected; 11 confirmed {.acyclic.}; 8 unannotated benign), and before/after metric (heap growth: 0MB/min; service restart: 0 in 72-hour soak). Include Nim version, --gc flag (arc/orc/refc), and whether the audit required reading generated C to diagnose.