<?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[Gojek Product + Tech - Medium]]></title>
        <description><![CDATA[Gojek&#39;s Product and Engineering Blog - Medium]]></description>
        <link>https://medium.com/gojekengineering?source=rss----3db38a207261---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Gojek Product + Tech - Medium</title>
            <link>https://medium.com/gojekengineering?source=rss----3db38a207261---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 12:03:52 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/gojekengineering" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Per-Bank Consumer Lanes: How We Solved Head-of-Line Blocking with the Bulkhead Pattern]]></title>
            <link>https://medium.com/gojekengineering/per-bank-consumer-lanes-how-we-solved-head-of-line-blocking-with-the-bulkhead-pattern-ef4220f9ad4f?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/ef4220f9ad4f</guid>
            <category><![CDATA[performance-optimization]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[system-architecture]]></category>
            <category><![CDATA[fintech]]></category>
            <dc:creator><![CDATA[Dito Raharjo]]></dc:creator>
            <pubDate>Fri, 02 Oct 2026 04:19:30 GMT</pubDate>
            <atom:updated>2026-10-02T04:19:29.311Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How the bulkhead pattern killed head-of-line blocking in a payment pipeline moving 4.5 million transfers a day.</em></p><p>Every bank transfer we process depends on an external system we do not control: the bank. Banks fail, banks get slow, and each one has its own limits. The hard part is not calling a bank, it is making sure one misbehaving bank does not take down transfers to every other bank. This article walks through the four architectures we went through to get there, and why each one was not enough.</p><h3>The workload</h3><p>Some numbers to set the scale:</p><ul><li><strong>3 to 4.5 million transfers / day</strong></li><li><strong>400 RPS at peak</strong></li></ul><p>Correctness and latency both matter. This is money movement and a stalled transfer is a real person waiting.</p><h3>The failures we design around</h3><p>Banks are unreliable dependencies. They can fail in four different ways, and each one requires a different response:</p><ul><li><strong>Total &amp; sudden downtime<br></strong>Connection refused or 5xx, bank’s endpoint unreachable.</li><li><strong>Tail latency<br></strong>The bank accepts the request but hangs. Worst case, a call sits open until our <strong>150s timeout</strong> fires.</li><li><strong>Different rate limits<br></strong>Every bank and every transfer type tolerates a different request rate. There is no single global limit that fits all of them.</li><li><strong>Burst traffic<br></strong>On peak hours or peak days, throughput to a bank spikes to <strong>10 to 15x</strong> normal.</li></ul><h3>The evolution at a glance</h3><p>We did not arrive at per-lane isolation in one step. Each version fixed the previous bottleneck and exposed the next one.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/895/1*uR84ZXOcwi-zGrIFDhbn4A.png" /><figcaption>Iteration of the implementations</figcaption></figure><h3>v1.0 — Synchronous, end to end</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BukrbITNML4ZniG8i9O-KA.gif" /><figcaption>Synchronous call end-to-end</figcaption></figure><p>The first version was fully synchronous. A customer request travelled straight through to the bank and back on the same blocking call chain, so the customer waited for the bank to respond.</p><p><strong>What didn’t work</strong></p><ul><li><strong>Customer latency was the bank’s latency<br></strong>The user experience was only ever as fast as the slowest bank.</li><li><strong>A slow bank took down the whole app<br></strong>When a bank got slow, request threads piled up waiting on it. Once the thread pool was exhausted, the entire application went down, not just transfers to that one bank.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ODQQbwt9K8XO7WTeQtCv-w.gif" /><figcaption>Threads got blocked waiting for a response from the bank with high response time</figcaption></figure><ul><li><strong>Two kinds of traffic, no separation<br></strong>We noticed our traffic split into two profiles that were competing for the same threads:<br>• <strong>Priority<br></strong>Sporadic and light, from normal user requests. Needs to finish ASAP.<br>• <strong>Bulk<br></strong>dense and in bursts, from merchant’s scheduled requests or merchant file uploads. These kind of transactions can afford to take time.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L7zUtl5_Qlk48-KD9e4rYQ.gif" /><figcaption>Burst traffic dominate the thread pool causing delay on organic customer traffic</figcaption></figure><h3>v2.0 — Asynchronous, two topics (priority and bulk)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OXP2VrTeZUwcviJ3Hc6Q6w.gif" /><figcaption>Synchronous process and lane separated into priority and bulk</figcaption></figure><p>We moved processing off the request path and made it asynchronous, backed by a queue. This version used two Kafka topics, one for priority and one for bulk.</p><p><strong>What improved</strong></p><ul><li>Bulk traffic no longer starved priority traffic.</li></ul><p><strong>What didn’t work</strong></p><ul><li><strong>Banks share a topic, so a busy bank chokes a quiet one<br></strong>Traffic per bank varies a lot. A high-traffic bank hogs the consumer and delays transfers to low-traffic banks on the same topic. And it shifts, bank A is hot today, bank B tomorrow.</li><li><strong>No per-bank throughput tuning<br></strong>If we know bank A can handle 100 RPS, we would like to consume its transfers faster. But with every bank in the same queue, speeding up consumption would also overrun banks with lower limits. One knob for everyone.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RlGpXLhztkZVnSqNuzOw5g.gif" /><figcaption>Still same issue, high response time from a bank is impacting the whole lane</figcaption></figure><h3>v3.0 — Asynchronous, a dedicated lane per-bank &amp; per-transfer type</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3yLc4gdYkINsTBl8hnsbdw.gif" /><figcaption>Asynchronous process, with granular lanes per-bank &amp; per-transfer type &amp; per-priority type and standalone consumer</figcaption></figure><p>The final version isolates every dimension. We built a standalone generic consumer service that calls a processor service, which calls the bank. Every bank × transfer type × priority/bulk combination gets both its own Kafka topic <em>and</em> its own dedicated consumer service — a fully independent <strong>lane</strong>.</p><p><strong>What changed from the previous version</strong></p><ul><li>From a single service working as consumer, now we have multiple deployments of the same generic service working as consumer for each topics</li><li>To make the most of each consumer, we also have the capability to configure consumer concurrency</li></ul><p>For example, Bank A — Intrabank — Priority topic has 36 partitions, the consumer setup would be:</p><ul><li>Pod count: 2, for minimal resource usage</li><li>Consumer concurrency thread count: 18</li></ul><p>Usually we only increase consumer concurrency when needed to scale up, because we still have a lot of unused CPU &amp; memory space on each pod.</p><p>This is the <strong>bulkhead pattern</strong> taken to its conclusion:</p><ul><li><strong>Full isolation per lane.<br></strong>No bank and/or transfer type can cause problems in another lane. In case of a specific bank’s transfer type having issue, it is fully isolated on its lane</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6LalV-dgIeydYJQKAPFjsA.gif" /><figcaption>Bank A’s BI FAST issue is not impacting other lane</figcaption></figure><ul><li><strong>Fine-grained control.<br></strong>We tune throughput independently for every bank, every transfer type, and priority vs bulk. When we have increased traffic for a specific bank’s transfer type, we can scale up only for that lane to accommodate the increased traffic</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BsXP4MFC0mJ_o8PGvndf_w.gif" /><figcaption>Bank A’s Intrabank overall traffic increased, we can scale up only for that lane’s consumer</figcaption></figure><h3>Results</h3><p>The clearest metric is <strong>queue wait time</strong>, how long a transfer sits before a consumer picks it up. Before, that number blew up under exactly the conditions where it mattered most. Now, it is flat.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/834/1*GLtWFv4IGznu2I3yHgRk4Q.png" /><figcaption>Improvement result after using v3.0 implementation</figcaption></figure><h3>Takeaway</h3><p><strong>Isolation has a granularity, and you have to push it down to where failures actually happen.</strong></p><ul><li>Going async (v2.0) stopped bulk from starving priority, but banks still shared the same consumers.</li><li>Only a dedicated lane per bank, transfer type, and priority type contained failure completely.</li></ul><p>If you call unreliable dependencies through shared capacity, you have coupled their fate. Bulkhead them at the granularity where they actually fail.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ef4220f9ad4f" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/per-bank-consumer-lanes-how-we-solved-head-of-line-blocking-with-the-bulkhead-pattern-ef4220f9ad4f">Per-Bank Consumer Lanes: How We Solved Head-of-Line Blocking with the Bulkhead Pattern</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Making Search Feel Instant: Moving GoFood Type-Ahead from HTTP to MQTT]]></title>
            <link>https://medium.com/gojekengineering/making-search-feel-instant-moving-gofood-type-ahead-from-http-to-mqtt-a4c440f09294?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/a4c440f09294</guid>
            <category><![CDATA[system-architecture]]></category>
            <category><![CDATA[backend]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[performance-optimization]]></category>
            <dc:creator><![CDATA[Lab Singh]]></dc:creator>
            <pubDate>Wed, 12 Aug 2026 03:06:28 GMT</pubDate>
            <atom:updated>2026-08-12T03:06:26.907Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><strong>TL;DR</strong> — GoFood’s search type-ahead opened a brand-new HTTP request on every keystroke. When we profiled it, we found that the largest contributor to latency was the network round trip, and it cost about the same whether you were on a budget phone or a flagship. So we moved type-ahead onto a connection that the app already keeps open and switched from request/response to MQTT publish/subscribe (using Courier, our open-source MQTT client). HTTP stayed on as an automatic fallback, and the whole thing shipped behind a remote flag. In a 5,000-run benchmark, the new path landed about 40% faster on average, and its worst case was far tamer.</blockquote><h3>The Situation:</h3><h4>For every search keystroke, RTT latency was higher than expected.</h4><p>Open GoFood, tap on search, and start typing. Suggestions show up as you type (“nas”, “nasi”, “nasi goreng”). That’s type-ahead, and for a feature like this, speed is critical.</p><p>However, we noticed that our search type-ahead requests were experiencing significant latency (<strong>p95 of about 500ms</strong>), even though the core recommendation model needed only about <strong>30ms</strong> to return suggestions.</p><h3>Investigations:</h3><p>To pinpoint the bottlenecks, we profiled and broke down the full API lifecycle into granular stages. We then ran comparative benchmarks across both low-end and flagship devices to understand which components of the Round-Trip Time (RTT) were bound by network constraints versus local device hardware.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Dk8cZJl3uMGPEoYvPyUhDw.png" /></figure><p>Our investigation highlighted two major areas contributing to the delay:</p><ul><li><strong>Connection Acquisition:</strong> Significant device overhead on lower-end hardware just to initiate the request.</li><li><strong>Network RTT:</strong> Substantial time spent on the end-to-end network round trip, largely driven by auth token resolution and internal routing redirections.</li><li>Both these stages together contributed to the HTTP latency</li></ul><p><strong>This told us where to push.</strong></p><ul><li>Device overhead is real, but it’s local to each phone. There’s only so much app-side work you can shave off.</li><li>The round trip was the opposite: everyone paid for it, on every keystroke, and it was the single biggest chunk. Fix it, and search gets quicker for the whole user base at once.</li></ul><h3>The Solution:</h3><h4>Long-Lived MQTT Connections</h4><ul><li><strong>Reused Connections:</strong> Eliminated per-keystroke HTTP handshake overhead by leveraging a single, long-lived connection already open in the app. For this we used MQTT</li><li><strong>Swappable Transport Layer:</strong> Decoupled the UI by implementing a swappable transport abstraction. The UI consumes the same data model, whether delivered via MQTT or the legacy HTTP fallback, ensuring safe feature-flagged rollouts and clean A/B testing.</li><li><strong>Simplified Request structure for Courier (MQTT):</strong> We chose MQTT because we already use Courier, our in-house MQTT client, and it offered several additional advantages:</li><li><strong>Persistent, Low-Overhead Connection:</strong> Swaps noisy, per-keystroke HTTP handshakes for a single, long-lived pub/sub channel — drastically cutting network RTT and connection overhead.</li><li><strong>Bypassed Auth &amp; Routing:</strong> Eliminating HTTP removes the need to resolve authentication tokens and handle internal routing redirections on every single letter typed.</li><li><strong>Stripped-Down, Direct Payloads:</strong> Removing bulky HTTP headers and dynamic components shrinks download sizes and allows the backend model to consume request data directly with zero preprocessing.</li><li><strong>Minimized Server Latency:</strong> The combination of lean payloads, skipped auth checks, and instant pub/sub message delivery maximizes backend response speeds.</li></ul><h4>The Task: Make it instant without breaking it</h4><p>Our goal was simple: minimise round-trip latency for instant suggestions. However, execution required meeting several critical guardrails:</p><ul><li><strong>Strict Request-Query Matching:</strong> Out-of-order or stale responses (matching a previous keystroke) must be discarded immediately to prevent a confusing UI state.</li><li><strong>Controlled Rollout/Kill Switch:</strong> Feature-flagged deployment to a small user subset with real-time telemetry monitoring and instant rollback capability.</li><li><strong>Graceful Degradation:</strong> Silent error handling for edge cases, like intermittent network drops or app backgrounding — failures must never surface to the user.</li><li><strong>Empirical Metrics:</strong> Rely strictly on hard, before-and-after performance telemetry rather than subjective perception.</li></ul><h3>Implementation details:</h3><ol><li><strong>MQTT Connection setup:</strong> Courier MQTT connection is created when the user opens the Gojek app. For our particular use case, when a user enters the Food Search screen, we create a subscription to a pre-defined topic to listen to server updates.</li><li><strong>The Request &amp; HTTP Fallback:</strong> As user types, the app publishes the search query as a lightweight message over the open MQTT channel.</li><li>If the channel isn’t connected when the user types, it executes a silent fallback to HTTP, so the user never sees a broken keystroke.</li><li><strong>Response Compression:</strong> The responses from the server are fully compressed to shrink the data footprint and shave extra milliseconds over the air.</li><li><strong>Deduplication (Latest Query Wins):</strong> Network responses can land out of order. Each request carries a unique ID, and the client instantly drops any stale messages — ensuring results for <em>“piz”</em> never overwrite the results for <em>“pizza”</em>.</li><li><strong>Built-in Timeout Guard:</strong> If nothing comes back from the backend within a tight, reasonable window, a timeout triggers a graceful UI state instead of hanging on an endless spinner.</li><li><strong>End-to-End Telemetry:</strong> Every event — from query sent, to response received, to stale response discarded, is logged to mathematically track and prove latency improvements.</li><li><strong>Foreground-Only Lifecycle:</strong> The app actively manages the MQTT connection <em>only</em> when in the foreground. The channel is immediately torn down in the background to prevent unused network overhead.</li><li><strong>Flag-Controlled Rollout:</strong> A remote configuration flag determines the routing path. If active for a user, it tries the new MQTT path; otherwise, it seamlessly defaults to the old, proven HTTP flow.</li></ol><h4>The architecture at a glance</h4><p><em>Here’s the old flow</em>, redrawn from scratch on every keystroke:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uSySOyboaYQWlCQxXYy9_w.png" /><figcaption>Old Flow of data through HTTP</figcaption></figure><p><em>And the new one:</em> a single open channel, a per-keystroke choice of path, HTTP as a backstop, and a guard against out-of-order replies.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7brZ-ScATI5wO4BTQ0d6Lw.png" /><figcaption>New Architecture using Courier with Remote toggle</figcaption></figure><h3>The Results:</h3><h4>Spike benchmark (5,000 runs per path)</h4><p><strong><em>The short version:</em></strong> the real-time MQTT path was about 40% faster on average, faster at both p50 and p90, and its worst case was nowhere near as bad (roughly 1.2 s against 6 s).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZufwqFSdoNMjleyfdSZa9A.png" /></figure><h4>Benchmark Insights:</h4><ul><li><strong>Consistent Performance:</strong> The latency reduction held at p90, proving the optimisation wasn’t inflated by a few outliers.</li><li><strong>Network Resilience:</strong> A significantly tighter tail suggests the real-time MQTT path handles degraded network conditions much better than HTTP.</li><li><strong>Test artifacts:</strong> The extreme maximum spikes on both paths were caused by OS scheduling overhead within the test framework, and are expected to stabilise on production devices.</li><li>We later did more spike with 10,000 runs for each path to validate the above results. And the patterns held.</li></ul><h3>🚀 Production Rollout Update (50% Traffic Experiment)</h3><p>We have successfully run the Courier experiment for a full week (with 5 days at 50% traffic). Based on strong performance and latency wins, <strong>we will be rolling out to 100% traffic.</strong></p><p>Below are the results</p><h4>📊 Performance Metrics</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3BgkVp-zQe3_jMU_24-nHw.png" /></figure><h4>💡 Secondary Improvements &amp; User Behaviour</h4><p>The substantial reduction in latency has directly improved the user experience, leading to higher engagement with the component:</p><ul><li><strong>Increased Engagement:</strong> Autocompletion clicks increased by <strong>+4.83%</strong>.</li><li><strong>Behavioural Shift:</strong> Searches originating from suggestions grew by <strong>+4.23%</strong></li><li><strong><em>(shifting from 40% — → 41.72% of total searches).</em></strong></li></ul><h3>Lessons learned and what’s next</h3><ul><li><strong>Target the Biggest Bottleneck:</strong> We optimised the network round trip because it was the most significant, device-independent lag. This fixed the experience universally — especially for users on low-end hardware who were already bottlenecked by local device overhead.</li><li><strong>Persistent Connection Trade-offs:</strong> Shifting away from per-keystroke HTTP handshakes doesn’t remove infrastructure complexity; it just moves it. You stop worrying about request overhead and start managing connection lifecycles (handling drops, reconnections, and app backgrounding/foregrounding states).</li><li><strong>Async Request Ordering:</strong> Once you move to an async stream, you have to manage out-of-order responses. Tagging payloads and enforcing a “latest query wins” policy to drop stale results is mandatory to keep the UI state accurate.</li><li><strong>Abstracted Transport Layers:</strong> Keeping the transport layer swappable was our safety net. Because the UI component didn’t care how data arrived, we could safely hide the feature behind a flag, fall back to HTTP instantly, and run clean production A/B tests.</li></ul><p><strong>What’s next:</strong> A smarter fallback that retries over HTTP when a real-time attempt actually fails, not just when the channel is down, plus keeping an eye on the production numbers to confirm the benchmark holds up at full scale.</p><p>Type-ahead is invisible when it works and annoying when it doesn’t. Getting it off a per-keystroke request and onto an open connection made the fast path the normal path.</p><blockquote>“Reducing latency by 40% didn’t just speed up the app — it fundamentally changed how users interact with it.”</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a4c440f09294" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/making-search-feel-instant-moving-gofood-type-ahead-from-http-to-mqtt-a4c440f09294">Making Search Feel Instant: Moving GoFood Type-Ahead from HTTP to MQTT</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Beyond the Main Frame: Architecture and Implementation of Secure iFrame-to-Native Communication in…]]></title>
            <link>https://medium.com/gojekengineering/beyond-the-main-frame-architecture-and-implementation-of-secure-iframe-to-native-communication-in-e77b30445c04?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/e77b30445c04</guid>
            <category><![CDATA[android-app-development]]></category>
            <category><![CDATA[webview]]></category>
            <category><![CDATA[miniapp]]></category>
            <category><![CDATA[ios-app-development]]></category>
            <category><![CDATA[iframe]]></category>
            <dc:creator><![CDATA[Tushar Nikam]]></dc:creator>
            <pubDate>Mon, 06 Jul 2026 03:32:50 GMT</pubDate>
            <atom:updated>2026-07-06T03:32:49.252Z</atom:updated>
            <content:encoded><![CDATA[<h3>Beyond the Main Frame: Architecture and Implementation of Secure iFrame-to-Native Communication in WebView Mini Apps</h3><blockquote>Cross-origin iframes in WebView mini apps can’t touch the native bridge same-origin policy blocks them. Most teams work around this with a relay through the main frame. We didn’t. Routing directly to native instead of through JavaScript meant security validation couldn’t be tampered with, and iframes stopped depending on a main frame that might navigate away or crash.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SAzA9aMJWxFx0Ho2Zm6MHA.png" /></figure><p>Every GoTo mini app runs inside a WebView. Some of those mini apps embed third-party iframes — a payment checkout here, a partner widget there. And when those iframes need to call a native API, the browser quietly blocks them. For a long time, we lived with a workaround. Then the workaround became the problem.</p><h3>The problem</h3><p>When a native application injects a JavaScript bridge object into a WebView, that object lives in the main frame’s window. Every iframe has its own isolated execution context, and cross-origin iframes cannot reach across into the main frame’s properties. A mini app embedding a payment checkout from a partner domain cannot simply call parent.NativeBridge.call() the browser blocks it immediately.</p><p>Three factors combine to create this constraint. Each iframe maintains its own JavaScript execution context with an independent window object. The Native bridge injection targets only the main frame — WKUserScript on iOS and @JavascriptInterface on Android. And SOP prevents cross-origin frames from accessing each other’s properties altogether.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Zbulximg3TD66FJA" /></figure><h3>Two approaches considered</h3><p>We evaluated two architectural paths before settling on our final design.</p><h3>Main frame relay</h3><p>The first approach routes everything through the main frame. The iframe sends requests via postMessage, the main frame invokes the native bridge, and the response is relayed back. It is straightforward to implement and requires no changes to the native layer.</p><p>The problem is coupling. If the main frame navigates away or crashes, iframe bridge access is lost. With multiple iframes, the main frame becomes a bottleneck. Worse, security validation lives in JavaScript — code that can be tampered with — rather than in native code where it cannot.</p><h3>Isolated iframe bridge (chosen approach)</h3><p>The second approach establishes direct communication between the native layer and each iframe. The SDK in the iframe sends requests through the standard bridge channel; native sends responses back via postMessage. Security validation happens in native code. Each iframe gets its own permission set. Partners do not need to implement any custom relay logic.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*o3yMR_jmbSzXokUP" /></figure><h3>Architecture</h3><p>The system has three distinct layers inside the native bridge, followed by a postMessage dispatch mechanism and per-frame SDK instances.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*MMielYaNWlbWjpVf" /></figure><h3>End-to-end call flow</h3><p>A complete bridge call from an iframe happens in two phases: configuration at launch, and the call itself at runtime.</p><p>During configuration, the host app fetches mini app metadata from the backend. This includes the main frame permission list and an array of iframe entries, each specifying an origin, its own permission set, and navigation whitelists. These are transformed into a WebKit configuration and passed to the SDK before any content loads. The bridge does not accept runtime configuration changes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*p-EkHzxdhEHCvoVf" /></figure><h3>Security model</h3><p>The three layers each address a distinct threat vector rather than providing redundant checks of the same thing.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DXxVtD0gHqvHWHJIQt5IPQ.png" /></figure><blockquote><em>The configuration lives on the backend and is fetched at launch time, not embedded in client code. The bridge does not accept runtime configuration changes the permission and whitelist setup is fixed for the lifetime of the session.</em></blockquote><h3>Platform differences</h3><p>The architectural pattern is identical across Android and iOS. Implementation details differ because the platforms expose different APIs for frame context.</p><p>Android uses WebViewCompat.addWebMessageListener() from the AndroidX WebKit library, which provides sourceOrigin and isMainFrame in the callback. The legacy @JavascriptInterface is retained for backward compatibility but does not carry origin information, so the JS SDK uses two separate call paths depending on whether postMessage mode is active.</p><p>iOS uses WKScriptMessage, which inherently provides WKFrameInfo with the request URL. No API migration was needed on iOS, the same bridge path handles both main frame and iframe calls, and the frame info is available directly in the delegate method.</p><h3>Engineering challenges</h3><h4>Thread-safe frame context correlation</h4><p>Bridge calls span multiple threads, incoming messages arrive on the WebView’s callback thread, consent checks may involve background threads, and handler execution happens on the main thread. We use a FIFO queue with atomic operations: a snapshot is stored at bridge entry and consumed exactly once when the handler executes. This guarantees sequential calls are correctly paired with their frame contexts.</p><h4>Response routing to cross-origin iframes</h4><p>Native can only run JavaScript via evaluateJavaScript in the main frame context. We inject a small dispatch script that runs in the main frame, iterates direct child iframes, matches the target hostname against each iframe’s src attribute, and calls contentWindow.postMessage() on the matching frame. We traverse only depth-1 iframes, as nested iframes inherit their parent’s configuration.</p><h4>Concurrent access to event-based handlers</h4><p>Some handlers, compass, accelerometer, gyroscope, continuously stream data and cannot support concurrent consumers. We enforce a single-session policy: when a handler already has an active session, subsequent registration attempts receive an error. The SDK propagates this to the caller. This keeps resource management simple and predictable.</p><h4>Backward compatibility</h4><p>Existing mini apps use a direct callback mechanism where native calls a global window.gpCoreCallback function. Changing this to postMessage would break all existing implementations. The bridge SDK maintains both paths simultaneously. Legacy callbacks continue to work for main frame calls; the postMessage path handles iframe calls. A configuration flag enables postMessage mode automatically when iframe definitions are present, mini apps without iframes see no behavior change at all.</p><h3>Decision summary</h3><p>DecisionRationaleAlternative rejectedDirect iframe bridgeNo coupling between frames; security validation in native code where it cannot be bypassedMain frame relay — coupled, unscalable, security logic lives in JSPostMessage for response deliveryOnly viable mechanism for cross-origin frame communicationevaluateJavaScript — cannot target cross-origin framesFIFO queue for origin trackingGuarantees sequential ordering; one-time consumption prevents replayCall ID mapping — adds protocol complexity with no additional security benefitDepth-1 iframe traversalCovers the common case; nested iframes inherit their parent’s configurationFull tree traversal — performance cost for an uncommon scenarioDual callback and postMessage pathsZero migration burden for existing implementationsBreaking change — requires coordinated cross-team migration</p><h3>Takeaways</h3><h3>Key principles</h3><p>Frame context must be captured at the earliest possible point in the call flow, before any async operations.</p><p>Permission sets must be independently configurable per frame. Inheriting permissions from the main frame defeats the purpose of scoping.</p><p>Response routing to cross-origin frames requires a dispatch mechanism that runs in the main frame context — there is no shortcut around this.</p><p>The FIFO queue pattern is a simple and effective solution for correlating asynchronous call contexts without adding protocol complexity.</p><p>Thanks for reading!</p><p>Let’s connect:<br>🔗 LinkedIn: [<a href="https://www.linkedin.com/in/tushar-nikam-dev/">https://www.linkedin.com/in/tushar-nikam-dev/</a>]<br>🐦 X: [<a href="https://twitter.com/champ_96k">https://twitter.com/champ_96k</a>]</p><p>Follow for more insights on tech, AI, and product building.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e77b30445c04" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/beyond-the-main-frame-architecture-and-implementation-of-secure-iframe-to-native-communication-in-e77b30445c04">Beyond the Main Frame: Architecture and Implementation of Secure iFrame-to-Native Communication in…</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Sidecar Bottleneck: Rethinking gRPC Connection Pooling]]></title>
            <link>https://medium.com/gojekengineering/the-sidecar-bottleneck-rethinking-grpc-connection-pooling-7ae062de10c6?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/7ae062de10c6</guid>
            <category><![CDATA[http2]]></category>
            <category><![CDATA[connection-pooling]]></category>
            <category><![CDATA[grpc]]></category>
            <category><![CDATA[istio-proxy]]></category>
            <dc:creator><![CDATA[Sayan Medya]]></dc:creator>
            <pubDate>Fri, 26 Jun 2026 08:24:31 GMT</pubDate>
            <atom:updated>2026-06-26T08:24:30.603Z</atom:updated>
            <content:encoded><![CDATA[<p>We’ve all been there. You’re monitoring a downstream service during a sudden traffic spike, and you start seeing latency creep up. Requests begin timing out. Naturally, you suspect the database is locking up, the network is saturated, or perhaps the upstream service is buckling under the load. But after digging in, you realize the call isn’t even leaving the pod. The bottleneck is right there in the living room: the connection between the application and its local Envoy proxy sidecar.</p><p>This is how we stumbled upon an artificial ceiling in our gRPC client configurations, the assumptions we challenged, and the tradeoffs we navigated to break through it.</p><h3>The Hidden Ceiling in Our Legacy Setup</h3><p>To set the scene, our architecture relies heavily on Istio and Envoy sidecars. When a service makes a call, it uses a fully qualified domain name (k8s FQDN) that resolves to a single service IP address. In this setup, there is a strict 1:1 mapping between a <a href="https://grpc.github.io/grpc-java/javadoc/io/grpc/Channel.html">gRPC channel</a> and the underlying TCP connection.</p><p>Everything was running smoothly until we started hitting unexplained limits during throughput spikes. Digging into the legacy client code, we found the culprit: a fixed connection pool operating with an HTTP/1.1 mindset. When the application spun up, it created a fixed set of TCP connections. Each outgoing request would “borrow” a connection from the pool, blocking it entirely until a response was received. If a sudden burst of requests arrived and the pool was busy, new requests had to wait in line. If they waited too long, they failed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VBhtqyJDq_XHigHgy1bBFw.png" /></figure><p>We were using gRPC, which is built on HTTP/2, but completely ignoring its superpower: multiplexing. By blocking whole channels per request, we were causing self-inflicted latency and throughput starvation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*92v5x462hPI5H0618p-bzw.png" /></figure><h3>Evaluating the Fix: Two Divergent Paths</h3><p>We knew we had to embrace HTTP/2 multiplexing, which allows multiple concurrent requests to flow over a single connection simultaneously. But how exactly should we architect this interaction with the sidecar? We debated two main approaches.</p><h4>Path 1: The HTTP/2 Purist (Single Connection)</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vl3Q-ODzLrJqmGdNlBZHRw.png" /></figure><p>The most theoretically sound approach in HTTP/2 is to use a single, heavily multiplexed TCP connection. Why pool connections when one pipe can handle it all? Before committing, we had to ask ourselves three crucial questions:</p><ul><li><strong>Will a single connection be reliable enough?</strong> Yes. Because this TCP connection is entirely local, established on the loopback interface (127.0.0.1) between the application container and the sidecar on the same Kubernetes node. It is incredibly stable and supports massive bandwidth. The dreaded <a href="https://http3-explained.haxx.se/en/why-quic/why-tcphol">TCP head-of-line blocking</a> isn’t a practical concern over localhost.</li><li><strong>Will a single connection hit any multiplexing limits?</strong> Servers enforce a limit on concurrent streams. Envoy defaults this to 1,024, but Istio configurations typically override this to a massive number (roughly 2 billion). We weren’t going to hit a queueing ceiling.</li><li><strong>What about Envoy proxy concurrency?</strong> Here is where the theory hit a wall. Envoy’s threading model is highly opinionated: it assigns each downstream connection to <em>exactly one worker thread</em>.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Mh7mEUhT9l2eFpngARFwyQ.png" /></figure><p>If our client uses only a single TCP connection, the sidecar handles all of that client’s outbound traffic on a single thread. Effectively, our proxy’s outbound processing becomes limited to the clock speed of a single CPU core.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9EYArb6hRdr5hchUViM24w.png" /></figure><h4>Path 2: The Pragmatic Middle Ground (Non-Blocking Small Pool)</h4><p>To solve Envoy’s single-thread bottleneck, we looked at a hybrid approach. Instead of one massive connection, we maintain a very small pool (say, 5 to 10 connections). Crucially, unlike our legacy setup, <strong>we don’t block them</strong>.</p><p>We utilise HTTP/2 multiplexing across all of them, distributing the requests using a random load-balancing strategy. By doing this, we trick the Envoy sidecar into distributing the traffic across multiple worker threads, unlocking multi-core concurrency for outbound requests.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KuCvZ-t_lGfhwEfTcC_HJg.png" /></figure><h4>Putting it to the Test</h4><p>We spun up a test environment, pushing the Istio proxy to its absolute limits to see how the single connection (Path 1) fared against the small pool (Path 2) under heavy synthetic load (using an Envoy concurrency setting of 2). The measured latency is internal processing time in Istio proxy.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aHjzft17Px9-wNjooGZs1A.png" /></figure><p>The data paints a clear picture. Under normal or moderate loads (sub-500 RPS per pod), both approaches perform beautifully. But as we artificially scaled the throughput into the thousands, Path 1’s single-thread constraint caused latency to skyrocket. Because Path 2 could utilize both of the proxy’s worker threads, its average latency was quite literally cut in half.</p><h3>The Takeaway</h3><p>In software engineering, everything is a tradeoff. The pure, single-connection approach is incredibly simple and solves the blocking issue for 99% of micro-services, as very few individual pods actually sustain thousands of outgoing RPS without scaling out first.</p><p>However, for high-throughput gateway nodes or extremely dense aggregators, that Envoy threading model constraint is a silent killer. In these contexts, <strong>Path 2</strong>, the pragmatic, non-blocking multiplexed pool is the preferred choice. It balances the architectural simplicity of HTTP/2 multiplexing with the raw reality of how proxy sidecars manage CPU threads under the hood.</p><h4><strong>References</strong></h4><ol><li><a href="https://grpc.io/docs/guides/performance/">gRPC docs: Performance Best Practices</a></li><li><a href="https://github.com/grpc/proposal/pull/516/files">gRFC: MAX_CONCURRENT_STREAMS Connection Scaling</a></li><li><a href="https://www.envoyproxy.io/docs/envoy/latest/intro/life_of_a_request">Envoy docs: Life of a Request</a></li><li><a href="https://blog.envoyproxy.io/envoy-threading-model-a8d44b922310">Envoy blogs: Threading model</a></li><li><a href="https://datatracker.ietf.org/doc/html/rfc9113">IETF RFC 9113: HTTP/2</a></li><li><a href="https://http3-explained.haxx.se/en/why-quic/why-tcphol">TCP head of line blocking</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7ae062de10c6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/the-sidecar-bottleneck-rethinking-grpc-connection-pooling-7ae062de10c6">The Sidecar Bottleneck: Rethinking gRPC Connection Pooling</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Orchestrating Chaos — How Technical Program Managers (TPM) Enable Scalable Collaboration]]></title>
            <link>https://medium.com/gojekengineering/orchestrating-chaos-how-technical-program-managers-tpm-enable-scalable-collaboration-7d26766e9a85?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/7d26766e9a85</guid>
            <category><![CDATA[technical-program-manager]]></category>
            <dc:creator><![CDATA[Akshaya Pai & Mohit Gupta]]></dc:creator>
            <pubDate>Mon, 22 Jun 2026 06:17:06 GMT</pubDate>
            <atom:updated>2026-06-22T06:17:04.971Z</atom:updated>
            <content:encoded><![CDATA[<h3>High-Growth Engineering Breeds Chaos</h3><p>When approaching any problem, organizations naturally gravitate toward defining the technical solution — moving methodically through the stages of the Software Development LifeCycle (SDLC). While the nature of problems may vary across business contexts and technical domains, one constant remains: <strong>chaos</strong>. This chaos stems from a fundamental human instinct — different stakeholders approaching the same problem with different perspectives on how it should be solved.</p><p>Every initiative involves a diverse set of stakeholders — end users, implementation teams, execution teams, and supporting functions. Each group views the problem through its own lens, shaped by priorities, constraints, and success metrics. As a result, alignment on the “right” solution or approach is rarely immediate. What emerges instead is fragmentation — multiple interpretations, competing priorities, and misaligned expectations.</p><p>This fragmentation often evolves into organizational chaos, becoming a significant barrier to delivering initiatives effectively while staying aligned with broader business goals.</p><p>At GoTo, the challenge is amplified by a fast-moving, high-autonomy environment where multiple business units operate with tightly coupled dependencies. Rapid product growth has led to a complex web of services and siloed datastores. In this setup, even small changes create ripple effects across teams. With work happening in real time across shared channels, differing perspectives and shifting priorities are inevitable. The result is a constant state of controlled chaos — where misalignment on ownership, effort, or business impact can slow execution and dilute outcomes.</p><h3>The TPM Compass: Two Case Studies in Structural Navigation</h3><p>At GoTo, Technical Program Managers (TPMs) are the people who help bring structure to complex technology programs, especially when initiatives span multiple business units, systems, and stakeholder groups. Their role is not just to manage technical efforts, but to drive cross-functional programs where alignment is often the hardest problem to solve.</p><p>In this complexity, TPMs act as a compass — bringing structure to ambiguity, aligning diverse perspectives, and ensuring that execution stays true to the program’s goals. In fast-moving environments, misalignment can quickly lead to missed timelines, fragmented outcomes, or diluted impact. TPMs mitigate this by creating clarity on priorities, dependencies, ownership, and sequencing — before and during execution.</p><p>At GoTo, this means going beyond coordination. TPMs actively shape how teams collaborate, anticipate points of conflict, and put mechanisms in place to ensure that progress across one group does not come at the cost of another.</p><p>The following case studies highlight how TPMs at GoTo navigated large-scale, high-stakes challenges — turning potential chaos into structured, predictable execution.</p><h3>Navigating the Digital Jungle: One Night Cloud Cutover</h3><p>On 26 May 2025, GoTo successfully completed the cloud migration of its ODS systems to Tencent Cloud — one of the most complex infrastructure transformations undertaken within the organization.</p><p><strong>The Challenge: High-Stakes Migration at Scale</strong></p><p>This was a high-stakes operation involving a planned 5-hour shutdown of Gojek services. Over 600 engineers across Indonesia, India, China and Singapore collaborated to migrate nearly 500 services and more than 1,000 datastores. With minimal margin for error, success depended not just on technical execution, but on <strong>precise coordination across hundreds of interdependent tasks</strong>.</p><p><strong>The TPM Approach: Orchestrating Precision and Alignment</strong></p><p>At the core of this effort was a well-defined orchestration layer, ensuring seamless alignment across all participating teams.</p><p>While engineering teams defined their individual execution steps, TPMs owned the <strong>end-to-end coordination of the migration</strong>. They designed and managed a detailed runbook that aligned all teams on sequencing, dependencies, and timing — ensuring that no step blocked another.</p><p>The work began several weeks before the cutover. TPMs drove multiple integration drills to surface gaps, resolve cross-team dependencies, and iteratively refine the execution plan. These drills helped teams work through complex scenarios with greater clarity, enabling teams to align on timelines, validate assumptions, and commit to well-defined execution expectations.</p><p>During the actual cutover, TPMs facilitated <strong>real-time coordination and decision-making</strong> — monitoring progress, managing handoffs, and ensuring seamless traffic switching and service recovery on the new cloud environment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zZlslFTJB7Ek02tQiUzISg.png" /><figcaption>Glimpse of the Cutover Runbook Structure</figcaption></figure><p><strong>The Outcome: Synchronized Execution at Scale</strong></p><p>The result was a highly coordinated and predictable execution. The migration was completed <strong>one hour ahead of schedule</strong>, with Gojek services restored smoothly, ready to serve millions of users at the start of the new day.</p><p>This initiative demonstrated how TPMs transform large-scale, high-risk programs into <strong>structured, synchronized outcomes</strong> by bringing clarity, alignment, and control to even the most complex systems.</p><h3>Breaking Barriers: How TPM Drove a Cross-Entity Program</h3><p>In a high-stakes environment involving external partners, language barriers, and asymmetric information sharing, the role of a TPM shifts from “tracking tasks” to “defining the path forward.”</p><p><strong>The Challenge: Asymmetric Visibility and Information Silos</strong></p><p>Recently, a program required teams at GoTo to integrate with a third-party entity. Unlike standard programs focused solely on Product and Engineering, this initiative involved a complex web of stakeholders, including <strong>Legal, Finance &amp; Accounting, and Driver Care Operations.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CU2-OsHYyxYCwxoJZ73Zaw.png" /></figure><p>Operating with an external partner introduced a “transparency paradox”: We required full visibility into their progress to ensure success, yet were restricted in the proprietary data we could share from our end. When these constraints were combined with things literally getting “lost in translation,” the risk of misalignment was high.</p><h4>The Approach: TPM as the Strategic Bridge</h4><p>Following a rigorous initial workshop, it was clear that TPM had to wear multiple hats to transition the program from planning to high-velocity execution. The following pillars were established for our ways of working.</p><ul><li><strong>Unified Documentation &amp; Visualization:</strong> Comprehensive Program Boards were built, that visualized tasks across all departments. By ensuring every flow, decision, and timeline was documented and acknowledged by both parties, we created a “single source of truth” that kept stakeholders updated in real-time.</li><li><strong>Cross-Functional Orchestration: </strong>Focused cadences were orchestrated specifically to close open questions between GoTo and the partner. Beyond the technical build, TPM also helped with the GTM (Go-To-Market) strategy with non-technical teams, ensuring Legal and Finance were aligned on payout flows and reconciliation before the first line of code was deployed.</li><li><strong>Precision Execution via Runbooks:</strong> Given the sensitivity of the flows, a detailed <strong>Deployment &amp; Validation Runbook </strong>was created. This provided a step-by-step sequence for engineering teams on both sides to follow, including real-time validation checkpoints. Having an appointed PIC for each step gave the organization the confidence to move forward with a high-risk launch.</li></ul><h4>Results: From Chaos to BAU</h4><p>The execution of all outlined tasks and steps leading to a successful launch helped establish a new <strong>Business As Usual (BAU)</strong> framework. By defining clear owners, Standard Operating Procedures (SOPs), and structured communication loops, we transformed a chaotic, multi-entity space into a repeatable, organized system.</p><p>Ultimately, this program proved that when complexity is high and communication is strained, a TPM’s greatest value is in building the structure that makes collaboration possible.</p><h3>The Road Ahead for TPMs: From Coordination to Intelligent Orchestration</h3><p>As our systems grow more interdependent, the role of a TPM is shifting. At a high-scale organization like GoTo, TPMs don’t just manage projects, they manage a network of dependencies.</p><p>They are moving away from mere coordination and toward <strong>intelligent orchestration at scale. </strong>This requires a fundamental mindset shift and, more importantly, their toolkit.</p><ol><li><strong>The Shift to Pre-Execution Rigor</strong></li></ol><p>In large-scale initiatives, such as the massive cloud migration, success is often won or lost before the first line of code is moved. TPMs are now doubling down on pre-execution planning and rigor to ensure that the “chaos” is controlled. This involves:</p><ul><li><strong>Ownership Clarity:</strong> Establishing ironclad accountability models so no service falls through the cracks.</li><li><strong>Measurable Success:</strong> Defining “done” and “successful” upfront with quantifiable metrics.</li><li><strong>Drills:</strong> Running structured simulations to find breaking points early.</li><li><strong>Resilience by Design:</strong> Building fallback and recovery strategies as a core part of the plan, rather than an afterthought.</li></ul><p><strong>2. The AI Accelerator</strong></p><p>One of the most promising accelerators in this journey is the adoption of AI-driven capabilities. In an environment where decisions are scattered across hundreds of threads and documents, AI acts as a force multiplier.</p><p>By leveraging AI to augment our daily operations, we can:</p><ul><li><strong>Synthesize Signal from Noise:</strong> Summarize cross-team discussions and surface hidden dependencies that manual tracking might miss.</li><li><strong>Proactive Risk Detection:</strong> Flag execution plan risks before they become blockers.</li><li><strong>Automated Transparency:</strong> Ensure stakeholders are informed at desired frequency without manual intervention, maximizing clarity while minimizing the effort</li></ul><p>While chaos is an inherent byproduct of any large-scale system especially within a diverse ecosystem like the GoTo group, the goal of a TPM isn’t to eliminate it. Instead, they aim to continuously evolve how we navigate and structure that entropy.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WVrgsQ74mD3_HxkxFaYyhg.png" /></figure><h3>TPM — The Cultural Architect</h3><p>In the complex web of a digital ecosystem, team interests will inevitably collide. The Fintech team wants ironclad security; the Growth team wants frictionless onboarding; the Platform team wants to deprecate the legacy database everyone is still using. If a TPM waits for <strong>agreement</strong> — where everyone is “happy” with the decision — the project will stall in a loop of endless stakeholder reviews.</p><p><strong>Alignment</strong>, however, is different from agreement. It means that while a team might not agree that a specific path is the absolute best, they understand <em>why</em> it was chosen, they believe the process was fair, and they are committed to making it successful.</p><p>Ultimately, the future of the TPM role is as much cultural as it is technical. They are facilitators of <strong>alignment over agreement.</strong> In an organization such as ours, teams will naturally have differing perspectives. Their job is to ensure that, despite these differences, every team can move forward cohesively with shared clarity on the final goals and the trade-offs required to get there.</p><p>As organizations continue to scale, this ability to orchestrate — rather than control — will become even more critical. And that is where TPMs will continue to evolve: not just as program managers, but as <strong>architects of clarity in an increasingly complex world</strong>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7d26766e9a85" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/orchestrating-chaos-how-technical-program-managers-tpm-enable-scalable-collaboration-7d26766e9a85">Orchestrating Chaos — How Technical Program Managers (TPM) Enable Scalable Collaboration</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[We Built a Zanzibar-Style IAM System From Scratch. Here’s Everything We Learned.]]></title>
            <link>https://medium.com/gojekengineering/we-built-a-zanzibar-style-iam-system-from-scratch-heres-everything-we-learned-bceb2686cad0?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/bceb2686cad0</guid>
            <category><![CDATA[ami]]></category>
            <category><![CDATA[access-control]]></category>
            <category><![CDATA[zanzibar]]></category>
            <category><![CDATA[rbac]]></category>
            <category><![CDATA[spicedb]]></category>
            <dc:creator><![CDATA[Armaan jain]]></dc:creator>
            <pubDate>Thu, 21 May 2026 06:11:56 GMT</pubDate>
            <atom:updated>2026-06-04T10:53:06.582Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kDnu_SOGs5IRlsrJtUP3GQ.jpeg" /></figure><p><em>A practitioner’s guide to building relationship-based access control for multi-tenant platforms </em>-<em> the architecture, the schema design, the pitfalls, and the hard-won lessons from production.</em></p><p>You know that moment when your access control system starts fighting you?</p><p>It starts small. Someone needs a permission that doesn’t fit neatly into any existing role. So you create a new one. Then another team needs a slight variation. Another role. Then someone asks: “Can we make it so Team Lead X can see reports for their team but not the team next door?” Suddenly, you’re staring at a database table with hundreds of roles, half of which no one remembers creating, and a check_permission function that&#39;s 400 lines of nested if-statements held together by comments like // TODO: fix this properly.</p><p>We’ve all been there.</p><p>This is the story of how we replaced that system with something fundamentally different - a Zanzibar-style, relationship-based access control system built on SpiceDB, Go, and PostgreSQL. Not a toy, but a production system handling multi-tenant organizational hierarchies, fine-grained resource permissions, ABAC conditions, and UI-level component visibility checks.</p><p>This article focuses on practical implementation details and trade-offs rather than providing a formal evaluation of every available system.</p><h3>Part 1: Why RBAC Breaks (And When You’ll Notice)</h3><p>Role-Based Access Control is a reliable, well-understood baseline for authorization. It works extremely well - until it doesn’t.</p><p>RBAC tends to break along three axes:</p><h3>The Hierarchy Problem</h3><p>Imagine a platform where each customer (tenant) has their own organizational tree:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5WJcLXYhfYV1gdhsrPlSCQ.png" /></figure><p>An administrator at the Company A level should <em>automatically</em> have management permissions over every subsidiary, business unit, department, and team below them. In RBAC, you’d either:</p><ul><li>Assign them a role on every single entity (nightmare to maintain)</li><li>Create a “Company A Admin” role with hardcoded access to everything below (breaks when the hierarchy changes)</li><li>Build custom hierarchy-traversal logic in your application (now you’re maintaining a permissions engine)</li></ul><p>None of these scale well.</p><h3>The Granularity Problem</h3><p>Users need different access to different <em>parts</em> of the same resource. User A can view a ticket but not the payment widget on it. User B can view the ticket <em>and</em> the payment widget but can’t close the ticket. User C can do everything - but only for tickets belonging to their team.</p><p>In RBAC, each unique combination of resource + action + scope becomes a new role. This is the classic role explosion problem. 50 resources × 5 actions × 10 organizational scopes = 2,500 roles. And you haven’t started on exceptions yet.</p><h3>The Context Problem</h3><p>“This resource can only be modified when its status is resolved.”</p><p>“This user can only access resources during business hours in their timezone.”</p><p>“This temporary promotion expires in two weeks.”</p><p>RBAC has no native vocabulary for conditions, time-bounds, or contextual attributes. These rules often end up embedded in application logic and scattered across microservices, each with its own slightly different interpretation of what ‘admin access’ means.</p><h3>Part 2: Zanzibar - Thinking in Relationships</h3><p>In 2019, Google published a paper describing Zanzibar - an authorization system used across many of its products, including Drive, YouTube, and Cloud. The core idea:</p><blockquote>Much of modern access control can be modeled as a graph problem.</blockquote><p>Instead of “Does this user have this role?”, you ask: “Does a path exist in the relationship graph from this user to this resource that grants this permission?”</p><p>Three building blocks:</p><h3>1. Schema - The Type System</h3><p>The schema defines entity types, their relationships, and how permissions are computed:</p><pre>definition user {}</pre><pre>definition folder {<br>    relation parent: folder<br>    relation owner: user<br>    relation editor: user<br>    relation viewer: user</pre><pre>permission edit = owner + editor + parent-&gt;edit<br>    permission view = owner + editor + viewer + parent-&gt;view<br>}</pre><pre>definition document {<br>    relation parent_folder: folder<br>    relation owner: user</pre><pre>permission edit = owner + parent_folder-&gt;edit<br>    permission view = owner + parent_folder-&gt;view<br>}</pre><p>This says: If you’re an owner of a folder, you can edit and view every document in it - and every document in every subfolder below it. Permission inheritance emerges from the graph structure, not from application code.</p><h3>2. Relationship Tuples - The Data</h3><p>Each tuple is a simple fact: resource#relation@subject.</p><pre>folder:engineering#owner@user:alice<br>folder:backend#parent@folder:engineering<br>document:design-doc#parent_folder@folder:backend</pre><p>Three tuples. From these, the system derives that Alice can edit and view design-doc - because she owns engineering, which is the parent of backend, which is the parent folder of design-doc.</p><h3>3. Permission Checks - The Graph Traversal</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mMS_NQtT-u5rGF0-ra7BaA.png" /></figure><p><em>Permission check: one query, one graph traversal, typically single-digit milliseconds</em></p><h3>Part 3: Choosing a Zanzibar Implementation</h3><p>Google never open-sourced Zanzibar itself, but several implementations exist. We evaluated three:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1alh8B3rk0CT6abpqEd4hg.png" /></figure><p>We also evaluated higher-level frameworks built on top of Zanzibar implementations, but ultimately rejected them. They abstract away the schema language, which means you lose features like permission exclusion (permission view = editor - banned), caveats for ABAC, bulk checks, and multi-level consistency control.</p><h3>Our Benchmarks</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*unmC5AJOz0ZziR-W6xERoQ.png" /></figure><p>Important: All benchmarks below were run on our internal Kubernetes setup with our production schema and K6 as the load testing tool. These results are directional, not definitive. Your numbers will differ based on schema complexity, tuple count, hardware, and query patterns. Always benchmark your own workload.</p><p>We tested with 1M and 10M relationship tuples, at 1,000 and 10,000 queries per second, sustained for 5 minutes each. Server configuration: 4 CPU cores, 4 GB memory. Database: 2 CPU, 4 GB memory (PostgreSQL).</p><h3>1M Relationships, 1,000 QPS</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*o5rkQB9DdLvfj2LqWOE2Jw.png" /></figure><h3>1M Relationships, 10,000 QPS</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K-meh9HXGS2KAcCM6EAfsw.png" /></figure><h3>10M Relationships, 10,000 QPS</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wavKaJmN6TLqXB5D5THQ1Q.png" /></figure><h3>Storage Footprint</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VyogFgTOwJLjhdQYVbXSPQ.png" /></figure><p>Our takeaway: OpenFGA showed lower average and median latency in several scenarios. SpiceDB showed tighter tail latency (P90/P95) and significantly lower max latency under high load. We chose SpiceDB because consistent tail latency mattered more for our use case, and the native distributed caching plus ZedToken consistency guarantees aligned with our multi-node deployment model.</p><h3>Part 4: Our Architecture</h3><p>The system decomposes into four bounded domains, each with its own responsibilities, database schema, and API contract:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6-Qi2xDXrjuskPTklrd43A.png" /></figure><p><em>High-level architecture: the BFF is the enforcement point, delegating to IAM for auth and SpiceDB for permissions</em></p><p>The BFF (Backend-for-Frontend) is the primary enforcement point. Every request flows through it:</p><ol><li>Authenticates - Validates the JWT, checks session status</li><li>Authorizes - looks up the permission mapping for the endpoint and calls SpiceDB</li><li>Filters - After getting the response, strips fields the user isn’t allowed to see</li></ol><h3>Authorised Request Flow</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*II8f8eSmAARp4tTJn3gMEA.png" /></figure><p><em>Sequence diagram: every request is checked against SpiceDB before reaching backend services</em></p><h3>Domain 1: Authentication</h3><p>Handles how users prove their identity. Completely decoupled from authorization.</p><ul><li>Password auth with Argon2id hashing (m=65536, t=3, p=1), lockout policies, and rotation</li><li>OIDC via Authorization Code + PKCE (Google Workspace, Okta, Azure AD, any OIDC provider)</li><li>MFA with TOTP (authenticator apps) and WebAuthn/FIDO2 (hardware keys, biometrics)</li><li>Session management with short-lived JWT access tokens (~15 min) and rotating opaque refresh tokens (~7 days)</li><li>Identity linking - one user, multiple authentication providers, single canonical identity</li></ul><h3>Password Login Flow</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*siDV2qT-TTAlVx5lQY6O9Q.png" /></figure><p><em>Password authentication flow with Argon2id verification and session creation</em></p><h3>OIDC Flow (Authorization Code + PKCE)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tz65nlp48FzrT2z1zLjKZQ.png" /></figure><p><em>OIDC Authorization Code + PKCE flow with id_token verification and identity upsert</em></p><h3>Session Lifecycle</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RjLE1PE8EjdnoQwR8aRaoA.png" /></figure><p><em>Session lifecycle: sessions are explicitly tracked with revocation support</em></p><p>Key design decisions:</p><ul><li>Personas vs. Roles: Personas drive authentication (how you prove who you are). Roles drive authorization (what you’re allowed to do). An “admin” persona might require MFA; an “admin” role grants management permissions. Don’t conflate them.</li><li>Tenant-scoped IdPs: Each tenant configures their own identity providers. Tenant A uses Google Workspace, Tenant B uses Okta, Tenant C uses username/password. The auth service supports all simultaneously.</li></ul><h3>Domain 2: Organization Hierarchy (Tenants)</h3><p>The structural backbone. Manages tenants, organizational units, and role definitions.</p><ul><li>Tenants - The top-level namespace. Each tenant is fully isolated.</li><li>Organizational Units - A recursive tree within each tenant. Uses PostgreSQL LTREE for efficient hierarchical queries.</li><li>Roles - Defined within a tenant. Each role is a named collection of permission relations, scoped to an organizational unit level.</li></ul><h3>Tenant Lifecycle</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-Ls-ZypKG95eYYI7x13_3Q.png" /></figure><p><em>Tenant lifecycle: Created → Active → Deactivated → Archived</em></p><h3>Org Unit Lifecycle</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FLSETcjDccmfvWwrqPgSvQ.png" /></figure><p><em>Org unit lifecycle with merge and split as first-class terminal states</em></p><p>Merge and split are first-class concepts. When two departments merge, the old units transition to a terminal “merged” state; a new unit inherits their members and permissions, preserving full audit history.</p><h3>Domain 3: User Management</h3><p>User management is not authentication. A user exists independently of how they log in. You can create a user, assign them roles, and configure their profile before they ever authenticate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mGND_Yf8xJ8-EdhV9Fu1dg.png" /></figure><p><em>User lifecycle with suspension support for temporary disablement</em></p><ul><li>Users belong to tenants (strict isolation via tenant_id)</li><li>Users are assigned roles within specific organizational units</li><li>Role assignments can have expiration dates (interim promotions, temporary access)</li><li>All mutations publish domain events for downstream consumers</li></ul><h3>Domain 4: Authorization</h3><p>The SpiceDB integration layer. Policy meets enforcement.</p><ul><li>Schema management - Versioned SpiceDB schemas deployed via CI/CD</li><li>Relationship synchronization - When tenants/units/users/roles change, SpiceDB tuples are written</li><li>Permission checking - gRPC calls to SpiceDB with proper consistency levels</li><li>Permission mapping - YAML configuration connecting endpoints and UI components to SpiceDB checks</li></ul><h3>Resource Creation Flow</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jPT26pCyy5VvPe_7UfMo-w.png" /></figure><p><em>Resource creation: SpiceDB tuples are created after the backend resource, with Kafka compensation on failure</em></p><h3>Part 5: The Schema - Where Everything Comes Together</h3><p>This is the most important part. Get the schema wrong and everything downstream suffers.</p><h3>Modeling Roles as Entities</h3><p>In most RBAC systems, roles are just strings in a database column. In our Zanzibar model, roles are first-class entities with their own relationships:</p><pre>definition user/human {}</pre><pre>definition role/human {<br>    relation bound_user: user/human</pre><pre>// What permissions this role grants<br>    relation tenant_manager: role/human<br>    relation org_unit_manager: role/human<br>    relation user_manager: role/human<br>    relation role_assigner: role/human<br>    relation resource_viewer: role/human<br>    relation resource_editor: role/human<br>    relation widget_executor: role/human<br>    // ... more relations as needed</pre><pre>// Derived permissions<br>    permission tenant_manage = tenant_manager-&gt;bound_user<br>    permission org_unit_manage = org_unit_manager-&gt;bound_user<br>    permission resource_view = resource_viewer-&gt;bound_user<br>    permission resource_edit = resource_editor-&gt;bound_user<br>    permission widget_execute = widget_executor-&gt;bound_user<br>}</pre><p>To create a role, assign a user, and grant it at an org unit:</p><pre>// &quot;team_lead&quot; can manage org units and view resources<br>role/human:team_lead#org_unit_manager@role/human:team_lead<br>role/human:team_lead#resource_viewer@role/human:team_lead</pre><pre>// Assign Alice to the role<br>role/human:team_lead#bound_user@user/human:alice</pre><pre>// Grant the role at the engineering org unit<br>tenant/org_unit:engineering#granted@role/human:team_lead</pre><p>The role is composable (mix any permissions), tenant-scoped (each tenant defines their own), and inspectable (query SpiceDB’s schema reflection API to list all permissions).</p><h3>Permission Inheritance Through the Hierarchy</h3><pre>definition tenant/space {<br>    relation granted: role/human<br>    relation root_space: tenant/space</pre><pre>permission resource_view = granted-&gt;resource_view<br>                             + root_space-&gt;resource_view<br>    permission org_unit_manage = granted-&gt;org_unit_manage<br>                               + root_space-&gt;org_unit_manage<br>}</pre><pre>definition tenant/org_unit {<br>    relation granted: role/human<br>    relation parent_org_unit: tenant/org_unit<br>    relation tenant_space: tenant/space</pre><pre>permission resource_view = granted-&gt;resource_view<br>                             + parent_org_unit-&gt;resource_view<br>                             + tenant_space-&gt;resource_view<br>    permission org_unit_manage = granted-&gt;org_unit_manage<br>                               + parent_org_unit-&gt;org_unit_manage<br>                               + tenant_space-&gt;org_unit_manage<br>}</pre><p>The + operator means union. Permissions flow through three paths:</p><ol><li>Direct grant - A role granted directly on this org unit</li><li>Parent inheritance - A role granted on any ancestor org unit (walks up recursively)</li><li>Tenant-level - A role granted at the tenant space level</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J__TaGA3-v94PuwlmsI0Cg.png" /></figure><p><em>One tuple at the tenant space level cascades permissions to every org unit below</em></p><h3>Resource-Level Access Control</h3><pre>definition resource/ticket {<br>    relation granted: role/human<br>    relation org_unit: tenant/org_unit<br>    relation owner: user/human<br>    relation excluded: user/human</pre><pre>permission view = owner<br>                    + granted-&gt;resource_view<br>                    + org_unit-&gt;resource_view<br>                    - excluded</pre><pre>permission edit = owner<br>                    + granted-&gt;resource_edit<br>                    + org_unit-&gt;resource_edit<br>                    - excluded</pre><pre>permission close = org_unit-&gt;org_unit_manage<br>                     - excluded<br>}</pre><p>There are four key things to notice here:</p><ol><li>Multiple permission paths: Own it, have a direct role grant, or inherit via org unit hierarchy.</li><li>Hierarchy integration: org_unit-&gt;resource_view traverses the entire ancestry chain.</li><li>Exclusion: The - excluded operator surgically revokes access for specific users despite broad grants.</li><li>Action-specific scoping: close requires org_unit_manage (managers only), while view is broader.</li></ol><h3>ABAC via Caveats</h3><p>SpiceDB supports conditional permissions through caveats:</p><pre>caveat ticket_is_resolved(ticket_status string) {<br>    ticket_status == &quot;resolved&quot;<br>}</pre><pre>definition resource/ticket {<br>    relation owner: user/human with ticket_is_resolved<br>    relation org_unit: tenant/org_unit</pre><pre>permission escalate = owner<br>                        + org_unit-&gt;granted_escalate<br>}</pre><p>At check time:</p><pre>// Allowed — ticket_status is &quot;resolved&quot;:<br>resource/ticket:123#escalate@user/human:bob  with {&quot;ticket_status&quot;: &quot;resolved&quot;}</pre><pre>// Denied — ticket_status is &quot;new&quot;:<br>resource/ticket:123#escalate@user/human:bob  with {&quot;ticket_status&quot;: &quot;new&quot;}</pre><p>RBAC and ABAC, unified in a single model. No separate policy engine. No Rego. One schema, one check.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P57Ru3uk_1YQnhJpxfpj6Q.png" /></figure><h3>Part 6: The Implementation</h3><h3>Tech Stack</h3><ul><li>Language: Go</li><li>API: gRPC with Protocol Buffers (12 service definitions)</li><li>Databases: PostgreSQL (operational data), SpiceDB backed by PostgreSQL (authorization)</li><li>Caching: Redis</li><li>Events: Kafka (audit logs, domain events, compensation events)</li><li>Auth: JWT (ES256), opaque refresh tokens, Argon2id, WebAuthn/FIDO2, TOTP</li><li>Infra: Kubernetes, Helm, Docker</li></ul><h3>The Relationship Manager</h3><pre>type RelationshipManager interface {<br>    WriteRelationship(ctx context.Context, tuples []Tuple) error<br>    DeleteRelationships(ctx context.Context, filter Filter) error<br>    AddResource(ctx context.Context, resource, orgUnit string) error<br>    RemoveResource(ctx context.Context, resource string) error<br>    AddOwner(ctx context.Context, resource, user string) error<br>    ChangeOwner(ctx context.Context, resource, oldUser, newUser string) error<br>    BindUserToRole(ctx context.Context, user, role, orgUnit string) error<br>    CheckBulkPermissions(ctx context.Context, items []CheckItem) ([]Result, error)<br>}</pre><p>Every state change flows through here. Create a resource? AddResource + AddOwner. Assign a role? BindUserToRole. Transfer ownership? ChangeOwner.</p><h3>Permission Mapping: Connecting APIs to SpiceDB</h3><pre>- name: get_ticket<br>  prefix: /api/v1/ticket_service<br>  path: /tickets/{ticket_id}<br>  method: GET<br>  authz:<br>    - name: get_ticket_permission<br>      permission: view<br>      subject_type: &quot;user/human&quot;       # from JWT or X-User-ID header<br>      object_type: &quot;resource/ticket&quot;<br>      object_id:<br>        value: ticket_id<br>        source: path_params            # extracted from URL</pre><p>The BFF reads this mapping, extracts the ticket ID from the URL, the user ID from the JWT, and calls SpiceDB: resource/ticket:123#view@user/human:current_user_id. No authorization logic in backend services. One mapping file, one enforcement point.</p><h3>Bulk UI Permission Checks</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3BDzUJoA2ly51qENeL6QFw.png" /></figure><p><em>Bulk permission checks: 20 items in one round trip, SpiceDB evaluates in parallel</em></p><pre>// Frontend usage<br>&lt;RequiredPermission has={[&quot;can_edit_ticket&quot;]}&gt;<br>  &lt;EditButton /&gt;<br>&lt;/RequiredPermission&gt;</pre><pre>&lt;RequiredPermission hasAny={[&quot;can_refund&quot;, &quot;can_close&quot;]}&gt;<br>  &lt;ActionMenu /&gt;<br>&lt;/RequiredPermission&gt;</pre><p>Components the user can’t access simply don’t render. No flickering, no “unauthorized” errors after clicking.</p><h3>Part 7: PostgreSQL LTREE + SpiceDB - A Powerful Duo</h3><p>The organizational hierarchy lives in two places, by design:</p><p>PostgreSQL (source of truth for <em>what exists</em>):</p><pre>CREATE TABLE units (<br>    id              TEXT PRIMARY KEY,<br>    tenant_id       TEXT NOT NULL REFERENCES tenants(id),<br>    hierarchy_path  LTREE NOT NULL,<br>    name            TEXT NOT NULL,<br>    slug            TEXT NOT NULL,<br>    type            TEXT NOT NULL,  -- company, subsidiary, department, team<br>    status          TEXT NOT NULL CHECK (status IN (<br>        &#39;created&#39;,&#39;active&#39;,&#39;deactivated&#39;,&#39;archived&#39;,&#39;merged&#39;,&#39;splitted&#39;<br>    )),<br>    metadata        JSONB,<br>    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),<br>    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()<br>);</pre><pre>CREATE INDEX idx_units_hierarchy ON units USING GIST (hierarchy_path);</pre><pre>-- All descendants of engineering:<br>SELECT * FROM units WHERE hierarchy_path &lt;@ &#39;acme.engineering&#39;;</pre><pre>-- Direct children only:<br>SELECT * FROM units WHERE hierarchy_path ~ &#39;acme.engineering.*{1}&#39;;</pre><p>SpiceDB (source of truth for <em>who can access what</em>):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vClRe9Jhm1dtT98bt0_Flw.png" /></figure><p><em>Database schema: tenants, units (with LTREE hierarchy), and roles (with JSONB permission relations)</em></p><p>PostgreSQL gives you fast subtree queries, ordering, and metadata. SpiceDB gives you fast permission inheritance. Each system does what it’s best at - you can’t SELECT * FROM org_units WHERE status = &#39;active&#39; ORDER BY name on SpiceDB.</p><h3>Part 8: Schema Versioning and Migration</h3><p>SpiceDB schemas evolve over time. Our approach uses versioned directories:</p><pre>schema/<br>  v0.0.0/schema.zed    -- Initial schema (base definitions)<br>  v0.0.1/schema.zed    -- Added note and log viewing permissions<br>  v0.0.2/schema.zed    -- Added dashboard and report viewer roles<br>  v0.0.3/schema.zed    -- Added resource listing permissions</pre><p>Safe migrations (always backward-compatible):</p><ul><li>Adding new relations to existing definitions</li><li>Adding new permissions</li><li>Adding new subject types to existing relations</li><li>Adding entirely new definitions</li></ul><p>Contingent migrations (require data cleanup first):</p><ul><li>Removing relations (must delete all tuples using that relation first)</li><li>Removing subject types from relations</li></ul><p>Dangerous migrations (avoid in production):</p><ul><li>Renaming relations (there’s no rename - it’s delete + create)</li><li>Changing permission expressions (can instantly grant or revoke access)</li></ul><p>CI/CD pipeline for schema changes:</p><ol><li>Diff preview - /v1/schema/diffschema to see what will change</li><li>Schema write - /v1/schema/write to apply</li><li>Verification - Diff again to confirm</li></ol><p>Every schema change is deployed separately from application code - no coordinated big-bang releases.</p><h3>Part 9: The Hard Lessons</h3><h3>1. Schema Design Is the Hardest Part</h3><p>We spent more time iterating on the SpiceDB schema than writing the entire Go service. The schema is your authorization model’s type system. There’s no “we’ll fix it in the application layer.”</p><p>Our biggest debate: should UI widgets be relations on the role (flat, specific) or standalone resource entities (generic, flexible)?</p><pre>// Option A: Widget as role relation (what we chose)<br>relation widget_payment_viewer: role/human<br>permission widget_payment_view = widget_payment_viewer-&gt;bound_user</pre><pre>// Option B: Widget as resource<br>definition resource/widget {<br>    relation granted: role/human<br>    permission view = granted-&gt;widget_view<br>    permission execute = granted-&gt;widget_execute<br>}</pre><p>Option A: Schema is the source of truth, no separate registry needed, but every new widget requires a schema migration. Option B: New widgets are just tuple insertions, but you need a widget registry in your service layer. Pick based on your rate of change.</p><h3>2. Dual-Write Consistency Is Non-Trivial</h3><p>Creating a user and assigning a role means writing to PostgreSQL <em>and</em> SpiceDB. If one fails, you have inconsistency. Our approach: PostgreSQL first, SpiceDB immediately after, Kafka compensation events on failure, periodic reconciliation job.</p><h3>3. Benchmark Your Actual Schema</h3><p>Published benchmarks use simple schemas. Our schema has 80+ role relations, 4 hierarchy levels, and 5+ relationship traversals per check. Always benchmark with your actual schema, realistic tuple counts, and realistic query patterns.</p><h3>4. Caveats Require Discipline</h3><p>If the caller doesn’t provide context, SpiceDB returns CAVEATED (a maybe). We treat it as DENIED by default and monitor for unexpected occurrences.</p><h3>5. Single Schema Per Instance</h3><p>All tenants share one SpiceDB schema. This works if tenants differ in <em>data</em> (which roles exist) but not in <em>structure</em> (the permission model itself).</p><h3>6. Don’t Forget Response Filtering</h3><p>Authorization isn’t just “can they call this endpoint?” It’s also “which fields in the response are they allowed to see?” We strip forbidden fields at the BFF layer before sending to the frontend.</p><h3>Part 10: The Numbers</h3><ul><li>12 gRPC services: authentication, authorization, user management, tenant management, roles, sessions, MFA, identity providers, personas, API resources, UI resources, email verification</li><li>16 domain modules in Go with clean model/service/store separation</li><li>80+ permission types modeled in SpiceDB</li><li>4 schema versions deployed via CI/CD (additive, backward-compatible)</li><li>~3.8ms median permission check latency at 1,000 QPS with 10M tuples</li><li>Sub-5ms P95 at moderate load on 4 CPU / 4GB SpiceDB</li></ul><h3>Should You Build This?</h3><p>Yes, if:</p><ul><li>You have multi-tenant customers with complex organizational hierarchies</li><li>Your permission model includes inheritance (team → department → company → tenant)</li><li>You need fine-grained access control on individual resources</li><li>You need both role-based and attribute-based checks</li><li>Your frontend needs to know what the user can see before rendering</li><li>Audit and compliance matter</li></ul><p>No, if:</p><ul><li>You have a single tenant with flat roles (admin, editor, viewer)</li><li>Your authorization is “is the user logged in? they can do everything”</li><li>You don’t need hierarchical permission inheritance</li><li>You’re a small team and shipping speed matters more than permission granularity</li></ul><p>The investment is real: schema design, dual-write consistency, SpiceDB operations, migration planning. But the payoff is an authorization system that scales with your organizational complexity instead of fighting against it.</p><p>Zanzibar-style authorization, when designed carefully, is one of the few approaches where the system becomes <em>easier</em> to reason about as complexity grows.</p><p>That’s the whole point.</p><p><strong>Credits :</strong></p><p>Engineers — <a href="https://www.linkedin.com/in/gianfebrian/">Muhammad Gian Ansyori</a>, <a href="https://www.linkedin.com/in/fwznbg/">Muhammad Fawwaz Naabigh</a> (Caretech Gojek)</p><p>Project Lead and Management — <a href="https://www.linkedin.com/in/ravicraj/">Ravi Raj</a> (Senior Engineering Manager — Gojek)</p><p><em>This article describes the architecture and implementation of a production IAM system built on SpiceDB, Go, PostgreSQL, and Kafka. The system handles multi-tenant organizational hierarchies, relationship-based access control, OIDC/password/MFA authentication, and UI-level permission checking.</em></p><p>,</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bceb2686cad0" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/we-built-a-zanzibar-style-iam-system-from-scratch-heres-everything-we-learned-bceb2686cad0">We Built a Zanzibar-Style IAM System From Scratch. Here’s Everything We Learned.</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why We Killed the Live Map: Using Motion to Cure Post-Booking Anxiety]]></title>
            <link>https://medium.com/gojekengineering/why-we-killed-the-live-map-using-motion-to-cure-post-booking-anxiety-ea479b00884e?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/ea479b00884e</guid>
            <category><![CDATA[interaction-design]]></category>
            <category><![CDATA[motion-ux]]></category>
            <category><![CDATA[lottie]]></category>
            <category><![CDATA[gojek]]></category>
            <category><![CDATA[product-design]]></category>
            <dc:creator><![CDATA[Jayant Mandan]]></dc:creator>
            <pubDate>Mon, 18 May 2026 02:40:52 GMT</pubDate>
            <atom:updated>2026-05-18T02:40:51.098Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>The Problem: The Waiting Game and a Frozen Dot</strong></p><p>You know the feeling. You buy something online, you hit the button, and then you just stare at the screen. You are waiting for some kind of sign that the system actually registered your order.</p><p>At Gojek, we serve over 170 million users. That means millions of people are staring at their phones, waiting for groceries on GoMart. In the beginning, our initial MVP for the post-booking flow was completely bare-bones. We just dropped users onto a map with a little dot representing a driver.</p><p>And then they would sit there. Watching a dot that refused to move.</p><p>It is a recipe for high anxiety. Users constantly wondered where their order was. People get nervous, assume something is broken, and ultimately cancel their orders. We had no live progress steppers, no clear driver location, and no exact ETA. The legacy design failed to highlight our biggest unique selling point: 15-minute delivery. It was time for a complete teardown.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/245/1*Rvum-OJGCExc9LIkuHeeSw.jpeg" /><figcaption>Old Post Booking Screen</figcaption></figure><p><strong>Stage 1: Rebuilding the Foundation and Adding a Safety Net</strong></p><p><strong>Maria</strong>, our Product Designer, stepped in and completely re-architected the layout. She brought in Gojek’s clean “Aloha” design system, which uses a boxed architecture to organize complex data. Out went the ambiguous screens, and in came clear progress steppers to answer exactly where the order was.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/413/1*6KohX9PWROU04hjVO9EUJg.png" /><figcaption>New Layout</figcaption></figure><p>She mapped out five different realities for the delivery:</p><p>- Packing<br>- On the way<br>- Early<br>- On-time<br>- Delayed.</p><p>Working closely with our UX writer <strong>Nadia</strong> to make the text sound like a real person was talking to you. A crisp green layout meant things were on time. A cool blue screen let you know it was raining outside. A warm orange screen offered a gentle apology if the driver was running late.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FGIJD8iE0y39rjIirbB6Yw.png" /><figcaption>Different states for Bottom sheet IA</figcaption></figure><p>We also added a clear<strong> “Cancel Purchase” button</strong> with a ten-second timer right on that first screen. People often panic immediately after ordering because they realize they made a mistake. Giving them a ten-second countdown gave them a safe window to fix those mistakes before the order processed.</p><p><strong>Stage 2: Replacing Spec Sheets for Real Motion</strong></p><p>Getting the foundation right was great. But the way those screens moved into each other had to feel entirely natural. Complex movements require precise timing.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PXW73ZXJDkoO23RKIaA4vA.png" /><figcaption>Spec sheet reference for a bottom sheet interaction</figcaption></figure><p>Usually, designers hand engineers a bunch of static spec sheets and hope for the best. As the designer leading the interaction and UX motion for this project, I decided to optimise that process. Instead, I swapped them for high-fidelity, interactive ProtoPie Recipes. I mapped out all the bottom-sheet interactions, the drag gestures, the flings, UX motion for maps and exactly how the top bar elements responded to a touch.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Tqq6lharq-WdbU17unQj0Q.gif" /><figcaption>Protopie recipie for the one of the interactions</figcaption></figure><p>I handed the engineers a living prototype. They could see the exact physics and easing of every transition, making our collaboration much smoother. It worked so well that we officially adopted ProtoPie for all our functional motion requirements.</p><p><strong>Stage 3: The Million-Dollar Question: Map or Animation?</strong></p><p>Those initial changes; Maria’s architecture, the conversational copy, the ProtoPie interactions, and the safety net completely changed user behavior and led to an <strong>8% overall reduction in order cancellations.</strong></p><p>But we still had a problem with the map.</p><p>Showing a live map while an order is just sitting in a warehouse being packed is very expensive. <strong>Ballooning Google Maps API costs</strong> for 170 million users is a massive issue. However, looking at a stationary dot is stressful for users.</p><blockquote>We had a hypothesis: we wanted to know if state-driven Lotties or a Live map would better reduce anxiety among users.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WAXhTNppfojwgsTa8BXA5w.gif" /><figcaption>Live map vs State driven lotties</figcaption></figure><p>We ran an A/B test in our warehouse areas. We kept the map for half the users. For the other half, we replaced it with custom Lottie animations.</p><p>This is where the magic of collaboration really happened. Our incredibly talented illustrator, <strong>Beny Styawan</strong> crafted beautiful, warm illustrations featuring our characters and our friendly cat mascot. She set the scene, and then I gave it life. By animating those illustrations, I created a continuous, comforting loop of progress. Together, we transformed a sterile waiting screen into a story.</p><p><strong>The Verdict: Motion as a Functional Tool</strong></p><p>We wanted to see if a friendly animation could beat a GPS dot.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rHad89gZRwEputPg1zXF8w.gif" /><figcaption>5 different post booking states</figcaption></figure><h3>It did.</h3><p>Lottie animations beat the live map variant, driving an additional <strong>0.9% drop in cancellations.</strong> We saved massive Map API costs by replacing maps with Lottie animations. The data was so clear that we rolled the animations out to 100% of our millions of active users.</p><p>People often talk about motion design as if it is just decoration. But it is much more than that. Behind the mood boards and the technical physics of a ProtoPie recipe is a simple desire to care for the person on the other side of the screen.</p><p>It is a way to show empathy.</p><p>It tells the user that the system is working for them so they can relax. When we replace uncertainty with warmth and movement, we build real trust. That sense of delight is not just a “nice to have.” It drives the business forward.</p><p>Thoughtful motion impacts everything from engagement and clicks to long term retention and revenue. At the end of the day, design is about solving complex problems for the company while staying deeply human for the user. That is what it is really for.v</p><h3>The team</h3><p><strong>Product designer:</strong> Maria Grace Sandra<br><strong>Motion design:</strong> Jayant Mandan<br><strong>Product Manager:</strong> Nayantara<br><strong>UX Writer:</strong> Nadiya Amalina<br><strong>Illustrator:</strong> Beny Styawan<br><strong>Engineering (Tech) :</strong> Naman Jain, Deep Mistry<br><strong>Engineering (Frontend):</strong> Jeffrey Immanuel, Rajat, Dhruvil Rameshbai<br><strong>QA: </strong>Pandu Kusumadewi, Felicia Aurelia<br><strong>Blog Cover Illustration: </strong>CA Team</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ea479b00884e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/why-we-killed-the-live-map-using-motion-to-cure-post-booking-anxiety-ea479b00884e">Why We Killed the Live Map: Using Motion to Cure Post-Booking Anxiety</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How a Revamped ‘Finding Driver’ Animation Reduced Cancellations by 19% for Gojek]]></title>
            <link>https://medium.com/gojekengineering/how-a-revamped-finding-driver-animation-reduced-cancellations-by-19-for-gojek-f1e0b2ed901d?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/f1e0b2ed901d</guid>
            <category><![CDATA[ux]]></category>
            <category><![CDATA[ux-design]]></category>
            <category><![CDATA[motion-design]]></category>
            <category><![CDATA[lottie]]></category>
            <category><![CDATA[ux-motion-design]]></category>
            <dc:creator><![CDATA[Binoy Cyriac]]></dc:creator>
            <pubDate>Mon, 04 May 2026 09:26:26 GMT</pubDate>
            <atom:updated>2026-05-04T09:26:24.939Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OHj1sxl6to3cnLo6XB6chA.jpeg" /></figure><h3>5 years in product design to motion design intern</h3><p>It’s the year 2020. I had just jumped ship to an under-construction motion design team at Gojek after spending five years of my career in product design. I opened After Effects once and decided to quit. But Abhinit (OG head of design, Gojek), convinced me to stay with the best advice he ever gave me:</p><blockquote>“Keep. At. It.”</blockquote><p>Alright, let’s do this one more time.</p><p>Two more weeks of tinkering with After Effects and YouTube tutorials, and I had successfully failed at creating a basic button click animation. I decided I should either convince Abhinit to shut down the motion design team altogether, or quit Gojek myself, rather than risk my career and reputation as a good designer.</p><p>As I was plotting my escape, Aniket (my product designer friend) walked up and said, <em>“Bro, we can’t figure out how to help our users feel less anxious while waiting for us to find them a driver. Wanna try solving it with motion?”</em> My immediate thought: <em>Oh no. Oh yes! Oh no. Wait - let’s go!</em></p><p>The excitement of having the opportunity to approach a design brief through a completely new lens, along with Jean’s (my motion design teammate and mentor) eerily optimistic confidence that I’d be able to pull this off (what was she thinking), made me shake hands with Aniket and set the project rolling.</p><p>But here’s the thing about being blissfully ignorant of your own limitations - it’s weirdly liberating. I didn’t know what I didn’t know about technical constraints, implementation complexity, or the Herculean effort our engineering team would need to put in. I just started sketching like an excited kid building a fantasy world.</p><p>The turning point came when we stopped worrying about getting the animation right, and started focusing on what our users were actually feeling.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A1dkgLlq8GIzcH2NlOiqDA.png" /><figcaption>Channeling questions about the brief through my inner child 😶</figcaption></figure><h3>The Problem: When Waiting Feels Like Forever</h3><p>There was a certain segment of users who used to drop-off when the waiting period during the finding driver stage stretched beyond five seconds. We wanted to take a look at why this was happening and try to address the issue in the best way possible.</p><h3>The Old Experience: The Endless Loop of Despair</h3><p>We had a basic looping animation - a pulsing graphic that repeats the same 2-3 second cycle. Forever. With no changes. No updates. Just… loop. Loop. Loop.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/848/1*lSjY8OlFCqa3nEilKKyv5g.gif" /></figure><p>What would be going on in a user’s mind?</p><ul><li><strong>“Is anything actually happening, or are they just showing me a placeholder?”</strong> (Valid question when nothing changes for 30 seconds)</li><li><strong>“They’re not even trying to find me a driver.”</strong> (When the animation gives zero indication of progress)</li><li><strong>“Let me check other apps real quick…”</strong> (And there goes the booking)</li></ul><p>This wasn’t something we could solve with competitive pricing or discount codes. This was about the <em>psychological experience</em> of waiting. The anxiety. The impatience. The sneaking suspicion that nothing’s actually happening behind the scenes.</p><p>We needed to make the waiting period feel active, transparent, and engaging.</p><h3>The Design Process: Explorations, Rejections, and Reality Checks</h3><p>I explored <strong>5-10 different iterations </strong>before we landed on the final solution.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CEjVYfYcFaV2bkhWz66HmQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LDI_2hzl-av_xAIailrh5g.jpeg" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Knvp_EddwPZIMm41-ptGEQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xhih8641NWeUPA2B9NvEfQ.png" /></figure><h3>What We Rejected (And Why)</h3><p><strong>Too Playful/Complex:</strong> Some early ideas were envisioned to be visually stunning but communicated nothing. We had to remember: this isn’t about showing off. It’s about reducing anxiety and keeping users engaged.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*rfwI6v1DCQ-6nZibh-6U4w.gif" /></figure><p><strong>Performance Killers:</strong> Complex animations could drain battery life and/or slow down the phone’s performance - a critical concern when someone’s desperately trying to book a ride and might already be at 10% battery.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*3KyCdvwELkefcJyHLlEM1g.gif" /></figure><p><strong>API Cost Bombs:</strong> We explored showing <em>actual real-time driver positions</em> on the map. Cool idea, right? Turns out it would’ve required constant API calls, increased server costs, drained user batteries, and slowed down phone performance. All for information that didn’t actually help the user.</p><p><strong>Engineering nightmare: </strong>Motion design lives or dies in implementation. We had a strong engineering team, but bandwidth is finite - and every animation decision carried a real cost. The question was never whether we <em>could</em> build it, but whether the impact for our consumers justified the effort. Finding that balance between design intention and engineering investment became one of the defining constraints of the project, and ultimately shaped the final animation as much as any creative decision did.</p><p>The balancing act was brutal: <strong>How do we make this engaging without being annoying? How do we show progress without lying to the user? How do we stay technically performant while being visually interesting?</strong></p><h3>The Insight: Three Stages of Hope</h3><p>After multiple brainstorming sessions, diving into past data, and making sense of user behaviour patterns, we got on to something :</p><p><strong>What if we broke down the waiting experience into three distinct stages, each with its own animation and messaging?</strong></p><p>The logic was - when you’re waiting for something important, your anxiety isn’t linear. It evolves:</p><ul><li><strong>0–5 seconds:</strong> <em>“Okay, this should be quick…”</em></li><li><strong>5–15 seconds:</strong> <em>“Hmm, taking a bit longer than expected…”</em></li><li><strong>15+ seconds:</strong> <em>“WHAT IS HAPPENING. ARE THEY EVEN TRYING?”</em></li></ul><p>So we designed for that emotional journey.</p><h3>Stage 1 (0 - 5 seconds): “We Got This”</h3><p><strong>Animation:</strong> An intentional, communicative animation that feels like diving into the map with the user - like we’re literally going on a hunt together to find the nearest driver.</p><p><strong>Why it works:</strong> The majority of our users get a driver during this stage. For them, this is all they ever see - a confident, purposeful search that resolves quickly. No room for anxiety.</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*adRUyfJNPTITFe6c_Mh2cA.gif" /></figure><h3>Stage 2 (5 - 15 seconds): “We’re Working On It”</h3><p>This is where things get interesting.</p><p><strong>Animation changes:</strong></p><ul><li>Multiple driver markers appear on the map and start animating</li><li>The pulse rate subtly increases (faster = more urgent search)</li><li>The arrow inside the pickup pin starts animating <em>upward</em> - like it’s raising its hand to get attention</li><li>Everything moves dynamically to show active searching, not stagnation</li></ul><p><strong>Why it works:</strong> The animation <em>evolves</em>. Users can see that something different is happening. We’re not just showing them the same loop - we’re visually communicating that the system is escalating its search.</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*AvN3F70BC6E9A1gy-4K5-A.gif" /></figure><h3>Stage 3 (15+ seconds): “We’re Going Full Throttle”</h3><p>Now we’re in edge-case territory. Only a small percentage of bookings reach this point, but we couldn’t abandon these users. They’re the ones who need us most.</p><p><strong>Animation changes:</strong></p><ul><li>The map zooms <em>out - </em>showing a wider search radius</li><li>The pickup pin starts <strong>jumping up and down</strong> - like someone frantically waving their arms for attention</li><li>The movement is inspired by human body language: desperation, urgency, determination</li></ul><p><strong>Why it works:</strong> We’re being honest (“yeah, this is taking a while”) while also showing empathy and effort. The jumping pin is a visual metaphor - it’s us, desperately trying to flag down any driver in the vicinity. It tells a story with minimal cognitive load.</p><p>The user might be frustrated, but at least they <em>know</em> we’re still fighting for them. Or be informed that this search might take longer than usual.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/360/1*rx1XZWwAGXAFglCav6vSPQ.gif" /></figure><h3>The Dream Team: Why This Worked</h3><p>I can’t take credit for this alone. Not even close.</p><p>This project was a masterclass in cross-functional collaboration:</p><ul><li>The <strong>product and business team</strong> believed motion design could drive impact and gave us the runway to explore.</li><li>The <strong>product designers</strong> helped break down the flow into stages and kept us grounded in user behaviour.</li><li><strong>UX Writers </strong>crafted messaging that was honest, reassuring, and concise.</li><li><strong>The Design System team</strong> and <strong>UI designers</strong> streamlined the UI to show only essential information during the wait</li><li>The <strong>engineering team </strong>implemented the animations (Lottie/JSON + native code) flawlessly across platforms, ran spike tests to ensure performance, and went through multiple iterations for handcrafting the UI transitions with patience and precision.</li><li>The <strong>QA team</strong> stress-testing every scenario to make sure the animations didn’t break under edge cases.</li><li><strong>Creative design</strong> and <strong>illustration teams</strong> for crafting the assets</li><li>My <strong>motion team-mates</strong> and Abhinit who guided and gave me the confidence to pull this off.</li></ul><p>Without their conviction that motion design could add value to this flow, none of this would have happened. As the pilot project driven by motion design at Gojek, they were the ones who helped convince stakeholders that investing bandwidth, effort and resources into this would bring results, even if it meant the sprint would get longer.</p><h3>The Launch: Measuring What Matters</h3><p>We ran a <strong>staged rollout</strong> : starting with a small percentage of users, then expanding based on results.</p><h3>The Test</h3><ul><li><strong>Duration:</strong> Over a month of data collection</li><li><strong>Metrics:</strong> Cancellation rate during the “finding driver” stage</li><li><strong>Hypothesis:</strong> If we make the waiting period more transparent and engaging, it will help mitigate waiting anxiety and users will be less likely to cancel.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/600/1*qBeopgdIDbW5aB0r56C6dg.gif" /><figcaption>Here’s the whole flow in action</figcaption></figure><h3>The Result</h3><blockquote>19% reduction in cancellations during the finding driver stage.</blockquote><p>We didn’t change the pricing. We didn’t add discounts. We didn’t magically find more drivers. We just made the <em>waiting experience</em> better through motion design.</p><h3>What This Proved: Motion Design Isn’t Just “Polish”</h3><p>Before this project, motion design at Gojek was… let’s say, experimental. Nice-to-have. The cherry on top after the “real work” was done.</p><p>This project changed that narrative.</p><p>It proved that:</p><ul><li><strong>Motion design can drive measurable business impact -</strong> not just “delight,” but actual revenue-affecting metrics.</li><li><strong>Motion design should be part of product development</strong> - not an afterthought, but a strategic tool in the design process.</li><li><strong>Motion design is worth the investment</strong> - the time, the effort, the engineering bandwidth</li></ul><p>This project paved the way for motion design to grow into a <strong>full-fledged central team at Gojek</strong>. What started as a scrappy pilot became proof that this discipline deserves a seat at the table.</p><h3>The Lessons: What I’d Tell My Past Self</h3><p><strong>Not knowing the rules</strong> is an advantage - use it while it lasts. I designed freely because I didn’t know what was “impossible,” and some of the best ideas came from that ignorance.</p><p>Animation that doesn’t <strong>communicate</strong> something earns its removal. Every motion in this project had a job. If it didn’t do that job, it didn’t ship.</p><p><strong>Human metaphors</strong> cut through complexity fast. A pickup pin waving for attention works because desperation is universal. When system states behave like people, they stop needing explanation.</p><p><strong>Constraints are just design briefs you didn’t write.</strong> Battery limits, performance budgets, API costs - each one pushed us toward something leaner and smarter than the original idea.</p><p>And nothing here was a solo effort. Engineers made it fast. Writers made it clear. Product made it count. QA made it solid. I just got to see it move.</p><h3>Final Thoughts: Motion Design is Product Design</h3><p>If there’s one thing I want you to take away from this, it’s this:</p><p><strong>Motion isn’t about making things pretty. It’s about making things understandable and intuitive.</strong></p><p>When a user is anxious, waiting, uncertain - motion can communicate what words alone cannot. It can show progress and build trust. It can turn a frustrating moment into a reassuring one.</p><p>At its core, this was never just about animation, it was about solving a very real user problem.</p><p>And sometimes, if you’re lucky and have an incredible team, it can reduce cancellations by 19%.</p><h3><strong>The team</strong></h3><p><strong>Motion design lead:</strong> Binoy Cyriac<br><strong>Motion design support team:</strong> Jean D’Souza, Avadh Dwivedi<br><strong>Product designer:</strong> Aniket Yadav<br><strong>Product Manager:</strong> Ifad Ardin<br><strong>UX Writer:</strong> Alverta Shani<br><strong>Illustrator:</strong> Beny Styawan<br><strong>UI:</strong> Isfarial Reihan R<br><strong>Engineering (Tech) :</strong> Anshuman Srivastava, Rajeev Singh<br><strong>Engineering (Frontend):</strong> Aditya Ladwa (Android), Anshul Vyas (IOS)<br><strong>QA:</strong> Prasanta Biswas<br><strong>Blog cover illustration: </strong>Zhafira Millenia</p><p><strong>About the Author:</strong> I’m a designer with 11 years of industry experience, including 8 years at Gojek. I embrace being a generalist - because why specialize when you can grow branches in multiple directions? Currently heading the creative and motion design team at Gojek, but always a humble student of design.</p><p>Let’s connect - <a href="https://www.linkedin.com/in/binoy-cyriac/">https://www.linkedin.com/in/binoy-cyriac/</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f1e0b2ed901d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/how-a-revamped-finding-driver-animation-reduced-cancellations-by-19-for-gojek-f1e0b2ed901d">How a Revamped ‘Finding Driver’ Animation Reduced Cancellations by 19% for Gojek</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How We Built an AI Brain for Our Observability Stack At Gojek]]></title>
            <link>https://medium.com/gojekengineering/how-we-built-an-ai-brain-for-our-observability-stack-at-gojek-11a288e578bb?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/11a288e578bb</guid>
            <category><![CDATA[agentic-ai]]></category>
            <category><![CDATA[rags]]></category>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[raga]]></category>
            <dc:creator><![CDATA[Shivangi Parashar]]></dc:creator>
            <pubDate>Mon, 27 Apr 2026 09:17:59 GMT</pubDate>
            <atom:updated>2026-04-27T09:29:25.809Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mifXawOM1MKtTO8BvDLkPg.png" /><figcaption>Architecture Of Observability AI Assistant</figcaption></figure><p><a href="https://www.linkedin.com/in/shivangi769/"><strong>Author: Shivangi Parashar</strong></a></p><h4>Introduction</h4><p>At Gojek’s Observability platform (Lens), more than<strong> 50,000 alerts</strong> fire every day across<strong> 40+ engineering teams </strong>running <strong>500+ microservices on Kubernetes/VM’s. </strong>Each one demands the same mechanical investigation: open Grafana, pull logs from Loki, check pod state in kubectl, and cross-reference recent deploys in the cloud. A senior engineer who owns the service can close the loop in 25–30 minutes. An on-call engineer at 3 AM, paged for a service they didn’t write, takes considerably longer.</p><p>The Math is simple: even after aggressive noise filtering, hundreds of alerts each day still need human review. At 25–30 minutes per alert across a 40+ team organization, that consumes a significant share of engineering capacity on work that is mechanical and repetitive, while often requiring limited human judgment.</p><p><strong>Challenges</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*70aUQN447BeiLlfS" /></figure><h4><strong>We built a system that does it in 1–2 mins.</strong></h4><p><strong>What it is:</strong> a multi-agent AI platform that receives an incoming alert, autonomously investigates across Prometheus metrics, Loki logs, Tempo traces, Kubernetes cluster state, and deployment history — simultaneously, in parallel — and hands the on-call engineer a structured, evidence-backed root cause analysis via Lark before they have finished reading the alert title.</p><p><strong>What it is not:</strong> a dashboard, a query assistant, or an auto-remediation tool. It never touches production. Its job is to compress the mechanical portion of incident investigation — the tab-switching, the context-gathering, the cross-referencing — to near zero. Human judgment, escalation, and remediation stay exactly where they belong.</p><p>This is how we built it.This is the full engineering story of how we built it.</p><h4>The Architecture: Six Layers of Intelligence</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tpq56qx5scZFvR1chfTnow.png" /><figcaption>Layering Architecture</figcaption></figure><p>Two feedback loops exist: Layer 6→3 when context is insufficient, and Layer 2→1 for human escalation. More on both shortly.</p><p>The entire workflow is a typed state machine, implemented as a LangGraph StateGraph. Every node reads from and writes to a shared ObservabilityState TypedDict. List fields use append semantics so parallel agents can write results without overwriting each other.</p><pre>class ObservabilityState(TypedDict, total=False):<br>    user_query: str<br>    agents_to_call: List[str]<br>    intent: Dict[str, Any]         # {&quot;parallel&quot;: bool, &quot;confidence&quot;: float}<br>    time_range: str<br>    promql_results: List[Dict[str, Any]]<br>    logql_results:  List[Dict[str, Any]]<br>    k8s_results:    List[Dict[str, Any]]<br>    traceql_results: List[Dict[str, Any]]<br>    agent_summaries: Dict[str, Any]<br>    final_answer: str<br>    path: Annotated[List[str], add]  # append-only execution trace<br>    # Resolved dynamically per team/alert<br>    prometheus_datasource_uid: str<br>    loki_datasource_uid: str<br>    tempo_datasource_uid: str</pre><p>The path field — typed with Annotated[List[str], add] — is critical for debugging. Every node appends its name. After a run you can reconstruct exactly which agents executed, in what order, and whether the feedback loop triggered.</p><h4>Layer 1: Entry and Context Resolution</h4><p>Alerts arrive from Grafana webhooks as a POST to /api/v1/alerts/analyze. The response is a <strong>Server-Sent Events (SSE) stream</strong> — the client gets a live feed of agent progress in real time, not a blocking wait for a final answer.</p><pre>@router.post(&quot;/analyze&quot;)<br>async def analyze_alert(request: AlertAnalysisRequest) -&gt; StreamingResponse:<br>    return StreamingResponse(<br>        _stream_analysis(request),<br>        media_type=&quot;text/event-stream&quot;<br>    )</pre><p>Events are typed: PROGRESS, AGENT_DATA, FINAL_ANSWER, and ERROR. The frontend renders each as it arrives. By the time the Correlation Agent finishes, the user has already watched each domain agent&#39;s findings stream in one by one — which dramatically improves perceived latency even when total wall-clock time is unchanged.</p><h4>Context Resolution: The Missing Link</h4><p>This is the layer most observability tools skip entirely, and where the system earns a large part of its intelligence.</p><p>An alert payload contains labels like alertname, severity, namespace, and sometimes a team or app label. But to actually investigate the alert, you need to know: which Grafana datasource has Prometheus data for this team? Which one has Loki logs? Which one has Tempo traces?</p><p>The <strong>Context Node</strong> resolves all of this before a single query runs:</p><pre>async def _resolve_datasource(team_name: str):<br>    datasources = [<br>        (&quot;Prometheus&quot;, f&quot;{team_name.lower()}_tencent&quot;),<br>        (&quot;Loki&quot;,       f&quot;{team_name.lower()}_logs&quot;),<br>        (&quot;Tempo&quot;,      f&quot;{team_name.lower()}_traces&quot;),<br>    ]<br>    for ds_type, ds_name in datasources:<br>        ds = await grafana_client.get_datasource_by_name(ds_name)<br>        if ds:<br>            resolved[ds_type] = ds.get(&quot;uid&quot;)</pre><p>The resolved UIDs flow into ObservabilityState.It uses state[&quot;prometheus_datasource_uid&quot;] — resolved specifically for the alerting team, seconds ago, from a live Grafana lookup.</p><h4>Layer 2: The Orchestrator — LLM as a Router</h4><p>The core architectural bet we made early: <strong>let the LLM decide which agents to invoke and in what order, instead of writing routing rules.</strong></p><p>The naive approach would be: if &quot;cpu&quot; in query → promql_agent. We could have built this in an afternoon. We didn&#39;t, for good reason: it doesn&#39;t scale, it can&#39;t handle compound queries, and every new agent requires updating a growing decision tree.</p><p>Instead, the Router Node sends the user’s query plus a structured description of every available agent to the LLM, which returns:</p><pre>{<br>  &quot;agents&quot;: [&quot;promql&quot;, &quot;k8s&quot;],<br>  &quot;confidence&quot;: 0.92,<br>  &quot;rationale&quot;: &quot;CPU spike suggests Prometheus metrics; pod restarts suggest K8s state&quot;,<br>  &quot;parallel&quot;: true,<br>  &quot;time_range&quot;: &quot;now-2h&quot;<br>}</pre><p>The parallel flag is the most operationally significant field. When the LLM decides two agents are independent — fetching metrics and checking pod state simultaneously — we run them with asyncio.gather():</p><pre>async def execute_agents_node(state: ObservabilityState):<br>    if parallel_mode:<br>        tasks = [agent_funcs[name](state) for name in agents_to_call]<br>        results = await asyncio.gather(*tasks, return_exceptions=True)<br>        # Merge results with explicit append semantics</pre><p>For a query that triggers PromQL + LogQL + K8s simultaneously, parallel execution cuts wall-clock time from roughly 15 seconds to under 6 seconds.</p><p>When we added the TraceQL agent for distributed tracing, the router learned to invoke it from natural-language context alone. Zero changes to routing code.</p><h4>Layer 3: The RAG Pipeline — Grounding LLM Queries in Reality</h4><p>This is where most AI observability tools fail, and where we spent the most engineering effort.</p><p><strong>The problem:</strong> LLMs hallucinate Prometheus metric names. A model generating PromQL will produce syntactically valid queries that reference metrics like container_cpu_usage instead of your actual metric container_cpu_usage_seconds_total{container!=&quot;POD&quot;}. The query returns no data. The engineer gets a useless answer.</p><p>Worse: even correct metric names have environment-specific label values. A model might generate {namespace=&quot;payments&quot;} when your actual label is {app_namespace=&quot;payments-v2&quot;}. Same problem.</p><p>The solution is a multi-stage RAG pipeline that grounds the LLM in your actual metric catalog before it generates a single character of PromQL.</p><h4>Stage 0: Offline Ingestion — Building the Metric Schema Index</h4><p>We built a custom ingestion script that crawls your Prometheus environment and extracts a structured schema for every metric:</p><p>High-cardinality labels (like pod_name or instance) are flagged rather than fully enumerated — we don&#39;t want 10,000 pod names polluting the embedding. The output is two artifacts:</p><ul><li>metric_schema_structured.json — full schema with all metadata</li><li>metric_schema_rag_docs.json — 22,457 embedding-ready text documents (~8MB)</li></ul><p>Each document looks like this:</p><pre>Metric: container_cpu_usage_seconds_total<br>Labels:<br>- container (examples: api-server, worker, sidecar)<br>- namespace (examples: payments, auth-service, data-pipeline)<br>- pod (HIGH CARDINALITY — do not match by value)<br>- cluster_name (examples: prod-cgk-01, prod-sin-01)</pre><p>This format is deliberately human-readable. We embed the same format the LLM will see as context, which improves retrieval alignment significantly.</p><h4>Why Qdrant: The Vector Database Decision</h4><p>We evaluated Pinecone, Weaviate, Chroma, pgvector, and Qdrant. The decision came down to four factors:</p><p><strong>1. Hybrid search out of the box.</strong> Qdrant supports combining dense vector similarity with BM25 full-text search via Reciprocal Rank Fusion (RRF) in a single query. For metric names, this matters enormously. A user asking for “HTTP error rate” matches semantically (dense) to http_requests_total{status=~&quot;5..&quot;}, but also needs BM25 to surface metrics that literally contain the string &quot;error.&quot; Semantic search alone misses exact matches; keyword search alone misses synonyms.</p><pre>query_response = self._client.query_points(<br>    collection_name=collection_name,<br>    prefetch=[<br>        Prefetch(<br>            query=query_vector,      # Dense: 384-dim BGE embedding<br>            using=&quot;text-dense&quot;,<br>            limit=limit,<br>        ),<br>        Prefetch(<br>            query=query_text,        # Sparse: BM25 over metric_name field<br>            using=&quot;metric_name&quot;,<br>            limit=limit,<br>        ),<br>    ],<br>    query=FusionQuery(fusion=Fusion.RRF),  # Reciprocal Rank Fusion<br>    score_threshold=score_threshold,<br>)</pre><p><strong>2. Filtering by datasource.</strong> We have two separate metric corpora: Lens metrics and Events Platform metrics. Each Qdrant point carries a datasource payload field. When the context node resolves that a query targets the Lens datasource, the PromQL agent scopes retrieval automatically:</p><pre>rag_filter = Filter(<br>    must=[FieldCondition(key=&quot;datasource&quot;, match=MatchValue(value=&quot;lens&quot;))]<br>)<br>results = await rag_helper.search_metrics(query=query, query_filter=rag_filter)</pre><p><strong>3. Performance at our scale.</strong> Our metric catalog has roughly 50,000 unique metric names across two datasources. Qdrant handles this with sub-50ms query latency at p95 for top-10 results. The BGE-small-en-v1.5 embedding model (384 dimensions) gives us a good trade-off between retrieval quality and index size.</p><p><strong>4. Operational simplicity.</strong> Qdrant runs as a single binary or Docker container. For an internal platform, we don’t want to manage a distributed vector database cluster. Qdrant’s simplicity matches our operational constraints.</p><h4>Stage 1: Online Enrichment — Live Label Values</h4><p>RAG gives us the metric name and example label values from index time. But label values drift. A cluster added last month won’t appear in a schema indexed two months ago.</p><p>The PromQL Agent addresses this with live enrichment: after retrieval, it queries Prometheus directly for current label values on the retrieved metrics:</p><pre>async def _enrich_with_live_labels(self, rag_results):<br>    for result in rag_results:<br>        metric_name = result[&quot;metadata&quot;][&quot;metric_name&quot;]<br>        labels = result[&quot;metadata&quot;].get(&quot;labels&quot;, [])</pre><pre>        if not labels:<br>            # RAG had no label info — discover from Prometheus<br>            labels = await self._discover_label_names(metric_name)</pre><pre>        if labels:<br>            live_values = await self._discover_label_values(metric_name, labels)<br>            result[&quot;metadata&quot;][&quot;label_values&quot;] = live_values</pre><p>_discover_label_values uses asyncio.Semaphore(3) to query Prometheus for up to 3 labels concurrently, with a cap of 10 values per label. The result: the LLM sees not just that a metric has a namespace label, but that right now, in your production environment, that label has values [&quot;payments&quot;, &quot;auth-service&quot;, &quot;data-pipeline&quot;, &quot;checkout&quot;].</p><h4>Stage 2: Validation — Cutting Hallucinations at the Knees</h4><p>Even with grounded context, LLMs sometimes add labels that look plausible but don’t exist. We validate every generated query against live Prometheus data before executing it:</p><pre>async def _check_label_exists(self, metric_name, label, op, value):<br>    check_expr = f&#39;count({metric_name}{{{label}{op}&quot;{value}&quot;}})&#39;<br>    result = await self.grafana_client.call_tool(<br>        &quot;query_prometheus&quot;,<br>        {&quot;expr&quot;: check_expr, &quot;queryType&quot;: &quot;instant&quot;, ...}<br>    )<br>    has_data = PromQLErrorHandler.check_has_data(result)<br>    return label, op, value, has_data</pre><p>All matchers in a query are validated in parallel. Invalid ones are silently dropped and the query is rebuilt:</p><pre>if valid:<br>    new_query = query.replace(matchers_str, &quot;, &quot;.join(valid))<br>else:<br>    new_query = re.sub(r&quot;\{[^}]*\}&quot;, &quot;&quot;, query)  # Strip all matchers</pre><p>In practice, this catches wrong assumptions on roughly 20–30% of queries in environments with non-standard label naming conventions.</p><h4>Layer 3: The Agent Fleet</h4><p>Six specialized agents, each owning a data source domain:</p><p><strong>PromQL Agent</strong> handles Prometheus metrics — CPU, memory, latency, request rates, error rates, throughput. It runs the full RAG pipeline described above and generates 3–4 related queries per request to build a multi-dimensional view of the system.</p><p><strong>LogQL Agent</strong> handles Loki logs — error messages, stack traces, application events. It follows a similar LLM-driven query generation pattern adapted for LogQL syntax and log-volume constraints.</p><p><strong>TraceQL Agent</strong> handles Tempo distributed traces — request latency percentiles, slow spans, error traces, end-to-end request flow. It connects to the same Grafana MCP, using the Tempo datasource UID resolved by the context node.</p><p><strong>K8s Agent</strong> handles Kubernetes cluster state — pod status, recent events, resource limits and requests, deployment history, node conditions. Uses the Kubernetes MCP, which wraps kubectl operations.</p><p><strong>GoCloud Agent</strong> handles our internal deployment platform — recent deploys, rollbacks, current running versions. Invoked asynchronously when the orchestrator suspects a deployment-related issue.</p><p><strong>DMS Agent</strong> handles apps deployed in vms. Provides context on whether recent schema migrations or pipeline deployments correlate with the observed issue.</p><blockquote>The critical design constraint across all agents: <strong>no agent takes action on production.</strong> Every agent is read-only. The platform investigates; humans decide.</blockquote><h4>Layer 4: MCP Integration</h4><p>All data access goes through <strong>Model Context Protocol (MCP) servers</strong> — an open standard for connecting AI agents to external tools via a typed, discoverable interface.</p><p><strong>We run five MCP servers:</strong></p><p><strong>Grafana MCP</strong> wraps the Grafana HTTP API. The PromQL, LogQL, and TraceQL agents all connect to this <em>single</em> server. It handles Prometheus queries, Loki queries, and Tempo queries as separate tools parameterized by datasource UID. This is a critical architectural point — it’s one Grafana MCP, not separate Prometheus MCP, Loki MCP, and Tempo MCP servers. The datasource UID on each call determines which backend is hit.</p><p><strong>Kubernetes MCP</strong> wraps kubectl. The K8s agent uses it to fetch pod state, events, describe resources, and check recent deployments.</p><p><strong>Arsenal MCP</strong> is our internal service registry. Used exclusively by the Context Node to resolve team ownership, application metadata, and datasource names from alert labels.</p><p><strong>GoCloud MCP / DMS MCP</strong> are internal platform tools for deployment and pipeline metadata.</p><h4>Layer 5: The Feedback Loop</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_MCAZGBPRiUc3A9n6udIkw.png" /><figcaption>Feedback Loop</figcaption></figure><p>After all agents complete, the <strong>Correlation Agent</strong> assembles their summaries and sends them to the LLM with a synthesis prompt:</p><pre>sections = [<br>    f&quot;User Question: {state[&#39;user_query&#39;]}&quot;,<br>    f&quot;Metrics Analysis:\n{summaries[&#39;promql&#39;][&#39;analysis&#39;]}&quot;,<br>    f&quot;Logs Analysis:\n{summaries[&#39;logql&#39;][&#39;analysis&#39;]}&quot;,<br>    f&quot;Kubernetes Analysis:\n{summaries[&#39;k8s&#39;][&#39;analysis&#39;]}&quot;,<br>    f&quot;Deployment Context:\n{summaries[&#39;gocloud&#39;][&#39;analysis&#39;]}&quot;,<br>]<br>prompt = CORRELATE_PROMPT_TEMPLATE.format(context=&quot;\n&quot;.join(sections))</pre><p>After the Correlation Agent produces its answer, we pass it through a <strong>Sufficient Context validator</strong>. If the correlation is “I don’t have enough information to conclude” — which happens when agents returned no data, or datasource UIDs couldn’t be resolved, or the alert doesn’t match any known metric patterns — the graph loops back to the Orchestrator</p><p>The re-routing pass has the previous attempt’s results in state. The orchestrator can decide to reinvoke which agents</p><h3>How We Reduced Hallucinations</h3><p><strong>How we reduce hallucinations :</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OYZ6iXnJnyErfJNUxS6ugQ.png" /></figure><ol><li><strong>RAG grounding</strong> — retrieve real metric names and label values from Qdrant before the LLM generates anything. Nothing gets invented from thin air.</li><li><strong>Live label enrichment</strong> — after retrieval, query Prometheus for current label values so the LLM sees real production data, not stale index snapshots.</li><li><strong>Post-generation validation</strong> — every label=value matcher in generated PromQL/LogQL is verified against live Prometheus before execution. Invalid ones are silently dropped.</li><li><strong>DeepEval faithfulness gate</strong> — scores every agent response inline. If faithfulness &lt; 0.7, Reflexion fires for that agent only. One pass restores quality in 78% of cases.</li></ol><h4>We Built an Observability Platform. Then We Had to Observe It.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Rvius3fuGZpPXViPOJ7yMw.png" /></figure><p>Every LLM call flows through a token tracker that exports to our OTLP collector:</p><pre>class TokenUsage:<br>    provider: str           # &quot;gemini&quot;, &quot;ollama&quot;, &quot;openai&quot;<br>    model: str<br>    prompt_tokens: int<br>    output_tokens: int<br>    total_tokens: int<br>    operation: str          # &quot;query_generation&quot;, &quot;synthesis&quot;, &quot;routing&quot;<br>    metadata: Dict[str, Any]</pre><p><strong>We export five metrics via OpenLIT:</strong></p><pre>llm.tokens.total      Counter   — total tokens across all operations<br>llm.tokens.prompt     Counter   — prompt tokens (primary cost driver)<br>llm.tokens.output     Counter   — output tokens<br>llm.calls.total       Counter   — LLM invocations per operation type<br>llm.tokens.per_call   Histogram — distribution of tokens per call</pre><p>Breaking down by operation has been critical for cost optimization. We discovered that query_generation (the PromQL agent generating 3–4 queries) consumed 40% of total tokens, and that the RAG context was the primary driver. Tuning _MAX_LABELS_IN_CONTEXT = 10 and _MAX_SAMPLE_VALUES = 3 in the context formatter reduced prompt tokens for that operation by <strong>35%</strong> with no measurable quality regression.</p><h3>System Execution Metrics</h3><p>Each analysis result is persisted with full execution metadata:</p><pre>execution_metadata = {<br>    &quot;analysis_time_seconds&quot;: elapsed,<br>    &quot;agents_called&quot;: list(agents_to_call),<br>    &quot;routing_path&quot;: state[&quot;path&quot;],<br>    &quot;feedback_loop_triggered&quot;: &quot;re_route&quot; in &quot; &quot;.join(state[&quot;path&quot;]),<br>    &quot;parallel_execution&quot;: state[&quot;intent&quot;].get(&quot;parallel&quot;, False),<br>}</pre><p>The metrics that matter most in production:</p><ul><li><strong>Analysis latency p50/p95/p99</strong> — broken down by agent count. Single-agent queries (pure Prometheus) run in ~4s. Multi-agent parallel runs average ~8s. Sequential multi-agent runs average ~14s.</li><li><strong>Feedback loop trigger rate</strong> — currently 8%, target is &lt;5%. Improvement comes from better datasource coverage and more complete Arsenal registration.</li><li><strong>Agent data yield rate</strong> — what fraction of agent invocations return actual data versus “no results.” Low yield on a specific agent signals a misconfigured datasource.</li><li><strong>RAG recall quality</strong> — what fraction of retrieved metrics actually appear in the final generated PromQL queries. Currently tracking at ~72% inclusion rate, which is acceptable given the validation step drops some retrieved metrics as irrelevant.</li></ul><h3>Conclusion</h3><p>The core insight that drove all of this: on-call engineers don’t have an information problem. They have a synthesis problem. The data is all there — in Prometheus, in Loki, in Tempo, in your K8s cluster. The work is correlating it across systems, under pressure, at 3 AM. That is exactly the kind of work that should be done by a machine.</p><p>We built the system.</p><p><a href="https://vimeo.com/1186878395">https://vimeo.com/1186878395</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=11a288e578bb" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/how-we-built-an-ai-brain-for-our-observability-stack-at-gojek-11a288e578bb">How We Built an AI Brain for Our Observability Stack At Gojek</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Mobile Release Best Practices: What We Learned While Scaling Across Apps]]></title>
            <link>https://medium.com/gojekengineering/mobile-release-best-practices-what-we-learned-while-scaling-across-apps-083b78807e65?source=rss----3db38a207261---4</link>
            <guid isPermaLink="false">https://medium.com/p/083b78807e65</guid>
            <category><![CDATA[release-management]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[release-engineering]]></category>
            <category><![CDATA[tech]]></category>
            <dc:creator><![CDATA[Priyansh Shandilya]]></dc:creator>
            <pubDate>Wed, 04 Mar 2026 02:40:11 GMT</pubDate>
            <atom:updated>2026-03-04T02:40:09.718Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7gTqVWTjCXce8YsoSiOpAg.jpeg" /></figure><blockquote>Imagine standing in front of a giant gate. On the other side are millions of users: drivers, consumers, and merchants, waiting for that gate to open. Once you unlock it, there’s no pulling anything back. No undo button. Every mobile release carries that quiet pressure.</blockquote><p>At Gojek, releasing an app is never just about submitting a build to the Play Store or App Store. It’s the point where weeks of engineering decisions finally meet real-world usage — across different platforms, regions, and user expectations.</p><p>As our ecosystem scaled across <strong>Driver, Consumer, and Merchant apps</strong>, releases naturally became more complex. More teams were contributing, more features were landing every week, and more dependencies had to line up at the right time.</p><p>Recently, with our move toward <strong>weekly mobile releases</strong>, that complexity has become even more visible.</p><p>Shipping more frequently brought speed, but it also forced us to look closely at our fundamentals — ownership, planning, automation, and post-release visibility.</p><p>As a key part of the Release Experience Team, I’ve collected lessons from this journey. Here’s everything, what helped us make mobile releases more <em>predictable, safer, and easier to operate at scale.</em></p><h3>The challenge</h3><p>Mobile releases are different from backend deployments in one important way: <strong>once an app version is out, you live with it</strong>.</p><p>Users don’t update instantly. Issues can’t be patched in minutes. Rollbacks are slow and painful. At scale, even a small mistake can impact millions of users.</p><p>As teams grow, some common patterns start to appear:</p><ul><li>Releases depend on a few people who hold all the context</li><li>With multiple products involved, teams are unsure what exactly is going out in each version</li><li>Last‑minute changes sneak in with good intentions</li><li>Monitoring starts only after users complain</li><li>Each release feels stressful, even when nothing goes wrong.</li></ul><p>Agree here?</p><p>Well, <strong>weekly releases</strong> simply amplify these problems.</p><p>What we learned is simple: <strong><em>speed alone doesn’t fix release pain, process does</em></strong><em>.</em></p><h3>Making releases predictable with planning</h3><p>One of the first things we learned is that releases can fail long before release day. They fail when teams don’t have clarity. For a long time, our release planning was in spreadsheets — detailed ones with freeze dates, testing milestones, rollout stages, and formulas doing impressive math behind the scenes. That worked well. But as apps and teams scaled, spreadsheets stopped scaling with us. Today, release planning happens through a dedicated in-house release management portal that brings visibility across platforms and regions. It helps track:</p><ul><li>Release timelines and milestones</li><li>Testing and regression progress</li><li>Bug trends and Automation Stability</li></ul><p>Explaining more about this portal will take up another entire blog, which we will be dropping soon. For now, let’s realize that the first step towards a successful release is the planning of the release itself.</p><p>From “When should we release this feature?” to “What are we releasing today?”</p><h3>Dedicated release ownership</h3><p>As release frequency increases, coordination becomes just as important as engineering quality.</p><p>Having <strong>clear release ownership</strong> turned out to be one of the biggest stabilizers for us.</p><p>The role of a release manager isn’t about pressing the publish button. It’s about maintaining the overall picture:</p><ul><li>Aligning engineering, QA, product, design, and operations</li><li>Tracking risks early in the cycle</li><li>Protecting the release from late instability</li><li>Acting as a single source of truth during rollout</li></ul><p>Without ownership, releases often become shared responsibility, which in practice means fragmented communication.</p><p>With ownership, decision-making becomes faster and expectations stay aligned.</p><p>At the same time, it’s important that this knowledge doesn’t live with just one person. Strong documentation and backups ensure the process remains resilient as teams evolve.</p><h3>Measuring success beyond “did it crash?”</h3><p>Not all releases fail loudly. Some quietly degrade performance. Some increase crash rates slightly. While, some introduce issues that appear only days later. This is why defining <strong>release KPIs</strong> is important.</p><p>Metrics like:</p><ul><li>Crash‑free sessions</li><li>ANR rate</li><li>Hotfix frequency</li><li>Rollout rollback count</li><li>Time from code-freeze to app submissions</li></ul><p>These signals help teams understand whether the release process is actually healthy. Used correctly, metrics highlight trends, not individuals. They help us improve continuously instead of reacting emotionally after incidents.</p><h3>Release trains: shipping on schedule, not on pressure</h3><p>One practice that significantly improved stability was adopting <strong>release trains</strong>.</p><p>The idea is simple:</p><blockquote><em>The train leaves every week. If a feature isn’t ready, it waits for the next one.</em></blockquote><p>This approach removes pressure to squeeze half‑tested features into a release just because the date is near. Teams gain confidence that another release is always around the corner. The result is better quality, fewer hotfixes, and much calmer release days.</p><h3>Automation: reducing human error</h3><p>Publishing a mobile app is rarely a single-click activity.</p><p>Between generating builds, signing artifacts, uploading binaries, configuring staged rollouts, and monitoring health, there are multiple points where small mistakes can have large impact.</p><p>Over time, we invested heavily in <strong>mobile CI/CD and release automation</strong>.</p><p>Large parts of the workflow, from build generation to rollout percentage increases, are now driven through pipelines instead of manual console actions. Including alerts and comms on Release Channels.</p><p>This brought us some instant benefits:</p><ul><li>Faster and repeatable rollout steps</li><li>Reduced dependency on console-level permissions</li><li>Improved security and auditability</li><li>Significantly fewer human errors</li></ul><p>Automation doesn’t remove humans completely, and it shouldn’t. Decision points still matter. But by letting systems handle repetition, teams are able to focus on other areas of improvement. Treating pipelines as first-class products — monitored, owned, and continuously improved — made weekly releases sustainable.</p><h3>Feature flags: releasing without fear</h3><p>One of the strongest safety nets in mobile releases is <strong>feature configuration</strong>.</p><p>Using feature flags allows teams to:</p><ul><li>Merge code safely</li><li>Ship features turned off</li><li>Enable functionality gradually</li><li>Instantly disable problematic flows</li></ul><p>This decouples deployment from exposure. In real scenarios, this has helped us handle high‑priority issues without interrupting user journeys, simply by toggling a feature off while fixes were prepared for the next release.</p><p>The trade‑off is discipline.</p><p>Unused flags and unclear ownership can create long‑term debt. Regular cleanup and documentation are essential. When managed well, feature flags dramatically reduce release anxiety. And you guessed it right, we have an internal tool developed to handle it too: <strong>CON</strong>fig-<strong>MAN</strong>ager</p><h3>Releasing more often to move faster — Weekly Cadence</h3><p>One of the hardest lessons for release managers is learning to say ‘<strong>no’</strong> to last‑minute changes. Even well‑intended changes introduce instability. Frequent releases make that conversation easier. With weekly releases:</p><ul><li>Features can safely move to the next cycle</li><li>Releases become smaller and easier to validate</li><li>Risk is distributed instead of concentrated</li></ul><p>Smaller releases are easier on engineers — and easier on users too. Interestingly, frequent updates also improve user perception. Regular app updates signal active development and allow feedback to flow more continuously.</p><h3>Monitoring after the gate opens</h3><p>A release doesn’t end when the app goes live. That’s when real usage begins. Automated monitoring helps teams understand what’s happening in production:</p><ul><li>Crash trends</li><li>Performance degradation</li><li>API failure spikes</li><li>Startup latency</li></ul><p>Without monitoring, teams rely on store reviews or support tickets which usually arrive too late. The challenge is balance. Too many alerts create noise, while too few create blind spots. Finding that balance turns releases from reactive to proactive. Having a clear <strong>SOP with crash/ANR budgets</strong> becomes a game changer for Release Managers to confidently call out when to halt/stop release, in case of spike numbers.</p><p>Mobile releases will never be completely risk-free. But they don’t have to be stressful.</p><p>Across Driver, Consumer, and Merchant platforms, the biggest lesson has been this: stability doesn’t come from releasing less, it comes from releasing better. Strong ownership, clear planning, predictable schedules, automation, safe rollout mechanisms, and honest metrics together create confidence.</p><p>Weekly releases didn’t make things harder.</p><p>They simply exposed where the structure was missing and gave us the opportunity to build it. The goal isn’t perfect releases. The goal is <strong>predictable, calm, confidence-driven releases</strong> — the kind that quietly works in the background while teams focus on building meaningful product improvements.</p><p>We’re still learning. We’re still refining. And with every release train that leaves the station, the system gets a little stronger.</p><p><em>If you’re working on mobile platforms at scale, these learnings may look familiar. And chances are, the best improvements not come just from moving faster — but from making releases easier to trust.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=083b78807e65" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gojekengineering/mobile-release-best-practices-what-we-learned-while-scaling-across-apps-083b78807e65">Mobile Release Best Practices: What We Learned While Scaling Across Apps</a> was originally published in <a href="https://medium.com/gojekengineering">Gojek Product + Tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>