<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SoftwareDevs mvpfactory.io</title>
    <description>The latest articles on DEV Community by SoftwareDevs mvpfactory.io (@software_mvp-factory).</description>
    <link>https://dev.to/software_mvp-factory</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3790305%2F141f30ba-972f-4b17-9b03-c77343f2747d.png</url>
      <title>DEV Community: SoftwareDevs mvpfactory.io</title>
      <link>https://dev.to/software_mvp-factory</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/software_mvp-factory"/>
    <language>en</language>
    <item>
      <title>Pruning and Quantizing CoreML Models for Real-Time On-Device Inference: Cutting Latency in Half Without Accuracy Loss</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Fri, 17 Jul 2026 14:49:55 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/pruning-and-quantizing-coreml-models-for-real-time-on-device-inference-cutting-latency-in-half-11ap</link>
      <guid>https://dev.to/software_mvp-factory/pruning-and-quantizing-coreml-models-for-real-time-on-device-inference-cutting-latency-in-half-11ap</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pruning&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Quantizing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CoreML&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Models&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Real-Time&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;On-Device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Inference"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Walk&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;through&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Apple's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;coremltools&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;palettization&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unstructured&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pruning&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;APIs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cut&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on-device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;latency&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;50%+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Neural&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Engine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;targets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;without&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sacrificing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;accuracy."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ios, swift, mobile, architecture&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/coreml-model-compression-latency&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What You Will Build&lt;/span&gt;

By the end of this walkthrough, you will have a CoreML compression pipeline that combines INT4 palettization and unstructured pruning to cut inference latency by 50–65% on Neural Engine targets — without blindly nuking your model's accuracy. We will cover the profiling workflow, the quantization order that preserves calibration integrity, and the pruning config that compounds your gains.

A typical MobileNet-class float32 model at ~14MB runs at roughly 8ms on the A17 Neural Engine. INT8 quantization drops that to ~5ms. Selective INT4 palettization gets you to ~3.5ms. That is a 56% latency reduction from a single compression pass, and most teams are not doing it.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Python 3.9+ with &lt;span class="sb"&gt;`coremltools 7.x`&lt;/span&gt; installed
&lt;span class="p"&gt;-&lt;/span&gt; A trained PyTorch model (MobileNet-class or similar)
&lt;span class="p"&gt;-&lt;/span&gt; A calibration dataloader (128+ batches recommended)
&lt;span class="p"&gt;-&lt;/span&gt; Xcode 15+ to validate the output &lt;span class="sb"&gt;`.mlpackage`&lt;/span&gt;
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1: Profile Before You Compress&lt;/span&gt;

Let me show you a pattern I use in every project. Never compress blindly. Start with per-layer sensitivity analysis using &lt;span class="sb"&gt;`get_weights_metadata`&lt;/span&gt;:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
import numpy as np&lt;br&gt;
import coremltools as ct&lt;br&gt;
from coremltools.optimize.coreml import get_weights_metadata&lt;/p&gt;

&lt;p&gt;model = ct.models.MLModel("MyModel.mlpackage")&lt;br&gt;
metadata = get_weights_metadata(model, weight_threshold=1024)&lt;/p&gt;

&lt;p&gt;for name, info in metadata.items():&lt;br&gt;
    w = info.val&lt;br&gt;
    sparsity = np.mean(np.abs(w) &amp;lt; 1e-6)&lt;br&gt;
    print(f"{name}: shape={w.shape}, sparsity={sparsity:.2%}, dtype={w.dtype}")&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Computing near-zero sparsity from `info.val` gives you ground truth on which layers already trend sparse. Convolutional layers in early blocks typically tolerate 70–80% induced sparsity with less than 0.5% accuracy drop. Attention layers and final classification heads are where you pay the real cost — keep those at float16 or conservative INT8.

---

## Step 2: Know Your Quantization Options

| Quantization | Size Reduction | Latency Gain (Neural Engine) | Accuracy Risk |
|---|---|---|---|
| Float16 (baseline) | 2x vs FP32 | — | Negligible |
| INT8 (linear) | 4x vs FP32 | ~35–40% | Low if calibrated |
| INT4 (palettized) | 8x vs FP32 | ~50–60% | Medium — layer-dependent |
| Mixed INT4/INT8 | 5–6x vs FP32 | ~45–55% | Low with profiling |

INT4 palettization benefits Neural Engine execution disproportionately because the ANE's memory bandwidth bottleneck is the primary constraint, not compute. On GPU execution units, INT4 gains shrink and you risk latency regressions from dequantization overhead. Mixed precision is almost always the right production choice.

---

## Step 3: The Conversion Pipeline That Does Not Break Calibration

Here is the gotcha that will save you hours. If you run post-training quantization on the ONNX graph *before* CoreML conversion, you lose the operator-level visibility coremltools needs to set accurate activation ranges per layer. Quantize in PyTorch space first, then trace.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
import torch&lt;br&gt;
import coremltools as ct&lt;br&gt;
from coremltools.optimize.torch.quantization import PostTrainingQuantizer, PostTrainingQuantizerConfig&lt;/p&gt;
&lt;h1&gt;
  
  
  Step 1: Quantize in PyTorch space BEFORE tracing
&lt;/h1&gt;

&lt;p&gt;config = PostTrainingQuantizerConfig.from_dict({&lt;br&gt;
    "global_config": {&lt;br&gt;
        "weight_dtype": "int8",&lt;br&gt;
        "activation_dtype": "int8"&lt;br&gt;
    }&lt;br&gt;
})&lt;br&gt;
quantizer = PostTrainingQuantizer(model, config)&lt;br&gt;
quantized_model = quantizer.compress(dataloader=calibration_loader, num_batches=128)&lt;/p&gt;
&lt;h1&gt;
  
  
  Step 2: Trace the quantized model
&lt;/h1&gt;

&lt;p&gt;example_input = torch.rand(1, 3, 224, 224)&lt;br&gt;
traced = torch.jit.trace(quantized_model, example_input)&lt;/p&gt;
&lt;h1&gt;
  
  
  Step 3: Convert — activation ranges are already embedded
&lt;/h1&gt;

&lt;p&gt;mlmodel = ct.convert(traced, inputs=[ct.ImageType(shape=example_input.shape)])&lt;br&gt;
mlmodel.save("CompressedModel.mlpackage")&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
128 calibration batches is the practical floor. Below 64, activation range estimates drift enough to cause silent accuracy degradation that only surfaces on edge-case inputs — exactly the kind of bug that clears your eval suite and then blows up in production.

---

## Step 4: Add Unstructured Pruning for Compounded Gains

The docs do not emphasize this enough, but pruning and quantization stack. Here is the minimal setup to get this working:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
from coremltools.optimize.coreml import OpThresholdPrunerConfig, prune_weights&lt;/p&gt;

&lt;p&gt;pruner_config = OpThresholdPrunerConfig(&lt;br&gt;
    threshold=1e-3,&lt;br&gt;
    minimum_sparsity_percentile=0.4,&lt;br&gt;
    maximum_sparsity_percentile=0.8,&lt;br&gt;
    weight_threshold=1024&lt;br&gt;
)&lt;br&gt;
pruned_model = prune_weights(model, config=pruner_config)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Unstructured pruning at 50–70% sparsity on feed-forward layers compounds with INT8 quantization — you get both memory bandwidth and compute wins. INT4 palettization alone gets you ~50% latency reduction. Adding 60% unstructured sparsity on compatible layers pushes that to 60–65%. That is the difference between a demo and something shippable.

(Speaking of apps that need to stay performant in the background — [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) is a good reminder that even utility apps benefit from lean on-device inference when they are competing with your IDE for CPU budget.)

---

## Gotchas

**Compressing attention heads will hurt you.** Sensitivity analysis exists for a reason. Blindly applying INT4 to attention layers is how you ship a model that passes eval and breaks in production on real-world input distributions.

**Post-ONNX PTQ fails silently.** This is the most common pipeline mistake I see. The failure mode does not surface on standard benchmarks — it appears on tail inputs, usually after launch.

**GPU vs Neural Engine targets differ.** INT4 gains shrink significantly on GPU execution units. If your deployment target could be either, run separate profiling passes and use mixed precision accordingly.

**128 calibration batches is a floor, not a suggestion.** Under 64 batches, your activation ranges become unreliable. Use your real training distribution, not synthetic data.

---

## Conclusion

Profile per-layer with `get_weights_metadata`, quantize in PyTorch space before tracing, and combine INT4/INT8 mixed precision with 60% unstructured sparsity on feed-forward layers. That pipeline consistently delivers 60–65% latency reduction on Neural Engine targets with manageable accuracy trade-offs. Skip sensitivity analysis and you are guessing — and on-device AI ships or dies on milliseconds.

