SYSTEMSENGINEERING ESSAY · 7 MIN READ

When Go is the right rewrite

Notebook dates are an editorial chronology, separate from publication dates.

I would choose Go for a rewrite when its execution model and operational tools make the required system easier to own within a measured resource budget. The source language alone does not establish that case.

Name the constraint before naming the language

A rewrite is a proposal to pay migration cost in exchange for a different set of constraints. The current service might be difficult to deploy, hard to diagnose, constrained by a runtime, or expensive to change safely. Those are different problems. Go could help with some and do little for others. I would write the problem as an observable condition: a resource budget is exceeded, a deployment dependency is unacceptable, or a critical change repeatedly crosses an unnecessarily complex boundary. Preference for a language is not yet that condition.

An illustrative request path spends 80 milliseconds waiting for external services and 20 milliseconds executing local work. Even halving the local work reduces the serial total only to 90 milliseconds under those invented assumptions. A rewrite justified by CPU speed alone would need to explain why that improvement matters. Perhaps the true benefit is simpler ownership of the service or more predictable operations. That can be a strong argument, but it should be stated and evaluated directly rather than disguised as a performance promise that the dependency graph cannot support.

Establish a baseline that can survive the migration

Go's diagnostics documentation describes CPU, memory and blocking profiles alongside tracing facilities, and warns that some measurements can interfere with each other. Those tools can make a Go implementation inspectable, but they do not establish what the existing system does. Before rewriting, I would preserve representative request inputs, output invariants, resource use and failure behaviour. The baseline should include workload shape and environmental conditions so the new implementation is compared against the same problem rather than a conveniently simplified version of it.

The key distinction is between a language limitation and an implementation choice. An inefficient query, an oversized payload or a serial dependency chain can survive translation perfectly. A rewrite may appear to solve the problem because it simultaneously changes those decisions. That can still be worthwhile, but the team should know which change produced the gain. I would prototype the narrowest expensive or awkward boundary first. If a small refactor in the existing language achieves the same outcome at much lower risk, it deserves to remain in the comparison.

References: [2] Go Diagnostics

Treat garbage collection as a budgeted mechanism

The Go garbage-collector guide explains a tradeoff between collector CPU work and memory headroom, and describes the runtime's memory limit as soft rather than an absolute process cap. That is enough to reject two simplistic claims: garbage collection makes Go unsuitable for every latency-sensitive service, and setting a memory limit removes the need to understand allocation. Suitability depends on allocation rate, live data, latency requirements and the resources available to the process. A language choice cannot decide those values on behalf of the workload.

Consider an illustrative container budget of 512 megabytes. If non-Go memory, stacks, runtime structures and operational headroom together need 192 megabytes, only 320 remain for the rest of the intended budget; assigning the entire container limit to one runtime control would ignore those obligations. This is accounting, not a recommendation for a specific memory-limit value. A pilot should measure actual components under load, including bursts and slow downstream consumers. Retained live data cannot be collected merely because a limit is inconvenient, and aggressive collection can consume time without solving that retention problem.

References: [1] A Guide to the Go Garbage Collector

Concurrency becomes easier only when ownership becomes clearer

Goroutines and channels can provide a direct way to express concurrent service work, but they do not make mutable shared state safe automatically. Go's memory model requires appropriate synchronization; its useful guarantees are strongest for data-race-free programs. Sending a pointer through a channel does not prohibit the sender from mutating the pointed-to object afterward. The application still needs an ownership convention or synchronization around those accesses. A rewrite that removes visible thread code while preserving ambiguous shared ownership has relocated the difficulty rather than resolved it.

I would prefer a design where one goroutine owns a mutable state machine and other components exchange explicit messages, when that matches the workload. But that owner can become a bottleneck, and its mailbox needs a bound and an overload policy. Alternatively, shared structures protected by locks can be simpler and faster for some access patterns. Neither style wins by being more idiomatic in the abstract. Evaluate contention, cancellation, shutdown and the number of in-flight operations, then choose the arrangement whose invariants are easiest to explain and enforce.

References: [3] The Go Memory Model

The boundary can be a better rewrite unit than the service

An existing service may contain a small computation with strict latency or memory requirements and a much larger orchestration layer. Rewriting both together can force a false all-or-nothing decision. An illustrative design might retain the specialized component behind a process boundary while implementing request handling and coordination in Go. Another might replace a slow external adapter first. The boundary adds serialization, versioning and deployment concerns, so it is not free; its value is allowing each component to satisfy different constraints without requiring one language to dominate the entire architecture.

Foreign-function integration is another option, but it changes failure and ownership boundaries in ways that deserve explicit review. Who allocates and frees memory? Can calls block for long periods? How are errors represented? What happens during shutdown? I would compare in-process integration with a separate process using the actual call frequency and payload size. A microsecond-scale operation called millions of times has a different boundary budget from a coarse batch operation. Treat interoperation cost as part of the candidate design, not as an inconvenient number to discover after choosing it.

Preserve the contract while changing the implementation

A migration can reproduce ordinary successful responses while changing crucial edge behaviour: ordering, duplicate handling, timeout semantics, numeric precision or which errors are retryable. I would define those invariants before running old and new implementations side by side. Shadow comparisons are useful for read-only operations, but blindly replaying writes can duplicate effects. An illustrative comparison harness should isolate external side effects and normalize only differences that the public contract genuinely permits. Normalizing away inconvenient disagreements can make two implementations appear equivalent when users would observe different behaviour.

Rollout also needs a reversible boundary. Keep a way to route a bounded share of suitable traffic to the new implementation, observe resource use and correctness, and return to the old implementation without corrupting shared state. Schema changes and background jobs can make rollback harder than switching a request router. I would identify those irreversible transitions explicitly and avoid claiming that a feature flag alone provides a complete recovery plan. The rewrite is successful only if the new system can be operated through failure as well as demonstrated on a clean input.

Illustrative rewrite acceptance gates; thresholds must come from the service's actual requirements. pseudocode
candidate must preserve:
    output and ordering invariants
    duplicate and retry semantics
    deadline and cancellation behaviour
candidate must satisfy:
    measured latency and memory budgets
    bounded concurrency under overload
    observable shutdown and recovery
rollout must retain:
    a tested routing rollback
    a compatible data-state recovery plan

Choose the tradeoff the team can sustain

The strongest case for Go is often a service whose dominant complexity is coordination, networking and operations, where the runtime's costs fit the budget and the team can maintain the result confidently. That is a conditional argument, not a universal productivity ranking. A different language may better express a critical invariant, integrate with the surrounding system or meet a strict resource envelope. Existing expertise also matters because a rewrite creates a new maintenance obligation at the same time it removes an old one.

My decision document would end with a small set of falsifiable reasons: which constraint the rewrite removes, what the pilot demonstrated, what it costs under stress and what would make us stop. It should include the option of keeping the current implementation. Go is the right rewrite when it improves the system we actually need to operate, with evidence strong enough to justify migration risk. It is the wrong rewrite when the most persuasive argument is relief at starting over, while the old system's difficult requirements remain undocumented and ready to reappear.

Sources and further reading

  1. A Guide to the Go Garbage Collector

    Explains the CPU/memory tradeoff and soft memory limit. The service budgets and rewrite criteria below are original illustrative analysis.

  2. Go Diagnostics

    Documents profiling and execution diagnostics, including measurement interference. The proposed baseline and rollout procedure are my own recommendations.

  3. The Go Memory Model

    Defines synchronization and data-race-free behaviour. The ownership guidance and service-level invariants here are my interpretation.

FROM THE NOTEBOOK.

Back to all notes