<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Cássius Paim on Medium]]></title>
        <description><![CDATA[Stories by Cássius Paim on Medium]]></description>
        <link>https://medium.com/@cassius.paim?source=rss-3527a01bf6d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*9T2Qgzm_PhXl93b5.</url>
            <title>Stories by Cássius Paim on Medium</title>
            <link>https://medium.com/@cassius.paim?source=rss-3527a01bf6d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 04 Nov 2026 15:00:46 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@cassius.paim/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Shared Resources, Hidden State: Why Pools and Caches Fail in Surprising Ways]]></title>
            <link>https://medium.com/@cassius.paim/shared-resources-hidden-state-why-pools-and-caches-fail-in-surprising-ways-375954ac3425?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/375954ac3425</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[connection-pooling]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[caching]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-11-03T13:01:03.728Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I analyze connection pools, distributed caches, and the state contracts behind safe reuse</h4><figure><img alt="A shared resource may remain intact while carrying state that is incompatible with its next consumer. Safe reuse depends on validating or transforming that state before handoff, not merely on the resource’s availability." src="https://cdn-images-1.medium.com/max/1024/1*fiVnIUsINFXuiZQLsHFtVg.png" /><figcaption>A shared resource may remain intact while carrying state that is incompatible with its next consumer. Safe reuse depends on validating or transforming that state before handoff, not merely on the resource’s availability.</figcaption></figure><p>A connection can remain open without being ready for another request. A cached response can remain available without being valid for another consumer.</p><p>In my previous article, <a href="https://medium.com/@cassius.paim/connection-pooling-is-state-management-what-go-1-27-changed-in-net-http-92502a12d258">Connection Pooling Is State Management</a>, I examined HTTP/1 connection reuse in Go 1.27. Here, that example becomes a starting point for a broader architectural question: what makes an existing resource safe to hand to someone else?</p><p>An optimization that avoids reconstruction also extends the lifetime of state. The next consumer inherits whatever the lifecycle manager preserves, intentionally or otherwise.</p><p><strong>Reuse is a semantic contract.</strong> Saving construction work is its benefit. Preserving compatible state is its condition.</p><p>Two engineering cases illustrate this condition. Although they involve different mechanisms and outcomes, both show that an available resource is not always ready for reuse. Counting open connections or responses found in the cache does not reveal whether those resources carry state that is compatible with the next consumer.</p><h3>When an HTTP connection needs restoration</h3><p>The Go team’s <a href="https://go.dev/blog/http-connection-reuse">Better HTTP/1 connection reuse</a>, by Nicholas Husin and Damien Neil, explains a sequential byte-stream constraint. Before the next response can be interpreted, the current response body needs a complete framing boundary.</p><p>Through Go 1.26, closing an unread HTTP/1 response body could force the transport to abandon a healthy connection. Starting in Go 1.27, the transport attempts asynchronous draining, bounded by 256 KiB or 50 ms. Successful cleanup can make the connection eligible for reuse, but does not guarantee that the next request uses it. These are implementation limits described in the October 2026 post, not general cleanup targets.</p><p>The <a href="https://go.dev/doc/go1.27#net/http">Go 1.27 release notes</a> document conservative automatic draining. The <a href="https://pkg.go.dev/net/http#Response">net/http API documentation</a> retains the caller’s responsibility to close response bodies.</p><p>The architectural interpretation is a recovery budget. Keeping a resource is useful only while restoring its usable state remains worth the work. That interpretation leads to a different question for cached data: what if the stored state is complete, but its representation belongs to another consumer?</p><h3>When a cache hit returns the wrong representation</h3><p>Cloudflare’s <a href="https://blog.cloudflare.com/hyperdrive-mysql/">Inside Hyperdrive: How we solved a rare Hyperdrive MySQL caching bug</a>, published on October 14, 2026, reports unexpected empty results affecting approximately 0.001% of queries for a high-volume customer. That frequency is Cloudflare’s measurement, not an independent benchmark.</p><p>Hyperdrive’s Client and Endpoint nodes could share a cache when colocated. They did not always write interchangeable representations. The Endpoint worked with Hyperdrive’s negotiated MySQL capabilities, while the Client could cache bytes translated for a Worker’s driver.</p><p>One relevant capability, CLIENT_DEPRECATE_EOF, changes result-set termination. A reader could retrieve bytes produced under different capability assumptions. Depending on the path, translation was bypassed or applied to the wrong input representation, producing empty results or hangs.</p><p>Cloudflare fixed this by making both nodes store query results in the same format: MySQL responses encoded using Hyperdrive’s own capabilities. This shared internal format is the canonical representation. Before sending a cached result to a Worker, Hyperdrive translates it to match that Worker’s negotiated capabilities. The cache therefore holds one consistent representation, while each consumer receives the format it expects.</p><p>This case does not establish a general defect in MySQL caching. It exposes a specific mismatch between shared storage and representation assumptions. The broader design question is how to make those assumptions explicit.</p><h3>Compatibility is a relationship</h3><p>The two cases differ in an essential way. The HTTP transport needs to finish handling the previous message. The cache needs to preserve an agreed interpretation of a completed value. Draining a connection would not fix the cache representation, and changing a cache key would not consume an HTTP body.</p><p>My synthesis is that reusability describes a relationship among a resource, its residual state, and the next consumer. It is not simply a property attached to an object forever.</p><p>A connection might be compatible with another operation in the same session context while being unsuitable for a different context. A serialized value might work for one decoder configuration and fail for another. A live socket or matching lookup key establishes only part of that relationship.</p><p>For design reviews, I use four properties as an analytical model. This is a proposed framework, not a specification from either engineering team.</p><pre>+--------------------------+------------------------------------------------+<br>| Property                 | Question at the handoff                        |<br>+--------------------------+------------------------------------------------+<br>| Identity                 | Which resource or logical result is this, and  |<br>|                          | in which scope?                                |<br>+--------------------------+------------------------------------------------+<br>| State compatibility      | Does its current state satisfy this consumer&#39;s |<br>|                          | expectations?                                  |<br>+--------------------------+------------------------------------------------+<br>| Bounded restoration      | Can an incompatible state be repaired within   |<br>|                          | an acceptable budget?                          |<br>+--------------------------+------------------------------------------------+<br>| Failure isolation        | Can failed restoration prevent exposure to     |<br>|                          | the next consumer?                             |<br>+--------------------------+------------------------------------------------+</pre><p>The last property makes the others actionable. A reset that runs after another borrower receives the resource is too late. In this model, release by a consumer and admission to the reusable set are separate events.</p><p>This distinction matters even without concurrency. Consumer A can finish before Consumer B starts and still leave B with the wrong state. Mutual exclusion prevents simultaneous access. It does not, by itself, establish a clean handoff through time.</p><p>Once compatibility is expressed as a relationship, several design strategies become available. Choosing among them requires knowing which part of the relationship can change.</p><h3>Reset, canonicalize, partition, or discard</h3><p>I use four strategies to organize that choice. They can coexist within one lifecycle rather than compete as mutually exclusive designs.</p><pre>+--------------+----------------------+-------------------+----------------------+<br>| Strategy     | What changes         | Benefit           | Cost or failure mode |<br>+--------------+----------------------+-------------------+----------------------+<br>| Reset        | The resource returns | Preserves.        | Incomplete cleanup   |<br>|              | to a defined         | expensive         | leaves hidden        |<br>|              | baseline             | construction work | dependencies         |<br>+--------------+----------------------+-------------------+----------------------+<br>| Canonicalize | Values enter an      | Reduces           | Conversion costs     |<br>|              | agreed internal      | representation    | and adapter          |<br>|              | representation       | variation inside  | complexity           |<br>|              |                      | the shared layer  |                      | <br>+--------------+----------------------+-------------------+----------------------+<br>| Partition    | Sharing is           | Makes some        | More resources and   |<br>|              | restricted to        | boundaries easier | less sharing         |<br>|              | compatible consumers | to enforce.       |                      |<br>+--------------+----------------------+-------------------+----------------------+<br>| Discard      | The resource or      | Ends dependence   | Reconstruction cost  |<br>|              | entry leaves the     | on uncertain      | and possible churn   |<br>|              | reusable set         | residual state    |                      |<br>+--------------+----------------------+-------------------+----------------------+</pre><p>A reset contract needs a target. Clearing a few fields is meaningful only if those fields cover the state that the next consumer depends on. For asynchronous cleanup, the model also needs an intermediate state in which the resource exists but cannot yet be borrowed.</p><p>Canonicalization moves the problem toward adapters. The shared layer accepts one representation, and boundary code handles differences. That reduces internal variation, but a canonical format still needs a defined scope and version. A conversion that drops distinctions relevant to a consumer changes semantics rather than preserving them.</p><p>Partitioning limits who shares. Separate tenant pools are a conceptual example, although each pool may still require session cleanup. Partitioning one dimension does not erase every other state dependency.</p><p>Discarding provides a fallback when compatibility cannot be established at an acceptable cost. For a cache, this can mean treating an entry as a miss and recomputing it. For an exclusively borrowed connection, it can mean closing it. For an actively shared resource, disposal also needs coordination with existing users.</p><p>These are analytical choices, grounded in the restoration and representation boundaries above. The most consequential cache decision deserves closer attention because it determines whether variation lives in the value or in its identity.</p><h3>Canonical values or richer cache keys?</h3><p>Suppose two consumers ask for the same logical result but expect different encodings. One design stores a canonical value and adapts it on retrieval. Another stores separate encoded variants.</p><p>The second design makes representation part of lookup identity. In a hypothetical cache, that identity might include the logical request, authorization scope, representation version, and a fingerprint of capabilities that actually affect the value. This is a conceptual schema, not a production key recipe.</p><pre>+-------------+-------------------------------+-------------------------------+<br>| Decision    | Canonical value               | Representation-aware variants |<br>+-------------+-------------------------------+-------------------------------+<br>| Storage     | One agreed internal form per  | Separate entries for relevant |<br>|             | logical result and scope      | representation differences    |<br>+-------------+-------------------------------+-------------------------------+<br>| Read path   | Conversion for each consumer  | Direct use when a compatible  |<br>|             | where needed                  | variant exists                |<br>+-------------+-------------------------------+-------------------------------+<br>| Reuse       | Consumers can share the       | Consumers share within        |<br>| opportunity | internal form                 | compatible variants           |<br>+-------------------+-------------------------------+-------------------------------+<br>| Evolution   | Maintain canonical schema and | Maintain key dimensions and   |<br>| burden      | adapters                      | variant validity              |<br>+-------------+-------------------------------+-------------------------------+<br>| Main risk   | Conversion fails to preserve  | An omitted dimension merges   |<br>|             | meaning                       | incompatible values           |<br>+-------------+-------------------------------+-------------------------------+</pre><p>My starting preference is to identify the semantic dimensions before choosing either design. A raw capability bitset may partition more than necessary if many bits do not change the cached representation. A smaller compatibility fingerprint may preserve more sharing, but its definition becomes another contract to maintain.</p><p>For a workload with expensive conversion and a small, stable set of representations, separate variants can be attractive. For a workload with many consumer combinations and inexpensive adaptation, canonical storage can reduce duplication. Neither conclusion follows from cache-hit ratio alone.</p><p>Authorization is a separate condition. Including tenant scope in identity can separate entries, but a cache key does not replace access checks. Canonicalizing a payload also does not make that payload appropriate for every tenant.</p><p>Evolution creates an additional handoff across deployments. If old and new writers assign different meanings to the same stored shape, a rolling deployment can mix interpretations even when each version works alone. In this model, schema versioning, namespaces, or invalidation make that transition explicit.</p><p>The same reasoning applies beyond cached bytes. A database connection gives the lifecycle question a more concrete operational form.</p><h3>A reset command is only part of a session contract</h3><p>PostgreSQL’s <a href="https://www.postgresql.org/docs/current/sql-discard.html">DISCARD documentation</a> illustrates how much can belong to a database session. DISCARD ALL releases session resources and resets state, including settings, prepared statements, temporary objects, and session authorization. It cannot run inside a transaction block.</p><p>That command is evidence that session state is broader than an open socket. It is not a universal prescription to execute it on every pool return. A pool’s intended baseline might include initialization settings or prepared resources that a broad reset removes.</p><p>My review approach is to describe the next borrower’s starting conditions first. Transaction handling, cleanup, and baseline initialization then become separate responsibilities with an explicit order. The PostgreSQL restriction above is a concrete reason that a reset operation cannot be treated as a context-free action.</p><p>For an application-defined session pool, the same review can examine tenant identity, locale, authorization context, or negotiated features. For a reusable buffer, it can examine logical length, parser offsets, and whether a subsequent consumer can access prior contents. These are hypothetical examples of state to inventory, not claims about a particular library’s reset guarantees.</p><p>In systems with sensitive data, incomplete handoffs can also become security boundaries. That possibility follows from the proposed model. It does not reclassify either engineering case as a security vulnerability.</p><p>Changing the unit of sharing can reduce some dependencies. It does not remove the need to identify the state that remains shared.</p><h3>Multiplexing changes the boundary</h3><p>HTTP/2 separates work into streams while retaining connection-level state. <a href="https://www.rfc-editor.org/rfc/rfc9113.html#section-5.2">RFC 9113, Sections 5.2 and 6.9</a>, specifies both stream-level and connection-level flow control. Section 4.3 also describes a connection-level field compression context.</p><p>The architectural implication is that isolation has scope. A stream can have its own lifecycle while still depending on a shared connection budget or decoding context. For this model, the review unit is therefore each state boundary, rather than only the physical connection or only the individual request.</p><p>That distinction also shapes diagnosis. When an application reports success, operators may still need to understand which boundary admitted the resource for reuse.</p><h3>Observe the handoff, not only the hit</h3><p>A cache-hit counter establishes that a value was found. A connection-reuse counter establishes that a connection served more work. Neither signal alone establishes semantic compatibility.</p><p>For the proposed model, useful telemetry depends on the strategy. A reset path could expose cleanup duration, failure counts, and discard reasons. A representation cache could expose schema versions and translation failures. A pool could correlate recreation rate with resources rejected at checkout or return.</p><p>These are instrumentation suggestions from this analysis, not a universal metrics standard. Bounded reason codes and sampled lifecycle events can be more practical than labels containing every resource or consumer identifier. Payload logging is unnecessary for many of these questions and can introduce a separate data-exposure problem.</p><p>A useful diagnostic event would explain that a resource was rejected because its representation version differed, or because cleanup exceeded its budget. That gives operators evidence about the decision instead of leaving them to infer it from downstream failures.</p><p>For a Staff or Principal Engineer reviewing a shared platform, those signals support a larger ownership question: which component is responsible for making the handoff valid?</p><h3>Reuse creates a lifecycle owner</h3><p>When a consumer constructs a resource, uses it once, and destroys it, destruction ends that resource’s ordinary ownership path. Reuse introduces an intermediary whose decisions outlive individual consumers.</p><p>That intermediary may be a pool, cache, proxy, runtime, or session manager. In the model developed here, its responsibility includes defining admission to the reusable set, preserving compatibility metadata, and isolating failed transitions.</p><p>I would bring three concrete artifacts to a design review: the state that survives release, the conditions required at the next acquisition, and the behavior when those conditions cannot be established. Tests can then target handoffs between incompatible contexts and failed cleanup, rather than only repeated happy-path operations.</p><p>This review framework follows from the case analysis rather than an external checklist. Its purpose is to make ownership visible at the place where an optimization extends state lifetime.</p><h3>Conclusion</h3><p>Reuse moves responsibility from construction to lifecycle management. Once a resource survives a single consumer, correctness depends on what the lifecycle owner preserves, transforms, restores, or rejects before the next handoff.</p><p>Pools and caches remain valuable precisely because construction and computation are expensive. Their efficiency becomes dependable when the architecture defines the state contract that makes sharing valid.</p><p>The design question is what must remain true before the next consumer receives the resource, and which component establishes that truth.</p><h3>💬 Join the Discussion</h3><p>Which hidden state has caused the most surprising handoff failure in your systems? How did you decide between resetting a resource, canonicalizing its representation, partitioning consumers, and discarding it? What evidence made the failure visible?</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em>;</p><h3>Tags</h3><p>#Software Architecture #Distributed Systems #Connection Pooling #Caching #Golang</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=375954ac3425" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Connection Pooling Is State Management: What Go 1.27 Changed in net/http]]></title>
            <link>https://medium.com/@cassius.paim/connection-pooling-is-state-management-what-go-1-27-changed-in-net-http-92502a12d258?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/92502a12d258</guid>
            <category><![CDATA[performance-optimization]]></category>
            <category><![CDATA[https]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[connection-pooling]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sat, 17 Oct 2026 22:48:25 GMT</pubDate>
            <atom:updated>2026-10-17T22:48:25.392Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How I examine HTTP/1 connection reuse across Go 1.26 and Go 1.27, and what it reveals about protocol state, bounded cleanup, and production performance</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zBn6fk-T8HbEpkLMJFGTOw.png" /></figure><p>A Go HTTP client can close every response body correctly, share a single http.Client across requests, and still abandon an otherwise healthy TCP connection. Until Go 1.27, even leaving a single trailing newline unread could be enough to prevent HTTP/1 connection reuse.</p><p>That behavior makes an important distinction visible: <strong>closing a resource is not the same thing as restoring its reusable state</strong>. The Go 1.27 improvement is interesting not merely because it reduces connection establishment overhead, but because it turns a protocol-state invariant into bounded, asynchronous work managed by the standard library.</p><p>The central principle is simple: <strong>a reusable connection is only reusable when the previous operation leaves the protocol in a state that the next operation can safely understand</strong>.</p><h3>An innocent HTTP client with a hidden cost</h3><p>Consider an application that needs only the status code of an HTTP response:</p><pre>func getStatus(url string) (int, error) {<br>    resp, err := http.Get(url)<br>    if err != nil {<br>        return 0, err<br>    }<br>    defer resp.Body.Close()<br>    return resp.StatusCode, nil<br>}</pre><p>The code follows the obligation to close the response body, but it never reads that body. In Go 1.26 and earlier, when using HTTP/1, that distinction could force the transport to discard the connection rather than return it to the idle pool. The <a href="https://go.dev/blog/http-connection-reuse">Go team’s October 2026 explanation</a> uses essentially this example and describes why it was so expensive.</p><p>The result is extra connection establishment, not necessarily an application-visible error. A service can therefore appear healthy while consuming more CPU, network handshakes, and latency than necessary. To understand why, we need to examine what the connection is actually carrying.</p><h3>The connection pool is also a protocol-state machine</h3><p>With HTTP/1, successive responses use the same ordered byte stream. net/http parses a response&#39;s status line and headers before returning Response to the caller. Its body is then read lazily through Response.Body. The boundaries of that body are established by the protocol&#39;s message framing, including Content-Length or chunked transfer encoding.</p><p>If a caller stops reading before the end of the response, bytes from response A still belong to A. They cannot be interpreted as the status line of response B. Returning that connection to the pool without restoring framing alignment would violate the protocol contract. This is the correctness constraint behind the older behavior, documented in the <a href="https://go.dev/blog/http-connection-reuse">official Go blog</a>.</p><p>The transport therefore needs more than an open TCP socket. It needs evidence that the previous HTTP response left the connection at a valid message boundary. The new behavior becomes clearer when we follow the application call that releases its response body.</p><h3>What happens after Response.Body.Close()?</h3><p>Starting in Go 1.27, net/http.Transport attempts to <strong>asynchronously drain</strong> an unread HTTP/1 response body after the application closes it. The current implementation limits that attempt to <strong>256 KiB of remaining content or 50 ms</strong>. If draining reaches the end of the response within those bounds, the connection may become eligible for reuse. If it does not, abandoning the connection remains an acceptable outcome. The limits and asynchronous behavior come from the <a href="https://go.dev/blog/http-connection-reuse">Go team&#39;s implementation write-up</a>; the <a href="https://go.dev/doc/go1.27">Go 1.27 release notes</a> describe the feature at the API-behavior level.</p><p>Closing a response body and making its underlying connection reusable are different operations. In Go 1.26 and earlier, a response that was not read to completion typically prevented the HTTP/1.1 connection from returning to the idle pool. In Go 1.27, the transport may instead attempt to restore a reusable state with bounded asynchronous draining. The following figure compares both outcomes after the caller closes the body.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TI6qZIGMCO_USIX9PitYjQ.png" /><figcaption><em>Figure 1. HTTP/1.1 connection handling after </em><em>Response.Body.Close() in Go 1.26 and Go 1.27. The new transport attempts bounded asynchronous draining, allowing reuse when the remaining response reaches EOF and the connection remains eligible.</em></figcaption></figure><p>Closing Response.Body completes the caller&#39;s lifecycle obligation, but the transport still decides whether the shared connection is safe for the next consumer. The diagram simplifies the transport&#39;s scheduling decisions. In particular, asynchronous cleanup does <strong>not</strong> guarantee that the very next request will wait for the recovered connection. A new request may use another connection while draining continues. Go also skips pointless draining when Connection: close already rules out reuse, as the <a href="https://go.dev/blog/http-connection-reuse">Go blog</a> explicitly notes.</p><p>The 256 KiB and 50 ms budgets are <strong>Go 1.27 implementation choices</strong>, not universal best practices or permanent API guarantees. Draining consumes bandwidth and time, so the transport does not attempt to preserve every connection at any cost. The practical API guidance remains to close the response body and let the transport manage the bounded cleanup.</p><h3>Connection reuse requires a clean protocol handoff</h3><p>The cleanup strategy illustrates a broader design requirement. A pooled HTTP/1.1 connection moves between successive consumers. Before Request B can use a connection previously serving Request A, the transport must know that Response A has ended and that the byte stream is positioned at a boundary the next operation can interpret correctly.</p><p>Reaching the logical end of Response A establishes that boundary. If the caller leaves response data unread, the transport must restore the required protocol state or discard the connection. Figure 2 illustrates that decision as a conceptual handoff, rather than a complete model of net/http.Transport internals.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_8CRtQQ70akKmkbHuhm_HA.png" /><figcaption><em>Figure 2. A reusable HTTP/1.1 connection requires a clean protocol handoff. Request B may reuse the connection only when Response A leaves a compatible protocol state, either directly or after successful cleanup. Otherwise, the connection is discarded.</em></figcaption></figure><p>A connection can remain open and healthy at the TCP layer without being safe to reuse at the HTTP layer. That distinction is why <strong>connection pooling is a state-management problem before it is a performance optimization</strong>. Eligibility comes from protocol correctness; reuse also depends on the connection remaining available in the transport’s pool.</p><p>The broader architectural principle is that a pooled resource is reusable only when its state is compatible with the next consumer. HTTP/1.1 makes this visible through response boundaries. HTTP/2 uses multiplexed frames to establish a different boundary, but it still has shared-resource constraints. With that handoff invariant established, we can examine the cost of recovering a connection and why Go 1.27 does not drain indefinitely.</p><h3>Why connection establishment matters</h3><p>A new HTTPS connection generally needs a TCP handshake followed by TLS negotiation before application traffic can fully use it. The exact round-trip cost depends on network conditions, TLS version, session resumption, and connection behavior, so a universal RTT count is misleading. The <a href="https://go.dev/blog/http-connection-reuse">Go team’s breakdown</a> contrasts TLS 1.2 and TLS 1.3 and explains why establishment remains measurable even with modern TLS.</p><p>Keeping an existing connection avoids repeating at least some of that setup. It can reduce latency, CPU utilization, and unnecessary churn, particularly across high-RTT networks. Yet preserving an existing connection is not free. If the response body contains hundreds of megabytes that the caller does not want, downloading all of it can cost much more than establishing another connection.</p><p>That tension is what makes Go 1.27’s approach more interesting than a blanket ‘always drain’ rule.</p><h3>The single-byte problem with JSON decoding</h3><p>A particularly surprising example comes from encoding/json.Decoder. A decoder can finish as soon as it has parsed one complete JSON value. Suppose the HTTP response contains:</p><pre>{&quot;status&quot;:&quot;ok&quot;}</pre><p>After the closing brace, one newline byte may remain unread. In HTTP/1 on older Go releases, even that residue could block reuse. Reading a valid JSON document does not necessarily mean reading the entire HTTP message body. The distinction is directly illustrated in <a href="https://go.dev/blog/http-connection-reuse">Better HTTP/1 connection reuse</a>.</p><p>This is why the change helps more than obviously careless callers. It also benefits programs that successfully decode the application payload but leave protocol framing slightly unfinished. The behavior turns application-level completion and transport-level completion into two separate concepts.</p><h3>Why manually draining everything was an imperfect workaround</h3><p>Before Go 1.27, developers often used the following pattern when they wanted to preserve HTTP/1 connections:</p><pre>io.Copy(io.Discard, resp.Body)<br>resp.Body.Close()</pre><p>It can work for small finite bodies, but a slow server or unexpectedly large response can make the drain expensive or unbounded. A byte cap addresses volume; a time cap addresses slow delivery. Coordination then becomes necessary to prevent blocking the caller or leaking goroutines. The <a href="https://go.dev/blog/http-connection-reuse">Go team’s walkthrough</a> shows how a seemingly simple cleanup routine grows increasingly complex once these constraints are considered.</p><p>That is the real engineering improvement: the standard library now handles a common cleanup decision close to the protocol implementation, where it has the relevant state. For most Go 1.27 applications, the <a href="https://go.dev/blog/http-connection-reuse">Go team’s guidance</a> is to <strong>close the body without adding unconditional application-side draining</strong>.</p><p>The general <a href="https://pkg.go.dev/net/http">net/http documentation</a> still recommends reusing Client and Transport instances. Both are safe for concurrent use, and creating a fresh client/transport for each request undermines connection pooling independently of response-body handling. Do not use DisableKeepAlives as a reflexive fix for pool behavior; it intentionally disables persistent connection reuse and should be chosen only for a measured requirement. The <a href="https://go.dev/doc/go1.27">Go 1.27 release notes</a> also caution that increased reuse can expose rare configurations with excessive idle-connection retention.</p><h3>HTTP/1 and HTTP/2 have different reuse boundaries</h3><p>Connection reuse means different things depending on how a protocol organizes work over a shared connection. The distinction matters here because the Go 1.27 improvement addresses a specific constraint of HTTP/1, not a universal limitation of connection pooling.</p><p>In HTTP/1.1, a persistent TCP connection carries a sequence of HTTP responses. The client must determine where one response ends before it can correctly interpret the next. If an application closes Response.Body while bytes remain unread, the TCP connection may still be healthy, but the HTTP transport has not necessarily reached a safe handoff point. Reusing that connection without accounting for the remaining body could cause the next operation to encounter data from the previous response.</p><p>HTTP/2 changes this boundary. It multiplexes multiple logical streams over one TCP connection, with frames carrying stream identifiers. Responses can progress concurrently, and frames from different streams can be interleaved. An unread body on one stream therefore does not create the same sequential-response delimitation problem for the other streams. The <a href="https://go.dev/blog/http-connection-reuse">Go team’s explanation of HTTP/1 connection reuse</a> explicitly distinguishes these behaviors.</p><p>Figure 3 contrasts the sequential response boundaries of HTTP/1.1 with the independently identified streams of HTTP/2.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_w4QnzJ0rKzItT3UXJcJ2A.png" /><figcaption><em>Figure 3. HTTP/1.1 requires a clean response boundary before sequential reuse, while HTTP/2 multiplexes independently framed streams on one connection. An unread body on one HTTP/2 stream does not introduce the same sequential framing problem for other streams.</em></figcaption></figure><p>The difference is one of compatibility boundaries, not a declaration that multiplexing makes resource cleanup unnecessary. HTTP/1.1 must preserve message framing between sequential consumers; HTTP/2 can isolate individual streams while still sharing connection-wide resources.</p><p>Multiplexing does not make shared resources free or eliminate lifecycle management. HTTP/2 still has per-stream and connection-level flow control, limits on concurrent streams, cancellation, buffering, and memory pressure. A consumer that stops reading can leave a stream consuming resources or flow-control capacity until it is handled appropriately. The difference is that the transport can manage or cancel the affected stream without necessarily giving up the underlying TCP connection. The <a href="https://www.rfc-editor.org/rfc/rfc9113">HTTP/2 specification, RFC 9113</a>, defines stream states, flow control, and cancellation behavior.</p><p>This protocol distinction matters when designing the benchmark. An HTTPS client may negotiate HTTP/2 automatically, which would shift the experiment toward a different reuse model and mask the HTTP/1 behavior under investigation. To compare Go 1.26 and Go 1.27 meaningfully, the laboratory should explicitly use HTTP/1, keep client and server conditions equivalent, and record actual reuse with net/http/httptrace. The <a href="https://go.dev/blog/http-connection-reuse">Go team&#39;s published experiment</a> uses GODEBUG=http2client=0 to disable HTTP/2 on the client side; a controlled local HTTP/1 server provides another way to keep the protocol fixed.</p><h3>A reproducible benchmark, not a universal speed claim</h3><p>The benchmark uses a local HTTP/1.1 httptest.Server and a reusable http.Client with a fresh Transport for each scenario. It deliberately avoids network and TLS variability to isolate HTTP/1 connection handling. The client uses <a href="https://pkg.go.dev/net/http/httptrace">httptrace.ClientTrace</a> and GotConnInfo.Reused to distinguish new acquisitions from reused connections. Server-side connection acceptance provides a second signal. The exact same eight scenarios were run with <strong>Go 1.26.8</strong> and <strong>Go 1.27.2</strong> on my local environment.</p><p>Each scenario makes <strong>100 sequential requests</strong>, uses a configured <strong>10 ms pause</strong> after each request, and recorded <strong>zero errors</strong>. The experiment includes bodies left unread, partially read bodies, a body near the cleanup byte budget, a large body, JSON decoding, slow delivery, Connection: close, and a control that reads to EOF.</p><h3>Connection reuse results</h3><p>The clearest evidence concerns connection state rather than absolute speed. The table reports request acquisitions with a previously used connection, and how many TCP connections the server accepted for each 100-request scenario.</p><pre>Scenario              | Reuse 1.26 | Reuse 1.27 | TCP 1.26 | TCP 1.27<br>----------------------+------------+------------+----------+---------<br>Ignored 1 KiB         | 0/100      | 99/100     | 100      | 1       <br>Partially read 64 KiB | 0/100      | 99/100     | 100      | 1       <br>Near 256 KiB          | 0/100      | 99/100     | 100      | 1       <br>Ignored 1 MiB         | 0/100      | 0/100      | 100      | 100     <br>JSON plus newline     | 99/100     | 99/100     | 1        | 1       <br>Slow body             | 0/100      | 0/100      | 100      | 100     <br>Connection: close     | 0/100      | 0/100      | 100      | 100     <br>Read to EOF           | 99/100     | 99/100     | 1        | 1       </pre><p>The Go 1.27.2 result is particularly clear for the <strong>1 KiB ignored body</strong>, <strong>64 KiB partially read body</strong>, and <strong>near-256 KiB partially read body</strong>. All three move from <strong>zero reused acquisitions to 99 out of 100</strong>, while server-accepted TCP connections fall from <strong>100 to one</strong>. This is strong local evidence of bounded draining restoring HTTP/1 connection eligibility. The near-256 KiB case does not precisely prove behavior at the 256 KiB cutoff: its 32-byte read leaves slightly less than 256 KiB, and internal buffering can affect the transport’s remaining work.</p><p>Conversely, both versions create 100 connections in the 1 MiB case, the slow-body case, and the Connection: close case. Those results are consistent with a byte budget, time budget, and explicit connection-close semantics respectively, although the CSV alone does not expose the transport&#39;s internal reason for discarding each connection.</p><p>An important counterexample is <strong>JSON plus newline</strong>: even Go 1.26.8 reused 99 out of 100 connections. The decoder’s measured application-facing read count is <strong>1,600 bytes across 100 requests</strong>, or 16 bytes per response, consistent with its reader obtaining the entire small payload. This controlled case did <strong>not</strong> reproduce a leftover-newline reuse failure. It should not be used as evidence that the Go 1.27 improvement fixed the JSON case in this particular run. The full-EOF control also reused 99 out of 100 connections in both versions, as expected.</p><h3>Request latency results</h3><p>These are the measured <strong>per-request total durations</strong>, in milliseconds, including the benchmark’s request processing phases and Body.Close(). Lower is better. Percentages are calculated from the reported p50 values, not from pooled raw samples.</p><pre>Scenario              | 1.26 p50/p95 ms | 1.27 p50/p95 ms | p50 delta<br>----------------------+-----------------+-----------------+----------<br>Ignored 1 KiB         | 0.586 / 0.993   | 0.382 / 0.915   | -34.8%   <br>Partially read 64 KiB | 0.738 / 1.106   | 0.400 / 0.711   | -45.8%   <br>Near 256 KiB          | 0.826 / 1.891   | 0.367 / 1.193   | -55.6%   <br>Ignored 1 MiB         | 0.736 / 1.547   | 0.886 / 1.853   | +20.4%   <br>JSON plus newline     | 0.412 / 0.849   | 0.353 / 0.728   | -14.3%   <br>Slow body             | 0.825 / 1.160   | 0.753 / 1.231   | -8.7%    <br>Connection: close     | 0.765 / 1.354   | 0.681 / 1.112   | -11.0%   <br>Read to EOF           | 0.615 / 0.847   | 0.626 / 0.936   | +1.8%    </pre><p>On the three scenarios where connection reuse changed, p50 request duration decreased by approximately <strong>34.8%</strong>, <strong>45.8%</strong>, and <strong>55.6%</strong>, respectively. These are local observations, not an across-the-board Go 1.27 speed claim. In the 1 MiB case, median duration actually increased by approximately <strong>20.4%</strong> in this run. Other scenarios show mixed changes despite identical reuse behavior, which cautions against attributing every latency difference to connection pooling.</p><p>The reported throughput also changes much less than the reuse ratio: for the ignored 1 KiB case it increases from <strong>84.31 to 88.63 requests/s</strong>. Because each scenario includes a 10 ms pause per request, wall-clock throughput is strongly influenced by artificial pacing and cannot be interpreted as the transport’s maximum sustainable throughput. Likewise, reused_close_to_gotconn_p50_ms near 11 ms chiefly reflects that configured pause plus scheduling and request acquisition; it <strong>does not directly measure the internal asynchronous drain duration</strong>.</p><h3>Reproducibility and limits</h3><p>A third file, results.csv, contains another run of <strong>Go 1.26.8</strong> with the same eight scenarios and 100 requests per scenario. Its reuse decisions match the primary Go 1.26.8 run exactly, but its latency results differ significantly. For example, ignored-1-KiB p50 is <strong>1.669 ms</strong> in the additional run versus <strong>0.586 ms</strong> in the paired comparison file. That variation reinforces the need for repeated, alternated runs before making strong statistical claims about latency.</p><p>The CSVs do not specify CPU, operating system, load, or detailed server timing, so no machine-specific conclusions are made here. This experiment validates the <strong>mechanism</strong>, especially connection eligibility and reuse; it does not quantify production HTTPS savings, TLS handshake costs, cross-region RTT, or effects under concurrency. The published <a href="https://go.dev/blog/http-connection-reuse">Go team benchmark</a> is a separate experiment, not a direct replication of these local numbers.</p><p>The complete project is prepared for publication in <a href="https://github.com/cassiuspaim/cassius-labs/tree/main/go-http-reuse-lab"><strong>go-http-reuse-lab repository</strong></a>. It contains the local server, controlled scenarios, instrumentation, CSV output, and comparison scripts. Replace the repository placeholder after publishing the code.</p><h3>What the metrics can and cannot establish</h3><p>A reuse ratio gives direct evidence about connection acquisition, but it is not a complete performance model. The correct cost comparison includes drained bytes, wasted network work, dial latency, TLS negotiations for real HTTPS deployments, CPU, idle pool occupancy, and tail latency. Local HTTP removes network variance but also hides the cross-region RTT and TLS effects that make establishment expensive in production.</p><p>The experiment should therefore separate <strong>mechanism</strong> from <strong>impact</strong>. The local tests establish whether the transport behaves differently under controlled response bodies. A second, explicitly labeled production-like benchmark can estimate whether that difference materially helps the application’s workload. These are distinct empirical questions and should not be collapsed into a single headline number.</p><p>The transport also makes an economic trade-off: a larger drain allowance may recover more connections while burning bandwidth and holding resources longer. A smaller allowance wastes fewer bytes but may discard connections that would have been cheap to salvage. Bounded asynchronous draining is a deliberate compromise, not evidence that 256 KiB or 50 ms is universally optimal.</p><h3>Shared resources need explicit compatibility rules</h3><p>An HTTP/1 connection is only one example of a broader systems principle. A database session returned to a pool may carry transaction state or session-level settings. A reusable buffer may contain stale contents. A cached protocol response may depend on capabilities that were negotiated for a different consumer. The handoff is safe only when the next consumer receives state it can interpret correctly.</p><p>A useful, independent parallel appeared in Cloudflare’s October 2026 account of a <a href="https://blog.cloudflare.com/hyperdrive-mysql/">Hyperdrive MySQL caching bug</a>. In that case, reuse of cached protocol representations had to respect negotiated capabilities. The specific failure mode is different from Go’s HTTP/1 body-draining problem, and neither implementation caused the other. They illuminate the same architectural requirement: <strong>shared resources need explicit compatibility rules</strong>.</p><p>The lesson extends to pool design and cache design alike. Before optimizing reuse, define the invariant that permits the next consumer to proceed safely. Then decide whether it is cheaper to restore the invariant, validate it, or discard the resource.</p><h3>Conclusion</h3><p>Go 1.27’s HTTP/1 change does not make closing a body synonymous with guaranteed connection reuse. It makes a common recovery step asynchronous, bounded, and internal to the transport. That is a more useful abstraction because it keeps the caller’s API simple while preserving the protocol correctness boundary.</p><p>The practical guidance is straightforward. Close every response body because that remains the API contract. Reuse clients and transports because pooling remains central to efficiency. Let Go 1.27 attempt bounded cleanup instead of adding universal manual drains. Finally, measure the actual workload before extrapolating from a published speedup.</p><p>The deeper engineering principle is more durable than this particular release: <strong>performance improvements built around reuse are correct only when the system can restore or verify the state needed by the next consumer</strong>.</p><h3>💬 Join the Discussion</h3><p>Have you encountered an HTTP client that looked correct but quietly created a fresh connection per request? Which metrics helped you distinguish connection churn from application latency? And where else have you had to make a shared resource safe before returning it to a pool?</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Golang · #HTTP · #Performance Engineering · #Connection Pooling · #Software Architecture</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=92502a12d258" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI Coding Agents Need SRE Practices, Not Just Better Prompts]]></title>
            <link>https://medium.com/@cassius.paim/ai-coding-agents-need-sre-practices-not-just-better-prompts-188a4cfdc258?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/188a4cfdc258</guid>
            <category><![CDATA[platform-engineering]]></category>
            <category><![CDATA[sre]]></category>
            <category><![CDATA[ai-engineering]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sun, 05 Jul 2026 16:35:44 GMT</pubDate>
            <atom:updated>2026-07-05T16:35:44.382Z</atom:updated>
            <content:encoded><![CDATA[<h4>Why governable agentic workflows need quotas, permissions, traces, and audit evidence</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0YEp-d7--PkXbLGL7POVGw.png" /></figure><blockquote><em>“The hardest single part of building a software system is deciding precisely what to build.”</em></blockquote><blockquote><em>Frederick P. Brooks Jr., “No Silver Bullet: Essence and Accidents of Software Engineering,” </em>Computer<em>, IEEE Computer Society, Vol. 20, №4, 1987, pp. 10–19. IEEE record: </em><a href="https://ieeexplore.ieee.org/document/1663532"><em>https://ieeexplore.ieee.org/document/1663532</em></a></blockquote><p>A <a href="https://medium.com/@cassius.paim/ai-agents-are-no-longer-coding-assistants-they-are-workflow-runtimes-ba72ddb832e5">previous article</a> in this line of work argued that AI agents are becoming workflow runtimes for delegated engineering work. This article goes one layer deeper: if coding agents are becoming workflow runtimes, then engineering teams need to operate them with SRE practices.</p><p>AI coding agents are becoming part of the software delivery system, and that shift changes the engineering problem. A coding assistant that only suggests snippets can be managed mostly through prompt quality, developer judgment, and code review. An agent that explores a repository, spawns subagents, executes commands, opens files, interacts with tools, consumes usage quotas, and produces pull requests behaves more like an operational workload.</p><p>Operational workloads need more than better instructions. They need budgets, permissions, logs, traces, failure modes, review gates, and audit evidence. That is the core argument of this article: as coding agents become more autonomous, engineering organizations should apply SRE and platform engineering practices to agentic workflows. Prompt engineering remains useful, but it is not a control plane.</p><p>That distinction matters because the control surface has moved. The next section makes that shift explicit: coding agents are no longer only assistants inside the developer experience; they are starting to behave like workloads inside the engineering platform.</p><h3>From assistant to workload</h3><p>The first generation of coding assistants fit naturally into the developer tooling category. They completed code, explained functions, generated tests, and answered questions inside an IDE or chat interface. The human remained the obvious execution boundary, but modern coding agents make that boundary less clear because they can decompose work, inspect codebases, run commands, call tools, and coordinate parallel execution.</p><p>OpenAI’s official Codex subagents documentation describes workflows that spawn specialized agents in parallel and collect their results into one response: <a href="https://developers.openai.com/codex/subagents">https://developers.openai.com/codex/subagents</a>. That capability is useful for complex work such as codebase exploration or multi-step implementation plans, but it also changes the operational footprint of the task. The work is no longer a single conversational exchange; it becomes concurrent execution.</p><p>Concurrency, tool access, and background execution are exactly where traditional software systems start needing operational controls. A single agent exploring a repository is already performing work. Multiple subagents exploring, editing, reviewing, or validating in parallel are closer to a distributed workflow: they may consume tokens, credits, CPU time, CI capacity, API quotas, network access, and developer attention while also amplifying mistakes because several execution paths can make assumptions at the same time.</p><p>The practical question is no longer only whether the prompt produced a good answer. The better engineering question is whether the agentic workflow was allowed to perform this work, with these tools, under these limits, and with enough evidence for a reviewer to trust the result. That is an SRE and platform engineering question.</p><p>Once the agent is treated as a workload, prompts become only one layer of the system. The next step is to separate intent from enforcement.</p><h3>Prompt quality is not an execution policy</h3><p>Prompts can express intent, constraints, and preferences, but they are not sufficient as enforcement mechanisms. A prompt can say “do not run unsafe commands,” but it cannot reliably replace sandboxing, allowlists, identity boundaries, or network egress controls. A prompt can ask an agent to be careful with cost, but it cannot replace budgets, quotas, usage attribution, or alerting. A prompt can request a summary of what changed, but it cannot replace durable logs, traces, diffs, test evidence, or reviewer-visible audit trails.</p><p>This distinction is familiar in production engineering. A service owner may document that a service should not exceed a latency budget, but the actual system still needs metrics, SLOs, dashboards, alerts, capacity limits, and incident response. A platform team may define that a deployment must follow policy, but the actual delivery path still needs admission controls, CI checks, RBAC, provenance, and rollback mechanisms. Coding agents should be treated the same way: the prompt is part of the interface, while the workflow runtime is the control surface.</p><p>This is not an argument against better prompts. It is an argument against treating prompts as the place where operational safety is enforced. The new failure modes make that difference easier to see.</p><h3>The new failure modes</h3><p>Agentic coding introduces failure modes that look less like autocomplete errors and more like operational incidents. The first one is resource consumption that becomes difficult to explain.</p><h3>Unbounded or surprising resource consumption</h3><p>When agents run multi-step tasks, spawn subagents, or execute background analysis, resource consumption can become non-obvious to the user. A recent public report described Codex users exhausting usage quotas faster than expected because internal features such as auto-review and subagents ran more frequently than intended; the same report says OpenAI reset usage caps, deployed a fix, and improved monitoring for similar background usage issues: <a href="https://www.businessinsider.com/openai-codex-usage-limit-warroom-fix-issue-2026-6">https://www.businessinsider.com/openai-codex-usage-limit-warroom-fix-issue-2026-6</a>.</p><p>The durable lesson is broader than one product incident. If agentic systems can perform background or parallel work, usage needs to be observable and attributable. Engineering teams should be able to answer which agent performed the work, which task triggered it, which tools were called, how much compute or token usage was consumed, whether the work was user-initiated or background automation, and which budget or quota it counted against.</p><p>Without these answers, agentic work becomes difficult to govern. Cost surprises are only one symptom. The same lack of attribution also weakens debugging, incident response, and accountability. Resource consumption is the most visible operational failure mode, but it is not the only one. The moment an agent can use tools, the platform also has to treat tool access as part of the threat model.</p><h3>Tool execution risk</h3><p>Coding agents are attractive because they can operate close to real engineering systems: repositories, terminals, package managers, test runners, CI pipelines, documentation, cloud APIs, and ticketing systems. That proximity is also the risk.</p><p>Recent reporting on Mozilla’s 0din research described a proof of concept where a seemingly clean GitHub repository could trick an AI coding agent into running malware during setup. The reported attack relied on the agent’s helpfulness and automated recovery behavior, eventually reaching command execution through instructions that did not appear obviously malicious at the repository level. Tom’s Hardware covered the 0din research and the clean-repository attack pattern here: <a href="https://www.tomshardware.com/tech-industry/cyber-security/ai-coding-agents-can-be-tricked-into-installing-malware-via-clean-github-repositories-mozillas-0din-team-shows-how-claude-code-can-be-exploited-by-its-own-helpfulness">https://www.tomshardware.com/tech-industry/cyber-security/ai-coding-agents-can-be-tricked-into-installing-malware-via-clean-github-repositories-mozillas-0din-team-shows-how-claude-code-can-be-exploited-by-its-own-helpfulness</a>.</p><p>This is not only a model-safety problem; it is an execution-boundary problem. A human developer can also run malicious setup scripts. The difference is that an agent may follow repository instructions more mechanically, try recovery steps automatically, and chain tool calls faster than a human would. That makes the local development environment, credentials, shell access, browser sessions, package manager configuration, and cloud credentials part of the agent threat model.</p><p>Safer agentic workflows need explicit controls: isolated sandboxes for untrusted repositories, network egress restrictions by default, command allowlists or risk-tiered approvals, secret redaction, credential isolation, separate identities for agent actions, and clear escalation points before destructive or external operations. These controls are not meant to prevent agents from using tools. They are meant to make tool use governable.</p><p>Once tool execution is governed, the next risk is more subtle. An agent may produce a change that satisfies a visible check while failing to preserve the intended security property.</p><h3>False confidence from scanner-passing fixes</h3><p>Security and infrastructure tasks are especially vulnerable to false success. A static-analysis warning disappears, a test turns green, a linter stops complaining, and the agent declares the issue fixed. That does not always mean the underlying risk was corrected.</p><p>The TerraProbe paper studied LLM-assisted Terraform security repairs and found that evaluating success only by disappearance of the targeted Checkov finding substantially overstated repair quality. In the reported results, targeted Checkov removal reached 83.3% for the primary model, but full-scanner cleanliness dropped to 10.4%, Terraform planning succeeded for 39.6%, and human adjudication found that 71.4% of plan-compared real-world repairs were deceptive fixes that passed automated checks while leaving the underlying vulnerability in place. The paper is available on arXiv as “TerraProbe: A Layered-Oracle Framework for Detecting Deceptive Fixes in LLM-Assisted Terraform”: <a href="https://arxiv.org/abs/2606.26590">https://arxiv.org/abs/2606.26590</a>.</p><p>This is an important signal for agentic software delivery because an agent can optimize for the visible gate rather than the security intent behind the gate. That behavior is not necessarily malicious; it may be a consequence of incomplete evaluation. If the workflow rewards removing a warning, the agent may remove the warning. If the workflow verifies behavioral intent, plan validity, blast radius, and policy alignment, the agent has less room to produce a scanner-passing but unsafe fix.</p><p>This is why agentic platforms need layered validation, not only final answer review. These failure modes point in the same direction: agents should not be avoided, but agentic work does need an operating model.</p><h3>What SRE practices look like for coding agents</h3><p>SRE is not only about running web services. It is a discipline for operating complex systems under reliability, cost, and safety constraints, and coding agents now belong inside that discipline.</p><p>The first step is to stop treating all agent tasks as equivalent. A README cleanup, a test-generation task, a dependency update, and a Terraform repair do not have the same risk profile.</p><h3>Define service-level expectations for agentic workflows</h3><p>Not every agent task needs the same reliability model. A practical approach is to define expectations by task class, because a documentation cleanup agent, a test-generation agent, and an infrastructure-remediation agent have different operational and security risk profiles. This article treats task classification as a platform engineering pattern, not as a finalized industry taxonomy for coding-agent workflows. Existing frameworks such as the NIST AI Risk Management Framework, which organizes AI risk management around Govern, Map, Measure, and Manage (<a href="https://www.nist.gov/itl/ai-risk-management-framework">https://www.nist.gov/itl/ai-risk-management-framework</a>), and the OWASP Top 10 for Agentic Applications, which focuses on security risks in AI agents that plan, act, and make decisions across workflows (<a href="https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/">https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/</a>), provide useful guidance for AI risk management and agent security. However, they do not prescribe a complete operational classification model for coding-agent tasks. AWS Bedrock AgentCore Evaluations points in a complementary direction by framing agent evaluation as a way to measure how agents and tools perform tasks, handle edge cases, maintain consistency, and meet quality standards before and after deployment: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations.html</a>.</p><p>The table below is a reference model for thinking about task classes. It is not a universal standard; it is a practical starting point for engineering teams that need different controls for different levels of risk.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DPdoZehesyZjfjUvHR7nAA.png" /></figure><p>The point is not that every organization should copy these exact categories. The point is that agent governance should move from “trust the assistant” to “classify the work and enforce the controls.” After task classification, the next practical concern is consumption. If agentic work cannot be attributed, it cannot be managed.</p><h3>Make cost and quota visible</h3><p>Agent cost should be observable at the task level. A useful agent task record should include the agent name and version, model or execution profile, initiating user or automation, repository and branch, task classification, tools used, token or compute consumption, CI minutes, elapsed time, retry count, and whether subagents or parallel workers were used.</p><p>This recommendation follows the same operational logic used for production observability: work should be attributable to the system, user, workflow, and time window that produced it. AWS Bedrock AgentCore Observability describes how agent observability can expose dashboards, error breakdowns, trace visualizations, and session or trace data through CloudWatch: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-view.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-view.html</a>.</p><p>This data should be visible to both the developer and the platform team. A developer needs to understand why a task consumed more resources than expected. A platform team needs aggregate views by repository, team, workflow type, and agent identity. Budgets can then be enforced per user, repository, team, workflow type, time window, agent identity, or environment.</p><p>For example, a codebase exploration task might allow many read-only file operations but limit shell execution. A refactoring task might allow test runs but cap retries. An infrastructure task might require approval before any command that reaches cloud APIs. Cost governance is not only a finance concern; it is also an operational safety mechanism.</p><p>Once budgets are visible, permissions become the next architectural boundary. Cost limits contain resource consumption; permission limits contain blast radius.</p><h3>Treat permissions as architecture</h3><p>Agent permissions should be designed, not inherited accidentally from the developer’s machine. A common anti-pattern is running an agent with the same broad access as the human operator: local shell, repository write access, package manager access, cloud credentials, browser session, SSH keys, and internal documentation. That is convenient, but it creates a large blast radius.</p><p>A safer architecture separates identity and capability. This recommendation aligns with the general security principle of least privilege, although coding-agent-specific implementations vary across platforms. The official AWS AgentCore documentation describes AgentCore as a managed service for deploying and operating agents securely at scale, and its service documentation includes components for observability, evaluation, and governed operation: <a href="https://docs.aws.amazon.com/bedrock-agentcore/">https://docs.aws.amazon.com/bedrock-agentcore/</a>.</p><p>The agent should have its own identity, and that identity should have scoped permissions that match the task. The workflow should log when permissions are used, and sensitive operations should require explicit approval. This is similar to how production systems should avoid sharing human credentials. Agents are not an exception just because they are invoked from a developer workflow.</p><p>A useful permission model distinguishes between reading a repository, writing a branch, opening a pull request, running tests, installing dependencies, accessing internal documentation, calling cloud APIs, accessing secrets, modifying CI/CD configuration, modifying infrastructure, and deploying to an environment. Most agent tasks do not need all of these capabilities.</p><p>The same principle applies to evidence. A scoped identity tells the platform what the agent was allowed to do. Traces show what it actually did.</p><h3>Require traces, not just summaries</h3><p>Agent summaries are useful, but they are not sufficient evidence. A summary is generated by the same system being evaluated. Reviewers need independent artifacts: diffs, command logs, test output, policy-check results, tool-call traces, and links to CI runs.</p><p>AWS Bedrock AgentCore Observability describes agent observability in terms of dashboards, trace visualizations, session data, error breakdowns, and production monitoring for agents: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-view.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-view.html</a>. AgentCore Evaluations also emphasizes evaluating agents before and after deployment through automated assessment tools: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations.html</a>. The same principles apply to coding agents.</p><p>A reviewer should be able to reconstruct what the agent was asked to do, which files it inspected, which commands it ran, which tools it called, which assumptions it made, which tests passed or failed, which policy checks were executed, which changes were discarded, and why the final diff was selected.</p><p>This is especially important when the agent uses subagents. Parallel work can make the final answer look simple while hiding divergent intermediate paths. If one subagent explored an unsafe direction and another produced the final patch, the workflow should still preserve enough trace data for audit and debugging. Tracing gives reviewers evidence; policy gates turn that evidence into enforceable workflow decisions.</p><h3>Build policy gates into the workflow</h3><p>Human review remains essential, but it should not be the only gate. Agentic workflows should enforce policy before, during, and after execution. Before execution, the runtime can classify task risk, select a sandbox level, assign scoped identity, restrict tools, define budget, and require approval for risky capabilities. During execution, it can monitor tool calls, stop on policy violations, limit retries, block suspicious commands, redact secrets, and enforce network boundaries. After execution, it can verify tests, run security checks, compare intended and actual changes, require reviewer approval, preserve audit evidence, and record cost and tool usage.</p><p>This is a prescriptive recommendation based on the operational pattern already used in CI/CD and platform engineering, but the standards landscape for agent runtime governance is now emerging quickly. The Agent Control Standard / Agent Control Specification defines a vendor-neutral approach for runtime governance across the agent lifecycle, including standardized hooks for tool calls, code execution, subagents, memory operations, final outputs, policy evaluation, evidence collection, and enforcement decisions such as allow, deny, modify, warn, or escalate: <a href="https://agentcontrolstandard.org/">https://agentcontrolstandard.org/</a> and <a href="https://commandline.microsoft.com/agent-control-specification-runtime-governance/">https://commandline.microsoft.com/agent-control-specification-runtime-governance/</a>. That makes it highly relevant to policy gates for coding-agent workflows, even if teams still need to map those controls to their own repositories, CI/CD systems, infrastructure boundaries, and review processes. AWS Bedrock AgentCore Observability points in the same operational direction by storing metrics, spans, and logs in CloudWatch for agent monitoring and debugging, while AgentCore Evaluations frames evaluation as a way to measure how agents and tools perform tasks before and after deployment: <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability.html</a> and <a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations.html">https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations.html</a>. TerraProbe reinforces why this matters for infrastructure and security work: its layered-oracle evaluation showed that targeted scanner-finding removal can overstate the safety of LLM-generated Terraform repairs: <a href="https://arxiv.org/abs/2606.26590">https://arxiv.org/abs/2606.26590</a>.</p><p>The stronger pattern is to shift from prompt-based governance to runtime governance. At this point, the pieces start to look familiar: identity, budgets, sandboxing, traces, validation, and review. Together, they form a workflow runtime around the agent.</p><h3>A reference model for agentic coding workflows</h3><p>A mature coding-agent platform can be modeled as an internal workflow runtime. The developer provides the task, constraints, and expected evidence. The runtime classifies the work, assigns the execution profile, grants limited capabilities, runs the agent, collects traces, validates the output, and sends the result through review.</p><p>The diagram below is intentionally simple. It is not a vendor architecture. It is a C4-style context sketch showing the operational boundary that should exist around coding agents.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6saAxWeLI6BDVWJLR8PfMg.png" /></figure><p>The important component is not the model itself. The important component is the runtime boundary around the model. That boundary is where organizations enforce who can run which agent, what the agent can access, what it can execute, how much it can spend, which evidence it must produce, which changes require approval, and how failures are detected and handled.</p><p>This is the same platform thinking already used for CI/CD, Kubernetes, GitOps, and production operations. Agentic coding workflows should join that platform surface. If agents become part of the platform surface, then platform teams inherit a new responsibility: making agent adoption safe without making it slow.</p><h3>The role of platform teams</h3><p>The next platform team will not only govern clusters, pipelines, and cloud accounts. It will govern human-agent workflows. That does not mean centralizing every agent interaction. Over-centralization would slow adoption and push developers toward unmanaged tools. The better approach is to provide paved roads.</p><p>A paved road for coding agents might include approved agent runtimes, sandbox templates, repository trust levels, default tool policies, secure package installation workflows, identity federation for agents, secret-safe execution environments, trace storage, cost dashboards, PR templates with agent evidence, and policy-as-code checks for high-risk changes. This lets teams use agents productively without inventing governance from scratch.</p><p>The platform team’s job is to make the safe path easier than the unsafe path. This also changes the questions engineering leaders should ask. The useful questions are no longer abstract questions about whether AI is safe. They are operational questions about which workflows exist, which controls are enforced, and which evidence is retained.</p><h3>What engineering leaders should ask</h3><p>A useful leadership review of agentic coding should avoid vague questions such as “Are we using AI safely?” Better questions are more concrete: Which engineering workflows currently use coding agents? Which agents can run commands? Which agents can access private repositories? Which agents can access secrets, cloud APIs, or internal documentation? Do agents use separate identities? Can a reviewer inspect tool calls and intermediate outputs? Are costs attributed by team, repository, task, and agent? Are high-risk tasks sandboxed? Are policy gates enforced by the runtime or only described in prompts? Can the organization reconstruct what happened after an agent-caused incident? Can agent-generated infrastructure or security fixes be evaluated beyond scanner silence?</p><p>These questions turn agent adoption from experimentation into operational design. They also create a more realistic standard for evaluating maturity. A mature organization is not one where every developer has access to the most powerful agent. A mature organization is one where the agentic workflow is observable, bounded, auditable, and aligned with the risk of the task.</p><h3>Conclusion</h3><p>AI coding agents are no longer only coding assistants. Once they can decompose work, spawn subagents, execute tools, consume quotas, and interact with engineering systems, they become workloads inside the software delivery platform.</p><p>Workloads need operational controls. Prompt quality still matters. Model choice still matters. Developer judgment still matters. But none of those replace budgets, permissions, sandboxing, observability, policy gates, and audit evidence.</p><p>The organizations that benefit most from coding agents will not be the ones that merely write better prompts. They will be the ones that build governable agentic workflows. That is the next platform engineering problem.</p><h3>💬 Join the Discussion</h3><p>How is your team treating coding agents today? Are they still individual developer tools, or are they already part of your platform engineering surface? The most useful discussion is likely to come from real-world trade-offs around sandboxing, tool permissions, cost attribution, traces, and reviewer evidence for agent-generated changes.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em>Tags</p><p>#PlatformEngineering #SRE #AIEngineering #SoftwareArchitecture #DevOps</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=188a4cfdc258" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI Agents Are No Longer Coding Assistants. They Are Workflow Runtimes.]]></title>
            <link>https://medium.com/@cassius.paim/ai-agents-are-no-longer-coding-assistants-they-are-workflow-runtimes-ba72ddb832e5?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/ba72ddb832e5</guid>
            <category><![CDATA[developer-productivity]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[platform-engineering]]></category>
            <category><![CDATA[engineering-mangement]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sun, 28 Jun 2026 15:41:21 GMT</pubDate>
            <atom:updated>2026-06-28T15:41:21.244Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I think about delegated AI work, review, and engineering ownership at scale</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oQqm7HyWvuzlqGM3oaTUCw.png" /></figure><blockquote><em>“The hardest single part of building a software system is deciding precisely what to build.”</em></blockquote><blockquote><em>Frederick P. Brooks Jr., “No Silver Bullet: Essence and Accidents of Software Engineering,” </em>Computer<em>, IEEE Computer Society, Vol. 20, №4, 1987, pp. 10–19. IEEE record: </em><a href="https://ieeexplore.ieee.org/document/1663532"><em>https://ieeexplore.ieee.org/document/1663532</em></a></blockquote><p>The most interesting part of the recent Codex usage data is not that people are using AI to write code. That part is no longer surprising.</p><p>The interesting part is that the shape of work is changing.</p><p>In the paper <em>The Shift to Agentic AI: Evidence from Codex</em>, Drew Johnston, David Holtz, Alex Martin Richmond, Christopher Ong, Prasanna Tambe, and Aaron Chatterji analyze Codex usage across individual users, organizational users, and OpenAI workers. The paper reports that weekly active Codex usage grew more than fivefold between January 1 and June 1, 2026, that Codex accounted for 63.3% of output tokens among organizational users and 99.8% among OpenAI workers, and that more than 10% of users managed three or more concurrent Codex agents at some point each week. The paper also reports that skill usage rose from 5.4% of active Codex users on March 1, 2026 to 26.6% on June 11, 2026. The arXiv version is the primary source for these claims: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a>.</p><p>These numbers are useful, but they should be read carefully: they come from an OpenAI-affiliated arXiv v1 paper based on aggregate usage data, classifiers, and samples. I treat them as evidence of a direction of travel, not as independent proof that AI agents cause better engineering outcomes.</p><p>The important signal is what this usage pattern points to: something more structural than “AI pair programming.” A pair programmer sits next to you. A workflow runtime receives work, executes steps, holds state, uses tools, produces artifacts, and creates operational evidence that other people need to review.</p><p>That is the mental shift I think engineering teams need to make. AI agents are becoming less like autocomplete and more like delegated execution environments. Once that happens, productivity is no longer only a question of prompt quality or model capability. It becomes a question of review design, governance, ownership, incident response, auditability, and task architecture.</p><p>That is where the engineering leadership problem begins.</p><h3>The data points to delegated production, not conversational assistance</h3><p>The Codex paper makes a useful distinction between conversational AI and agentic AI. In conversational interfaces, the user asks, the model answers, and the exchange is usually evaluated as a message-level interaction. In agentic interfaces, the user delegates work. The agent can inspect context, use tools, modify artifacts, run commands, and continue execution across a thread. The paper explicitly frames Codex usage as delegated workflows rather than isolated conversations: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>That distinction explains why active-user counts are not enough. Two teams can both say “we use AI,” while one team uses it as a search-and-drafting assistant and the other uses it to run parallel tasks across repositories, documents, tests, data analysis, and operational triage. Those are not the same adoption pattern. They imply different control surfaces.</p><p>The task data reinforces this. According to the paper, Codex usage remains anchored in software production, but not only in code generation. Users delegate code implementation, code understanding, code validation, engineering operations, application management, documentation, data analysis, research, and business-function workflows. The paper also reports that the share of individual users submitting at least one task classified as likely to require eight or more hours of experienced-human work rose from 2.1% to 25.6% between December 2025 and May 2026. This is a classifier-based estimate of task complexity, not a direct measurement of actual human time spent on those tasks: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>That is the important part: the unit of work is getting larger. The initial request is no longer necessarily “write this function.” It may be “analyze this subsystem, propose the change, implement it, run tests, update documentation, and prepare the pull request.” At that point, the human is not merely accepting a suggestion. The human is managing delegated production.</p><p>Once work becomes delegated production, the old assistant metaphor starts to break. The next question is what metaphor gives engineering teams better operational leverage.</p><h3>“Workflow runtime” is not a product label. It is an operating model.</h3><p>This does not mean that every AI agent is literally a workflow engine in the same category as Temporal, Airflow, Step Functions, or a CI/CD orchestrator. That would be too narrow.</p><p>The point is that engineering teams increasingly need to reason about agentic work using runtime concepts: execution context, state, permissions, isolation, retries, observability, artifacts, review gates, and ownership boundaries.</p><p>A conversational assistant can be managed through usage guidelines. A workflow runtime needs a control plane.</p><p>The Codex paper describes intensive users coordinating multiple agent threads, using skills, running longer tasks, and reusing codified workflows. It reports that among OpenAI users, nearly 28.6% managed five or more concurrent agents at some point during the week studied. It also describes skills and plugins as reusable instructions, capabilities, and integrations that allow workflow knowledge to be shared across users and organizations: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>That behavior looks less like “asking better questions” and more like “operating a pool of workers.”</p><p>A simple C4-style sketch makes the boundary clearer. The exact notation is less important than the separation between human intent, agent execution, organizational policy, and production systems.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IFu2_JFYe4r7lSp4dJtioA.png" /><figcaption>A C4-style context sketch showing agentic workflow runtimes as the execution layer between human delegation, engineering systems, and review, governance, audit, and incident processes.</figcaption></figure><p>This is the architecture that appears implicitly when agents stop being one-off assistants. The agentic layer is not just another UI. It becomes a place where work is shaped, decomposed, executed, observed, and accepted.</p><p>That shift creates a leadership problem because engineering teams already know how to review code, operate services, and audit release pipelines. They are much less mature at reviewing delegated agent workflows as first-class execution artifacts.</p><h3>Review moves from “is this diff correct?” to “is this delegated execution trustworthy?”</h3><p>Traditional code review focuses on a diff. That is still necessary, but it is no longer sufficient when an agent produces the diff after exploring a large context, running commands, choosing trade-offs, and potentially modifying multiple artifacts.</p><p>In my view, reviewing agent-produced work requires at least three layers.</p><p>The first layer is artifact review: the code, document, query, migration, configuration, or analysis produced by the agent. This is the part teams already understand.</p><p>The second layer is process review: what the agent was asked to do, which files and systems it touched, what assumptions it made, what tests it ran, what evidence it produced, and where it asked for clarification.</p><p>The third layer is intent review: whether the task itself was well-formed, appropriately scoped, owned by the right person, and safe to delegate in the first place.</p><p>This is not a theoretical concern. The Codex paper’s taxonomy includes code review, security audit, testing, build and release pipelines, environment configuration, job management, and production monitoring as observed task categories or labels in the agentic workflow data. That means agents are already being pointed at the parts of engineering work where mistakes can affect security, reliability, and delivery: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>For this reason, I would not treat “AI-generated” as the main review flag. The more useful question is whether the work is evidence-backed. A small AI-generated refactor with tests, rationale, and limited blast radius may be safer to review than a large human-authored change with vague intent and no validation. Conversely, a large agent task that touches authorization, deployment, customer data, or production operations needs a higher review bar regardless of how clean the diff looks.</p><p>This aligns with a broader software delivery principle: high-performing engineering organizations measure and improve the system of delivery, not only individual output. The DORA research program has studied software delivery performance and organizational capabilities for more than a decade; its 2024 report is not a source about AI agents specifically, but it is a useful source for the underlying delivery-systems logic I am applying here: <a href="https://dora.dev/research/2024/dora-report/">https://dora.dev/research/2024/dora-report/</a>.</p><p>The practical consequence is that code review needs to expand into workflow review. Once teams accept that, governance becomes less about prohibiting AI and more about defining the rails within which delegated work can run.</p><h3>Governance has to sit inside the workflow, not outside it</h3><p>A policy document that says “use AI responsibly” will not be enough for agentic work. It may be necessary, but it is too far away from the execution path.</p><p>The NIST AI Risk Management Framework is useful here because it organizes AI risk management around Govern, Map, Measure, and Manage. I would not treat the framework as an engineering checklist by itself, but it gives a strong vocabulary for turning vague AI governance into operational controls: <a href="https://www.nist.gov/itl/ai-risk-management-framework">https://www.nist.gov/itl/ai-risk-management-framework</a></p><p>For agentic engineering work, “Govern” means defining who can delegate which classes of tasks, with which permissions, under which approval rules. “Map” means identifying the systems, repositories, environments, data classes, and users affected by the delegated workflow. “Measure” means capturing quality, safety, reliability, security, and productivity signals from the work. “Manage” means changing permissions, review requirements, rollout patterns, or usage guidelines when the measured risk changes.</p><p>The key is that these controls need to be attached to the workflow. They cannot live only in a wiki.</p><p>A mature agent task should leave behind enough metadata for a reviewer or auditor to reconstruct what happened. A useful task record can include the human owner, task intent, repository or system scope, tools used, files changed, tests or validations run, external context accessed, reviewer decisions, and final outcome. This is a practical engineering recommendation rather than a claim from a single standard, but it is consistent with NIST’s emphasis on governance, mapping, measurement, and management of AI risks.</p><p>For software artifacts, this also connects to supply-chain thinking. SLSA describes provenance as verifiable information about where, when, and how an artifact was produced. The official SLSA provenance specification is focused on software build provenance, not agentic task provenance, but the analogy is directly useful: if a build artifact needs provenance, an agent-produced change needs at least enough provenance for engineering review and incident investigation: <a href="https://slsa.dev/spec/v1.2/provenance">https://slsa.dev/spec/v1.2/provenance</a></p><p>That does not mean every AI task needs heavyweight compliance. A documentation cleanup and a production migration should not carry the same governance load. The point is risk-based friction. The more autonomy, system access, production impact, data sensitivity, or blast radius a task has, the more evidence and approval it needs.</p><p>That brings governance back to ownership. Without ownership, the workflow may be observable but still unaccountable.</p><h3>Ownership cannot be delegated to the agent</h3><p>The phrase “the agent did it” is operationally useless.</p><p>Agents can execute work. They cannot own product consequences, production risk, architectural coherence, customer impact, or organizational accountability. Those responsibilities still belong to people and teams.</p><p>The hard part is not letting agents execute work. The hard part is redesigning the organization around review, ownership, and evidence. They will adopt agents as individual productivity tools first. Then the volume of generated work will rise. Then review queues, ownership boundaries, incident analysis, and architectural consistency will start to feel the pressure.</p><p>The Codex paper’s conclusion points in this direction. It argues that as AI use shifts from consultation to delegation, organizations may need to rethink how work is allocated, reviewed, and coordinated. It also states that jobs may increasingly involve directing, monitoring, and integrating agent outputs rather than executing every component task directly: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>That is a leadership problem, not only a tooling problem.</p><p>For engineering teams, one rule should remain explicit: delegation does not move ownership away from the team that accepts the work. The owner is responsible for the task boundary, permission level, acceptance criteria, review path, and final decision to merge, publish, deploy, or discard the output. I am stating this as an engineering leadership position, not as a direct quotation from the Codex paper.</p><p>This does not reduce the value of agents. It increases it. Clear ownership allows agents to operate more safely because they are attached to a known decision context. The agent can produce candidate work. The owner decides whether that work belongs in the system.</p><p>The next place ownership matters is incident response, because production systems do not care whether the root cause originated from a human, an agent, or a mixed workflow.</p><h3>Incident response needs to include agentic work as causal context</h3><p>If agents contribute to code, configuration, documentation, runbooks, migrations, dashboards, tests, or operational analysis, then incidents will eventually involve agentic work somewhere in the causal chain.</p><p>That does not mean agents are uniquely dangerous. Humans also introduce regressions, misread systems, over-trust stale documentation, and make unsafe assumptions. The point is that incident analysis must be able to reconstruct the work system that produced the failure.</p><p>Google’s SRE material on postmortem culture emphasizes blameless analysis, learning from failure, and focusing on processes, tools, and systems rather than individual blame. That principle applies cleanly to agentic work: the useful question is not “which agent made the mistake?” but “which workflow allowed this output to be accepted without the right evidence, review, or guardrail?” The Google SRE chapter is available here: <a href="https://sre.google/sre-book/postmortem-culture/">https://sre.google/sre-book/postmortem-culture/</a></p><p>This is why audit trails matter. If an incident involves an AI-assisted change, the postmortem should be able to answer practical questions: What was the delegated task? Who owned it? What constraints were given? What did the agent inspect? What tests passed? What signals were ignored? What review gate accepted the change? What assumption was wrong?</p><p>Without that evidence, teams will fall back to narrative guessing. With that evidence, teams can improve task templates, permissions, test strategies, review criteria, rollback procedures, and agent access policies.</p><p>In incident analysis, the better question is not whether AI was involved. It is whether the workflow that accepted the output had enough control, evidence, review, and ownership for the risk of the task.</p><p>That brings the discussion to the most underrated part of agent adoption: task design.</p><h3>Task design becomes an engineering interface</h3><p>The Codex paper reports that the most complex requests are concentrated at the start of sessions. The first turn of a thread is more than twice as likely as the fourth turn to require work estimated at over one hour for an experienced human. That suggests that the initial task description is increasingly where users frame the broadest portion of work: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>This makes task design an engineering interface.</p><p>A weak task says: “Fix this.”</p><p>A stronger task says: “Investigate why this endpoint times out under load, inspect the handler and downstream queries, propose the smallest safe change, run the relevant tests, avoid schema changes, and summarize the evidence before suggesting a diff.”</p><p>The second task is not just a better prompt. It defines scope, constraints, acceptable actions, validation expectations, and review evidence. That is why task design will become a leadership skill. Staff-level engineers and tech leads will increasingly shape work by decomposing it into delegable units with clear boundaries.</p><p>For agentic work to be reviewable, the task design needs to make six fields explicit: objective, scope, constraints, tools, evidence, and reviewer decision. The objective explains the outcome. The context names the relevant system, incident, customer, or product behavior. The scope limits what the agent may touch. The constraints state what not to change. The validation defines expected evidence. The handoff tells the agent what form the result should take for human review.</p><p>This is a practical pattern, not a universal standard. Its value is that it makes the task reviewable before execution and auditable after execution.</p><p>As agents get better, the bottleneck may move from code production to work framing. Brooks’ old observation still applies: deciding precisely what to build is hard. Agentic AI does not remove that difficulty. It moves more execution behind the task boundary, making the boundary itself more important.</p><h3>The productivity question is really an organizational design question</h3><p>It is tempting to evaluate agents by asking whether they make individual developers faster. That question matters, but it is too small.</p><p>The Codex paper situates its argument in the literature on general-purpose technologies and organizational complements. One reference it uses is Paul David’s article <em>The Dynamo and the Computer</em>, which analyzed how productivity gains from electrification were tied to complementary changes in factory layout and work organization. Although David’s work predates AI workflows, the lesson still applies: new technology creates leverage only when organizations redesign the work around it. David’s article was published in <em>The American Economic Review</em>, Vol. 80, №2, 1990, pp. 355–361; a stable JSTOR record is available here: <a href="https://www.jstor.org/stable/2006600">https://www.jstor.org/stable/2006600</a>.</p><p>That analogy is useful because early AI adoption often substitutes a new tool into an old workflow. The developer still owns the ticket, writes most of the code, asks the AI for help, copies a snippet, and opens a pull request. The workflow remains recognizable.</p><p>Agentic AI becomes more interesting when teams redesign the workflow itself. A tech lead may run several investigation agents in parallel. A platform team may codify recurring migration tasks into reusable skills. An incident commander may delegate log summarization, dependency tracing, and rollback documentation to separate agents while humans make the production decisions. A product engineering team may turn repetitive release-note, test-gap, and documentation work into reviewable background tasks.</p><p>The Codex data suggests that frontier users are already moving in that direction. But the paper is also careful to note that OpenAI is not representative of the typical organization: adoption frictions are lower, internal knowledge sharing is high, and the workforce is unusually close to the systems being developed. That caveat matters because it prevents the wrong conclusion. The lesson is not “copy OpenAI’s usage pattern tomorrow.” The lesson is that low-friction environments reveal what the operating model may become when tooling, access, culture, and review processes mature: <a href="https://arxiv.org/abs/2606.26959">https://arxiv.org/abs/2606.26959</a></p><p>For most engineering organizations, the adoption curve will be constrained less by model capability than by trust, permissions, codebase understanding, review capacity, and the ability to make agent work observable.</p><p>That is why I think the runtime metaphor matters. It forces teams to ask operational questions early, before delegated work becomes invisible infrastructure.</p><h3>Conclusion</h3><p>AI agents are not only better coding assistants. They are becoming workflow runtimes for delegated knowledge work.</p><p>That does not mean humans disappear from engineering. It means the human role changes. More value shifts toward framing work, defining constraints, supervising execution, reviewing evidence, integrating outputs, and owning decisions.</p><p>The Codex usage data is important because it shows the early shape of this transition: more complex delegated tasks, more parallel execution, more reusable workflow instructions, more non-coding use cases, and more work organized around agents rather than conversations.</p><p>For engineering teams, the practical implication is clear. Do not treat agent adoption as a sidecar productivity experiment forever. Treat it as a change in the software delivery system.</p><p>That means designing review paths for delegated execution, putting governance inside the workflow, assigning human ownership before agents act, capturing audit evidence, including agentic work in incident analysis, and teaching engineers how to design tasks as carefully as they design interfaces.</p><p>The teams that benefit most from agentic AI will not be the teams that merely generate more code. They will be the teams that learn how to operate delegated work safely, observably, and with clear ownership.</p><h3>💬 Join the Discussion</h3><p>How are you adapting engineering workflows around AI agents?</p><p>Are agents still personal assistants in your team, or are they already becoming part of your delivery system? I am especially interested in the organizational side: review gates, ownership boundaries, audit trails, incident analysis, and how you decide which tasks are safe to delegate.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#AI Agents, #Software Engineering, #Engineering Leadership, #Developer Productivity, #Platform Engineering</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ba72ddb832e5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hands-on Memory Profiling and Optimization in Go]]></title>
            <link>https://medium.com/@cassius.paim/hands-on-memory-profiling-and-optimization-in-go-6db855596345?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/6db855596345</guid>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[pprof]]></category>
            <category><![CDATA[performance-engineering]]></category>
            <category><![CDATA[memory-optimization]]></category>
            <category><![CDATA[benchmark]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sun, 21 Jun 2026 17:00:26 GMT</pubDate>
            <atom:updated>2026-07-02T10:25:38.539Z</atom:updated>
            <content:encoded><![CDATA[<h4>A practical workflow for measuring allocations, reading pprof data, and optimizing hot paths without guessing</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RQnH_no_HdzkHNpR0PcK6g.png" /></figure><blockquote>“Measurement is a crucial component of performance improvement since reasoning and intuition are fallible guides and must be supplemented with tools like timing commands and profilers.”</blockquote><blockquote>Brian W. Kernighan and Rob Pike, The Practice of Programming, Addison-Wesley, 1999, Chapter 7, “Performance”, Section 7.2, “Timing and Profiling”.</blockquote><p>This is the fifth and final article in the series. The previous articles built the mental model: stack and heap, escape analysis, garbage collection, everyday allocations, data structures, goroutines, and retained memory. Now we turn that model into a workflow.</p><p>This article is hands-on. The companion project for this article is intended to be published as a GitHub repository named go-memory-profiling-lab. The repository contains benchmark cases, allocation-heavy examples, optimized alternatives, a tiny HTTP server with net/http/pprof, and commands you can run locally. Replace the placeholder repository URL below with your own GitHub link when publishing.</p><p>Full code repository: <a href="https://github.com/cassiuspaim/cassius-labs/tree/main/go-memory-profiling-lab">https://github.com/cassiuspaim/cassius-labs/tree/main/go-memory-profiling-lab</a>.</p><p>Before optimizing anything, we need one rule: measure before and after. The official Go diagnostics documentation presents profiling, tracing, runtime statistics, and debugging tools as the way to diagnose performance problems rather than guessing from source code alone: <a href="https://go.dev/doc/diagnostics">https://go.dev/doc/diagnostics</a>. The runtime/pprof documentation also shows that go test can produce profiles directly from benchmarks: <a href="https://pkg.go.dev/runtime/pprof">https://pkg.go.dev/runtime/pprof</a>.</p><p>Requires Go 1.26 or newer for the examples in this article. The Go 1.26 release notes identify Go 1.26 as the current release family: <a href="https://go.dev/doc/go1.26">https://go.dev/doc/go1.26</a>. The examples use the standard testing package, the go test benchmark flags, runtime/pprof, and net/http/pprof; the net/http/pprof documentation also notes that, as of Go 1.22, the documented endpoints must be requested with GET: <a href="https://pkg.go.dev/net/http/pprof">https://pkg.go.dev/net/http/pprof</a>.</p><h3>The project structure</h3><p>The companion project is intentionally small. It exists to make memory behavior visible, not to model a real business domain. The repository is structured like this:</p><pre>go-memory-profiling-lab/<br>├── cmd/<br>│   └── server/<br>│       └── main.go<br>├── internal/<br>│   └── processor/<br>│       ├── pool.go<br>│       ├── pool_test.go<br>│       ├── processor.go<br>│       ├── processor_test.go<br>│       ├── stream.go<br>│       └── stream_test.go<br>├── go.mod<br>├── Makefile<br>└── README.md</pre><p>The internal/processor package contains the benchmarkable examples used throughout the article. processor.go and processor_test.go cover text formatting, strings.Builder, preallocation, and slice retention. pool.go and pool_test.go cover temporary buffer reuse with sync.Pool. stream.go and stream_test.go compare full JSON decoding with stream-oriented processing using encoding/json.Decoder and encoding/json.Encoder.</p><p>The cmd/server package exposes a small HTTP endpoint and imports net/http/pprof so you can collect heap and goroutine profiles from a running process. That structure gives us two measurement modes: local benchmarks and runtime profiles. Local benchmarks create a controlled feedback loop for comparing implementations with -benchmem and -memprofile. Runtime profiles show how memory behaves when the code runs inside a long-lived process. We will start with local benchmarks because they make allocation changes easier to isolate before moving into profile-driven investigation.</p><h3>Start with benchmark allocation output</h3><p>The first command to run for local allocation investigation is:</p><pre>go test -bench=. -benchmem ./...</pre><p>This command has three important pieces. go test compiles and runs package tests. -bench=. tells the test binary to run benchmarks whose names match the regular expression .; because . matches every benchmark name, it runs all benchmarks. -benchmem prints memory allocation statistics for each benchmark. The final ./... asks the Go command to apply the command recursively to the current module&#39;s packages. The go test command documents -bench and explains that no benchmarks are run by default unless a benchmark pattern is provided; it also documents -benchmem as the flag that prints benchmark allocation statistics: <a href="https://pkg.go.dev/cmd/go#hdr-Testing_flags">https://pkg.go.dev/cmd/go#hdr-Testing_flags</a>.</p><p>A benchmark in the project looks like this:</p><pre>func BenchmarkFormatUsersSlow(b *testing.B) {<br>    users := sampleUsers(100)<br>    b.ReportAllocs()<br>    for b.Loop() {<br>        _ = FormatUsersSlow(users)<br>    }<br>}</pre><p>The testing package defines B as the type passed to benchmark functions to manage benchmark timing and control the number of iterations: <a href="https://pkg.go.dev/testing#B">https://pkg.go.dev/testing#B</a>. In this example, b *testing.B is not application data; it is the benchmark controller provided by the testing framework. It decides how many times the measured code should run, exposes methods such as Loop, ReportAllocs, ResetTimer, StopTimer, and StartTimer, and records benchmark results.</p><p>The call to sampleUsers(100) happens before the measured loop, because the benchmark is trying to measure FormatUsersSlow, not test-data construction. b.ReportAllocs() asks this benchmark to report allocation statistics; the documentation states that ReportAllocs is equivalent to setting -test.benchmem for that benchmark function: <a href="https://pkg.go.dev/testing#B.ReportAllocs">https://pkg.go.dev/testing#B.ReportAllocs</a>. The for b.Loop() form is the current benchmark style documented by the testing package; it keeps running the loop body as long as the benchmark should continue and manages the benchmark timer for the measured loop: <a href="https://pkg.go.dev/testing#B.Loop">https://pkg.go.dev/testing#B.Loop</a>.</p><p>A typical output line looks like this:</p><pre>BenchmarkFormatUsersSlow-12      237861      4892 ns/op      7424 B/op      204 allocs/op</pre><p>Those columns come from the benchmark output printed by go test. The first column is the benchmark name and execution suffix. The second column is the number of benchmark iterations completed. ns/op is nanoseconds per operation, so it describes time per measured call. B/op is bytes allocated per operation, and allocs/op is the number of allocations per operation. The allocation columns appear because -benchmem or b.ReportAllocs() enabled allocation reporting.</p><p>None of these columns is sufficient alone. A change that reduces allocs/op but increases ns/op significantly may not be a win. A change that improves a microbenchmark but makes production code harder to maintain may not be worth it. The purpose of the benchmark is to create evidence, not to turn every allocation into a defect.</p><p>Once you have a baseline, optimize one thing at a time. Otherwise, you cannot attribute the result to a specific change.</p><h3>Compare a slow string-building path with strings.Builder</h3><p>The intentionally slow version uses fmt.Sprintf and repeated concatenation:</p><pre>func FormatUsersSlow(users []User) string {<br>    out := &quot;&quot;<br>    for _, user := range users {<br>        out += fmt.Sprintf(&quot;%s:%d\n&quot;, user.Name, user.Score)<br>    }<br>    return out<br>}</pre><p>This code is clear, but it creates work in a loop. fmt.Sprintf is general-purpose formatting. Repeated out += ... can create intermediate strings because strings in Go are immutable, and each new result may require a new string value. That does not make fmt.Sprintf wrong; it means general formatting and repeated string construction should be measured when they appear in hot paths.</p><p>The optimized version uses strings.Builder, documented as a tool to efficiently build a string using write methods: <a href="https://pkg.go.dev/strings#Builder">https://pkg.go.dev/strings#Builder</a>.</p><pre>func FormatUsersBuilder(users []User) string {<br>    var b strings.Builder<br>    b.Grow(len(users) * 16)</pre><pre>    for _, user := range users {<br>        b.WriteString(user.Name)<br>        b.WriteByte(&#39;:&#39;)<br>        b.WriteString(strconv.Itoa(user.Score))<br>        b.WriteByte(&#39;\n&#39;)<br>    }<br>    return b.String()<br>}</pre><p>var b strings.Builder creates a builder value that accumulates text before producing the final string. Grow is a capacity hint for the builder&#39;s internal byte storage. It is about memory capacity, not CPU scheduling or processing priority. Calling b.Grow(n) tells the builder to make room for at least n additional bytes, which can reduce internal reallocations while the string is being built. In this example, len(users) * 16 is deliberately rough because user names vary; it is only an estimate of the output size, not a correctness requirement.</p><p>WriteString appends text without the general formatting machinery of fmt.Sprintf. WriteByte writes fixed delimiters such as : and \n. strconv.Itoa converts an integer to decimal text; the strconv package documents numeric conversions at <a href="https://pkg.go.dev/strconv">https://pkg.go.dev/strconv</a>.</p><p>The recommendation is not “never use fmt.” The recommendation is narrower: in measured hot paths, prefer a purpose-built construction path when general formatting is responsible for significant allocation or CPU cost. If a path is not hot, fmt.Sprintf may be the clearer choice.</p><h3>Preallocate slices when size is known</h3><p>Another common example is building slices. The slow path starts with a nil slice and lets append grow capacity repeatedly:</p><pre>func NamesSlow(users []User) []string {<br>    var names []string<br>    for _, user := range users {<br>        names = append(names, user.Name)<br>    }<br>    return names<br>}</pre><p>The optimized version preallocates the output capacity:</p><pre>func NamesPreallocated(users []User) []string {<br>    names := make([]string, 0, len(users))<br>    for _, user := range users {<br>        names = append(names, user.Name)<br>    }<br>    return names<br>}</pre><p>The language specification documents make for slices and explains that the optional capacity controls the initial capacity: <a href="https://go.dev/ref/spec#Making_slices_maps_and_channels">https://go.dev/ref/spec#Making_slices_maps_and_channels</a>. Here, len(users) is already known, so the capacity hint is not guesswork. It expresses expected output size and can reduce reallocations as append fills the result.</p><p>This is a good optimization candidate because it improves clarity and performance together. The code says what it means: the result will contain at most one name per user.</p><h3>Use memory profiles when benchmark output is not enough</h3><p>Benchmark allocation columns tell you that allocation exists. Profiles help you investigate where allocation cost accumulates. Generate a memory profile from benchmarks with:</p><pre>go test -bench=. -benchmem -memprofile=mem.out ./internal/processor</pre><p>This command runs benchmarks for one package, prints allocation statistics, and writes an allocation profile to mem.out. The package path matters here: -memprofile writes a single profile file, so it must be used with a single package such as ./internal/processor, not with the recursive ./... pattern. The -memprofile flag is documented by the go test command as writing an allocation profile after tests have passed: <a href="https://pkg.go.dev/cmd/go#hdr-Testing_flags">https://pkg.go.dev/cmd/go#hdr-Testing_flags</a>.</p><p>The runtime/pprof documentation explains how Go profiles can be collected and inspected with go tool pprof: <a href="https://pkg.go.dev/runtime/pprof">https://pkg.go.dev/runtime/pprof</a>. For this article, the focus is memory profiling, so the benchmark workflow uses -memprofile to write allocation data for a single package:</p><pre>go test -bench=. -benchmem -memprofile=mem.out ./internal/processor</pre><p>The workflow is intentionally narrow: run the benchmark, ask go test to write a memory profile, and inspect that profile with go tool pprof. CPU profiling follows a similar inspection model and deserves its own article; here, keeping the scope limited to memory makes the allocation workflow easier to reason about.</p><p>After the profile is generated, the fastest first read is usually the textual top view:</p><pre>go tool pprof -top mem.out</pre><p>That command prints the largest contributors in the profile and then returns to the normal shell. A simplified output may look like this:</p><pre>Showing nodes accounting for 5.50MB, 100% of 5.50MB total<br>      flat  flat%   sum%        cum   cum%<br>    3.00MB 54.55% 54.55%     3.00MB 54.55%  processor.FormatUsersSlow<br>    1.50MB 27.27% 81.82%     1.50MB 27.27%  fmt.Sprintf<br>    1.00MB 18.18%   100%     1.00MB 18.18%  strings.(*Builder).grow</pre><p>The exact numbers will vary by machine, Go version, benchmark duration, and input size, so the example should be read structurally rather than as a fixed expected output. flat means memory attributed directly to that function. flat% is that direct contribution as a percentage of the selected profile. sum% is the running total of flat% up to the current row, which helps show how quickly the top entries explain the profile. cum means cumulative memory attributed to that function plus the functions it calls. cum% is the cumulative contribution as a percentage of the selected profile. A function with high flat allocation is allocating directly. A function with low flat but high cum may be an entry point that calls allocation-heavy helpers.</p><p>When -top is not enough, open the interactive shell:</p><pre>go tool pprof mem.out</pre><p>The (pprof) prompt means the profile is loaded and ready for exploration. It is not a hang. Inside the prompt, top shows the same kind of ranking interactively, help lists available commands, and quit exits back to the shell:</p><pre>(pprof) top<br>(pprof) help<br>(pprof) quit</pre><p>After identifying a function, use list to connect the profile back to source code:</p><pre>(pprof) list FormatUsersSlow</pre><p>list annotates source lines with profile data, which is useful when a function contains multiple allocation sites. This matters because a benchmark can tell you that a function allocates, while list can often show which line is responsible. If the slow path combines fmt.Sprintf, repeated string concatenation, and helper calls, line-level attribution helps avoid optimizing the wrong part of the function.</p><p>For visual exploration, pprof also supports graph views from inside the interactive shell:</p><pre>(pprof) web</pre><p>The web command renders a call graph. It is useful for seeing whether allocations come directly from the function under investigation or from deeper calls. In graph views, larger or hotter nodes usually represent functions with greater contribution to the selected profile, and edges show call relationships. This can be easier to understand than a flat list when allocation cost is distributed across multiple helper functions.</p><p>The graph view has one external dependency: Graphviz. pprof uses the dot executable from Graphviz to render graph-based views. If Graphviz is not installed or dot is not available on PATH, web may fail with an error like this:</p><pre>failed to execute dot. Is Graphviz installed? Error: exec: &quot;dot&quot;: executable file not found in $PATH</pre><p>On macOS, install Graphviz with:</p><pre>brew install graphviz</pre><p>On Debian or Ubuntu, install it with:</p><pre>sudo apt-get update<br>sudo apt-get install graphviz</pre><p>You can verify the installation with:</p><pre>dot -V</pre><p>A browser-based alternative is:</p><pre>go tool pprof -http=:8080 mem.out</pre><p>This starts a local web UI for exploring the same profile. The UI is often easier to navigate than the terminal because it provides views such as top entries, graph-oriented exploration, source views, and flame graphs depending on the profile and local tooling. Some graph-based views may still require Graphviz, but the textual and source-oriented views remain useful even without it.</p><p>A practical first sequence is therefore:</p><pre>go test -bench=. -benchmem -memprofile=mem.out ./internal/processor<br>go tool pprof -top mem.out<br>go tool pprof mem.out</pre><p>Then, inside the interactive shell:</p><pre>(pprof) top<br>(pprof) list FormatUsersSlow<br>(pprof) quit</pre><p>Use graph views after the textual view has already shown where to look. That order keeps the workflow simple: benchmark output tells you whether a path allocates, pprof -top shows the main contributors, list connects the profile to source lines, and graph or HTTP views help when call relationships are harder to follow.</p><p>For production-like services, a benchmark profile may not be enough. You may need runtime profiles from a running server, especially when memory behavior depends on real request shape, concurrency, cache state, or long-lived goroutines.</p><h3>Add net/http/pprof carefully</h3><p>The project includes a tiny server that imports net/http/pprof for side effects:</p><pre>import _ &quot;net/http/pprof&quot;</pre><p>The official net/http/pprof documentation says the package serves runtime profiling data under /debug/pprof/ and is typically imported for the side effect of registering HTTP handlers: <a href="https://pkg.go.dev/net/http/pprof">https://pkg.go.dev/net/http/pprof</a>. As of Go 1.22, the documented endpoints must be requested with GET, so avoid examples that rely on other HTTP methods.</p><p>Run the server:</p><pre>go run ./cmd/server</pre><p>Then request profiles from another terminal:</p><pre>curl &quot;http://localhost:8080/work?n=1000&quot;<br>go tool pprof &quot;http://localhost:8080/debug/pprof/heap&quot;<br>go tool pprof &quot;http://localhost:8080/debug/pprof/goroutine&quot;</pre><p>The curl command generates work so the server has something to profile. The heap profile helps inspect live heap behavior. The goroutine profile helps inspect goroutines, which is useful when blocked goroutines are retaining references or accumulating unexpectedly.</p><p>In a real service, do not expose pprof endpoints publicly. The standard package registers handlers, but deployment safety is your responsibility. Bind profiling endpoints to an internal interface, protect them behind authentication, or enable them only in controlled environments. This is operational security guidance; the net/http/pprof package documents handler registration, not a complete deployment security policy.</p><p>Once pprof is available safely, heap and goroutine profiles can connect local benchmark intuition with production-like runtime behavior.</p><h3>Optimize retained memory, not only allocation count</h3><p>The project includes a slice-retention example because it demonstrates a non-obvious optimization: sometimes you allocate more to retain less.</p><pre>func PrefixView(data []byte, n int) []byte {<br>    return data[:n]<br>}</pre><p>This returns a small slice, but the returned slice may retain a large backing array. The safer alternative copies the prefix:</p><pre>func PrefixCopy(data []byte, n int) []byte {<br>    out := make([]byte, n)<br>    copy(out, data[:n])<br>    return out<br>}</pre><p>This version allocates a new small buffer. That allocation is intentional because the new buffer has the desired lifetime. The old large buffer can become unreachable if no other references remain. This is why memory optimization cannot be reduced to allocs/op alone.</p><p>A good pprof workflow distinguishes allocation pressure from retained heap. Allocation profiles tell you where allocation happens. Heap profiles help show what remains live. Goroutine profiles help find blocked goroutines that retain references.</p><h3>Use sync.Pool only for the right shape of reuse</h3><p>Some hot paths allocate short-lived buffers repeatedly. If those buffers are created, used, and discarded on every operation, the allocation rate can become visible in benchmarks and memory profiles. In that specific shape of problem, sync.Pool can be useful: it lets the program reuse temporary objects across operations without making correctness depend on reuse.</p><p>The official documentation defines a Pool as a set of temporary objects that may be individually saved and retrieved, and it explicitly says that items stored in the pool may be removed automatically at any time: <a href="https://pkg.go.dev/sync#Pool">https://pkg.go.dev/sync#Pool</a>. That detail matters because it means a pool is not a cache, not an ownership mechanism, and not a lifecycle guarantee. It is only a best-effort reuse mechanism for temporary objects.</p><p>A small example makes the intended shape clearer. Suppose a hot path builds many temporary byte buffers:</p><pre>var bufferPool = sync.Pool{<br>    New: func() any {<br>        return new(bytes.Buffer)<br>    },<br>}<br><br>func encodeRecord(record Record) []byte {<br>    buf := bufferPool.Get().(*bytes.Buffer)<br>    buf.Reset()<br>    defer func() {<br>        if buf.Cap() &lt;= 64*1024 {<br>            bufferPool.Put(buf)<br>        }<br>    }()<br>    buf.WriteString(record.ID)<br>    buf.WriteByte(&#39;:&#39;)<br>    buf.WriteString(strconv.Itoa(record.Count))<br>    out := make([]byte, buf.Len())<br>    copy(out, buf.Bytes())<br>    return out<br>}</pre><p>The New function creates a buffer only when the pool cannot provide one. Get retrieves a temporary buffer from the pool, and the type assertion converts the returned any back to *bytes.Buffer. Reset is required because the buffer may contain data from a previous use. The deferred function returns the buffer to the pool only if its capacity is below a chosen threshold. That guard avoids keeping unusually large buffers alive after rare large inputs.</p><p>The copy before returning is also intentional. Returning buf.Bytes() directly would expose memory owned by the pooled buffer. Once the buffer is put back into the pool, another operation may reuse and mutate it. Creating out gives the caller an independent result with a clear lifetime.</p><p>This example shows both the value and the cost of pooling. sync.Pool may reduce repeated allocation of temporary buffers under load, but it also adds lifecycle rules: get, reset, use, copy if ownership must leave the function, and put back only when safe. That is why pooling should usually come after simpler options have been measured. Preallocation and streaming often improve clarity first. Pooling is useful when the allocation shape fits, the benchmark shows pressure, and the added lifecycle complexity remains contained.</p><h3>Stream instead of loading everything</h3><p>One of the most effective memory optimizations is architectural: avoid loading all data into memory when streaming is enough. For JSON, for example, the standard encoding/json package provides Decoder and Encoder for stream-oriented processing: <a href="https://pkg.go.dev/encoding/json#Decoder">https://pkg.go.dev/encoding/json#Decoder</a> and <a href="https://pkg.go.dev/encoding/json#Encoder">https://pkg.go.dev/encoding/json#Encoder</a>.</p><p>The difference is easier to see with code. A simple implementation may decode the full request into memory, build a full response slice, and only then encode the response:</p><pre>type InputUser struct {<br>    ID     string `json:&quot;id&quot;`<br>    Active bool   `json:&quot;active&quot;`<br>}<br><br>type OutputUser struct {<br>    ID string `json:&quot;id&quot;`<br>}<br><br>func FilterActiveUsersInMemory(r io.Reader, w io.Writer) error {<br>    var users []InputUser<br>    if err := json.NewDecoder(r).Decode(&amp;users); err != nil {<br>        return err<br>    }<br>    active := make([]OutputUser, 0, len(users))<br>    for _, user := range users {<br>        if user.Active {<br>            active = append(active, OutputUser{ID: user.ID})<br>        }<br>    }<br>    return json.NewEncoder(w).Encode(active)<br>}</pre><p>This version is straightforward, but it has a clear memory shape: the input slice keeps all decoded users in memory, and the output slice keeps all active users in memory before writing the response. For small payloads, this may be perfectly acceptable. For large payloads or high concurrency, the peak memory cost can become more important than small local allocation optimizations.</p><p>A streaming version changes the shape of the problem. Instead of holding the entire input and output at once, it reads one JSON element at a time and writes matching results progressively:</p><pre>func FilterActiveUsersStreaming(r io.Reader, w io.Writer) error {<br>    dec := json.NewDecoder(r)<br><br>    token, err := dec.Token()<br>    if err != nil {<br>        return err<br>    }<br>    if delimiter, ok := token.(json.Delim); !ok || delimiter != &#39;[&#39; {<br>        return fmt.Errorf(&quot;expected JSON array&quot;)<br>    }<br>    if _, err := io.WriteString(w, &quot;[&quot;); err != nil {<br>        return err<br>    }<br>    first := true<br>    enc := json.NewEncoder(w)<br>    for dec.More() {<br>        var user InputUser<br>        if err := dec.Decode(&amp;user); err != nil {<br>            return err<br>        }<br>        if !user.Active {<br>            continue<br>        }<br>        if !first {<br>            if _, err := io.WriteString(w, &quot;,&quot;); err != nil {<br>                return err<br>            }<br>        }<br>        first = false<br>        if err := enc.Encode(OutputUser{ID: user.ID}); err != nil {<br>            return err<br>        }<br>    }<br>    token, err = dec.Token()<br>    if err != nil {<br>        return err<br>    }<br>    if delimiter, ok := token.(json.Delim); !ok || delimiter != &#39;]&#39; {<br>        return fmt.Errorf(&quot;expected end of JSON array&quot;)<br>    }<br>    _, err = io.WriteString(w, &quot;]&quot;)<br>    return err<br>}</pre><p>Here, json.Decoder reads from the io.Reader incrementally. The first Token call validates that the input starts with a JSON array. dec.More() then reports whether there are more elements inside that array. Each call to Decode materializes only one InputUser at a time. On the output side, json.Encoder writes each selected OutputUser to the io.Writer as soon as it is available. The first flag exists only to keep the JSON array valid by inserting commas between elements.</p><p>This version is more complex, but it changes peak memory behavior. It does not need a []InputUser containing the full request and it does not need a []OutputUser containing the full response. That can matter more than replacing one string-building pattern with another, because the optimization changes the architecture of data flow instead of only changing one local operation.</p><p>The recommendation is conditional: choose streaming when it preserves correctness and reduces peak memory without making the protocol, validation, or error handling unreasonably complex. For small payloads, full decoding may be simpler and good enough. For large payloads, high concurrency, or pipelines that naturally process one item at a time, streaming can be the more memory-aware design.</p><h3>A practical memory optimization workflow</h3><p>A practical workflow is deliberately boring.</p><p>First, identify a symptom: high RSS, GC CPU, tail latency, out-of-memory kills, high allocation rate, or goroutine growth. Second, collect evidence with benchmarks, heap profiles, allocation profiles, goroutine profiles, runtime metrics, or traces. Third, isolate a code path. Fourth, make one change. Fifth, measure again.</p><p>The first optimizations to consider are usually the least clever ones: preallocate when size is known, avoid avoidable formatting in hot loops, copy small slices that retain large arrays, bound caches, stream large data, cancel goroutines, and remove accidental retention. Only after that should pooling or deeper rewrites enter the discussion.</p><p>That order keeps Go code readable. It also matches the central message of the whole series: good memory management in Go is not manual control. It is understanding lifetime, allocation behavior, garbage collection, measurement, and trade-offs.</p><h3>Conclusion</h3><p>Memory optimization in Go starts with measurement. -benchmem shows allocation cost per operation. -memprofile and go tool pprof show where allocation and retained heap accumulate. net/http/pprof helps connect runtime behavior to production-like workloads. Escape analysis explains compiler decisions, but benchmarks and profiles decide whether those decisions matter.</p><p>The best optimizations usually make lifetime clearer: preallocate known sizes, avoid accidental retention, bound caches, cancel goroutines, stream large data, and use specialized builders only when the path is hot. The goal is not to eliminate every allocation. The goal is to make memory behavior predictable enough for production.</p><h3>💬 Join the Discussion</h3><p>What memory optimization has paid off most in your Go services: preallocation, reducing formatting, avoiding slice retention, bounding caches, streaming, pprof-driven fixes, or careful sync.Pool usage? Share the benchmark or production signal that proved it worked.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Go, #pprof, #Benchmarks, #Memory Optimization, #Performance Engineering</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6db855596345" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Goroutines, Retained Memory, and Leaks in Go]]></title>
            <link>https://medium.com/@cassius.paim/goroutines-retained-memory-and-leaks-in-go-7721193f41ff?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/7721193f41ff</guid>
            <category><![CDATA[memory-management]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[concurrency]]></category>
            <category><![CDATA[goroutines]]></category>
            <category><![CDATA[memory-leak]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Wed, 10 Jun 2026 12:27:53 GMT</pubDate>
            <atom:updated>2026-06-10T12:27:53.813Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I think about concurrency, reachability, and memory leaks at scale</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eQA-epFtEEYVo3FXcvW7ZA.png" /></figure><blockquote>“Program testing can be used to show the presence of bugs, but never to show their absence.”</blockquote><blockquote><em>Edsger W. Dijkstra, “Notes on Structured Programming,” in </em>Structured Programming<em>, Academic Press, 1972, pp. 1–82.</em></blockquote><p>This is the fourth article in the series. The previous article focused on everyday allocations: slices, maps, strings, values, pointers, and abstractions. Now we move to a more operational problem: Go programs can leak memory even though Go has a garbage collector.</p><p>That statement sounds contradictory only if we define leaks as “forgotten frees.” In a garbage-collected language, many leaks are retained-memory problems. The program still holds references to data it no longer needs, so the garbage collector must keep that data alive. The official Go GC guide explains the collector in terms of reachability and live heap, which is the key conceptual foundation for this article: <a href="https://go.dev/doc/gc-guide">https://go.dev/doc/gc-guide</a>.</p><p>Concurrency amplifies this problem because goroutines, channels, timers, tickers, contexts, and closures can all extend lifetimes in ways that are not visually obvious.</p><h3>Goroutines are lightweight, not free</h3><p>Each goroutine has its own stack, and Go stacks grow dynamically. This design makes it practical to create many goroutines, but every goroutine still consumes runtime resources. A goroutine has a stack, scheduler metadata, and often references to application data. If a goroutine remains blocked forever, everything reachable from that goroutine can remain live.</p><p>A simple worker illustrates the issue:</p><pre>func worker(ch &lt;-chan int) {<br>    for v := range ch {<br>        fmt.Println(v)<br>    }<br>}</pre><p>This worker exits only when ch is closed. If the channel is never closed and no more values are sent, the goroutine can remain blocked indefinitely. If it captured or referenced request-scoped data, that data may be retained longer than expected.</p><p>The problem is not that this worker is wrong. It may be perfectly correct when ownership of channel closure is clear. The problem appears when lifecycle is implicit. Production goroutine design needs an exit path that matches the system’s ownership model.</p><p>That naturally leads to cancellation.</p><h3>Context cancellation is a memory-management tool</h3><p>context.Context is often discussed as a request cancellation or timeout mechanism, but it is also part of memory management because it defines when work should stop. The official context package documentation says that the function returned by WithCancel, WithDeadline, or WithTimeout releases resources associated with the context and should be called as soon as operations complete: <a href="https://pkg.go.dev/context">https://pkg.go.dev/context</a>.</p><p>That is a prescriptive standard-library recommendation, not just style. Failing to cancel contexts can keep timers, child contexts, and work alive longer than intended. In servers, that can translate into retained memory and goroutine buildup under error paths.</p><p>In practice, a goroutine that performs request-scoped work should usually select on ctx.Done() if it may block. A channel pipeline should have a clear owner responsible for close or cancellation. A background loop should have a shutdown signal.</p><p>Once goroutines have explicit lifetimes, we can inspect the data structures they interact with.</p><h3>Channels can retain references</h3><p>A channel buffer can hold values, and those values may include pointers to large objects. As long as a value remains in the channel, it is reachable. A blocked sender or receiver can also keep references alive through its stack.</p><p>This matters in pipelines. Suppose one stage produces large payloads and sends pointers into a buffered channel. If downstream slows or exits early without draining or cancellation, the channel may retain payloads. The garbage collector cannot reclaim them because the channel still references them.</p><p>The operational rule I use is: design channel ownership and shutdown before optimizing channel performance. That recommendation is based on the memory model of reachability and on Go’s documented channel semantics in the language specification: <a href="https://go.dev/ref/spec#Channel_types">https://go.dev/ref/spec#Channel_types</a>. If a channel is part of a long-lived component, its buffering and closure policy are memory decisions, not only concurrency decisions.</p><p>This is also why fan-out/fan-in systems should be profiled under failure and cancellation paths, not only happy paths. Many leaks appear when one component exits and another keeps waiting.</p><h3>Closures can preserve more than expected</h3><p>Closures capture variables, not just values. When a closure outlives the function that created it, captured state may need to outlive that frame. That can be exactly what we want, such as an accumulator or callback. It can also retain request objects, buffers, loggers, or dependencies longer than expected.</p><p>The key review question is: what did this closure capture, and how long can the closure live? A closure stored in a global registry has a different lifetime than a closure passed to a short synchronous helper. A goroutine closure has a different lifetime than a local function call.</p><p>Escape analysis can help identify when captured variables move to the heap, but it does not decide whether the lifetime is architecturally correct. It only preserves correctness under the lifetime expressed by the code.</p><p>The same distinction applies to caches and maps.</p><h3>Unbounded maps are leaks with good intentions</h3><p>A map that only grows is a retention mechanism. If it is intended to be a cache, it needs a cache policy: maximum size, expiration, eviction, segmentation, or explicit invalidation. Without such a policy, the garbage collector will keep entries alive as long as the map is reachable.</p><p>This is not a Go-specific problem, but Go makes it easy to express maps directly, so accidental caches are common. A package-level map used for deduplication, memoization, or request tracking can quietly become a memory leak if entries are never removed.</p><p>The fix is architectural rather than syntactic. Decide the ownership of the data. Decide when the data becomes invalid. Decide what happens under high cardinality. Then implement the map around that lifecycle.</p><p>Once you view maps through reachability, slice leaks become another form of the same problem.</p><h3>Small references can retain large memory</h3><p>A small slice can retain a large backing array. A small struct can hold a pointer to a large graph. A channel value can hold a pointer to a large payload. A map key or value can retain more than its visible size suggests.</p><p>The garbage collector sees object graphs. Humans often see local code snippets. Memory leaks happen in the gap between those views.</p><p>For slices, the fix may be to copy the needed subset into new storage. For maps, the fix may be deletion or bounded eviction. For goroutines, the fix may be cancellation. For channels, the fix may be drain, close, or backpressure. For global variables, the fix may be removing global lifetime from data that is not truly global.</p><p>The common principle is to reduce unintended reachability.</p><h3>Timers, tickers, and background loops need lifecycle ownership</h3><p>Timers and tickers can also participate in leaks when their lifecycle is unclear. The standard library documentation for time.Ticker states that a ticker can be stopped to release associated resources: <a href="https://pkg.go.dev/time#Ticker.Stop">https://pkg.go.dev/time#Ticker.Stop</a>. Modern Go versions have improved timer garbage collection behavior, but explicit lifecycle still matters for correctness and for long-lived loops.</p><p>A background loop should answer three questions. What starts it? What stops it? What does it retain while running? If those answers are not clear, the loop is a leak candidate.</p><p>This is especially important in services with reloadable configuration, tenant-specific workers, subscription streams, or long polling. The memory leak may not appear in unit tests because the lifecycle is driven by production behavior.</p><p>That brings us to the measurement step.</p><h3>How to confirm retained memory in production systems</h3><p>You cannot reliably identify retained memory from code reading alone. Code review can find likely patterns, but profiles show whether those patterns matter. Heap profiles can show where memory is allocated and retained. Goroutine profiles can show blocked goroutines. Runtime metrics can show heap growth and GC behavior. The official Go diagnostics page points to profiling and runtime tools for this investigation: <a href="https://go.dev/doc/diagnostics">https://go.dev/doc/diagnostics</a>.</p><p>A useful way to analyze memory growth is to separate two questions. First, is the service allocating heavily but eventually releasing memory? Second, are objects remaining reachable longer than intended? The first problem points to allocation pressure. The second points to retained memory.</p><p>The fifth and final article turns that investigation into a hands-on workflow with benchmarks, -benchmem, memory profiles, go tool pprof, and practical optimization techniques.</p><h3>Conclusion</h3><p>Go’s garbage collector reclaims unreachable heap objects. It does not reclaim objects that are still referenced by goroutines, channels, maps, slices, globals, closures, timers, or caches. In other words, memory can keep growing in Go not because the program forgot to call free, but because the program is still holding references to data it no longer needs.</p><p>The practical goal is to make lifetimes explicit. Goroutines need exit paths. Channels need ownership. Caches need bounds. Contexts need cancellation. Slices need careful copying when small views retain large arrays. Once the lifecycle is clear, the runtime can do its job.</p><h3>💬 Join the Discussion</h3><p>What kind of memory leak have you seen most often in Go: goroutine leaks, unbounded maps, slice retention, channels holding references, context cancellation issues, or cache growth? I am especially interested in how your team detected the issue and what changed afterward.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Go, #Goroutines, #Memory Leaks, #Concurrency, #Memory Management</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7721193f41ff" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Everyday Allocations in Go: Slices, Maps, Strings, Values, and Pointers]]></title>
            <link>https://medium.com/@cassius.paim/everyday-allocations-in-go-slices-maps-strings-values-and-pointers-892c6c49e72b?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/892c6c49e72b</guid>
            <category><![CDATA[golang-string]]></category>
            <category><![CDATA[golang-map]]></category>
            <category><![CDATA[golang-slices]]></category>
            <category><![CDATA[performance-engineering]]></category>
            <category><![CDATA[golang]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Wed, 03 Jun 2026 20:54:27 GMT</pubDate>
            <atom:updated>2026-06-03T20:54:27.528Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I think about allocation behavior in ordinary production code</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zh9Z2aD1dgQxMUEJMDWITQ.png" /></figure><blockquote>“Plan to throw one away; you will, anyhow.”</blockquote><blockquote><em>Frederick P. Brooks Jr., </em>The Mythical Man-Month: Essays on Software Engineering<em>, Addison-Wesley, 1975, Chapter 11.</em></blockquote><p>This is the third article in the series. The first article explained stack, heap, and escape analysis. The second article connected heap allocation to garbage collection, allocation pressure, and runtime trade-offs. Now we can move into everyday Go code: the ordinary constructs that create, retain, copy, and share memory.</p><p>This article is analytical rather than hands-on. I will show small code fragments, but the goal is not to prescribe micro-optimizations. The goal is to build a sharper eye for when normal Go patterns allocate, when that matters, and how data structure semantics affect lifetime.</p><p>The official Go specification is the primary language reference for built-ins such as make, new, append, slices, maps, and strings. The current specification is available at <a href="https://go.dev/ref/spec">https://go.dev/ref/spec</a>, and the sections on making slices/maps/channels, appending/copying slices, and string types are especially relevant: <a href="https://go.dev/ref/spec#Making_slices_maps_and_channels">https://go.dev/ref/spec#Making_slices_maps_and_channels</a>, <a href="https://go.dev/ref/spec#Appending_and_copying_slices">https://go.dev/ref/spec#Appending_and_copying_slices</a>, and <a href="https://go.dev/ref/spec#String_types">https://go.dev/ref/spec#String_types</a>.</p><p>With that baseline, we can look at the code patterns that usually deserve attention in hot paths.</p><h3>Allocation is common in normal Go code</h3><p>Go code allocates memory through explicit built-ins such as make and new, but also through operations that are less visually obvious. append may allocate when a slice needs a larger backing array. Map growth may allocate as the table expands. String construction can allocate. Formatting with fmt.Sprintf often allocates. JSON encoding can allocate. Interface conversions, reflection, closures, and goroutines can all influence allocation behavior depending on code shape.</p><p>None of these tools are bad. In fact, they are part of writing clear Go. The mistake is to treat allocation as surprising only when new appears. The more useful habit is to ask whether a value is created, whether a backing store grows, whether data is copied, and whether a reference keeps something alive longer than expected.</p><p>That habit becomes especially important with slices, because a slice is small as a value but can retain a large backing array.</p><h3>Slices are descriptors over arrays</h3><p>A slice value describes a segment of an underlying array. Conceptually, it contains a pointer to array storage, a length, and a capacity. The Go specification defines slice expressions and describes how capacity is derived from the underlying array: <a href="https://go.dev/ref/spec#Slice_expressions">https://go.dev/ref/spec#Slice_expressions</a>.</p><p>This is why copying a slice value is cheap but not necessarily independent. Two slice values can point to the same underlying array. Mutating one can affect the other if they overlap. Appending to one may reuse the same backing array or allocate a new one if capacity is exceeded.</p><p>The subtle production issue is retention. Consider this function:</p><pre>func firstBytes(data []byte) []byte {<br>    return data[:10]<br>}</pre><p>The returned slice has length 10, but it can keep the entire backing array of data alive. If data came from a 20 MB payload, a 10-byte result may retain 20 MB. The garbage collector sees the backing array as reachable because the returned slice still points into it.</p><p>When that matters, the fix is to copy the small data into new storage with the intended lifetime:</p><pre>func firstBytesCopy(data []byte) []byte {<br>    out := make([]byte, 10)<br>    copy(out, data[:10])<br>    return out<br>}</pre><p>This intentionally allocates a small new backing array to release a large old one. It is a perfect example of why “fewer allocations” is not always the right goal. Sometimes the right allocation reduces retained memory.</p><p>Once slice retention is clear, map growth becomes easier to reason about.</p><h3>Maps allocate for buckets and growth</h3><p>A Go map is a hash table managed by the runtime. The language specification defines map types and the make(map[K]V, size) form, where the optional capacity hint can reserve initial space: <a href="https://go.dev/ref/spec#Map_types">https://go.dev/ref/spec#Map_types</a> and <a href="https://go.dev/ref/spec#Making_slices_maps_and_channels">https://go.dev/ref/spec#Making_slices_maps_and_channels</a>.</p><p>The capacity hint is not a precise allocation contract, but it communicates expected scale. In hot paths or bulk loading code, creating a map with a reasonable size hint can reduce growth work. For example:</p><pre>func indexUsers(users []User) map[string]User {<br>    byID := make(map[string]User, len(users))<br>    for _, user := range users {<br>        byID[user.ID] = user<br>    }<br>    return byID<br>}</pre><p>This is not a universal rule that every map needs a size hint. For small maps, cold paths, or unknown cardinality, adding a hint may not matter. A production-oriented recommendation is conditional: when approximate cardinality is already known and the code is allocation-sensitive, size the map intentionally. That recommendation is grounded in the language’s documented capacity hint and should still be verified with benchmarks when performance is the motivation.</p><p>Maps can also retain memory by design. A map that only grows is a cache without an eviction policy. The garbage collector cannot collect values that are still referenced by map entries. That moves us from allocation into lifecycle management, which becomes central in the fourth article.</p><p>Before that, strings deserve their own treatment because their immutability changes how construction works.</p><h3>Strings are immutable, so construction strategy matters</h3><p>The Go specification defines strings as immutable sequences of bytes: <a href="https://go.dev/ref/spec#String_types">https://go.dev/ref/spec#String_types</a>. Immutability makes strings safe and simple to reason about, but repeated construction can allocate. A loop that repeatedly concatenates strings may create intermediate strings, especially when the compiler cannot optimize the pattern into a single allocation.</p><p>In hot paths, strings.Builder is the standard library tool designed to build strings efficiently. Its documentation states that a Builder is used to efficiently build a string using write methods, and it provides Grow to reserve capacity: <a href="https://pkg.go.dev/strings#Builder">https://pkg.go.dev/strings#Builder</a>.</p><p>A small example makes the intent visible:</p><pre>func joinIDs(ids []string) string {<br>    var b strings.Builder<br>    for i, id := range ids {<br>        if i &gt; 0 {<br>            b.WriteByte(&#39;,&#39;)<br>        }<br>        b.WriteString(id)<br>    }<br>    return b.String()<br>}</pre><p>This does not mean every + should become a builder. Simple concatenation is readable and often optimized well enough. The builder becomes relevant when repeated construction is measured in a hot path or when expected output size is known and Grow can reduce reallocations.</p><p>The same reasoning applies to bytes.Buffer for byte-oriented construction, documented at <a href="https://pkg.go.dev/bytes#Buffer">https://pkg.go.dev/bytes#Buffer</a>. Pick the tool that matches the data model: strings for text, bytes for byte streams.</p><h3>Interfaces, reflection, closures, and goroutines can hide allocation behavior</h3><p>Some allocation sources are less obvious because they are tied to abstraction. Passing concrete values through interfaces may require boxing depending on the value and usage. Reflection can allocate because it manipulates dynamic representations. Closures can allocate captured state when that state outlives the current frame. Goroutines can force values to survive longer than the launching function.</p><p>The important thing is not to fear these features. Interfaces are central to Go design. Reflection powers serialization, testing helpers, and frameworks. Closures are expressive. Goroutines are the language’s concurrency model. The production rule is narrower: when one of these appears in a hot path, check whether it is contributing to allocation pressure before rewriting it.</p><p>That check should combine escape analysis and benchmarks. Escape analysis can show why a value moved to the heap. Benchmarks with -benchmem can show whether the path allocates per operation. Profiles can show whether those allocations matter in aggregate. The official diagnostics page provides the map of Go tools for that workflow: <a href="https://go.dev/doc/diagnostics">https://go.dev/doc/diagnostics</a>.</p><p>With abstractions covered, we can address one of the most persistent Go performance myths.</p><h3>Values versus pointers is a semantic decision first</h3><p>Passing a struct by value copies it. Passing a pointer copies the pointer and permits mutation of the pointed-to object. For small immutable values, copying can be cheap and clear. For large structs, pointer passing can avoid copying but may introduce indirection, aliasing, and possible heap lifetime.</p><p>Consider a small value type:</p><pre>type Point struct {<br>    X, Y int<br>}</pre><pre>func Move(p Point) Point {<br>    p.X++<br>    return p<br>}</pre><p>This function has value semantics. It does not mutate the caller’s value. The code is easy to reason about, and for a tiny struct the copy is usually not the bottleneck.</p><p>Now compare the pointer version:</p><pre>func MoveInPlace(p *Point) {<br>    p.X++<br>}</pre><p>This function communicates mutation. That may be the correct API when in-place update is intended. It is not automatically faster. If the pointer causes a value to escape, or if pointer-rich structures reduce locality, performance may get worse.</p><p>A safer recommendation is: choose value or pointer based on semantics first, then measure performance where it matters. This avoids the false rule that “pointer is faster” and keeps API design aligned with ownership and mutation.</p><h3>Conclusion</h3><p>Everyday Go code allocates in more places than new. Slices can retain large arrays. Maps grow and retain entries. Strings are immutable, so construction strategy matters in hot paths. Interfaces, reflection, closures, and goroutines can influence lifetime. Values and pointers encode semantics before performance.</p><p>The next article takes this memory model into concurrency and leaks. We will look at goroutine stacks, blocked goroutines, channels, timers, tickers, contexts, unbounded maps, and retained references in garbage-collected systems.</p><h3>💬 Join the Discussion</h3><p>Which everyday Go allocation surprised you most in production: slices retaining payloads, maps growing without bounds, string construction, reflection, interfaces, or pointer-heavy APIs? I would like to hear examples where the measured result contradicted the team’s intuition.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Go, #Slices, #Maps, #Strings, #Pointers, #Performance Engineering</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=892c6c49e72b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Garbage Collection and Allocation Pressure in Go]]></title>
            <link>https://medium.com/@cassius.paim/garbage-collection-and-allocation-pressure-in-go-12118531fb10?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/12118531fb10</guid>
            <category><![CDATA[gogc]]></category>
            <category><![CDATA[gomemlimit]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[production-performance]]></category>
            <category><![CDATA[garbage-collection]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sun, 24 May 2026 13:43:27 GMT</pubDate>
            <atom:updated>2026-05-24T13:43:27.476Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I think about heap growth, latency, and runtime trade-offs at scale</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*M0DB6PrIChFRD409pDmImQ.png" /></figure><blockquote>“We should forget about small efficiencies.”</blockquote><blockquote><em>Donald E. Knuth, “Structured Programming with go to Statements,” </em>ACM Computing Surveys<em>, Vol. 6, №4, ACM, 1974, p. 268.</em></blockquote><p>This is the second article in the series. In the first article, we built a mental model for stack, heap, and escape analysis. That model explained why some values can live inside function lifetime while others need heap lifetime. Now we need to follow the consequence: once objects live on the heap, the runtime must decide when they are still useful and when their memory can be reclaimed.</p><p>Go uses garbage collection so developers do not manually free heap objects. That is a major simplification for correctness and maintainability, but it does not make heap memory free. The official Go GC guide is explicit that garbage collection has costs, and it frames those costs around live heap, new heap allocation, CPU overhead, and memory usage. It is the primary source I use for this article: <a href="https://go.dev/doc/gc-guide">https://go.dev/doc/gc-guide</a>.</p><p>The right production question is not whether the garbage collector is good or bad. The question is whether our allocation behavior creates a cost profile that fits the latency, throughput, and memory envelope of the service.</p><h3>What the garbage collector actually collects</h3><p>The garbage collector is responsible for heap memory. More precisely, it identifies heap objects that are no longer reachable from the program’s roots and makes their memory reusable. It does not collect values that are still reachable, even if the application will never use them again. Reachability is a runtime property, not an expression of developer intent.</p><p>That distinction explains many production memory problems. If a map keeps references to request objects, those objects are live. If a channel buffer holds pointers to large payloads, those payloads are live. If a small slice points into a large backing array, the backing array can remain live. The collector cannot infer that we are “done” with an object when a reachable reference still exists.</p><p>This is why Go can have memory leaks even with a garbage collector. The leak is not usually a forgotten free. It is retained memory: objects remain reachable through maps, caches, goroutines, channels, closures, globals, or slices. We will examine those leak patterns in the fourth article. For now, the important point is that the GC only reclaims unreachable heap objects.</p><p>Once we understand what can be collected, the next question is what makes collection expensive.</p><h3>Allocation rate and live heap shape GC cost</h3><p>The GC has work to do when programs allocate. New heap objects increase the amount of memory the runtime must eventually account for. Live objects increase the amount of memory the collector must trace and preserve. A service that allocates many short-lived objects creates allocation throughput for the runtime to process. A service that retains many objects increases live heap and may increase scanning work.</p><p>The Go GC guide describes this cost model in terms of a trade-off between CPU cost and memory cost. A lower heap target generally means the collector runs more often and uses more CPU, but keeps memory usage lower. A higher heap target can reduce GC CPU frequency but allows more memory growth before collection. This is not a moral trade-off; it is an operational one. The reference model is documented in the “Understanding costs,” “GOGC,” and “Memory limit” sections of <a href="https://go.dev/doc/gc-guide">https://go.dev/doc/gc-guide</a>.</p><p>In production, allocation pressure is the combination of allocation rate, object lifetime, and live heap size. A service can allocate frequently without catastrophic latency if those allocations are small, short-lived, and within the service’s CPU budget. Another service can allocate less frequently but retain large structures and create memory pressure at the container or node level.</p><p>That means a good memory review does not count allocations in isolation. It asks where allocations happen, how large they are, how long they survive, and whether they occur on critical paths.</p><h3>Why too many allocations can hurt latency</h3><p>Allocations are not only about memory size. Allocation rate can translate into CPU work, synchronization pressure inside the allocator, more frequent garbage collection, and cache effects. In latency-sensitive services, those costs may show up as higher tail latency even when average latency looks acceptable.</p><p>The safe way to state this is not “allocations are always bad.” Allocations are normal in Go. The Go standard library itself uses heap allocation where it improves API design or correctness. The more accurate claim is: allocations inside hot paths deserve measurement because they can increase runtime work. That position is consistent with the Go diagnostics documentation, which points developers to profiling, tracing, runtime statistics, and pprof when diagnosing performance issues: <a href="https://go.dev/doc/diagnostics">https://go.dev/doc/diagnostics</a>.</p><p>This is also why reducing allocations can improve performance. If a hot path creates fewer heap objects while preserving readability and semantics, the runtime has less heap work to process. But the word “can” matters. A change that reduces one allocation while adding complexity, lock contention, copying, or cache misses may not improve the system.</p><p>This is the core discipline: optimize only where the cost is measured, and prefer changes that make lifetime clearer rather than merely clever.</p><h3>Understanding GOGC without treating it as a magic knob</h3><p>GOGC controls the trade-off between GC CPU and memory growth. The official Go GC guide explains it as a percentage that defines the target heap growth relative to the live heap after the previous collection, with the default value being GOGC=100: <a href="https://go.dev/doc/gc-guide#GOGC">https://go.dev/doc/gc-guide#GOGC</a>. A GC cycle is one complete collection round in which the runtime identifies reachable objects and makes unreachable heap memory available for reuse. Lower GOGC values make the collector run more frequently and keep the heap smaller; higher values allow more heap growth between GC cycles.</p><p>A safer way to treat GOGC is as a tuning mechanism for an already understood memory/CPU trade-off. It is not a substitute for fixing avoidable retention, unbounded caches, or allocation-heavy hot paths. Turning GOGC down can reduce peak memory, but it usually increases the amount of CPU spent on garbage collection. Turning it up can reduce GC frequency, but it increases the application&#39;s memory footprint. Both moves can be correct in different operating envelopes.</p><p>For containerized services, this distinction matters. A service that runs close to its memory limit may need a different strategy than a batch job optimized for throughput. The same application can prefer different GC behavior depending on whether the bottleneck is CPU, latency, memory, or cost.</p><p>Once memory limits enter the discussion, GOGC is no longer the only runtime-level control worth knowing.</p><h3>GOMEMLIMIT and memory-aware operation</h3><p>Go also supports a soft memory limit through GOMEMLIMIT and the corresponding runtime API. The official GC guide explains that this setting gives the runtime a target for total memory use, rather than making GC decisions only from the heap growth ratio controlled by GOGC: <a href="https://go.dev/doc/gc-guide#Memory_limit">https://go.dev/doc/gc-guide#Memory_limit</a>. The runtime/debug package exposes the same setting programmatically through SetMemoryLimit: <a href="https://pkg.go.dev/runtime/debug#SetMemoryLimit">https://pkg.go.dev/runtime/debug#SetMemoryLimit</a>.</p><p>A useful way to think about GOMEMLIMIT is as an operational guardrail. It is especially useful when a Go process runs inside a container with a fixed memory limit, because it helps the runtime make garbage collection decisions with a concrete memory budget in mind. For example, if a container is limited to 1 GiB, setting GOMEMLIMIT below that value can give the runtime room to react before the process risks being killed by an out-of-memory condition.</p><p>But GOMEMLIMIT is not a substitute for application-level memory design. It cannot make an unbounded cache bounded, remove old entries from a map, stop a blocked goroutine, or make retained references disappear. If the application still keeps data reachable through a variable, slice, map, channel, closure, or goroutine, the garbage collector must treat that data as live.</p><p>That distinction is important. Runtime knobs can influence when the garbage collector runs and how aggressively it tries to stay within a memory budget, but the code still defines object lifetimes. In Go, memory-aware operation starts in the runtime, but memory discipline still belongs in the application design.</p><h3>Throughput, latency, and memory are connected decisions</h3><p>The GC trade-off is usually presented as throughput versus latency, but production systems also care about memory footprint, cloud cost, autoscaling behavior, and failure modes. A lower memory footprint can reduce infrastructure cost but increase CPU. More CPU for GC can affect request latency. A larger heap can improve throughput, but it also increases the risk of container out-of-memory kills when memory limits are tight.</p><p>This is why memory management in Go is an architectural concern, not just a micro-optimization concern. It affects API design, data ownership, batch sizes, cache policy, streaming strategy, and concurrency. A handler that reads an entire payload into memory expresses a different memory model than one that streams. A cache with no maximum size expresses a different operational contract than one with eviction.</p><p>The garbage collector will enforce reachability rules. It will not choose your service boundaries, cache policy, or data flow. Those decisions remain engineering decisions.</p><h3>Conclusion</h3><p>Go’s garbage collector removes manual memory reclamation from application code, but it does not remove the cost of heap lifetime. Heap objects must be traced, live data must be retained, and allocation-heavy hot paths can become CPU and latency problems.</p><p>The next article moves from runtime behavior into everyday Go code. We will look at where allocations appear in slices, maps, strings, interfaces, closures, and common library calls, and why measuring matters more than guessing.</p><h3>💬 Join the Discussion</h3><p>How does your team reason about GC behavior in production? Do you tune GOGC or GOMEMLIMIT, redesign allocation-heavy paths, enforce cache limits, or rely mostly on pprof after incidents? I would like to hear which trade-offs worked and which ones created surprises.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Go, #Garbage Collection, #GOGC, #GOMEMLIMIT, #Production Performance</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=12118531fb10" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Stack, Heap, and Escape Analysis in Go]]></title>
            <link>https://medium.com/@cassius.paim/stack-heap-and-escape-analysis-in-go-46ce4e405eeb?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/46ce4e405eeb</guid>
            <category><![CDATA[runtime]]></category>
            <category><![CDATA[memory-management]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[software-performance]]></category>
            <category><![CDATA[escape-analysis]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sun, 17 May 2026 14:46:09 GMT</pubDate>
            <atom:updated>2026-05-17T14:46:09.148Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I think about value lifetime, allocation behavior, and compiler decisions at scale</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FIHzIKq73nO8pHEuwTuj1g.png" /></figure><blockquote>“The competent programmer is fully aware of the strictly limited size of his own skull.”</blockquote><blockquote><em>Edsger W. Dijkstra, “The Humble Programmer” (EWD 340), Communications of the ACM, Vol. 15, №10, ACM, 1972, pp. 859–866; transcribed in the E.W. Dijkstra Archive, University of Texas at Austin: </em><a href="https://www.cs.utexas.edu/~EWD/transcriptions/EWD03xx/EWD340.html"><em>https://www.cs.utexas.edu/~EWD/transcriptions/EWD03xx/EWD340.html</em></a><em>.</em></blockquote><p>This is the first article in a five-part series on memory management in Go. The series starts with the question every production Go engineer eventually faces: <em>where do my values live, and why should I care?</em> Go does not ask us to manually allocate and free memory, but it still rewards engineers who understand lifetime, ownership, allocation pressure, and measurement.</p><p>The official Go garbage collector guide starts from the same principle: Go takes responsibility for arranging the storage of values, and in most everyday code the developer does not need to decide manually where values are stored. The deeper point is not that memory placement is irrelevant. It is that memory placement is a compiler/runtime decision, and good engineering comes from understanding the forces behind that decision. The official guide is the best primary reference for this mental model: <a href="https://go.dev/doc/gc-guide">https://go.dev/doc/gc-guide</a>.</p><p>That distinction is the foundation for the rest of the series. Before discussing profiling, leaks, or optimization, we need a shared language for stack, heap, and escape analysis.</p><h3>The stack is cheap because it follows function lifetime</h3><p>When a goroutine calls a function, the runtime needs space for that function’s local state: parameters, local variables, temporary values, and return bookkeeping. The stack is the natural place for values whose lifetime is bounded by a call chain. Allocation and release are simple because stack memory grows and shrinks with function execution.</p><p>This is why stack allocation is usually cheaper than heap allocation. It does not mean the stack is magic or free; it means the lifetime of the data is structurally obvious. When a function returns, the stack frame can be discarded as part of normal execution. There is no need for the garbage collector to later discover whether the value is still live.</p><p>In Go, each goroutine has its own stack. That matters because Go encourages concurrency with many goroutines, and a fixed large stack per goroutine would make that model expensive. Go stacks start small and grow dynamically as needed; the runtime manages this growth so application code does not need to reserve large stack segments manually. This design is one of the reasons goroutines are lightweight compared with operating system threads, but “lightweight” should not be confused with “free.” We will return to goroutine memory in the fourth article.</p><p>For now, the important idea is that stack allocation works well when lifetime is local and predictable. Once a value must outlive the frame that created it, the compiler needs another option. That is where the heap enters the model.</p><h3>The heap exists for values whose lifetime is not local</h3><p>The heap is used for objects whose lifetime cannot be represented as a simple call-stack lifetime. If a function creates a value and returns a pointer to it, that value cannot disappear when the function returns. If a closure captures a variable and the closure survives the function call, the captured state cannot be tied to the original stack frame. If a goroutine references data after the launching function returns, that data may need to survive independently.</p><p>Heap allocation solves that lifetime problem, but it creates runtime work. Heap objects must eventually be discovered as live or dead. The garbage collector traces reachable objects, keeps live objects, and reclaims objects that are no longer reachable. The Go GC guide describes Go’s collector as a tracing garbage collector and explains the cost model around live heap, new heap allocations, CPU work, and memory limits: <a href="https://go.dev/doc/gc-guide">https://go.dev/doc/gc-guide</a>.</p><p>This does not make heap allocation bad. Heap allocation is the correct mechanism when a value needs heap lifetime. The mistake is to treat “heap” as a moral category. A production service can allocate on the heap constantly and still be healthy if allocation rate, latency, and memory limit are acceptable. Another service can allocate far less and still have serious problems if it retains large objects unintentionally.</p><p>That is why the useful question is not “how do I avoid the heap?” The useful question is “what lifetime did I express, and what runtime work follows from it?” The compiler answers part of that question through escape analysis.</p><h3>Escape analysis is the compiler’s lifetime investigation</h3><p>Escape analysis is the compiler mechanism that decides whether a value can safely remain on the stack or must be moved to the heap. A value “escapes” when the compiler determines that its lifetime may extend beyond the function frame or that its address may be observed in a way that prevents stack-only storage.</p><p>The classic example is returning a pointer to a local variable:</p><pre>func NewCounter() *int {<br>    value := 0<br>    return &amp;value<br>}</pre><p>This code is safe in Go. The compiler is not returning a dangling pointer to a dead stack frame. Instead, escape analysis sees that value must survive the return, so the value is placed where it can remain valid after the function exits. Conceptually, the variable escaped.</p><p>Closures create another common case:</p><pre>func Accumulator() func(int) int {<br>    total := 0<br>    return func(v int) int {<br>        total += v<br>        return total<br>    }<br>}</pre><p>The inner function captures total. Since the returned function can be called after Accumulator returns, the captured state needs a lifetime beyond the original stack frame. That does not mean closures are bad; it means closures encode lifetime. In hot paths, that encoded lifetime may matter.</p><p>Goroutines can create similar pressure. If a value is captured by a goroutine and the launching function returns, the compiler and runtime must preserve correctness. Again, the issue is not syntax. The issue is whether the value’s lifetime can be proven local.</p><p>The natural next question is how to see what the compiler decided instead of guessing.</p><h3>Inspecting compiler decisions without turning them into superstition</h3><p>Go exposes escape information through compiler diagnostics. The go build command passes compiler options through -gcflags; the official cmd/go documentation defines -gcflags as arguments passed to go tool compile: <a href="https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies">https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies</a>. In practice, these commands are useful starting points:</p><pre>go build -gcflags=&quot;-m&quot; ./...<br>go build -gcflags=&quot;-m -m&quot; ./...</pre><p>The first command gives useful escape and inlining output. The second increases detail and can be noisy. I treat the output as a diagnostic signal, not a scoreboard. When the compiler reports that something “moved to heap,” it is explaining a decision made under the current code shape, compiler version, target architecture, and optimization context.</p><p>That warning should lead to a question: did this allocation appear in a path that matters? If the answer is no, changing the code can easily reduce readability without improving the system. If the answer is yes, escape output can point to the lifetime expression that created the allocation.</p><p>This is the first place where Go memory work becomes engineering rather than trivia. We use compiler diagnostics to locate a possible cost, then benchmarks and profiles to decide whether that cost is relevant.</p><h3>Pointers do not automatically mean heap allocation</h3><p>A common simplification says that using a pointer means allocating on the heap. That is not how Go works. A pointer is a way to reference a value. Heap allocation is a storage decision. The two often correlate, but they are not equivalent.</p><p>A function can accept a pointer to a stack-allocated value:</p><pre>type Point struct {<br>    X, Y int<br>}</pre><pre>func Move(p *Point) {<br>    p.X++<br>}</pre><pre>func Example() Point {<br>    p := Point{X: 1, Y: 2}<br>    Move(&amp;p)<br>    return p<br>}</pre><p>Whether p escapes depends on how the pointer is used, not merely on the presence of &amp;p. If the pointer does not outlive the caller’s frame and is not stored somewhere with a longer lifetime, the compiler may keep the value on the stack. The exact decision is implementation-specific, so the correct habit is to inspect and measure rather than generalize.</p><p>This distinction matters because pointer-heavy code can also hurt performance in ways unrelated to heap allocation. Pointers can add indirection, reduce locality, complicate aliasing, and make mutation harder to reason about. Passing everything by pointer because “pointers are faster” is a weak rule. Passing a value or pointer should start from semantics: ownership, mutation, size, and lifetime.</p><p>Once we stop treating pointers as a shortcut, we can discuss the real runtime consequence of heap lifetime: garbage collection.</p><h3>A practical mental model for production code</h3><p>In production systems, I find it useful to classify memory decisions through four questions.</p><p>First, what is the lifetime of the value? If the value exists only inside a call, the stack is the natural fit. If the value must survive beyond that call, heap lifetime may be necessary.</p><p>Second, what references keep the value alive? A small slice, a closure, a channel buffer, a map entry, or a goroutine can retain much more memory than the code visually suggests.</p><p>Third, is this path hot enough to care? Allocating in a startup path or admin endpoint rarely deserves the same attention as allocating per request in a high-QPS service.</p><p>Fourth, did we measure? Go gives us escape diagnostics, benchmarks, memory profiles, runtime metrics, and pprof. The best production decisions use those tools together.</p><p>This model deliberately avoids absolutism. Stack is usually cheaper, but heap is not a failure. Pointers are useful, but not automatically faster. The garbage collector removes manual free logic, but it does not remove memory economics.</p><h3>Conclusion</h3><p>Go memory management starts with lifetime. The stack is efficient when values are local to execution. The heap is necessary when values must survive independently. Escape analysis connects the two by translating code shape into storage decisions.</p><p>The next article builds on this foundation by focusing on the garbage collector itself: what it collects, how allocation pressure affects latency and CPU, and how GOGC and GOMEMLIMIT fit into the operational model of a production Go service.</p><h3>💬 Join the Discussion</h3><p>How do you currently reason about stack versus heap in Go reviews? Do you rely on escape analysis output, benchmarks, production profiles, or mostly intuition? I am especially interested in the rules your team has abandoned after measuring real systems.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Go, #Memory Management, #Escape Analysis, #Runtime, #Software Performance</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=46ce4e405eeb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How I Would Design an AI-Powered Mother’s Day Experience with Go]]></title>
            <link>https://medium.com/@cassius.paim/how-i-would-design-an-ai-powered-mothers-day-experience-with-go-073b2bc3af60?source=rss-3527a01bf6d------2</link>
            <guid isPermaLink="false">https://medium.com/p/073b2bc3af60</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[llm-architecture]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[system-design-concepts]]></category>
            <dc:creator><![CDATA[Cássius Paim]]></dc:creator>
            <pubDate>Sun, 10 May 2026 20:05:21 GMT</pubDate>
            <atom:updated>2026-05-10T20:05:21.767Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I think about LLM product architecture, privacy, and reliability at seasonal scale</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k6Qd1s2iTJ2XU7oUE7qjNA.png" /></figure><blockquote><em>“The bearing of a child takes nine months, no matter how many women are assigned.”</em></blockquote><blockquote><em>Frederick P. Brooks Jr., </em>The Mythical Man-Month: Essays on Software Engineering<em>, Addison-Wesley, 1975, Chapter 2, “The Mythical Man-Month.”</em></blockquote><h3>This is a design exercise, not a production case study</h3><p>This article is not a postmortem of a real product I have built. It is a system design exercise: how I would approach the architecture of an AI-powered Mother’s Day experience using Go, large language models, asynchronous processing, observability, and privacy-first design principles.</p><p>I am making that distinction explicit because it matters. Technical writing should demonstrate thinking without overstating experience. A hypothetical architecture can still be valuable if it is honest about its scope, clear about its assumptions, and grounded in the constraints that real systems face once they leave the prototype phase.</p><p>The scenario is simple: imagine a consumer product wants to help users create thoughtful Mother’s Day messages, gift ideas, or reminders. The experience sounds emotionally lightweight, but the engineering problem is not trivial. It touches personalization, latency, cost, safety, privacy, traffic spikes, and the uncomfortable question of how much emotional expression should be delegated to software.</p><p>That tension is the useful part of the exercise. Before talking about Go, queues, or LLM calls, the first architectural decision is to define what the system should and should not try to become.</p><h3>The product should assist intention, not replace it</h3><p>A Mother’s Day AI experience should not try to automate affection. That is my opinion, not a universal product rule. The more defensible product goal is narrower: reduce friction around expression while keeping the user in control of meaning.</p><p>In practical terms, that means the system can suggest message drafts, tone variations, gift categories, or reminder flows, but it should not pretend to know the relationship better than the person using it. In an emotional context, personalization is powerful precisely because it is easy to overuse.</p><p>This framing also changes the technical design. If the product is assistance-oriented, the LLM becomes one component inside a larger system, not the product itself. The system needs boundaries around what data enters prompts, what outputs are allowed, how the user can edit the result, and how the platform behaves when generation fails.</p><p>The <a href="https://developers.openai.com/api/docs/guides/production-best-practices">OpenAI production best-practices guide</a> frames the move from prototype to production around secure access, robust architecture, and readiness for high traffic volumes. The same principle appears from a different angle in Google’s <a href="https://ai.google.dev/gemini-api/docs/safety-guidance">Gemini safety and factuality guidance</a>, which emphasizes that developers remain responsible for applying generative models safely and responsibly in their own applications. Together, those two vendor perspectives are useful here because a seasonal consumer experience can compress traffic, safety, factuality, and product risk into a short window.</p><p>Once the product goal is constrained, the architecture becomes easier to reason about. The next question is where Go fits in a system whose intelligence depends on an external model.</p><h3>Go is not the model layer; it is the production layer around the model</h3><p>When people discuss AI products, the conversation often collapses into model choice. Model quality matters, but most production failures around LLM features are not only model failures. They are also failures in request orchestration, timeouts, retries, cost control, input validation, observability, data handling, and fallback behavior.</p><p>That is where Go is a strong fit. I would not choose Go because it is the best language for training models. I would choose it because it is a pragmatic language for building the services around an LLM: HTTP APIs, workers, moderation gateways, cache layers, event consumers, and integration boundaries.</p><p>The <a href="https://go.dev/doc/">official Go documentation</a> presents the language through simplicity, tooling, packages, and concurrency primitives, which are directly useful for service-oriented systems that need predictable operational behavior.</p><p>For the API layer specifically, Go’s <a href="https://pkg.go.dev/net/http">net/http package</a> is part of the standard library and provides the foundation for HTTP clients and servers. That matters in this design because an LLM-backed product will spend much of its life managing network calls: from user-facing APIs to model providers, cache systems, catalogs, analytics, and internal services.</p><p>The important point is not that Go magically solves scalability. It does not. The point is that Go gives the surrounding system a simple, observable, deployable shape. With that role defined, we can move from language choice to system boundaries.</p><h3>A high-level architecture for the experience</h3><p>I would design the experience as a set of small services rather than one large “AI service.” The reason is not microservices enthusiasm. The reason is separation of risk. Prompt construction, user data handling, LLM invocation, content review, and delivery should not all be inseparable decisions.</p><p>A simplified architecture could look like this:</p><pre>User<br>  |<br>  v<br>Web or Mobile Client<br>  |<br>  v<br>Go API Gateway<br>  |<br>  +--&gt; Consent and Profile Service<br>  |<br>  +--&gt; Prompt Orchestration Service<br>  |       |<br>  |       +--&gt; Safety and Policy Layer<br>  |       |<br>  |       +--&gt; LLM Provider<br>  |<br>  +--&gt; Gift Recommendation Service<br>  |<br>  +--&gt; Message History / Draft Store<br>  |<br>  +--&gt; Async Job Queue<br>          |<br>          +--&gt; Go Workers for batch generation, reminders, and retries</pre><p>The API Gateway owns authentication, rate limits, request validation, and coarse-grained product flow. The Prompt Orchestration Service decides how user input becomes model input. The Safety and Policy Layer checks whether the request and response are acceptable. Workers handle delayed reminders, background enrichment, and retryable tasks that should not block the user interface.</p><p>A diagram like this hides the hardest part: data boundaries. The architecture only becomes meaningful when each edge has a policy. Which fields can be used for personalization? Which fields are never sent to an LLM? Which generated drafts are stored? For how long? Are they encrypted? Can users delete them? Can support staff access them?</p><p>These questions are not secondary. They are the core of the system design. Once an experience uses personal relationships as product context, privacy cannot be deferred to the end.</p><h3>Privacy is a product constraint, not only a compliance concern</h3><p>For this scenario, I would design around data minimization. The user should be able to get value without handing over a full personal history. A useful prompt might need the relationship context, preferred tone, and a few details the user chooses to provide. It does not need unrestricted access to contacts, message history, purchase history, or private notes by default.</p><p>This is both an ethical stance and an engineering simplification. Less collected data means fewer failure modes, fewer logs to sanitize, fewer access paths to audit, and fewer explanations owed to the user.</p><p>In LLM systems, privacy is also tied to prompt security. The <a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">OWASP Top 10 for LLM Applications 2025</a> lists risks such as Prompt Injection and Sensitive Information Disclosure, and those categories are relevant even in a seemingly harmless Mother’s Day assistant because the system may process user-provided text, relationship details, and potentially generated content intended for another person.</p><p>I would therefore treat prompts as sensitive operational artifacts. Prompt templates should be versioned. User-provided context should be separated from system instructions where the model API supports that separation. Logs should avoid storing raw personal content unless there is a clear product need, explicit retention policy, and proper access control.</p><p>Provider choice also belongs in the privacy design. Anthropic’s <a href="https://platform.claude.com/docs/en/manage-claude/api-and-data-retention">Claude API data-retention documentation</a> describes retention-related controls such as Zero Data Retention for eligible API use cases, while Google’s <a href="https://docs.cloud.google.com/vertex-ai/generative-ai/docs/learn/safety-overview">Vertex AI generative AI safety overview</a> frames safety as a layered concern across content, brand, alignment, security, and privacy. I would not treat those documents as interchangeable implementation recipes, but they reinforce the same architectural point: privacy and safety are not one middleware function; they are provider, data, logging, retention, and product-policy decisions working together.</p><p>This privacy posture changes how reliability is implemented. A system that refuses to store unnecessary data has fewer ways to recover from failure by “just replaying everything,” so it needs more deliberate operational design.</p><h3>Reliability means graceful degradation, not perfect generation</h3><p>In a seasonal experience, traffic does not arrive evenly. Mother’s Day creates a predictable deadline and a compressed usage window. The system may see spikes in the days before the date, and especially on the day itself. The product must be designed for this concentration of demand.</p><p>My default design would separate synchronous and asynchronous paths. Short interactions, such as generating one message draft, can happen synchronously if the latency budget is acceptable. Heavier tasks, such as producing several variations, running gift recommendation enrichment, or scheduling reminders, should move through a queue.</p><p>The Go services should use explicit timeouts, bounded concurrency, and cancellation propagation. Go’s <a href="https://pkg.go.dev/context">context package</a> is the standard mechanism for carrying deadlines, cancellation signals, and request-scoped values across API boundaries, which is especially relevant when a user-facing request fans out to model providers or internal services.</p><p>Reliability also means having non-LLM fallbacks. If generation is slow or unavailable, the product can offer editable templates, previously generated drafts, or a simpler rules-based flow. This matters because the user’s need is not “invoke an LLM.” The user’s need is “help me express something meaningful before a deadline.”</p><p>After reliability, the next constraint is cost. LLM systems can fail technically, but they can also succeed themselves into an unsustainable bill.</p><h3>Cost control belongs in the architecture from the beginning</h3><p>A Mother’s Day experience could produce many small generations: message drafts, tone variants, gift explanations, subject lines, reminder text, and maybe localized versions. Each interaction looks cheap in isolation. At scale, the aggregate cost can become a product constraint.</p><p>I would treat cost as an observable dimension, not just a finance concern. Each request should be attributable to a feature, model, prompt version, and user-facing action. Without that attribution, the team may know that the bill went up but not which product decision caused it.</p><p>Production LLM architecture should include request budgets, model routing, caching where appropriate, and limits on uncontrolled regeneration. A user asking for five tone variants is a product feature. A client accidentally retrying the same generation twenty times is a systems bug.</p><p>This is also where vendor documentation helps prevent a narrow design. OpenAI’s <a href="https://developers.openai.com/api/docs/guides/production-best-practices">production guidance</a> is useful for thinking about high-traffic readiness, Anthropic’s <a href="https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/overview">prompt engineering overview</a> explicitly notes that latency and cost problems are not always best solved by more prompt tuning, and Anthropic’s <a href="https://platform.claude.com/docs/en/build-with-claude/token-counting">token counting guide</a> connects token estimation to rate-limit, cost, model-routing, and prompt-optimization decisions. I would apply that combined lesson here by making cost, rate limits, model selection, and retry behavior first-class design concerns rather than late-stage optimizations.</p><p>Once cost is visible, the team can make better product trade-offs. It can decide when to use a more capable model, when a smaller model is enough, when a cached suggestion is acceptable, and when the user should be asked for more context instead of generating blindly.</p><h3>Observability should explain product behavior, not only server health</h3><p>Traditional service observability tells us whether the API is up, how long requests take, and whether dependencies are failing. LLM observability needs that, but it also needs product-level signals.</p><p>For this experience, I would want to know which prompt versions produce drafts users actually edit and send, which tones are frequently rejected, which flows have high abandonment, and which failure paths users can recover from. These signals should be measured with care, because they can easily become invasive if they capture too much content.</p><p>This is another place where the architecture must encode values. We can measure latency, error rates, token usage, cache hit ratio, model provider failures, moderation outcomes, and prompt version performance without storing every raw message indefinitely.</p><p>Observability is also how the team learns whether the premise of the product is valid. If users consistently rewrite the generated message from scratch, the system may be technically functional and emotionally unhelpful. If they use the tool only as a blank-page breaker, then the product should optimize for fast, editable starting points rather than polished final prose.</p><p>Safety observability should follow the same product-aware logic. Google’s <a href="https://ai.google.dev/gemini-api/docs/safety-settings">Gemini safety settings documentation</a> describes adjustable filtering across harm categories during prototyping, which is a reminder that moderation thresholds are product decisions, not only infrastructure defaults. For this design, I would track safety outcomes as operational signals: not to collect private content indefinitely, but to understand whether the chosen policy is too permissive, too restrictive, or misaligned with the emotional context of the feature.</p><p>That leads to the final design question: what principles should survive when an AI idea moves from concept to production-oriented architecture?</p><h3>What this architecture makes explicit</h3><p>The goal of this article is not to claim that I built a Mother’s Day campaign. I did not. The goal is to make the architecture explicit enough to evaluate the trade-offs behind it: user emotion, privacy, scale, cost, reliability, and operational accountability.</p><p>A strong AI product is not just a prompt wrapped in a web form. It is a system of boundaries. Boundaries around user data. Boundaries around model behavior. Boundaries around cost. Boundaries around latency. Boundaries around what the product should automate and what should remain human.</p><p>Go fits this architecture because it is useful for building those boundaries as production services. The LLM may provide language generation, but the surrounding system provides trust.</p><p>That is the architectural lesson behind the exercise: the maturity of an AI system is not measured only by the intelligence of its model. It is measured by the quality of the engineering decisions that contain, observe, and operationalize that model.</p><h3>Conclusion</h3><p>A Mother’s Day AI assistant sounds like a soft product idea, but it exposes serious engineering questions. How much context is enough? What should never be sent to the model? What happens when generation fails? How do we control cost during a seasonal spike? How do we keep the user’s intention at the center of the experience?</p><p>My answer is to treat the LLM as one dependency inside a broader Go-based production architecture. The product should assist expression, not manufacture emotion. The architecture should make privacy, reliability, observability, and cost visible from the beginning.</p><p>That is the value of a system design exercise: it lets us explore production judgment before production pressure arrives.</p><h3>💬 Join the Discussion</h3><p>How would you design an AI feature that deals with emotionally sensitive user intent? Would you optimize for personalization, privacy, speed, or user control first? I would be interested in hearing how other engineers, engineering managers, and architects think about these trade-offs when LLMs move from novelty to product infrastructure.</p><p><em>Originally published on Medium by </em><strong><em>Cassius Paim</em></strong><em>, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin</em></p><h3>Tags</h3><p>#Golang #Artificial Intelligence #System Design #LLM Architecture #Software Architecture</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=073b2bc3af60" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>