**Relevant docs:** [coremltools optimization guide](https://apple.github.io/coremltools/docs-guides/source/optimization-workflow.html) · [Neural Engine performance best practices](https://developer.apple.com/documentation/coreml)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Batched Prompt Scheduling for On-Device LLMs</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Fri, 17 Jul 2026 08:07:58 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/batched-prompt-scheduling-for-on-device-llms-1ik6</link>
      <guid>https://dev.to/software_mvp-factory/batched-prompt-scheduling-for-on-device-llms-1ik6</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Batched&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Prompt&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Scheduling&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;On-Device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLMs:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Priority&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Queues,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Preemption,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Inference&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Engine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;That&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Never&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Stalls&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;UI"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;three-layer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;inference&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;scheduler&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;multiplexes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on-device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;calls&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;across&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;foreground&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;chat,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;suggestions,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;background&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;summarization&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;preemption,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;partial&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;KV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;eviction,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lifecycle-aware&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;token-budget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;governor."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android, kotlin, mobile, architecture&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/android-on-device-llm-scheduling&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial you will have a working three-layer inference scheduler that multiplexes on-device LLM calls across concurrent Android callers — foreground chat, inline suggestions, and background summarization — without canceling in-flight jobs or stalling the UI.&lt;/p&gt;

&lt;p&gt;Here is the problem we are solving first: on a Tensor G3 chip, a 3B parameter model saturates roughly 85% of the NPU during active generation. A foreground chat request arriving mid-inference on a background summary job sees first-token latency jump from 140ms to over 1,400ms. That is a 10× regression from a single design oversight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Android project targeting API 26+&lt;/li&gt;
&lt;li&gt;A running on-device inference engine (MediaPipe, llama.cpp JNI, or similar)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;androidx.lifecycle:lifecycle-process&lt;/code&gt; dependency for &lt;code&gt;ProcessLifecycleOwner&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Three-Layer Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│           InferenceOrchestrator          │
│  ┌──────────────┐  ┌───────────────┐    │
│  │ PriorityQueue│  │ TokenBudget   │    │
│  │ (min-heap)   │  │ Governor      │    │
│  └──────────────┘  └───────────────┘    │
│         │                  │             │
│  ┌──────▼──────────────────▼──────────┐ │
│  │        PreemptionController        │ │
│  │  (KV cache snapshot + eviction)    │ │
│  └────────────────────────────────────┘ │
│                    │                     │
│         ┌──────────▼──────────┐          │
│         │   InferenceEngine   │          │
│         └─────────────────────┘          │
└─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1 — Priority Queue With Preemption
&lt;/h3&gt;

&lt;p&gt;Assign each request a &lt;code&gt;RequestPriority&lt;/code&gt; based on caller type:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller Type&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Max Token Budget&lt;/th&gt;
&lt;th&gt;Preemptible&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foreground Chat&lt;/td&gt;
&lt;td&gt;CRITICAL (0)&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inline Suggestion&lt;/td&gt;
&lt;td&gt;HIGH (1)&lt;/td&gt;
&lt;td&gt;128 tokens&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background Summary&lt;/td&gt;
&lt;td&gt;NORMAL (2)&lt;/td&gt;
&lt;td&gt;512 tokens&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline Indexing&lt;/td&gt;
&lt;td&gt;LOW (3)&lt;/td&gt;
&lt;td&gt;1024 tokens&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The queue is a &lt;code&gt;PriorityBlockingQueue&amp;lt;InferenceJob&amp;gt;&lt;/code&gt; backed by a min-heap on priority ordinal. Let me show you a pattern I use in every project — the &lt;code&gt;PreemptionController&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PreemptionController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InferenceEngine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;preempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InferenceJob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InferenceJob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPreemptible&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;snapshot&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;snapshotKVCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;checkpointState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;
            &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suspendGeneration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is not here: a cancellation call. You snapshot the KV cache and park the job. When the high-priority request completes, the background job resumes from that checkpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Partial KV Cache Eviction
&lt;/h3&gt;

&lt;p&gt;Full eviction forces re-prefilling the entire prompt context on resume — expensive. The minimal setup that works: retain the static system-prompt portion of the KV cache (it never changes) and evict only the dynamic conversation turns.&lt;/p&gt;

&lt;p&gt;Retaining the first N layers of the KV cache during preemption cuts re-prefill cost by 40–60% on prompts with fixed system instructions exceeding 512 tokens. Cache eviction only fires when memory pressure exceeds 70% of available LPDDR5 bandwidth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Token-Budget Governor Tied to Process Lifecycle
&lt;/h3&gt;

&lt;p&gt;Here is the gotcha that will save you hours. Most implementations wire the budget governor to a timer. Timers are guesses. Android's &lt;code&gt;ProcessLifecycleOwner&lt;/code&gt; exposes authoritative &lt;code&gt;ON_RESUME&lt;/code&gt; and &lt;code&gt;ON_STOP&lt;/code&gt; events — use those instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenBudgetGovernor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ProcessLifecycle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;budgetMultiplier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0f&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addObserver&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;budgetMultiplier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;ON_RESUME&lt;/span&gt;  &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.0f&lt;/span&gt;   &lt;span class="c1"&gt;// full budget, foreground&lt;/span&gt;
                &lt;span class="nc"&gt;ON_STOP&lt;/span&gt;    &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.25f&lt;/span&gt;  &lt;span class="c1"&gt;// aggressive throttle, background&lt;/span&gt;
                &lt;span class="k"&gt;else&lt;/span&gt;       &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5f&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;budgetFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InferenceJob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseBudget&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;budgetMultiplier&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the app moves to background, token output throttles to 25% of base budget. Battery savings are a side effect — the real goal is ensuring the next foreground request hits the engine with full resources already reclaimed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Naive Queue&lt;/th&gt;
&lt;th&gt;Priority Scheduler&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foreground chat, no background jobs&lt;/td&gt;
&lt;td&gt;138ms TTFT&lt;/td&gt;
&lt;td&gt;135ms TTFT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreground chat, background summary active&lt;/td&gt;
&lt;td&gt;1,420ms TTFT&lt;/td&gt;
&lt;td&gt;161ms TTFT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inline suggestion under load&lt;/td&gt;
&lt;td&gt;890ms TTFT&lt;/td&gt;
&lt;td&gt;148ms TTFT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background resume after preemption&lt;/td&gt;
&lt;td&gt;N/A (canceled)&lt;/td&gt;
&lt;td&gt;+22% vs fresh start&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 22% overhead on background job resume is the cost of partial KV cache re-prefill. That is the right trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do not model this like a coroutine dispatcher.&lt;/strong&gt; Throwing requests at a shared dispatcher and hoping for the best works with one caller. With three concurrent callers it produces that 1,400ms spike shown above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four priority tiers is the right number.&lt;/strong&gt; CRITICAL, HIGH, NORMAL, LOW covers the full production caller space. More granularity adds scheduling overhead with negligible SLA benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lifecycle events, not timers.&lt;/strong&gt; The docs do not make this obvious, but &lt;code&gt;ProcessLifecycleOwner&lt;/code&gt; is the correct authority for budget decisions. A &lt;code&gt;Handler&lt;/code&gt;-based poll introduces 100–500ms of lag at exactly the moment precision matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A three-layer scheduler — priority queue with preemption, partial KV cache eviction, and a lifecycle-aware token budget governor — brings foreground chat from 1,420ms down to 161ms TTFT under load without canceling a single background job.&lt;/p&gt;

&lt;p&gt;Relevant reading: &lt;a href="https://developer.android.com/reference/androidx/lifecycle/ProcessLifecycleOwner" rel="noopener noreferrer"&gt;Android ProcessLifecycleOwner docs&lt;/a&gt;, &lt;a href="https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android" rel="noopener noreferrer"&gt;MediaPipe LLM Inference API&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>WebSocket Multiplexing Over HTTP/2: Building a Single-Connection Real-Time Layer for Mobile APIs Without SSE Complexity</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Thu, 16 Jul 2026 13:18:34 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/websocket-multiplexing-over-http2-building-a-single-connection-real-time-layer-for-mobile-apis-5bk8</link>
      <guid>https://dev.to/software_mvp-factory/websocket-multiplexing-over-http2-building-a-single-connection-real-time-layer-for-mobile-apis-5bk8</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WebSocket&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Multiplexing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Over&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HTTP/2:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Mobile&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Real-Time&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Fix&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;You&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Did&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Know&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;You&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Needed"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP/2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;silently&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;breaks&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;WebSocket&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;upgrades&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mobile.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Here&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connect-protocol&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Traefik&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;configuration&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gives&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;you&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bidirectional&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;streaming&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;over&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;single&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;persistent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connection."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mobile, api, architecture, performance&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://blog.mvpfactory.co/websocket-http2-mobile-real-time&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What We Will Build
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial you will understand why your WebSocket connections are almost certainly falling back to HTTP/1.1 without any warning, and you will have working OkHttp (Kotlin) and URLSession (Swift) configurations using connect-protocol to get true bidirectional streaming over a single HTTP/2 connection — wired through a correctly configured Traefik reverse proxy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Android project using OkHttp, or iOS project targeting iOS 15+&lt;/li&gt;
&lt;li&gt;Traefik as your reverse proxy&lt;/li&gt;
&lt;li&gt;A backend service that speaks h2c or h2&lt;/li&gt;
&lt;li&gt;Familiarity with gRPC-style streaming APIs&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Problem You Probably Have Right Now
&lt;/h2&gt;

&lt;p&gt;Let me show you a pattern I see in nearly every mobile codebase I audit.&lt;/p&gt;

&lt;p&gt;The team instruments their app, sees "WebSocket connected," and assumes the connection is riding their HTTP/2 infrastructure. It is not.&lt;/p&gt;

&lt;p&gt;WebSocket depends on the &lt;code&gt;Upgrade: websocket&lt;/code&gt; header — a purely HTTP/1.1 construct. HTTP/2 has no upgrade mechanism. It uses binary framing and stream multiplexing from the first byte. RFC 8441 introduced an extended &lt;code&gt;CONNECT&lt;/code&gt; method to tunnel WebSocket over HTTP/2, but support across clients, proxies, and load balancers remains inconsistent.&lt;/p&gt;

&lt;p&gt;In practice, when OkHttp or URLSession attempt a WebSocket connection against an HTTP/2 server, TLS ALPN negotiation either falls back to &lt;code&gt;http/1.1&lt;/code&gt; or the connection is rejected. You get one TCP connection per WebSocket — defeating the entire point of HTTP/2.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;HTTP/1.1 WebSocket&lt;/th&gt;
&lt;th&gt;HTTP/2 Stream&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upgrade mechanism&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Upgrade: websocket&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;Not supported natively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiplexing&lt;/td&gt;
&lt;td&gt;No — one conn per socket&lt;/td&gt;
&lt;td&gt;Yes — 100 concurrent streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy compatibility&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Poor without RFC 8441&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TCP connection overhead on LTE runs 200–400ms (Grigorik, &lt;em&gt;High Performance Browser Networking&lt;/em&gt;, O'Reilly). Three concurrent WebSocket channels means three connections and three TLS handshakes. With HTTP/2 multiplexing, those three streams share one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Android/Kotlin: OkHttp + connect-kotlin
&lt;/h2&gt;

&lt;p&gt;Here is the minimal setup to get this working. Use the &lt;a href="https://github.com/connectrpc/connect-kotlin" rel="noopener noreferrer"&gt;connect-kotlin library&lt;/a&gt; from Buf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;okHttpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OkHttpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;protocols&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTP_2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTP_1_1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;protocolClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProtocolClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConnectOkHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;okHttpClient&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProtocolClientConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;networkProtocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NetworkProtocol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CONNECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;codec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProtoCodec&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stub&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatServiceClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;protocolClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyMap&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChatRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hello"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap &lt;code&gt;OkHttpClient&lt;/code&gt; in &lt;code&gt;ConnectOkHttpClient&lt;/code&gt; and specify &lt;code&gt;NetworkProtocol.CONNECT&lt;/code&gt;. Force &lt;code&gt;HTTP_2&lt;/code&gt; first in the protocols list — OkHttp will not fall back unless the server explicitly negotiates &lt;code&gt;http/1.1&lt;/code&gt; via ALPN.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — iOS/Swift: URLSession + connect-swift
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLSessionConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;httpAdditionalHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"application/connect+proto"&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProtocolClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URLSessionHTTPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ProtocolClientConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;networkProtocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;codec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ProtoCodec&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;URLSession on iOS 15+ supports HTTP/2 natively. The connect-swift library handles framing — no custom stream management required.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Traefik Configuration
&lt;/h2&gt;

&lt;p&gt;The docs do not make this obvious, but two settings here are non-negotiable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:443"&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;serversTransport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;myTransport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;forwardingTimeouts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;responseHeaderTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0s"&lt;/span&gt;

&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;routers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Host(`api.example.com`)"&lt;/span&gt;
      &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h2c://backend:8080"&lt;/span&gt;
        &lt;span class="na"&gt;serversTransport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myTransport&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;h2c://&lt;/code&gt; scheme routes to your backend over HTTP/2 cleartext. The &lt;code&gt;responseHeaderTimeout: "0s"&lt;/code&gt; disables the default timeout that silently kills long-lived streams.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;responseHeaderTimeout&lt;/code&gt; is not optional.&lt;/strong&gt; Traefik will terminate long-lived streams after its default timeout. Set it to &lt;code&gt;"0s"&lt;/code&gt; before you go to production — this is the one that bites people most often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify ALPN before you ship anything.&lt;/strong&gt; Use Charles Proxy or Proxyman to confirm which protocol your connections actually negotiate. If you see &lt;code&gt;http/1.1&lt;/code&gt; in ALPN, you are leaving performance on the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RFC 8441 is not a reliable fix.&lt;/strong&gt; Support across clients, proxies, and load balancers is still inconsistent. connect-protocol sidesteps this entirely by never using the WebSocket upgrade mechanism.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The WebSocket-over-HTTP/1.1 fallback is one of the most common invisible performance regressions on mobile. connect-protocol, combined with correct Traefik configuration, eliminates the problem at the transport layer and gives you type-safe generated clients for both Kotlin and Swift via Buf's libraries.&lt;/p&gt;

&lt;p&gt;Verify your ALPN negotiation today — you may be surprised what you find.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Subscription Paywall Architecture for KMP Apps</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Thu, 16 Jul 2026 08:19:14 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/subscription-paywall-architecture-for-kmp-apps-13gf</link>
      <guid>https://dev.to/software_mvp-factory/subscription-paywall-architecture-for-kmp-apps-13gf</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KMP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Subscription&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Architecture:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;One&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Engine,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Two&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Platforms"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;shared&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;KMP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;subscription&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;engine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;expect/actual&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;interfaces,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;server-side&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;receipt&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;validation,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;single&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;entitlement&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;machine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;iOS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kotlin, mobile, architecture, android&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/kmp-subscription-architecture&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What We Are Building&lt;/span&gt;

By the end of this tutorial you will have a shared Kotlin Multiplatform subscription engine that abstracts RevenueCat, StoreKit 2, and Google Play Billing behind a single &lt;span class="sb"&gt;`expect`&lt;/span&gt;/&lt;span class="sb"&gt;`actual`&lt;/span&gt; interface, runs one entitlement state machine for both platforms, and handles grace periods, billing retries, and cache invalidation from a single source of truth.

No more duplicating billing edge-case logic across Swift and Kotlin.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Kotlin Multiplatform project with &lt;span class="sb"&gt;`commonMain`&lt;/span&gt;, &lt;span class="sb"&gt;`iosMain`&lt;/span&gt;, and &lt;span class="sb"&gt;`androidMain`&lt;/span&gt; source sets
&lt;span class="p"&gt;-&lt;/span&gt; RevenueCat SDK configured on both platforms (or your preferred billing provider)
&lt;span class="p"&gt;-&lt;/span&gt; A backend endpoint for server-side receipt validation
&lt;span class="p"&gt;-&lt;/span&gt; Familiarity with Kotlin coroutines and sealed classes
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1 — Define the Purchase Interface in commonMain&lt;/span&gt;

Let me show you a pattern I use in every project. Define the abstraction first, before you touch any platform SDK.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
// commonMain&lt;br&gt;
interface PurchaseProvider {&lt;br&gt;
    suspend fun purchase(productId: String): PurchaseResult&lt;br&gt;
    suspend fun restorePurchases(): List&lt;br&gt;
    suspend fun getOfferings(): List&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;expect fun createPurchaseProvider(): PurchaseProvider&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Each platform delivers its own `actual` implementation:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
// iosMain&lt;br&gt;
actual fun createPurchaseProvider(): PurchaseProvider = RevenueCatIOSProvider()&lt;/p&gt;

&lt;p&gt;// androidMain&lt;br&gt;
actual fun createPurchaseProvider(): PurchaseProvider = RevenueCatAndroidProvider()&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The `expect`/`actual` boundary keeps your shared business logic decoupled from any specific vendor — including RevenueCat itself.

---

## Step 2 — Normalize the Entitlement Contract

iOS validates via the App Store Server API with JWT-signed JWS transactions. Android uses Google Real-Time Developer Notifications via Pub/Sub. Two completely different mechanisms — but your shared module should not care about either.

Your backend exposes a single `/validate-receipt` endpoint and returns a platform-agnostic payload:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
// commonMain&lt;br&gt;
data class EntitlementState(&lt;br&gt;
    val isActive: Boolean,&lt;br&gt;
    val expiresAt: Instant,&lt;br&gt;
    val inGracePeriod: Boolean,&lt;br&gt;
    val billingRetryActive: Boolean,&lt;br&gt;
    val billingRetryUntil: Instant?&lt;br&gt;
)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Here is what your backend absorbs so your KMP module does not have to:

| Concern | iOS | Android |
|---|---|---|
| Validation method | JWS transaction (JWT) | Purchase token via REST |
| Real-time events | App Store Server Notifications | RTDN via Cloud Pub/Sub |
| Grace period signal | `expirationIntent` field | `paymentState = 0` |
| Billing retry window | Up to 60 days | Up to 30 days |

---

## Step 3 — Build the State Machine in Shared Code

This is where most teams lose real money. Grace periods and billing retries gate feature access — model them as first-class states, not UI edge cases.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
sealed class SubscriptionState {&lt;br&gt;
    object Active : SubscriptionState()&lt;br&gt;
    data class GracePeriod(val expiresAt: Instant) : SubscriptionState()&lt;br&gt;
    data class BillingRetry(val retryUntil: Instant) : SubscriptionState()&lt;br&gt;
    object Expired : SubscriptionState()&lt;br&gt;
    object NeverSubscribed : SubscriptionState()&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;class SubscriptionStateMachine(&lt;br&gt;
    private val provider: PurchaseProvider,&lt;br&gt;
    private val cache: EntitlementCache&lt;br&gt;
) {&lt;br&gt;
    fun resolve(entitlement: EntitlementState): SubscriptionState = when {&lt;br&gt;
        entitlement.isActive -&amp;gt; SubscriptionState.Active&lt;br&gt;
        entitlement.inGracePeriod -&amp;gt; SubscriptionState.GracePeriod(entitlement.expiresAt)&lt;br&gt;
        entitlement.billingRetryActive &amp;amp;&amp;amp; entitlement.billingRetryUntil != null -&amp;gt;&lt;br&gt;
            SubscriptionState.BillingRetry(entitlement.billingRetryUntil)&lt;br&gt;
        entitlement.expiresAt &amp;lt; Clock.System.now() -&amp;gt; SubscriptionState.Expired&lt;br&gt;
        else -&amp;gt; SubscriptionState.NeverSubscribed&lt;br&gt;
    }&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;suspend fun refresh(): EntitlementState {
    val fresh = provider.restorePurchases()
    val state = resolveFromResults(fresh)
    cache.write(state)
    return state
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
This runs entirely in `commonMain`. Your SwiftUI views and Compose screens observe the same `SubscriptionState` flow — no platform-specific branching required.

---

## Step 4 — Cache Without Race Conditions

Cache on device, validate server-side asynchronously, and never block the UI on cold start. Here is the minimal setup to get this working: a two-layer strategy using local `EncryptedSharedPreferences`/Keychain for instant reads, with a background refresh triggered on app foreground. Invalidate eagerly on any purchase event.

Five minutes is a reasonable TTL default — short enough that a cancelled subscriber won't keep seeing premium content, long enough to avoid hammering your validation endpoint on typical mobile sessions. Make it configurable so you can tighten it for high-value paywalls without shipping a release.

---

## Gotchas

**Starting with the platform SDK instead of the interface.** The docs do not mention this, but the abstraction boundary also gives you a clean seam for unit testing your entire subscription lifecycle without a physical device or a live App Store sandbox account. Define `PurchaseProvider` in `commonMain` first, always.

**Treating grace periods as UI state.** A subscriber in a grace period still has active access — but your state machine needs to know when to show a recovery paywall. If `inGracePeriod` and `billingRetryActive` are not first-class states, you will ship a bug on one platform that was already fixed on the other six months earlier.

**Putting receipt validation logic in the client.** JWT handling for the App Store Server API and RTDN for Google Play belong on your backend. Return a normalized `EntitlementState` with a dedicated `billingRetryUntil` field and let shared code consume it cleanly.

**Hardcoding the cache TTL.** Five minutes works for most paywalls. For high-value content you will want to tighten this without a release cycle. Make it configurable from day one.

---

## Conclusion

Six subscription concerns across two platforms used to mean twelve code paths to maintain. With KMP's `expect`/`actual` pattern, a normalized backend contract, and a state machine living entirely in `commonMain`, you collapse that to one engine with thin platform adapters. The billing edge case you fix today gets fixed on both platforms simultaneously.

**Resources:**
- [Kotlin Multiplatform — expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html)
- [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi)
- [Google Play Developer API — Subscriptions](https://developer.android.com/google/play/billing/getting-ready)
- [RevenueCat Kotlin Multiplatform](https://www.revenuecat.com/docs/getting-started/installation/kotlin-multiplatform)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgreSQL Index-Only Scans and Visibility Maps: The Query Optimization Layer Most Developers Never Reach</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:40:05 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/postgresql-index-only-scans-and-visibility-maps-the-query-optimization-layer-most-developers-never-1lm</link>
      <guid>https://dev.to/software_mvp-factory/postgresql-index-only-scans-and-visibility-maps-the-query-optimization-layer-most-developers-never-1lm</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PostgreSQL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Index-Only&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Scans:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hidden&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VACUUM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Dependency&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Covering&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Indexes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Depend&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;On"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Why&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PostgreSQL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;covering&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;indexes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;silently&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fall&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;back&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;heap&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;access&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;how&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tune&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;VACUUM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;visibility&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;maps&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;high-write&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SaaS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;workloads."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql, performance, architecture, devops&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/postgresql-index-only-scans-visibility-map&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What we will cover&lt;/span&gt;

Today you will learn why your covering index is lying to you. &lt;span class="sb"&gt;`EXPLAIN`&lt;/span&gt; says &lt;span class="sb"&gt;`Index Only Scan`&lt;/span&gt;. The plan looks clean. But heap fetches are still happening — silently, expensively — and your query performance is paying the price. We are going to diagnose this using real query plans, fix it with targeted autovacuum tuning, and build a weekly monitoring query you can drop straight into production.

(Side note: if you are the kind of developer who goes deep on database internals, you probably spend long hours at your desk. I have been using &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;HealthyDesk&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://play.google.com/store/apps/details?id=com.healthydesk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; for break reminders and guided desk exercises — worth keeping in your toolkit alongside your SQL profiling habits.)

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; PostgreSQL 13+ (examples run on PG 15)
&lt;span class="p"&gt;-&lt;/span&gt; Access to &lt;span class="sb"&gt;`pg_stat_user_tables`&lt;/span&gt; and &lt;span class="sb"&gt;`pg_stat_progress_vacuum`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; A table with meaningful write volume — event logs and audit tables are the most common offenders
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1 — Understand what index-only scans actually require&lt;/span&gt;

Here is the pattern I explain in every PostgreSQL review I do. Developers learn requirement one: all columns in the query must exist in the index. Create a covering index, ship it, done.

They miss requirement two — and this is the one that kills you in production.

PostgreSQL must confirm tuple visibility &lt;span class="gs"&gt;**without touching the heap**&lt;/span&gt;. It does this via the &lt;span class="gs"&gt;**visibility map**&lt;/span&gt; — a compact bitmap, one bit per heap page. When the all-visible bit is set, VACUUM has confirmed every tuple on that page is visible to all transactions. Index-only scan proceeds cleanly. When that bit is not set — because writes have dirtied the page since the last VACUUM — PostgreSQL falls back to a heap fetch. Your "optimized" query just became a regular index scan with extra steps.

&lt;span class="gu"&gt;## Step 2 — Read the signals in EXPLAIN and pg_stat_user_tables&lt;/span&gt;

Run this on your target query:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
EXPLAIN (ANALYZE, BUFFERS)&lt;br&gt;
SELECT user_id, created_at FROM events&lt;br&gt;
WHERE tenant_id = 42 AND created_at &amp;gt; now() - interval '7 days';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Watch for this in the output:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
Index Only Scan using idx_events_covering on events&lt;br&gt;
  Heap Fetches: 18402&lt;br&gt;
  Buffers: shared hit=4821 read=3107&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Non-zero `Heap Fetches` is the smoking gun. Now cross-reference with `pg_stat_user_tables`:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
SELECT&lt;br&gt;
  relname,&lt;br&gt;
  n_live_tup,&lt;br&gt;
  n_dead_tup,&lt;br&gt;
  round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio_pct,&lt;br&gt;
  last_autovacuum,&lt;br&gt;
  last_vacuum,&lt;br&gt;
  n_mod_since_analyze&lt;br&gt;
FROM pg_stat_user_tables&lt;br&gt;
WHERE relname = 'events'&lt;br&gt;
ORDER BY n_dead_tup DESC;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
If `dead_ratio_pct` is above 5% and `last_autovacuum` is hours or days old on an active table, you have found your problem.

## Step 3 — Tune autovacuum for high-write SaaS workloads

The docs do not make this obvious, but autovacuum's defaults were designed for moderate OLTP workloads — not a SaaS backend processing thousands of events per minute. The default `autovacuum_vacuum_scale_factor` of 0.20 means autovacuum will not trigger on a 10-million-row table until 2 million rows have been modified. That is a long window to accumulate dead tuples.

Here is the minimal setup to get this working for high-write tables:

| Parameter | Default | High-Write Recommendation |
|---|---|---|
| `autovacuum_vacuum_scale_factor` | 0.20 | 0.01–0.05 |
| `autovacuum_vacuum_threshold` | 50 rows | 100–500 rows |
| `autovacuum_vacuum_cost_delay` | 2ms | 0–1ms |
| `autovacuum_max_workers` | 3 | 5–8 |
| `autovacuum_naptime` | 1 min | 15–30 sec |

Apply this per-table rather than globally — more surgical, fewer surprises:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
ALTER TABLE events SET (&lt;br&gt;
  autovacuum_vacuum_scale_factor = 0.02,&lt;br&gt;
  autovacuum_vacuum_cost_delay = 1&lt;br&gt;
);&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
## Step 4 — Prime the visibility map and verify

Once autovacuum is tuned, run a manual VACUUM to recover existing tables immediately:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
VACUUM (ANALYZE, VERBOSE) events;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Monitor large tables with `pg_stat_progress_vacuum`. Then re-run your `EXPLAIN (ANALYZE, BUFFERS)`. On a 50-million-row events table ingesting roughly 8,000 writes per minute on PostgreSQL 15 (c5.2xlarge, gp3 storage), this dropped heap fetches from ~20,000 per query to under 50, with query latency falling by approximately 60%.

Let me show you a pattern I use in every production database I maintain — a weekly diagnostic query to stay ahead of this:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
SELECT&lt;br&gt;
  relname,&lt;br&gt;
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,&lt;br&gt;
  round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,&lt;br&gt;
  age(relfrozenxid) AS xid_age,&lt;br&gt;
  last_autovacuum&lt;br&gt;
FROM pg_stat_user_tables&lt;br&gt;
JOIN pg_class ON pg_stat_user_tables.relid = pg_class.oid&lt;br&gt;
WHERE schemaname = 'public'&lt;br&gt;
  AND n_live_tup &amp;gt; 10000&lt;br&gt;
ORDER BY dead_pct DESC NULLS LAST&lt;br&gt;
LIMIT 20;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
---

## Gotchas

**`EXPLAIN` without `ANALYZE` will not show heap fetches.** The node name `Index Only Scan` appears in the plan regardless of whether heap fetches are happening at runtime. Always use `EXPLAIN (ANALYZE, BUFFERS)` to get actual execution data.

**Global autovacuum changes affect every table.** Tune per-table with `ALTER TABLE ... SET (...)` on your hottest tables first. Avoid changing cluster-wide settings without understanding your full workload mix.

**A freshly created covering index is not enough.** Existing heap pages will not have their all-visible bits set until VACUUM runs. After adding a covering index to a large, active table, run `VACUUM ANALYZE` manually before expecting index-only scan to deliver its full benefit.

---

## Conclusion

Covering indexes are only half the story. The visibility map is the invisible layer between your index strategy and actual query performance. Verify with `EXPLAIN (ANALYZE, BUFFERS)`, tune autovacuum per-table with a scale factor of 1–5% on write-heavy workloads, and treat VACUUM as a first-class performance concern rather than a maintenance afterthought. Your event logs and audit tables will thank you.

**Resources:**
- [PostgreSQL VACUUM documentation](https://www.postgresql.org/docs/current/sql-vacuum.html)
- [pg_stat_user_tables reference](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ALL-TABLES-VIEW)
- [Autovacuum tuning guide (PostgreSQL wiki)](https://wiki.postgresql.org/wiki/Autovacuum)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>gRPC Bidirectional Streaming with Backpressure: Building a Real-Time Mobile API That Doesn't Collapse Under Load</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:34:19 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/grpc-bidirectional-streaming-with-backpressure-building-a-real-time-mobile-api-that-doesnt-30cg</link>
      <guid>https://dev.to/software_mvp-factory/grpc-bidirectional-streaming-with-backpressure-building-a-real-time-mobile-api-that-doesnt-30cg</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gRPC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Bidirectional&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Streaming&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Backpressure&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Done&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Right&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10k&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Streams"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Flow&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;control&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;windows,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cancellation&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;propagation,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;OkHttp/gRPC-Kotlin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;coroutine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bridge&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;keeps&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;streaming&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;endpoint&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;alive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;under&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;real&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;including&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Netty&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tuning&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10k&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;concurrent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;streams."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kotlin, android, mobile, api&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://blog.mvpfactory.co/grpc-bidirectional-streaming-android-backpressure&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What We Are Building&lt;/span&gt;

By the end of this tutorial you will have a production-ready gRPC bidirectional streaming setup on Android. Specifically: a correctly wired OkHttp/gRPC-Kotlin coroutine bridge with explicit HTTP/2 flow control windows, structured cancellation that sends &lt;span class="sb"&gt;`RST_STREAM`&lt;/span&gt; automatically, and a Netty server tuned to handle 10,000 concurrent streams without an OOM kill.

This is not a hello-world streaming example. This is the setup I use in every project where streaming matters.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Android project targeting API 24+
&lt;span class="p"&gt;-&lt;/span&gt; Kotlin coroutines (&lt;span class="sb"&gt;`kotlinx-coroutines-android`&lt;/span&gt;)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`grpc-kotlin-stub`&lt;/span&gt; and &lt;span class="sb"&gt;`grpc-okhttp`&lt;/span&gt; on the client
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`grpc-netty`&lt;/span&gt; on the server side
&lt;span class="p"&gt;-&lt;/span&gt; Basic familiarity with gRPC service definitions and protobuf
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Why This Is Harder Than It Looks&lt;/span&gt;

At 1,000 concurrent streams your app feels fine. At 10,000 it OOM-kills — and the default Netty configuration is usually why.

Bidirectional streaming removes the natural backpressure that REST and unary gRPC provide. With those patterns, the client controls when it makes the next request. Bidirectional streaming removes that constraint entirely. Your server can now push frames faster than the Android client can process them, and when it does, you get buffer bloat, OOM kills, and head-of-line stalls where one slow stream degrades every stream on the same HTTP/2 connection.

Here is the math that makes this a real problem. At 10,000 concurrent streams on a single Netty server, the default &lt;span class="sb"&gt;`INITIAL_WINDOW_SIZE`&lt;/span&gt; of 65,535 bytes per stream means roughly &lt;span class="gs"&gt;**625 MB of flow control buffer space**&lt;/span&gt; before sending a single byte of application payload:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;10,000 streams × 65,535 bytes ÷ 1,048,576 = ~625 MB&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;
&lt;span class="nc"&gt;That&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;theoretical&lt;/span&gt; &lt;span class="n"&gt;concern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;It&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt; &lt;span class="n"&gt;cliff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="p"&gt;---&lt;/span&gt;

&lt;span class="err"&gt;##&lt;/span&gt; &lt;span class="nc"&gt;Step&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Tune&lt;/span&gt; &lt;span class="nc"&gt;Your&lt;/span&gt; &lt;span class="nc"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt; &lt;span class="nc"&gt;Control&lt;/span&gt; &lt;span class="nc"&gt;Windows&lt;/span&gt;

&lt;span class="nc"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;defines&lt;/span&gt; &lt;span class="n"&gt;two&lt;/span&gt; &lt;span class="n"&gt;levels&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt; &lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;connection-level&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;stream-level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;Both&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;tuned&lt;/span&gt; &lt;span class="n"&gt;independently&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;Here&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="nc"&gt;I&lt;/span&gt; &lt;span class="n"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Parameter&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Default&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Recommended&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nc"&gt;Mobile&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Why&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="p"&gt;|---|---|---|---|&lt;/span&gt;
&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;535&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="nc"&gt;KB&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="nc"&gt;MB&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt; &lt;span class="nc"&gt;KB&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="n"&gt;starting&lt;/span&gt; &lt;span class="n"&gt;point&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;most&lt;/span&gt; &lt;span class="n"&gt;mobile&lt;/span&gt; &lt;span class="n"&gt;payloads&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;535&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="nc"&gt;MB&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Must&lt;/span&gt; &lt;span class="n"&gt;exceed&lt;/span&gt; &lt;span class="n"&gt;per-stream&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="n"&gt;so&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;never&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;bottleneck&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Max&lt;/span&gt; &lt;span class="n"&gt;concurrent&lt;/span&gt; &lt;span class="n"&gt;streams&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Unlimited&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt; &lt;span class="n"&gt;per&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Caps&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt; &lt;span class="n"&gt;exposure&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;keeps&lt;/span&gt; &lt;span class="n"&gt;latency&lt;/span&gt; &lt;span class="n"&gt;predictable&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Max&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="nc"&gt;KB&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;Smaller&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="n"&gt;let&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;scheduler&lt;/span&gt; &lt;span class="n"&gt;interleave&lt;/span&gt; &lt;span class="n"&gt;streams&lt;/span&gt; &lt;span class="n"&gt;fairly&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;

&lt;span class="nc"&gt;On&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nc"&gt;Netty&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wire&lt;/span&gt; &lt;span class="n"&gt;these&lt;/span&gt; &lt;span class="n"&gt;through&lt;/span&gt; &lt;span class="nc"&gt;`NettyServerBuilder`&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
NettyServerBuilder.forPort(50051)&lt;br&gt;
    .flowControlWindow(1 * 1024 * 1024) // 1 MB per stream&lt;br&gt;
    .maxConcurrentCallsPerConnection(200)&lt;br&gt;
    .maxInboundMessageSize(4 * 1024 * 1024)&lt;br&gt;
    .addService(YourStreamingService())&lt;br&gt;
    .build()&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Raising the stream window above 1 MB typically yields diminishing returns on mobile links and increases memory pressure. Keep the connection-level window larger than the per-stream window — otherwise the connection becomes the bottleneck the moment multiple streams are active simultaneously.

---

## Step 2: Wire the OkHttp/gRPC-Kotlin Coroutine Bridge

On Android, `grpc-kotlin` exposes bidirectional streaming as `Flow&amp;lt;Request&amp;gt;` in, `Flow&amp;lt;Response&amp;gt;` out. Elegant — but the bridge between OkHttp's thread-pool model and Kotlin's structured concurrency requires explicit attention.

Here is the minimal setup to get this working correctly:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
// Client-side channel with explicit flow control window&lt;br&gt;
val channel = OkHttpChannelBuilder&lt;br&gt;
    .forAddress("api.example.com", 443)&lt;br&gt;
    .flowControlWindow(512 * 1024) // 512 KB per stream&lt;br&gt;
    .maxInboundMessageSize(2 * 1024 * 1024)&lt;br&gt;
    .build()&lt;/p&gt;

&lt;p&gt;val stub = YourServiceGrpcKt.YourServiceCoroutineStub(channel)&lt;/p&gt;

&lt;p&gt;// Bidirectional streaming with structured cancellation&lt;br&gt;
val requestFlow: Flow = flow {&lt;br&gt;
    emit(buildInitialRequest())&lt;br&gt;
    // emit subsequent messages driven by UI events&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;coroutineScope {&lt;br&gt;
    stub.bidirectionalStream(requestFlow).collect { response -&amp;gt;&lt;br&gt;
        processResponse(response)&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
When the `coroutineScope` is cancelled — Activity destruction, navigation, explicit user action — cancellation propagates through the `Flow` collector, signals the gRPC stub, and sends an HTTP/2 `RST_STREAM` frame to the server automatically. Clean path. What breaks it is launching collection in `GlobalScope` or a `viewModelScope` that is not tied to the stream lifecycle.

---

## Step 3: Eliminate HOL Stalls at Scale

HTTP/2 mitigates application-layer head-of-line stalls across streams, but TCP-layer HOL remains — only HTTP/3 (QUIC) eliminates it fully. Within HTTP/2, tuning `maxFrameSize` to 16–32 KB keeps individual frames small enough that the scheduler can interleave streams fairly.

Add the socket buffer options alongside your flow control config:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
NettyServerBuilder.forPort(50051)&lt;br&gt;
    .withChildOption(ChannelOption.SO_RCVBUF, 2 * 1024 * 1024)&lt;br&gt;
    .withChildOption(ChannelOption.SO_SNDBUF, 2 * 1024 * 1024)&lt;br&gt;
    .flowControlWindow(1 * 1024 * 1024)&lt;br&gt;
    .maxConcurrentCallsPerConnection(200)&lt;br&gt;
    // ... .build()&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Combined with a reasonable stream window size, this keeps P99 latency stable as concurrent stream count grows.

---

## Gotchas

**Zombie streams are more common than crashes.** The most common failure mode is not an OOM — it is a zombie stream. The Android client navigates away, the coroutine is cancelled, but the server keeps pushing frames because `RST_STREAM` was never sent. This happens when the stub call is wrapped in a `try/catch` that swallows `CancellationException`, the stream is collected inside a `launch` that outlives the UI lifecycle, or a blocking call on the gRPC thread pool prevents the cancellation signal from being dispatched. Treat `CancellationException` as a first-class signal, not an error.

**Never collect a bidirectional stream in `GlobalScope`.** Scope collection to the narrowest lifecycle that owns the stream — `viewModelScope` tied to the owning ViewModel, or a custom `CoroutineScope` tied directly to the UI component.

**The docs do not make the two-level window distinction obvious.** Most tutorials tune the stream window and ignore the connection window. If your connection window is smaller than your stream window, the connection becomes the bottleneck the moment two streams are active simultaneously. Always set the connection window to at least 4–8 MB.

**Don't skip profiling before you scale.** HOL stalls at 10k streams are a configuration problem, not a capacity problem. Profile stream frame interleaving in staging before you hit it in production.

---

## Before You Ship

1. Set explicit flow control windows on both client and server. Start with 512 KB–1 MB per stream and adjust based on profiled throughput, not intuition.
2. Tie stream lifecycle to the narrowest possible coroutine scope and let structured concurrency handle `RST_STREAM` propagation automatically.
3. Instrument your connection-level window utilization before scaling. The numbers above are starting points — your payload profile will determine the right values.

**Relevant docs:**
- [gRPC-Kotlin GitHub](https://github.com/grpc/grpc-kotlin)
- [Netty NettyServerBuilder API](https://grpc.github.io/grpc-java/javadoc/io/grpc/netty/NettyServerBuilder.html)
- [HTTP/2 Flow Control — RFC 9113](https://www.rfc-editor.org/rfc/rfc9113#name-flow-control)

Get these three things right and bidirectional streaming at 10k concurrent streams becomes a configuration exercise, not a production incident.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Continuous Batching for On-Device LLM Inference on Android: Serving Multiple Requests Without the Throughput Cliff</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Tue, 14 Jul 2026 14:27:48 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/continuous-batching-for-on-device-llm-inference-on-android-serving-multiple-requests-without-the-49oh</link>
      <guid>https://dev.to/software_mvp-factory/continuous-batching-for-on-device-llm-inference-on-android-serving-multiple-requests-without-the-49oh</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Continuous&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Batching&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;On-Device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Inference&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Implement&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;continuous&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;batching&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;llama.cpp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;KV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;management,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dynamic&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;batch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;assembly,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;queue&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;architecture&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;holds&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;p50&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;latency&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;under&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;concurrent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;load."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android, kotlin, mobile, architecture&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/continuous-batching-on-device-llm-android&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What We Are Building&lt;/span&gt;

By the end of this tutorial, you will have a working request scheduler, a per-sequence KV cache slot allocator, and a dynamic batch assembly loop — the three components you need to serve multiple concurrent LLM requests from a single llama.cpp instance on Android without your throughput falling off a cliff.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Android project with llama.cpp integrated via JNI or a Kotlin/Native bridge
&lt;span class="p"&gt;-&lt;/span&gt; Familiarity with Kotlin coroutines and &lt;span class="sb"&gt;`Channel`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Basic understanding of how autoregressive token generation works
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## The Problem Nobody Warns You About&lt;/span&gt;

Most teams treat the inference engine like a function call: one request in, wait for completion, next request in. That works at one user. At five concurrent users on the same device — think a local Android server powering multiple app features simultaneously — you hit a throughput cliff so steep it looks like a wall.

The culprit is &lt;span class="gs"&gt;**prefill starvation**&lt;/span&gt;. While a long generation decodes token-by-token, every new request sits idle. GPU/NPU compute is underutilized, KV cache memory sits half-empty, and your p50 latency balloons proportionally to queue depth.

The server-side world solved this years ago with &lt;span class="gs"&gt;**continuous batching**&lt;/span&gt; (also called iteration-level scheduling). The on-device world is only catching up now.

Here is the comparison that will make this concrete:

| Property | Static Batching | Continuous Batching |
|---|---|---|
| Scheduling unit | Full request | Single decode iteration |
| New request joins | After current batch completes | Next available iteration |
| KV cache allocation | Fixed at batch start | Dynamic per sequence slot |
| GPU utilization | Spiky, often &amp;lt;50% | Sustained, typically 70–90% |
| p50 latency under load | Grows linearly with queue | Near-flat to moderate concurrency |
| Implementation complexity | Low | Moderate |

With static batching, a 5-request queue behind a 500-token generation means request 5 waits for ~2,500 decode steps before its prefill even begins. With continuous batching, that same request joins the batch at the next iteration boundary.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1: The Request Queue&lt;/span&gt;

Let me show you a pattern I use in every project — decouple the queue from the inference loop with typed data and coroutine channels.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
data class InferenceRequest(&lt;br&gt;
    val id: String,&lt;br&gt;
    val prompt: String,&lt;br&gt;
    val maxTokens: Int,&lt;br&gt;
    val responseChannel: Channel&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;class RequestScheduler(private val maxConcurrent: Int = 4) {&lt;br&gt;
    private val pending = ArrayDeque()&lt;br&gt;
    private val active = mutableMapOf()&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fun enqueue(request: InferenceRequest) {
    pending.addLast(request)
    tryPromote()
}

private fun tryPromote() {
    while (active.size &amp;lt; maxConcurrent &amp;amp;&amp;amp; pending.isNotEmpty()) {
        val req = pending.removeFirst()
        val slot = SlotAllocator.acquire() ?: return // KV cache full
        active[req.id] = SequenceSlot(req, slot)
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
`maxConcurrent` is not arbitrary — it is bounded by your KV cache capacity. Exceed it and you either evict sequences mid-generation or OOM. Size it at initialization from available VRAM or shared memory, not at runtime.

---

## Step 2: Per-Sequence KV Cache Slot Manager

llama.cpp exposes `llama_kv_cache_seq_rm` and `llama_kv_cache_seq_cp` for sequence-level control. Here is the minimal setup to get this working:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
class SlotAllocator(private val totalSlots: Int) {&lt;br&gt;
    private val free = ArrayDeque((0 until totalSlots).toList())&lt;br&gt;
    private val inUse = mutableSetOf()&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@Synchronized
fun acquire(): Int? = free.removeFirstOrNull()?.also { inUse.add(it) }

@Synchronized
fun release(slot: Int) {
    inUse.remove(slot)
    free.addLast(slot)
    // Signal scheduler via coroutine channel
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Each active sequence owns exactly one slot. When a generation completes or is cancelled, `release()` fires and the scheduler immediately promotes the next pending request. No idle cycles.

---

## Step 3: Dynamic Batch Assembly

At each decode step, re-assemble the batch from all active sequences:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
suspend fun runBatchStep(active: Map) {&lt;br&gt;
    val batch = llama_batch_init(active.size, 0, 1)&lt;br&gt;
    active.values.forEachIndexed { i, seq -&amp;gt;&lt;br&gt;
        llama_batch_add(batch, seq.nextToken, seq.position, intArrayOf(seq.slot), i == active.size - 1)&lt;br&gt;
    }&lt;br&gt;
    llama_decode(ctx, batch)&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;active.values.forEach { seq -&amp;gt;
    val logits = llama_get_logits_ith(ctx, seq.batchIndex)
    val token = sample(logits, seq.samplingParams)
    seq.emit(token)
    if (token == eosToken || seq.length &amp;gt;= seq.request.maxTokens) {
        seq.complete()
    }
}
llama_batch_free(batch)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The loop runs continuously as long as any sequence is active. New requests slot in; completed sequences drain out. The engine never idles waiting for a single long generation to finish.

---

## Gotchas

**Size the slot allocator at startup, not at runtime.** Calculate maximum concurrent sequences from available KV cache memory before accepting any requests. Fail fast rather than evict mid-generation — eviction destroys the sequence state you spent compute building.

**Decouple your queue from your batch loop using coroutines.** The `RequestScheduler` and the decode loop should communicate through channels, not shared mutable state. This keeps cancellation and backpressure composable.

**Benchmark p50, not just throughput.** The docs do not mention this, but continuous batching trades marginal single-request latency for better tail behavior under load. Measure at the 50th and 95th percentile under realistic concurrent load — that is where you see the real gains.

**The sweet spot is 3–6 concurrent sequences.** Below that, batch management overhead can slightly inflate single-request latency. Above ~8 on a mobile GPU, you hit memory pressure before CPU scheduling becomes the bottleneck. Tune `maxConcurrent` empirically against your target device tier.

---

## Conclusion

Naive serial inference on Android will not survive concurrent load. The three components you built here — the request scheduler, the slot allocator, and the dynamic batch loop — give you iteration-level scheduling with per-sequence KV cache control. That is the same architecture that keeps server-side LLM inference healthy under concurrent users, brought to your Android process.

Your next step is profiling `maxConcurrent` on your actual target device. Start at 4, measure p50 and p95 under 5 simultaneous requests, and adjust from there.

**Further reading:**
- [llama.cpp batching documentation](https://github.com/ggerganov/llama.cpp/blob/master/docs/batched.md)
- [Continuous Batching: How HuggingFace TGI handles it](https://huggingface.co/docs/text-generation-inference/conceptual/scheduling)
- [Android NNAPI and GPU delegate memory constraints](https://developer.android.com/ndk/guides/neuralnetworks)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Flash Attention on Android</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Tue, 14 Jul 2026 08:40:18 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/flash-attention-on-android-3pm2</link>
      <guid>https://dev.to/software_mvp-factory/flash-attention-on-android-3pm2</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Flash&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Attention&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Tiled&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SGEMM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;RenderScript&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Cut&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Prefill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Bandwidth&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;40–55%"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bandwidth&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;compute&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;real&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bottleneck&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;during&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prefill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Here&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;how&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;implement&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Flash&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Attention's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tiling&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;strategy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ScriptIntrinsicBLAS.SGEMM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reduce&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;peak&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DRAM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reads&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;O(n²)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;O(n)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Snapdragon&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Gen&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Dimensity&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9300."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;android&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kotlin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mobile&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;architecture&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/flash-attention-android-tiled-sgemm-renderscript&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What We Will Build
&lt;/h2&gt;

&lt;p&gt;A tiled attention kernel for Android that applies Flash Attention's online softmax strategy through &lt;code&gt;ScriptIntrinsicBLAS.SGEMM&lt;/code&gt;, cutting peak memory bandwidth by &lt;strong&gt;40–55%&lt;/strong&gt; during LLM prefill on flagship SoCs — without changing total FLOPs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠ API Notice:&lt;/strong&gt; &lt;code&gt;ScriptIntrinsicBLAS&lt;/code&gt; was deprecated in API 31 (Android 12). If you are starting a new project, the tiling strategy and softmax accumulator logic shown here port directly to a Vulkan compute path. Read to the end for that takeaway.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Android NDK familiarity&lt;/li&gt;
&lt;li&gt;Basic understanding of matrix attention (QKV)&lt;/li&gt;
&lt;li&gt;Snapdragon Profiler or Mali Graphics Debugger for bandwidth measurement&lt;/li&gt;
&lt;li&gt;A device running Snapdragon 8 Gen 3 or Dimensity 9300 (or comparable)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Bottleneck Most Engineers Misdiagnose
&lt;/h2&gt;

&lt;p&gt;Let me show you a pattern I see in every struggling on-device inference project. Teams drop weights from FP16 to INT8, shave 30% off model size, and still find the attention layer dragging latency. They quantized the wrong thing.&lt;/p&gt;

&lt;p&gt;Here is the actual diagnosis. Qualcomm places Adreno 750 FP32 throughput at approximately &lt;strong&gt;1.9 TFLOPS&lt;/strong&gt;. Peak LPDDR5X bandwidth sits around &lt;strong&gt;77 GB/s&lt;/strong&gt;. A naive multi-head attention at sequence length 1024 makes &lt;strong&gt;three full DRAM round-trips per attention layer&lt;/strong&gt; — write QK^T scores, read for softmax, read again for V multiplication. Your execution units are not starved for work. They are stalled on memory transactions.&lt;/p&gt;

&lt;p&gt;Flash Attention fixes this by keeping intermediate scores in on-chip SRAM and recomputing in fused passes. HBM reads drop from O(n²) to O(n). On Android, we approximate that same guarantee through tiled SGEMM with cache-resident scratch buffers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Allocate Tile Buffers
&lt;/h2&gt;

&lt;p&gt;Break Q, K, and V into row-tiles of size &lt;code&gt;TILE_SIZE&lt;/code&gt;. The score scratch buffer must never touch main memory as a full N×N matrix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;blas&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScriptIntrinsicBLAS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0f&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headDim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFloat&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;qTile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Allocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTyped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createXY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;headDim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;kTile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Allocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTyped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createXY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headDim&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scoreTile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Allocation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTyped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createXY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At &lt;code&gt;TILE_SIZE=64&lt;/code&gt; and &lt;code&gt;HEAD_DIM=128&lt;/code&gt;, &lt;code&gt;scoreTile&lt;/code&gt; is &lt;strong&gt;16 KB&lt;/strong&gt; — fitting entirely in L1 on both Snapdragon 8 Gen 3 and Dimensity 9300.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — The Tile Loop with Online Softmax
&lt;/h2&gt;

&lt;p&gt;Here is the minimal setup to get this working. The key is maintaining running accumulators &lt;code&gt;m_i&lt;/code&gt; (running max) and &lt;code&gt;l_i&lt;/code&gt; (running normalization sum) so you never need the full N×N score matrix in memory simultaneously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qi&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;seqLen&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;loadQTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qTile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;mi&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NEGATIVE_INFINITY&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;li&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0f&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;outputTile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FloatArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tileSize&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;headDim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;0f&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ki&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;seqLen&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;loadKTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ki&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kTile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;blas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SGEMM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ScriptIntrinsicBLAS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NO_TRANSPOSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;ScriptIntrinsicBLAS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TRANSPOSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qTile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kTile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="mf"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scoreTile&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FloatArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tileSize&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;scoreTile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;miNew&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;liNew&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mi&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;miNew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sumOf&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;miNew&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDouble&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;toFloat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;vTile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadVTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ki&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rescale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mi&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;miNew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="n"&gt;headDim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;outputTile&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;headDim&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                    &lt;span class="n"&gt;rescale&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;outputTile&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;headDim&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
                    &lt;span class="nf"&gt;dotVRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vTile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tileSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;miNew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;mi&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;miNew&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;liNew&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;outputTile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;outputTile&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;/=&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt;
    &lt;span class="nf"&gt;writeOutputTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputTile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Benchmark Results
&lt;/h2&gt;

&lt;p&gt;Tested against a naive FP32 baseline (full score matrix materialized to LPDDR5X), 32-head attention at HEAD_DIM=128. Median of 20 sustained runs, thermal throttling confirmed absent via hardware performance counters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SoC&lt;/th&gt;
&lt;th&gt;Seq Length&lt;/th&gt;
&lt;th&gt;Naive BW (GB/s)&lt;/th&gt;
&lt;th&gt;Tiled BW (GB/s)&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Snapdragon 8 Gen 3&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;38.4&lt;/td&gt;
&lt;td&gt;23.1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapdragon 8 Gen 3&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;61.7&lt;/td&gt;
&lt;td&gt;29.6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;52%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dimensity 9300&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;35.1&lt;/td&gt;
&lt;td&gt;21.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dimensity 9300&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;57.8&lt;/td&gt;
&lt;td&gt;26.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Savings compound with sequence length — exactly what the O(n²) → O(n) HBM read reduction predicts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tile size is not a free parameter.&lt;/strong&gt; This is the gotcha that will save you hours. Too small and you underutilize SGEMM's vectorized paths. Too large and your score buffer spills from L1 to L2, immediately collapsing the bandwidth advantage. &lt;code&gt;TILE_SIZE=64&lt;/code&gt; was the empirical sweet spot on both test SoCs. The docs do not mention this, but a tile size tuned on Snapdragon is not portable to Dimensity without re-measurement. Always profile per target device with Snapdragon Profiler or Mali Graphics Debugger before shipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure bandwidth first.&lt;/strong&gt; Above 70% of peak DRAM utilization, you are memory-bound. Tiling is your lever, not quantization or kernel fusion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If you are starting a new project today, skip RenderScript and go straight to Vulkan compute — the online softmax accumulator pattern and tile loop above port without modification, and Vulkan workgroup shared memory gives you explicit control over on-chip residency that RenderScript only approximates.&lt;/p&gt;

&lt;p&gt;The core insight stands regardless of API: measure memory bandwidth, size tiles to L1, and let the O(n²) → O(n) HBM reduction do its work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; &lt;a href="https://developer.qualcomm.com/software/snapdragon-profiler" rel="noopener noreferrer"&gt;Snapdragon Profiler docs&lt;/a&gt; · &lt;a href="https://arxiv.org/abs/2205.14135" rel="noopener noreferrer"&gt;Flash Attention paper (Dao et al.)&lt;/a&gt; · &lt;a href="https://developer.android.com/ndk/guides/graphics/getting-started" rel="noopener noreferrer"&gt;Android Vulkan compute guide&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgreSQL Connection Pooling Deep Dive</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:27:26 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/postgresql-connection-pooling-deep-dive-113i</link>
      <guid>https://dev.to/software_mvp-factory/postgresql-connection-pooling-deep-dive-113i</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PgBouncer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Transaction&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Mode&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;50k&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Concurrent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Mobile&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Users"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Master&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PgBouncer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;transaction-mode&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pooling&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mobile&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backends&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;avoid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prepared&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;statement&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pitfalls,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tune&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pool&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;config,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;catch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pool&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exhaustion&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;it&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pages&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;you&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3am."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql, api, mobile, architecture&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/pgbouncer-transaction-mode-mobile-scale&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What We Are Building&lt;/span&gt;

By the end of this tutorial, you will have a production-ready PgBouncer configuration that handles 50k concurrent mobile users on 4 DB cores, with ORM patches applied, and monitoring queries that catch pool exhaustion 3–5 minutes before your error rates spike.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; PostgreSQL running (any recent version)
&lt;span class="p"&gt;-&lt;/span&gt; PgBouncer installed (&lt;span class="sb"&gt;`apt install pgbouncer`&lt;/span&gt; or equivalent)
&lt;span class="p"&gt;-&lt;/span&gt; Basic familiarity with connection strings and INI config files
&lt;span class="p"&gt;-&lt;/span&gt; An ORM in your stack — Kotlin with Exposed/JDBC, or Python with SQLAlchemy
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Why Connection Pooling Is Non-Negotiable at This Scale&lt;/span&gt;

PostgreSQL creates one OS process per connection. At 50k concurrent mobile clients — even if only 10% are active — you are looking at 5,000 backend processes consuming 5–10MB of RAM each. On a 4-core machine with 32GB RAM, that ceiling arrives fast.

| Approach | Connections to DB | RAM overhead | 4-core feasibility |
|---|---|---|---|
| Direct connections | ~5,000 | ~25–50GB | No |
| PgBouncer session mode | ~500 | ~2.5–5GB | Marginal |
| PgBouncer transaction mode | ~50–100 | ~250–500MB | Yes |

Transaction mode is the only path that makes this work. It also comes with sharp edges — let me walk you through every one of them.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1: The Exact PgBouncer Configuration&lt;/span&gt;

A Tuesday morning traffic spike took our API down in under 4 minutes. The culprit was a default PgBouncer config with &lt;span class="sb"&gt;`max_client_conn`&lt;/span&gt; bumped and nothing else changed. Here is what we settled on after that incident:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
ini&lt;br&gt;
[pgbouncer]&lt;br&gt;
pool_mode = transaction&lt;br&gt;
max_client_conn = 10000&lt;br&gt;
default_pool_size = 80&lt;br&gt;
reserve_pool_size = 20&lt;br&gt;
reserve_pool_timeout = 3&lt;br&gt;
max_db_connections = 100&lt;br&gt;
server_idle_timeout = 600&lt;br&gt;
client_idle_timeout = 60&lt;br&gt;
query_wait_timeout = 30&lt;br&gt;
server_login_retry = 0.5&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Here is why each value matters:

- **`default_pool_size = 80`** — With 4 cores, PostgreSQL's sweet spot is typically 2–4× core count for CPU-bound queries. Mobile API queries are often IO-bound, so 80 gives headroom without thrashing.
- **`max_client_conn = 10000`** — Mobile clients reconnect aggressively on app resume. This absorbs bursts without rejecting connections at the load balancer.
- **`reserve_pool_size = 20`** — Your 3am insurance. When the main pool saturates, these connections handle the surge while your alert fires.
- **`query_wait_timeout = 30`** — Fail fast rather than queue forever. Mobile clients will retry; zombie queued connections serve nobody.
- **`client_idle_timeout = 60`** — The docs do not mention this, but setting it to `0` lets stale connections accumulate when mobile clients background the app without closing sockets cleanly. Under 30s causes excessive reconnect churn given how aggressively mobile OSes cycle network state. 60 seconds is the middle ground.

---

## Step 2: Fix the Prepared Statement Problem

Here is the gotcha that will save you hours.

Named prepared statements (`PREPARE stmt AS SELECT ...`) are session-scoped in PostgreSQL. In transaction mode, the connection returned to the pool after each transaction may be a physically different connection next time. Your `EXECUTE stmt` lands on a connection that has never seen that `PREPARE` — and you get:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
plaintext&lt;br&gt;
ERROR: prepared statement "stmt" does not exist&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
ORMs are the worst offenders. Hibernate, SQLAlchemy with psycopg3, and many others use named prepared statements by default. This breaks silently in staging and catastrophically under production load.

**Fix for Python (SQLAlchemy + psycopg3):**

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
engine = create_engine(&lt;br&gt;
    DATABASE_URL,&lt;br&gt;
    connect_args={"prepare_threshold": None}  # psycopg3 only&lt;br&gt;
)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&amp;gt; **psycopg2 note:** psycopg2 does not use named server-side prepared statements by default, so no special configuration is required. The issue is specific to psycopg3 and drivers like asyncpg that use `PREPARE` explicitly.

**Fix for Kotlin (Exposed / JDBC):**

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val url = "jdbc:postgresql://host/db?prepareThreshold=0"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Let me show you a pattern I use in every project — apply this at the connection pool level so it is impossible to miss in code review.

---

## Step 3: Understand the Mode Tradeoffs

| Feature | Session mode | Transaction mode | Statement mode |
|---|---|---|---|
| Named prepared statements | Works | Breaks | Breaks |
| Advisory locks | Works | Breaks | Breaks |
| `SET` variables | Persists | Lost | Lost |
| `LISTEN/NOTIFY` | Works | Breaks | Breaks |
| Multiplexing efficiency | Low | High | Highest |
| Mobile API suitability | Poor | Excellent | Avoid |

Session mode is what you reach for when you cannot refactor the ORM — but it kills your multiplexing ratio. Statement mode is rarely correct for anything beyond read-only analytics. For stateless mobile API backends, transaction mode is the right call.

---

## Step 4: Monitor Before It Pages You

Here is the minimal setup to get early warning on pool exhaustion.

**Against the PgBouncer virtual database:**

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
-- Watch sv_used approaching max_client_conn&lt;br&gt;
-- cl_waiting is your canary metric&lt;br&gt;
SHOW POOLS;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
**Against PostgreSQL directly:**

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
SELECT&lt;br&gt;
  state,&lt;br&gt;
  wait_event_type,&lt;br&gt;
  wait_event,&lt;br&gt;
  count(*) AS count&lt;br&gt;
FROM pg_stat_activity&lt;br&gt;
WHERE datname = 'your_db'&lt;br&gt;
GROUP BY 1, 2, 3&lt;br&gt;
ORDER BY 4 DESC;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Set an alert when `cl_waiting` exceeds 50 for more than 30 seconds. Pool exhaustion typically appears there 3–5 minutes before error rates spike in your API metrics — that window is your incident prevention.

---

## Gotchas

1. **Prepared statements are silent in staging.** Your test database has low concurrency, so connections are rarely reused mid-session. You will not see `prepared statement does not exist` until production load forces multiplexing. Apply `prepareThreshold=0` before you deploy, not after.

2. **Advisory locks disappear.** If your application uses `pg_advisory_lock()` for distributed locking, transaction mode will break it — the lock releases when the connection returns to the pool, not when your application logic expects. Use a Redis-based lock or refactor to explicit locking within a single transaction.

3. **`SET` variable state is lost.** If you set `search_path` or session-level configuration variables, those are gone after each transaction. Set them at the database or role level instead.

4. **Pool sizing under mobile traffic is not steady-state.** Mobile clients burst hard on app resume — size and benchmark under that pattern, not synthetic constant load. Start at 2–4× core count and tune from real traffic data.

---

## Conclusion

Transaction-mode PgBouncer is the difference between a 4-core database server handling 50k mobile users and one that falls over Tuesday morning. The configuration is straightforward once you know the defaults that will hurt you — `client_idle_timeout`, pool sizing, and prepared statement caching are the three places where most tutorials leave you exposed.

Apply `prepareThreshold=0` at the driver level, alert on `cl_waiting &amp;gt; 50`, and you have a setup that handles traffic spikes without a 3am page.

**Relevant docs:**
- [PgBouncer configuration reference](https://www.pgbouncer.org/config.html)
- [psycopg3 prepare_threshold](https://www.psycopg.org/psycopg3/docs/api/connections.html)
- [PostgreSQL pg_stat_activity](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Prefill Caching for On-Device LLMs: Reusing KV States Across Sessions to Cut First-Token Latency by 60% on Android</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:15:52 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/prefill-caching-for-on-device-llms-reusing-kv-states-across-sessions-to-cut-first-token-latency-by-1eaj</link>
      <guid>https://dev.to/software_mvp-factory/prefill-caching-for-on-device-llms-reusing-kv-states-across-sessions-to-cut-first-token-latency-by-1eaj</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Persistent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;KV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Cut&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;First-Token&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Latency&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;60%"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Learn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;how&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;serialize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;llama.cpp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;KV&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;disk&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fingerprinting&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slash&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;first-token&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;latency&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;60%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on-device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;apps."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android, kotlin, mobile, architecture&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/android-kv-cache-persistence-llm-latency&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What We Are Building&lt;/span&gt;

By the end of this tutorial, you will have a working Android implementation that persists your on-device LLM's KV cache between app sessions. The payoff: first-token latency drops from ~820ms to ~310ms — a 62% reduction — by treating your system prompt as a cacheable asset rather than throwaway computation.

&lt;span class="gs"&gt;**Prerequisites:**&lt;/span&gt; Android project with llama.cpp integrated via JNI, Kotlin, and a model running at Q4_K_M quantization (Llama 3.2 3B is the reference here).
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## The Problem Worth Solving&lt;/span&gt;

Every time your app launches, it re-encodes the entire system prompt from scratch. For a 512-token system prompt on a Pixel 8, that is 600–900ms of pure prefill computation before the user sees a single output token. Content that has not changed since last Tuesday, paid for again in full.

Let me show you a pattern I use in every project that eliminates this cost.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1: Fingerprint Your Prompt&lt;/span&gt;

Before restoring a KV cache, you need to verify it is still valid. Hash the exact byte sequence of your system prompt — including special tokens and chat template formatting — and store it alongside the cache file.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
fun fingerprintPrompt(tokens: IntArray): String {&lt;br&gt;
    val buffer = ByteBuffer.allocate(tokens.size * 4)&lt;br&gt;
    tokens.forEach { buffer.putInt(it) }&lt;br&gt;
    return MessageDigest.getInstance("SHA-256")&lt;br&gt;
        .digest(buffer.array())&lt;br&gt;
        .joinToString("") { "%02x".format(it) }&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
On session start: compute the fingerprint of your intended system prompt. Match → load the cache. No match → re-encode and write a fresh one.

---

## Step 2: Serialize KV State with llama.cpp

llama.cpp exposes `llama_state_save_file` and `llama_state_load_file` for exactly this. The state file encodes the full KV cache for all layers. On a 3B parameter model at Q4_K_M, expect 80–200MB depending on context length.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
// After prefill completes, persist to disk&lt;br&gt;
val cacheFile = File(context.cacheDir, "kv_${fingerprint}.bin")&lt;br&gt;
llamaCpp.saveState(nativeCtx, cacheFile.absolutePath)&lt;/p&gt;

&lt;p&gt;// On next launch, attempt restore&lt;br&gt;
if (cacheFile.exists() &amp;amp;&amp;amp; fingerprintMatches()) {&lt;br&gt;
    llamaCpp.loadState(nativeCtx, cacheFile.absolutePath)&lt;br&gt;
    // Skip prefill entirely — jump straight to generation&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
---

## Step 3: Use mmap for Cold-Load Performance

Loading a 150MB binary synchronously on app startup is painful. The docs do not mention this, but memory-mapping the cache file lets the OS page it in on demand rather than blocking startup.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val channel = FileInputStream(cacheFile).channel&lt;br&gt;
val mapped = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size())&lt;br&gt;
// Pass mapped buffer to JNI layer for state restoration&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The OS faults in pages as the inference engine reads KV values during generation, spreading I/O across the first few output tokens. This also avoids the memory spike a synchronous `read()` into a heap buffer causes — critical on mid-range devices where RAM is tight.

---

## Measured Results

| Scenario | First-Token Latency | Notes |
|---|---|---|
| Cold launch, no cache | 820ms | Full prefill, 512-token system prompt |
| Cold launch, mmap cache restore | 310ms | ~62% reduction |
| Warm launch (in-memory) | 90ms | Already loaded, baseline |
| Cache miss (prompt changed) | 850ms | Re-encode + write new cache file |

---

## Gotchas

**Cache invalidation is not binary.** Most teams get this wrong. If the first 400 tokens of your system prompt are stable but the last 100 are dynamic (injected user context), you can restore the cached KV state for the stable prefix and only re-encode the delta. Store per-token-range fingerprints alongside the full-cache fingerprint — it adds ~2KB of metadata and cuts re-encode cost proportionally to the stable prefix ratio.

**Design for prefix stability from day one.** Static portions of your system prompt must come first. Dynamic context (user preferences, session data) should trail the stable prefix. Shuffling this later is painful.

**For cache files above 50MB, mmap is not optional on Android.** The OS may kill your process during a large heap allocation. Memory-mapping sidesteps this entirely.

**62% is the ceiling for this approach.** If you need further gains, look at prefix-span reuse (above) and model quantization tradeoffs — do not chase the last few milliseconds with heroic JNI gymnastics before those levers are exhausted.

---

## Conclusion

Here is the minimal setup to get this working: fingerprint before every prefill, serialize state after every fresh encode, and mmap on restore. Those three steps eliminate the majority of first-token latency in session-persistent LLM apps.

If you are building context-aware tooling developers use throughout the workday — the kind of session-persistent assistant that benefits most from this technique — the 900ms cold-start pause is the first thing users notice. I run [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) alongside my dev work for break reminders and desk exercises; the snappiness of on-device inference matters when the tool is interrupting your flow to tell you to stand up.

**Further reading:**
- [llama.cpp state API](https://github.com/ggerganov/llama.cpp)
- [Android `FileChannel.map()` docs](https://developer.android.com/reference/java/nio/channels/FileChannel)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgreSQL Write-Ahead Log Internals for Zero-Downtime Schema Migrations</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Fri, 10 Jul 2026 13:45:47 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/postgresql-write-ahead-log-internals-for-zero-downtime-schema-migrations-4m5l</link>
      <guid>https://dev.to/software_mvp-factory/postgresql-write-ahead-log-internals-for-zero-downtime-schema-migrations-4m5l</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Zero-Downtime&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PostgreSQL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Schema&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Migrations:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;WAL,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Locks,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Patterns&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;That&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Don't&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Stall&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Production"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Learn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;how&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PostgreSQL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;WAL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MVCC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;interact&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;during&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DDL.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Understand&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ACCESS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;EXCLUSIVE&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lock&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;escalation&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migration&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;patterns&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prevent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;incidents."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql, architecture, devops, performance&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://blog.mvpfactory.co/zero-downtime-postgresql-schema-migrations&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What You Will Learn
&lt;/h2&gt;

&lt;p&gt;By the end of this workshop, you will understand exactly why naive &lt;code&gt;ALTER TABLE&lt;/code&gt; commands cause production incidents, how PostgreSQL's WAL and MVCC interact during DDL, and the exact patterns you need to run schema changes on live tables without stalling reads or writes.&lt;/p&gt;

&lt;p&gt;This is not theoretical. I have seen a 10ms migration cause a 45-second incident. Here is how to never let that happen to you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL 11+ (some behavior differs on earlier versions — I will call it out)&lt;/li&gt;
&lt;li&gt;Basic familiarity with SQL DDL&lt;/li&gt;
&lt;li&gt;A production table you care about&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Problem Most Teams Get Wrong
&lt;/h2&gt;

&lt;p&gt;Here is the assumption that causes incidents:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"MVCC protects us from DDL contention."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does not. MVCC protects you from concurrent &lt;em&gt;data&lt;/em&gt; modifications. It does nothing for schema changes.&lt;/p&gt;

&lt;p&gt;When you run this on a live, busy table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL acquires an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock — the strongest in its eight-tier hierarchy. It conflicts with &lt;em&gt;everything&lt;/em&gt;, including the &lt;code&gt;ACCESS SHARE&lt;/code&gt; locks held by ordinary &lt;code&gt;SELECT&lt;/code&gt; queries.&lt;/p&gt;

&lt;p&gt;The dangerous part is not the lock itself. It is the queue. Here is the exact sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;PostgreSQL requests &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on &lt;code&gt;orders&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An existing &lt;code&gt;SELECT&lt;/code&gt; holds &lt;code&gt;ACCESS SHARE&lt;/code&gt; — the DDL request queues behind it&lt;/li&gt;
&lt;li&gt;Every query arriving after it queues behind the waiting DDL&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;SELECT&lt;/code&gt; finishes; DDL acquires the lock, runs in ~10ms&lt;/li&gt;
&lt;li&gt;Lock releases — 200 queued queries hit simultaneously&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The migration took 10ms. The incident lasted 45 seconds. Your connection pool exhausted before the lock even released.&lt;/p&gt;




&lt;h2&gt;
  
  
  How WAL and MVCC Actually Interact During DDL
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's Write-Ahead Log records every change — data modifications, index updates, DDL operations — before applying them to data files. This guarantees durability and powers point-in-time recovery.&lt;/p&gt;

&lt;p&gt;MVCC gives each transaction a consistent snapshot. Readers see the row version that existed when their transaction started. Writers create new versions rather than modifying in place. This works beautifully for &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;INSERT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But DDL modifies the system &lt;em&gt;catalog&lt;/em&gt;, not row versions. Catalog changes must be serialized — there is no safe "in-between" version of a table schema. &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; is the enforcement mechanism. No way around it; only ways to minimize the window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step-by-Step: Migration Patterns That Don't Block
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Always Set lock_timeout First
&lt;/h3&gt;

&lt;p&gt;Let me show you a pattern I use in every project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail fast, never queue. A migration that times out is recoverable. A migration that queues is an incident. Set this before every DDL statement on a live system — no exceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Safe Column Addition (Pre-PG 11 Compatible)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Step 1: Add nullable column (brief catalog lock, no table rewrite)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Step 2: Backfill in batches using keyset pagination&lt;/span&gt;
&lt;span class="c1"&gt;-- last_id = 0&lt;/span&gt;
&lt;span class="c1"&gt;-- WHILE rows_affected &amp;gt; 0:&lt;/span&gt;
&lt;span class="c1"&gt;--   UPDATE orders&lt;/span&gt;
&lt;span class="c1"&gt;--     SET metadata = '{}'&lt;/span&gt;
&lt;span class="c1"&gt;--   WHERE id &amp;gt; $last_id&lt;/span&gt;
&lt;span class="c1"&gt;--     AND id &amp;lt;= $last_id + 10000&lt;/span&gt;
&lt;span class="c1"&gt;--     AND metadata IS NULL;&lt;/span&gt;
&lt;span class="c1"&gt;--   last_id += 10000&lt;/span&gt;
&lt;span class="c1"&gt;--   sleep(100ms)  -- throttle between batches&lt;/span&gt;

&lt;span class="c1"&gt;-- Step 3: Set default for future rows (brief lock)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs do not always make this clear, but in PostgreSQL 11+, adding a column with a non-volatile default no longer rewrites the table — the default is stored in &lt;code&gt;pg_attribute&lt;/code&gt; and materialized on read. This eliminates one of the most common migration incidents entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Index Creation: No Exceptions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Never on production:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Always:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; uses &lt;code&gt;ShareUpdateExclusiveLock&lt;/code&gt;, which is why it does not stall reads or writes. The lock conflict matrix makes this clear: &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; blocks everything; &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; blocks almost nothing in normal operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Structural Changes on Large Tables
&lt;/h3&gt;

&lt;p&gt;For column type changes or constraint additions on large tables, pg_repack rebuilds the table as an online copy, then performs an atomic swap with a brief lock at the end. On a 500GB table, naive &lt;code&gt;ALTER COLUMN TYPE&lt;/code&gt; can hold &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; for 20+ minutes. pg_repack reduces that final lock window to under a second — but plan for the disk headroom and job duration upfront.&lt;/p&gt;

&lt;p&gt;For the highest-stakes migrations: apply the change on a replica, redirect traffic, promote. The &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; window shrinks to the cutover itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The queue is invisible until it is too late.&lt;/strong&gt; Your monitoring will not show the DDL as slow — it is waiting, not running. Watch &lt;code&gt;pg_stat_activity&lt;/code&gt; for &lt;code&gt;state = 'idle in transaction'&lt;/code&gt; and &lt;code&gt;wait_event_type = 'Lock'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batching with &lt;code&gt;BETWEEN&lt;/code&gt; and hardcoded ranges breaks on tables with gaps.&lt;/strong&gt; Use keyset pagination (&lt;code&gt;id &amp;gt; $last_id&lt;/code&gt;) as shown above. It advances through actual existing rows safely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pg_repack is not zero-configuration.&lt;/strong&gt; It needs roughly the same free disk space as the table it rebuilds. Underestimating this has caused more than one well-planned migration to fail mid-run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lock_timeout&lt;/code&gt; does not retry for you.&lt;/strong&gt; Set it, catch the error in your migration tooling, and retry at a quieter moment. This is recoverable. Queuing is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Three rules to carry forward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;SET lock_timeout&lt;/code&gt; before every DDL statement on a live system&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; without exception&lt;/li&gt;
&lt;li&gt;For structural changes on large tables, evaluate pg_repack or a logical replication swap&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the minimal setup to get this working in any migration tool: run each DDL statement in its own transaction, set &lt;code&gt;lock_timeout&lt;/code&gt; at the session level, and never let a column backfill happen inside the same transaction as the &lt;code&gt;ALTER TABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Between long migration runs, this is genuinely a good moment to step away from the screen. I use &lt;a href="https://play.google.com/store/apps/details?id=com.healthydesk" rel="noopener noreferrer"&gt;HealthyDesk&lt;/a&gt; for guided desk stretches — waiting on pg_repack to rebuild a 400GB table is exactly the right time for a break reminder.&lt;/p&gt;

&lt;p&gt;For further reading: the &lt;a href="https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-TABLES" rel="noopener noreferrer"&gt;PostgreSQL explicit locking documentation&lt;/a&gt; has the full 8×8 lock conflict matrix. It is worth bookmarking before your next schema migration.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>gRPC-Web and Connect Protocol on Mobile: Replacing REST with Type-Safe Streaming APIs in Android and iOS Without a Proxy</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Fri, 10 Jul 2026 12:13:45 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/grpc-web-and-connect-protocol-on-mobile-replacing-rest-with-type-safe-streaming-apis-in-android-470j</link>
      <guid>https://dev.to/software_mvp-factory/grpc-web-and-connect-protocol-on-mobile-replacing-rest-with-type-safe-streaming-apis-in-android-470j</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drop&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Envoy:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Native&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gRPC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Streaming&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;iOS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Connect&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Protocol"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Eliminate&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gRPC-Web&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;proxy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;switching&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Connect&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Protocol.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Full&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;protobuf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;codegen,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HTTP/2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bidirectional&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;streaming,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;composable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;interceptors&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Envoy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sidecar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;required."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kotlin, android, ios, api&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://mvpfactory.co/blog/grpc-connect-protocol-android-ios&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## What We Are Building&lt;/span&gt;

By the end of this, you will have replaced your Envoy sidecar with Connect Protocol on both Android and iOS — getting native HTTP/2 bidirectional streaming, compile-time type safety from protobuf codegen, and a composable interceptor pipeline for auth, retry, and observability. No proxy. No workarounds.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; An existing gRPC or REST API backend
&lt;span class="p"&gt;-&lt;/span&gt; Android project using Gradle (Kotlin DSL)
&lt;span class="p"&gt;-&lt;/span&gt; iOS project using Swift Package Manager
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`buf`&lt;/span&gt; CLI installed for proto generation
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1: Why You Are Paying the Proxy Tax&lt;/span&gt;

Standard gRPC-Web forces this architecture:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mobile client → Envoy (translate framing) → gRPC backend&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;
&lt;span class="nc"&gt;That&lt;/span&gt; &lt;span class="nc"&gt;Envoy&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="n"&gt;operational&lt;/span&gt; &lt;span class="n"&gt;burden&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;another&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;gRPC-Web&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;because&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="n"&gt;browsers&lt;/span&gt;&lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cannot&lt;/span&gt; &lt;span class="n"&gt;speak&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="nc"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;framing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;Mobile&lt;/span&gt; &lt;span class="n"&gt;apps&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;Most&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt; &lt;span class="n"&gt;cargo-cult&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt; &lt;span class="n"&gt;anyway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nc"&gt;Connect&lt;/span&gt; &lt;span class="nc"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maintained&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nc"&gt;Buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fixes&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;properly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;It&lt;/span&gt; &lt;span class="n"&gt;defines&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;lightweight&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="nc"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="nc"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;both&lt;/span&gt; &lt;span class="n"&gt;browser-friendly&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;natively&lt;/span&gt; &lt;span class="n"&gt;gRPC-compatible&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;sidecar&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="p"&gt;---&lt;/span&gt;

&lt;span class="err"&gt;##&lt;/span&gt; &lt;span class="nc"&gt;Step&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Protobuf&lt;/span&gt; &lt;span class="nc"&gt;Codegen&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="nc"&gt;Android&lt;/span&gt;

&lt;span class="nc"&gt;Here&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;minimal&lt;/span&gt; &lt;span class="n"&gt;setup&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;working&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nc"&gt;Gradle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
// build.gradle.kts&lt;br&gt;
plugins {&lt;br&gt;
    id("com.google.protobuf") version "0.9.4"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;dependencies {&lt;br&gt;
    implementation("com.connectrpc:connect-kotlin-okhttp:0.6.0")&lt;br&gt;
    implementation("com.connectrpc:connect-kotlin:0.6.0")&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;protobuf {&lt;br&gt;
    protoc { artifact = "com.google.protobuf:protoc:3.25.1" }&lt;br&gt;
    plugins {&lt;br&gt;
        create("connect-kotlin") {&lt;br&gt;
            artifact = "com.connectrpc:protoc-gen-connect-kotlin:0.6.0:jvm8@jar"&lt;br&gt;
        }&lt;br&gt;
    }&lt;br&gt;
    generateProtoTasks {&lt;br&gt;
        all().forEach { task -&amp;gt;&lt;br&gt;
            task.plugins { create("connect-kotlin") }&lt;br&gt;
        }&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
This generates both protobuf models and Connect-flavored service stubs in a single Gradle task. No separate script step.

&amp;gt; Pin versions against the [Connect-Kotlin releases page](https://github.com/connectrpc/connect-kotlin/releases) before shipping.

---

## Step 3: Codegen on iOS with Swift Package Manager

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
swift&lt;br&gt;
// Package.swift&lt;br&gt;
dependencies: [&lt;br&gt;
    .package(url: "&lt;a href="https://github.com/connectrpc/connect-swift" rel="noopener noreferrer"&gt;https://github.com/connectrpc/connect-swift&lt;/a&gt;", from: "0.12.0"),&lt;br&gt;
],&lt;br&gt;
targets: [&lt;br&gt;
    .target(name: "MyApp", dependencies: ["Connect"])&lt;br&gt;
]&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Proto generation on iOS uses `buf generate` with the `connect-swift` plugin. Wire it into an Xcode build phase or Makefile pre-build step — it outputs typed `ProtocolClient` stubs directly.

&amp;gt; Pin versions against the [Connect-Swift releases page](https://github.com/connectrpc/connect-swift/releases).

---

## Step 4: Bidirectional Streaming Over HTTP/2

This is the part that makes the switch worth it. Here is a live bidirectional stream in Connect-Kotlin:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val stream = client.eliza().converse()&lt;/p&gt;

&lt;p&gt;launch {&lt;br&gt;
    stream.responseChannel().collect { response -&amp;gt;&lt;br&gt;
        println(response.sentence)&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;stream.send(ConverseRequest { sentence = "Hello" })&lt;br&gt;
stream.close()&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Not long polling. Not chunked SSE. Both send and receive channels are live simultaneously over a single HTTP/2 stream. URLSession on iOS exposes the same primitive via Connect-Swift's `BidirectionalStreamInterface`.

---

## Step 5: The Interceptor Pipeline

Let me show you a pattern I use in every project. Cross-cutting concerns — auth, retry, observability — belong in the protocol layer, not scattered across call sites:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val client = ProtocolClient(&lt;br&gt;
    httpClient = ConnectOkHttpClient(OkHttpClient()),&lt;br&gt;
    config = ProtocolClientConfig(&lt;br&gt;
        host = "&lt;a href="https://api.example.com" rel="noopener noreferrer"&gt;https://api.example.com&lt;/a&gt;",&lt;br&gt;
        serializationStrategy = GoogleJavaProtobufStrategy(),&lt;br&gt;
        interceptors = listOf(&lt;br&gt;
            ::AuthInterceptor,&lt;br&gt;
            ::RetryInterceptor,&lt;br&gt;
            ::TracingInterceptor&lt;br&gt;
        )&lt;br&gt;
    )&lt;br&gt;
)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
No Envoy filter chains to configure. No sidecar YAML to maintain.

---

## Connect vs REST: Why the Numbers Matter

Google's protobuf benchmarks show serialization running 3–10× faster than JSON, with payloads 20–60% smaller. A typical list of 50 user records runs ~1.2 KB protobuf vs ~3.8 KB JSON — a difference that compounds under mobile data constraints.

| Dimension | OkHttp REST (JSON) | Connect-Kotlin |
|---|---|---|
| Payload size | Verbose JSON | Binary protobuf (~3–5× smaller) |
| Type safety | Runtime (Gson/Moshi) | Compile-time (proto) |
| Streaming | Polling or SSE workarounds | Native bidirectional |
| Code generation | Manual or OpenAPI | `buf generate` → stubs |
| Proxy required | No | No |

HTTP/2 eliminates HTTP-level head-of-line blocking, meaningfully reducing latency variance on lossy LTE connections. TCP-level head-of-line blocking still requires HTTP/3 over QUIC to fully resolve — but removing the HTTP layer alone is a real win.

---

## Gotchas

**Version drift is silent.** The artifact versions in these examples will drift. A mismatched `protoc-gen-connect-kotlin` version generates stubs that compile but misbehave at runtime. Pin against the release pages before shipping.

**Do not commit generated files.** Treat `buf generate` output as a CI artifact. Both Android and iOS can generate typed clients from the same `.proto` source of truth — there is no reason to check in generated stubs.

**Audit your Envoy topology first.** Here is the gotcha that will save you hours: if Envoy exists solely to serve mobile clients rather than browsers, it is a removal candidate. If it serves both, keep it for web and let mobile bypass it with Connect's native HTTP/2 transport.

---

## Conclusion

Connect Protocol is not a workaround — it is the correct architecture for mobile gRPC today. The proxy was always a concession to browser limitations. Mobile clients never had that limitation.

Start by auditing whether Envoy is there for browsers or for mobile. If mobile is the only client, remove it. Wire codegen into CI, centralize cross-cutting concerns in the interceptor chain, and let the type system catch API contract mismatches at compile time instead of at 2 AM on-call.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
