<?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>Wiring Android's ML Kit Translator to a Quantized On-Device LLM for Context-Aware Translation</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:34:47 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-ml-kit-translator-to-a-quantized-on-device-llm-for-context-aware-translation-3c4</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-ml-kit-translator-to-a-quantized-on-device-llm-for-context-aware-translation-3c4</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;Context-aware&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;translation:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;wiring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ML&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Kit&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;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quantized&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="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;Wire&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ML&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Kit's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;language&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;detection&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;translation&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;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quantized&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;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;context-sensitive&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;translation.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Covers&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;alignment,&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;model&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;loading,&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;sub-80ms&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;batching."&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-mlkit-on-device-llm-translation&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

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

We are wiring ML Kit's &lt;span class="sb"&gt;`LanguageIdentifier`&lt;/span&gt; and &lt;span class="sb"&gt;`Translator`&lt;/span&gt; to a 4-bit INT4 quantized LLM running via MediaPipe's LLM Inference API or ExecuTorch. The result is a context-aware translation pipeline that understands tone, domain, and discourse — no network call required.

Here is the pipeline at a glance:

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

&lt;/div&gt;



&lt;p&gt;Input Text&lt;br&gt;
    │&lt;br&gt;
    ▼&lt;br&gt;
ML Kit LanguageIdentifier          (~3–5ms)&lt;br&gt;
    │&lt;br&gt;
    ▼&lt;br&gt;
ML Kit Translator (source → pivot) (~15–25ms)&lt;br&gt;
    │&lt;br&gt;
    ▼&lt;br&gt;
Embedding Adapter Layer            (~5ms)&lt;br&gt;
    │&lt;br&gt;
    ▼&lt;br&gt;
Quantized LLM (context pass)       (~35–50ms)&lt;br&gt;
    │&lt;br&gt;
    ▼&lt;br&gt;
Final Translation Output&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;Target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;under&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;paragraph-length&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;benchmarked&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="nc"&gt;Snapdragon&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="nc"&gt;Gen&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="nf"&gt;device&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nc"&gt;GB&lt;/span&gt; &lt;span class="nc"&gt;RAM&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;

&lt;span class="err"&gt;##&lt;/span&gt; &lt;span class="nc"&gt;Prerequisites&lt;/span&gt;

&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Android&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="n"&gt;targeting&lt;/span&gt; &lt;span class="nc"&gt;API&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;+&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;ML&lt;/span&gt; &lt;span class="nc"&gt;Kit&lt;/span&gt; &lt;span class="nc"&gt;Translate&lt;/span&gt; &lt;span class="n"&gt;dependency&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;MediaPipe&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;ExecuTorch&lt;/span&gt; &lt;span class="n"&gt;integrated&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;on-device&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt; &lt;span class="n"&gt;inference&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;bit&lt;/span&gt; &lt;span class="nc"&gt;INT4&lt;/span&gt; &lt;span class="n"&gt;quantized&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt; &lt;span class="p"&gt;(~&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="err"&gt;–&lt;/span&gt;&lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="nc"&gt;MB&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;six&lt;/span&gt; &lt;span class="n"&gt;languages&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;English&lt;/span&gt; &lt;span class="n"&gt;pivot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="n"&gt;trained&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt; &lt;span class="nc"&gt;TFLite&lt;/span&gt; &lt;span class="nc"&gt;FlatBuffer&lt;/span&gt; &lt;span class="p"&gt;(~&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nc"&gt;MB&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;Treat&lt;/span&gt; &lt;span class="nc"&gt;ML&lt;/span&gt; &lt;span class="nc"&gt;Kit&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;preprocessor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;

&lt;span class="nc"&gt;Most&lt;/span&gt; &lt;span class="n"&gt;teams&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;wrong&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;they&lt;/span&gt; &lt;span class="n"&gt;treat&lt;/span&gt; &lt;span class="nc"&gt;ML&lt;/span&gt; &lt;span class="nc"&gt;Kit&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="n"&gt;translation&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;ML&lt;/span&gt; &lt;span class="nc"&gt;Kit&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;context-blind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="s"&gt;"Bank"&lt;/span&gt; &lt;span class="n"&gt;gets&lt;/span&gt; &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="n"&gt;identically&lt;/span&gt; &lt;span class="n"&gt;whether&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;surrounding&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;about&lt;/span&gt; &lt;span class="n"&gt;finance&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;riverbank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nc"&gt;Use&lt;/span&gt; &lt;span class="nc"&gt;English&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;universal&lt;/span&gt; &lt;span class="n"&gt;pivot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;ML&lt;/span&gt; &lt;span class="nc"&gt;Kit&lt;/span&gt; &lt;span class="n"&gt;identifies&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;translates&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nc"&gt;English&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;the&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt; &lt;span class="n"&gt;performs&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;context-sensitive&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="n"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;This&lt;/span&gt; &lt;span class="n"&gt;collapses&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nc"&gt;O&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="err"&gt;²&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nc"&gt;O&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;architecture&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt; &lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="nc"&gt;MB&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nc"&gt;At&lt;/span&gt; &lt;span class="n"&gt;six&lt;/span&gt; &lt;span class="n"&gt;supported&lt;/span&gt; &lt;span class="n"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direct&lt;/span&gt; &lt;span class="n"&gt;pairing&lt;/span&gt; &lt;span class="n"&gt;requires&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;The&lt;/span&gt; &lt;span class="n"&gt;pivot&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt; &lt;span class="n"&gt;requires&lt;/span&gt; &lt;span class="n"&gt;six&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;freeing&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="nc"&gt;MB&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;quantized&lt;/span&gt; &lt;span class="nc"&gt;LLM&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;Build&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;adapter&lt;/span&gt;

&lt;span class="nc"&gt;ML&lt;/span&gt; &lt;span class="nc"&gt;Kit&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="n"&gt;representations&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;completely&lt;/span&gt; &lt;span class="n"&gt;different&lt;/span&gt; &lt;span class="n"&gt;manifolds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;You&lt;/span&gt; &lt;span class="n"&gt;cannot&lt;/span&gt; &lt;span class="n"&gt;pass&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;correctly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nc"&gt;Train&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;small&lt;/span&gt; &lt;span class="n"&gt;projection&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;parallel&lt;/span&gt; &lt;span class="n"&gt;multilingual&lt;/span&gt; &lt;span class="nf"&gt;corpus&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CCAligned&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;FLORES-200&lt;/span&gt; &lt;span class="n"&gt;held-out&lt;/span&gt; &lt;span class="n"&gt;splits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MSE&lt;/span&gt; &lt;span class="n"&gt;loss&lt;/span&gt; &lt;span class="n"&gt;against&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt; &lt;span class="nc"&gt;Compile&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nc"&gt;MB&lt;/span&gt; &lt;span class="nc"&gt;TFLite&lt;/span&gt; &lt;span class="nc"&gt;FlatBuffer&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;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="p"&gt;:&lt;/span&gt;

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
class EmbeddingAdapter(private val interpreter: Interpreter) {&lt;br&gt;
    fun adapt(translationResult: TranslationResult): FloatArray {&lt;br&gt;
        val input = floatArrayOf(&lt;br&gt;
            translationResult.confidence,&lt;br&gt;
            translationResult.segmentCount.toFloat(),&lt;br&gt;
            // domain embedding from ML Kit metadata&lt;br&gt;
        )&lt;br&gt;
        val output = Array(1) { FloatArray(ADAPTER_DIM) }&lt;br&gt;
        interpreter.run(input, output)&lt;br&gt;
        return output[0]&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 adds ~5ms and lets the LLM condition on translation provenance rather than treating pivot output as raw user text.

## Step 3: Batch at sentence boundaries, infer once

Paragraph input breaks the single-pass assumption. Split at sentence boundaries using ML Kit's `EntityExtraction`, run translations in parallel with coroutines, then feed the LLM a single concatenated context window:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val translations = segments.map { segment -&amp;gt;&lt;br&gt;
    async(Dispatchers.Default) { translateSegment(segment) }&lt;br&gt;
}.awaitAll()&lt;/p&gt;

&lt;p&gt;val contextWindow = buildContextWindow(translations, adapterOutput)&lt;br&gt;
llmInference.generateAsync(contextWindow, ::onToken)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The LLM sees full paragraph context in one inference call. That is how you recover discourse-level accuracy without multiplying inference calls.

## Step 4: Prefetch model pairs inside the memory ceiling

Resident memory for one language pair plus the LLM sits around 280MB on a mid-range Snapdragon 7 Gen 1 device. Use LRU eviction keyed on session language frequency and load pairs asynchronously via `WorkManager`:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val prefetchRequest = OneTimeWorkRequestBuilder()&lt;br&gt;
    .setConstraints(Constraints.Builder()&lt;br&gt;
        .setRequiresCharging(false)&lt;br&gt;
        .setRequiredNetworkType(NetworkType.CONNECTED)&lt;br&gt;
        .build())&lt;br&gt;
    .build()&lt;/p&gt;

&lt;p&gt;WorkManager.getInstance(context).enqueueUniqueWork(&lt;br&gt;
    "model_prefetch", ExistingWorkPolicy.KEEP, prefetchRequest&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 long-session apps — I have [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) running during my work sessions, which means the app stays foregrounded for hours — the idle prefetch window is generous and the LRU cold-start penalty only surfaces on first launch or after an extended idle period.

## Gotchas

**Low ML Kit confidence breaks adapter conditioning.** Gate adapter use on a 0.7 confidence threshold. Below that, skip the LLM pass entirely and return raw ML Kit output. The adapter was trained on high-confidence pairs; feeding it low-signal input degrades LLM conditioning and can produce output worse than the baseline pivot alone.

**The LLM over-corrects proper nouns and numbers.** The docs do not mention this, but quantized models occasionally hallucinate corrections on named entities and numeric strings. Here is the gotcha that will save you hours: run a lightweight post-pass that diffs LLM output against the ML Kit baseline and falls back for any segment where entities or numbers diverge:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
fun safeContextPass(mlKitOutput: String, llmOutput: String): String {&lt;br&gt;
    val entities = extractEntities(mlKitOutput)&lt;br&gt;
    return if (llmOutput.preservesEntities(entities)) llmOutput else mlKitOutput&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;
**Direct model pairing does not scale.** At five supported languages, direct pairing hits the 500MB ceiling. The pivot strategy is not a compromise — it is the only viable architecture at that count.

## Conclusion

Let me show you the pattern I use in every on-device translation project: ML Kit as a fast preprocessor; a trained adapter layer that bridges embedding spaces; one LLM inference call over the full paragraph context; and a confidence-gated fallback that keeps the system honest.

Benchmarked on Snapdragon 7 Gen 1: 58–85ms end-to-end for paragraph-length input. Context-aware, on-device translation with no network dependency and a memory footprint your users will not notice.

**Relevant resources:**
- [ML Kit Translation docs](https://developers.google.com/ml-kit/language/translation)
- [MediaPipe LLM Inference API for Android](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android)
- [ExecuTorch for Android](https://pytorch.org/executorch/stable/getting-started-setup.html)
- [CCAligned corpus](https://opus.nlpl.eu/CCAligned.php)
- [FLORES-200 benchmark](https://github.com/facebookresearch/flores)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Wiring Android's Room Database to a Local LLM for Semantic Query Rewriting</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:39:22 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-room-database-to-a-local-llm-for-semantic-query-rewriting-4dho</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-room-database-to-a-local-llm-for-semantic-query-rewriting-4dho</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;Android&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Room&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;On-Device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Embeddings:&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;Hybrid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Search&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Pipeline&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;Hits&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sub-100ms"&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;Wire&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;Room&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;on-device&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;embeddings&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;FTS5&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;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hybrid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;retrieval&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pipeline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hitting&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sub-100ms&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;mid-range&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hardware.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Stop&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;letting&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;keyword&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fail&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;users."&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, architecture, mobile&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/android-room-fts5-on-device-embeddings-hybrid-search&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 hybrid retrieval pipeline for Android: FTS5 for high-recall candidate selection, a quantized on-device embedding model for intent rewriting, and a cosine similarity re-ranker that keeps you in the 50ms tier instead of the 300ms tier. This runs on mid-range hardware, fits a 30MB memory budget, and delivers semantic accuracy that pure SQL cannot touch.&lt;/p&gt;

&lt;p&gt;Let me show you a pattern I use in every project where local search matters.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Android project with Room 2.5+&lt;/li&gt;
&lt;li&gt;ONNX Runtime for Android dependency&lt;/li&gt;
&lt;li&gt;A quantized INT8 embedding model (MiniLM-style, ~23MB after quantization)&lt;/li&gt;
&lt;li&gt;Kotlin coroutines&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1 — The Problem With Pure SQL Search
&lt;/h2&gt;

&lt;p&gt;Most teams treat Room like a key-value store with a WHERE clause. That is the mistake.&lt;/p&gt;

&lt;p&gt;A user typing &lt;em&gt;"meeting notes from last Tuesday about the budget"&lt;/em&gt; returns zero results when your stored record says &lt;em&gt;"Q3 planning session - finance review."&lt;/em&gt; Same semantic content, zero lexical overlap. FTS5 improves recall with BM25 ranking, but it still operates on surface tokens. The moment you introduce synonyms or natural-language phrasing, you are back to zero.&lt;/p&gt;

&lt;p&gt;The fix is semantic query rewriting: intercept the raw query, project it into an embedding space, and use that vector to rerank FTS5 candidates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Define Your FTS5 Virtual Table
&lt;/h2&gt;

&lt;p&gt;FTS5 is your high-recall, low-latency first pass. Note the annotation — &lt;code&gt;@Fts4&lt;/code&gt; without &lt;code&gt;ftsVersion&lt;/code&gt; defaults to FTS4, not FTS5.&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="nd"&gt;@Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tableName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;NoteEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@PrimaryKey&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// ftsVersion = FTS_VERSION_5 required — @Fts4 defaults to FTS4 without it&lt;/span&gt;
&lt;span class="nd"&gt;@Fts4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contentEntity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NoteEntity&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ftsVersion&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FtsOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FTS_VERSION_5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tableName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"notes_fts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;NoteFts&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FTS5 with BM25 on a 100K-row corpus on a mid-range Snapdragon 6-series device completes in the 15–40ms window. That is your floor.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Wire the On-Device Embedding Model
&lt;/h2&gt;

&lt;p&gt;Quantize your model to INT8 before you even think about shipping. A typical 90MB MiniLM-style model drops to ~23MB and inference falls from ~180ms to ~55ms on CPU.&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;EmbeddingEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&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;val&lt;/span&gt; &lt;span class="py"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrtSession&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrtEnvironment&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEnvironment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"minilm_int8.onnx"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;embed&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="nc"&gt;String&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="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;tokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;maxLength&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&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;inputTensor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OnnxTensor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTensor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"input_ids"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;inputTensor&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"last_hidden_state"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meanPooling&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;l2Normalize&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;Store embeddings as &lt;code&gt;BLOB&lt;/code&gt; columns in Room, quantized to INT8 at write time. At 128 dimensions, each vector costs 128 bytes — negligible at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — The Hybrid Ranker
&lt;/h2&gt;

&lt;p&gt;Run FTS5 for the top-50 candidates, then re-rank with cosine similarity against the query embedding. The cosine pass only touches the candidate set, not the full corpus.&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;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;hybridSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NoteEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;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;queryVec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embeddingEngine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedWithCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;ftsResults&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;noteDao&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ftsSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ftsResults&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;storedVec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dequantize&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;score&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosineSimilarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryVec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;storedVec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sortedByDescending&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;second&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;take&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&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;first&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;Here is the minimal LRU cache that eliminates inference on repeated queries — five lines that save you 55ms per hit:&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;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;queryCache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;LinkedHashMap&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&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;&amp;gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;removeEldestEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eldest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&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;&amp;gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;embedWithCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lowercase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;queryCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrPut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;A 100-entry INT8 cache adds ~8MB overhead and collapses cache-hit latency to single-digit milliseconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Latency Reality Check
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;P50 Latency (Snapdragon 6xx)&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pure FTS5 BM25&lt;/td&gt;
&lt;td&gt;20–40ms&lt;/td&gt;
&lt;td&gt;~2MB&lt;/td&gt;
&lt;td&gt;Low–Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pure Vector Scan (full corpus)&lt;/td&gt;
&lt;td&gt;280–400ms&lt;/td&gt;
&lt;td&gt;50–200MB&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid (FTS5 + re-rank top-50)&lt;/td&gt;
&lt;td&gt;55–95ms&lt;/td&gt;
&lt;td&gt;~25MB&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid + result cache&lt;/td&gt;
&lt;td&gt;5–15ms (hit)&lt;/td&gt;
&lt;td&gt;+8MB LRU&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;&lt;strong&gt;Skip the FTS5 recall phase and the query planner destroys you.&lt;/strong&gt; A full in-memory cosine scan over 100K vectors runs 280ms before you add model inference. Keep FTS5 as the gating layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never run embedding inference on the main thread.&lt;/strong&gt; Always dispatch to &lt;code&gt;Dispatchers.Default&lt;/code&gt;. Debounce your query input with a 200ms delay — this alone cuts unnecessary embedding calls by ~60% on incremental search UIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;INT8 quality drop is marginal.&lt;/strong&gt; Typically less than 2% on MTEB benchmarks. The 4x memory savings and 60–70% inference speedup are not optional — they are the difference between shipping and not shipping on mid-range devices.&lt;/p&gt;




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

&lt;p&gt;The hybrid FTS5 + on-device embedding pipeline runs on mid-range Android hardware today, fits within a 30MB memory budget, and delivers retrieval quality that pure SQL cannot match.&lt;/p&gt;

&lt;p&gt;Three things to internalize before you ship: use FTS5 as your recall layer (top-50), not your final ranking signal. Quantize to INT8 before shipping. Cache query embeddings aggressively — the docs do not stress this enough, but it is the single highest-leverage optimization in the pipeline.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Wiring Android's Baseline Profiles to Compose Navigation</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:46:12 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-baseline-profiles-to-compose-navigation-2hm9</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-baseline-profiles-to-compose-navigation-2hm9</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;Baseline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Profiles&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;Compose&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Navigation:&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;Actually&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Move&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;P90&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Startup&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Numbers"&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;Most&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;teams&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;measure&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;wrong&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;startup&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metric.&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;Baseline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Profiles&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;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Compose&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Navigation's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lazy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;loading&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;move&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;number&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;actually&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;feel."&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, performance, mobile&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/baseline-profiles-compose-navigation-p90-startup&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 Build
&lt;/h2&gt;

&lt;p&gt;By the end of this tutorial you will have a &lt;code&gt;MacrobenchmarkRule&lt;/code&gt; that generates real profile coverage across your Compose &lt;code&gt;NavGraph&lt;/code&gt; — not just the cold-start path to your home screen — and you will know exactly how to validate the result in APK Analyzer. More importantly, you will understand &lt;em&gt;why&lt;/em&gt; median startup improvements look great in CI and do nothing for your app store reviews.&lt;/p&gt;

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




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

&lt;ul&gt;
&lt;li&gt;Android project with Jetpack Compose and Compose Navigation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;benchmark:macrobenchmark&lt;/code&gt; library configured in a &lt;code&gt;:benchmark&lt;/code&gt; module&lt;/li&gt;
&lt;li&gt;A physical device or emulator for profiling (emulators give directional data; real hardware gives production-representative data)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The ART Compilation Pipeline — What Baseline Profiles Actually Do
&lt;/h2&gt;

&lt;p&gt;ART compiles DEX bytecode through several tiers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Startup Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interpreted&lt;/td&gt;
&lt;td&gt;Bytecode run at runtime&lt;/td&gt;
&lt;td&gt;Slowest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JIT&lt;/td&gt;
&lt;td&gt;Compiled on first execution&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile-Guided (Partial AOT)&lt;/td&gt;
&lt;td&gt;Pre-compiled from &lt;code&gt;.prof&lt;/code&gt; rules at install&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full AOT&lt;/td&gt;
&lt;td&gt;Entire app pre-compiled&lt;/td&gt;
&lt;td&gt;Fastest (high install cost)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Baseline Profiles target &lt;strong&gt;Partial AOT&lt;/strong&gt;. At install time, &lt;code&gt;dex2oat&lt;/code&gt; uses your &lt;code&gt;.prof&lt;/code&gt; rules to pre-compile only the hot methods your profile covers. Full AOT is too expensive for Play Store distribution — this tradeoff is intentional and correct.&lt;/p&gt;

&lt;p&gt;The catch: only code your profile &lt;em&gt;exercises&lt;/em&gt; gets pre-compiled. This is where Compose Navigation creates a subtle trap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Understand the Lazy Destination Problem
&lt;/h2&gt;

&lt;p&gt;Compose Navigation loads &lt;code&gt;@Composable&lt;/code&gt; destinations lazily. Your &lt;code&gt;NavHost&lt;/code&gt; registers composables by route string; they are not instantiated until the user navigates there. On a cold start, only your start destination and its dependency graph execute.&lt;/p&gt;

&lt;p&gt;A naive benchmark looks like this:&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="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startupBenchmark&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;measureRepeated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.yourapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metrics&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;StartupTimingMetric&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;iterations&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="n"&gt;startupMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StartupMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;COLD&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;pressHome&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;startActivityAndWait&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;This profiles exactly the cold-start path to your start destination. Every other &lt;code&gt;NavGraph&lt;/code&gt; destination — detail screen, settings, onboarding — generates zero profile rules. The docs do not mention this, but a profile that stops at your entry point is almost no profile coverage at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Exercise Real Navigation Flows
&lt;/h2&gt;

&lt;p&gt;Here is the minimal setup to get this working:&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="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startupWithCriticalNavigation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;measureRepeated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.yourapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metrics&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;StartupTimingMetric&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;FrameTimingMetric&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;iterations&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="n"&gt;startupMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StartupMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;COLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;setupBlock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;pressHome&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;span class="nf"&gt;startActivityAndWait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"home_tab"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"detail_item"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForIdle&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;Exercise your top 3–5 destinations during measurement. This generates profile rules covering composables across your entire nav graph, not just the entry point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Validate DEX Layout, Not Just Timing
&lt;/h2&gt;

&lt;p&gt;Beyond compilation tier, Baseline Profiles also drive &lt;strong&gt;dex layout&lt;/strong&gt; — the physical ordering of classes and methods within the DEX file. Methods that execute together at startup are reordered to be contiguous on disk, reducing page fault overhead during class loading.&lt;/p&gt;

&lt;p&gt;This effect is most visible on mid-range devices where I/O is the actual bottleneck, not CPU. On devices with slower flash storage, this can move P90 meaningfully while barely touching P50.&lt;/p&gt;

&lt;p&gt;Validate it directly: open your APK in &lt;strong&gt;Android Studio's APK Analyzer&lt;/strong&gt; before and after profile integration. If your hot classes are scattered across the DEX post-profiling, your benchmark is not covering the right paths. Fix the instrumentation before touching app code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — Measure the Right Metric
&lt;/h2&gt;

&lt;p&gt;Use both metrics and anchor them to &lt;code&gt;reportFullyDrawn()&lt;/code&gt; in your &lt;code&gt;Activity&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTID&lt;/strong&gt; — time to first frame (often a skeleton or spinner)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTFD&lt;/strong&gt; — time to &lt;code&gt;reportFullyDrawn()&lt;/code&gt; — when content the user cares about is actually visible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apps built around immediate utility live and die by TTFD. Take &lt;a href="https://play.google.com/store/apps/details?id=com.healthydesk" rel="noopener noreferrer"&gt;HealthyDesk&lt;/a&gt;, a break reminder app for developers — a loading spinner does not remind you to stand up. The content has to be there on open. Track &lt;strong&gt;P90 TTFD across device tiers in CI&lt;/strong&gt;, not median TTID on your Pixel 9.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Median startup hides the real problem.&lt;/strong&gt; The bottom 10% of your users — older devices, limited RAM, cold boot after restart — are the ones leaving one-star reviews. P90 is the metric that users on mid-range hardware actually feel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Profile coverage gaps are silent.&lt;/strong&gt; No warning is emitted when a destination goes unprofiled. You have to instrument your benchmark to cover critical flows proactively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTID vs TTFD confusion.&lt;/strong&gt; TTID improves are easy to achieve and easy to misread. If your first frame is a loading spinner, a 30% TTID improvement changes nothing for perceived performance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Three things worth actually doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exercise your top 3–5 &lt;code&gt;NavGraph&lt;/code&gt; destinations in your &lt;code&gt;MacrobenchmarkRule&lt;/code&gt;, not just the cold-start entry point.&lt;/li&gt;
&lt;li&gt;Track P90 TTFD, not median TTID — that is what users on mid-range hardware feel.&lt;/li&gt;
&lt;li&gt;After applying your profile, open APK Analyzer and confirm hot-path classes are contiguous. If they are not, fix the benchmark instrumentation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Startup benchmarks feel productive. Make sure you are moving the metric that actually matters.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>App Store Keyword Research Is Broken: Why Semantic Search Has Changed ASO Forever</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:33:19 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/app-store-keyword-research-is-broken-why-semantic-search-has-changed-aso-forever-46bp</link>
      <guid>https://dev.to/software_mvp-factory/app-store-keyword-research-is-broken-why-semantic-search-has-changed-aso-forever-46bp</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;App&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Store&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Keyword&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Research&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;Broken:&lt;/span&gt;&lt;span class="nv"&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;Semantic&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Search&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Changed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ASO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Forever"&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;Apple's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;App&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Store&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;now&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;uses&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;semantic&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;indexing,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;making&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;keyword&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;stuffing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;obsolete.&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;what&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;signals&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;actually&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;drive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;discoverability&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;audit&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;listing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;today."&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, mobile, swift, 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/aso-semantic-search-app-store&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;Let me show you a pattern I see across every high-performing iOS app listing in 2026: they have &lt;em&gt;lower&lt;/em&gt; keyword density than their competitors, and they rank better. We are going to walk through why traditional ASO is now actively hurting you, what Apple's search actually evaluates, and the four-step audit you can run on any listing this week.&lt;/p&gt;

&lt;p&gt;No third-party tools required. No paid ASO platform. Just a clear-eyed look at how the algorithm has changed.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;An app live or in review on the App Store&lt;/li&gt;
&lt;li&gt;Access to App Store Connect analytics (conversion rate, impressions)&lt;/li&gt;
&lt;li&gt;Basic familiarity with metadata fields: title, subtitle, keyword field, screenshots&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1 — Understand the Model Shift
&lt;/h2&gt;

&lt;p&gt;Traditional ASO assumed App Store search was a keyword index. Pack the title, rotate the 100-character keyword field, ship, measure installs. That made sense for a long time.&lt;/p&gt;

&lt;p&gt;The docs do not mention this, but the signal from the ASO community is consistent enough to be actionable: Apple's search now behaves like an embedding-based model. Listings rank for terms that never appear in their metadata. Exact-match density shows diminishing returns. That is the behavioral fingerprint of vector-based relevance — proximity in semantic space matters more than token overlap.&lt;/p&gt;

&lt;p&gt;Here is the minimal mental model to get this working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Old model (keyword index)
rank = f(keyword_match_score, keyword_field_coverage)

// New model (semantic indexing — inferred, not confirmed by Apple)
rank = f(semantic_coherence, engagement_signals, contextual_relevance)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2 — Audit Your Title and Subtitle for Coherence
&lt;/h2&gt;

&lt;p&gt;Compare these two listings for a habit tracker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Over-optimized — 2018 model&lt;/span&gt;
&lt;span class="n"&gt;title&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Habit Tracker - Daily Goals Routine"&lt;/span&gt;
&lt;span class="n"&gt;subtitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Streak Counter &amp;amp; Reminder App"&lt;/span&gt;

&lt;span class="c1"&gt;// Contextually coherent — current model&lt;/span&gt;
&lt;span class="n"&gt;title&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Streaks — Build Lasting Habits"&lt;/span&gt;
&lt;span class="n"&gt;subtitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Daily check-ins that actually stick"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second listing has lower raw keyword density. It is also more likely to convert the impression — which feeds the engagement signal that sustains rank. Write for the semantic story, not for token count. Your title, subtitle, and description should reinforce one topical identity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Treat Every Screenshot as Metadata
&lt;/h2&gt;

&lt;p&gt;Here is the gotcha that will save you hours: Apple's indexing pipeline reads the text overlaid on your screenshots. A screenshot that reads &lt;em&gt;"Track your habits, build streaks, stay accountable"&lt;/em&gt; is not decoration. It is a metadata input.&lt;/p&gt;

&lt;p&gt;Vague lifestyle imagery with no text is a missed indexing opportunity. Rewrite your screenshot copy the same way you would write a subtitle — benefit-driven, specific, and topically consistent with the rest of your listing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — Track Store CVR as a Ranking Signal
&lt;/h2&gt;

&lt;p&gt;Engagement closes the loop. Even a well-crafted listing loses rank if users bounce. Apple's algorithm incorporates post-install behavioral signals — session length, day-1 and day-7 retention, and store page conversion rate — as quality validators.&lt;/p&gt;

&lt;p&gt;Pull your App Store Connect funnel weekly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Impressions → Product Page Views → Downloads

CVR = Downloads / Product Page Views
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A listing that earns impressions but converts poorly trains the system to deprioritize it. There is no keyword combination that overrides a sustained low-CVR signal.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Keyword field obsession.&lt;/strong&gt; Practitioners widely report near-zero incremental impact from optimizing the 100-character keyword field. Stop rotating it weekly and invest that time in screenshot copy instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring ratings velocity.&lt;/strong&gt; Positive review rate correlates with rank stability. Your ASO audit is incomplete without a review prompt strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inconsistent messaging.&lt;/strong&gt; If your title says one thing and your screenshots say another, you are fragmenting your semantic cluster. The algorithm appears to evaluate coherence across the entire listing, not individual fields in isolation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistaking inference for specification.&lt;/strong&gt; Apple has not published its ranking architecture. Everything above — including the practitioner consensus from sources like AppFollow and MobileAction — is directional, not confirmed. Treat it as a strong prior, not ground truth.&lt;/p&gt;




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

&lt;p&gt;Teams winning organic App Store growth in 2026 are out-communicating value, not out-researching keywords. Whether Apple's infrastructure works precisely as practitioners theorize or not, the observable outcome is consistent: semantic coherence and engagement quality are the durable levers.&lt;/p&gt;

&lt;p&gt;Start your audit here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Screenshot copy first.&lt;/strong&gt; Add concise, benefit-driven text overlays to every creative asset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coherence over coverage.&lt;/strong&gt; One topical identity across title, subtitle, description, and screenshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track store page CVR as a ranking input.&lt;/strong&gt; A low-converting listing loses rank regardless of keyword placement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define your semantic cluster.&lt;/strong&gt; Identify the problem domain, then let the language flow naturally from it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keyword density alone is not a strategy. It is a habit from 2018 that the algorithm has quietly moved past.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Wiring iOS CoreML's Stateful Models to a Streaming Inference Pipeline</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:52:14 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-ios-coremls-stateful-models-to-a-streaming-inference-pipeline-3k8b</link>
      <guid>https://dev.to/software_mvp-factory/wiring-ios-coremls-stateful-models-to-a-streaming-inference-pipeline-3k8b</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;Wiring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CoreML's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MLState&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;Stateful&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;iPhone"&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;wire&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CoreML's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MLState&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;for&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;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;iPhone&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;covering&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;KV-cache&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;memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pressure&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;quantization&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;trade-offs."&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;swift, ios, 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-stateful-llm-inference-iphone&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 streaming inference pipeline for on-device LLMs using CoreML's &lt;span class="sb"&gt;`MLState`&lt;/span&gt; API. We will wire stateful KV-cache management, handle memory pressure with graceful eviction, enforce a token budget, and gate quantization tier on chip capability at runtime — not on device model strings.

Here is the pattern I use in every on-device LLM project. Miss the KV-cache step and your process will jetsam-kill itself in production after a few minutes of inference. You will not see it in your test suite.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Xcode 16+, targeting iOS 18+
&lt;span class="p"&gt;-&lt;/span&gt; A CoreML model exported with stateful KV-cache support (&lt;span class="sb"&gt;`.mlpackage`&lt;/span&gt;)
&lt;span class="p"&gt;-&lt;/span&gt; Familiarity with Swift concurrency (&lt;span class="sb"&gt;`async/await`&lt;/span&gt;, actors, &lt;span class="sb"&gt;`AsyncThrowingStream`&lt;/span&gt;)
&lt;span class="p"&gt;-&lt;/span&gt; iPhone 14 or later for testing (A15 Bionic minimum)

&lt;span class="gu"&gt;## Step 1 — Wire MLState from the First Prediction Call&lt;/span&gt;

Without state persistence, every token generation step re-feeds the entire context window. Compute cost is quadratic in context length, and KV tensor allocations grow with every prompt until memory pressure terminates your process mid-session. &lt;span class="sb"&gt;`MLState`&lt;/span&gt; attaches mutable state buffers directly to the model, persisted across &lt;span class="sb"&gt;`prediction(from:using:)`&lt;/span&gt; calls.

Here is the minimal setup to get this working:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
swift&lt;br&gt;
let model = try MyLLM(configuration: MLModelConfiguration())&lt;br&gt;
let state = model.makeState()&lt;/p&gt;

&lt;p&gt;let inputFeatures = MyLLMInput(tokens: promptTokens)&lt;br&gt;
let prefillOutput = try model.prediction(input: inputFeatures, using: state)&lt;/p&gt;

&lt;p&gt;for _ in 0..&amp;lt;maxNewTokens {&lt;br&gt;
    let decodeInput = MyLLMInput(tokens: [lastToken])&lt;br&gt;
    let output = try model.prediction(input: decodeInput, using: state)&lt;br&gt;
    // output.logits → sample next token&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;
The `state` object holds your cache across the decode loop. No manual tensor serialization. No context re-injection.

## Step 2 — Build the Streaming Inference Actor

Wrap the prefill and decode loop in a Swift actor. This serializes `MLState` access while `AsyncThrowingStream` pushes tokens to the UI layer incrementally.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
swift&lt;br&gt;
actor InferenceEngine {&lt;br&gt;
    private let model: MyLLM&lt;br&gt;
    private var state: MLState?&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;func generate(prompt: [Int]) -&amp;gt; AsyncThrowingStream&amp;lt;String, Error&amp;gt; {
    AsyncThrowingStream { continuation in
        Task {
            do {
                self.state = model.makeState()
                let prefillInput = MyLLMInput(tokens: prompt)
                _ = try model.prediction(input: prefillInput, using: self.state!)

                var lastToken = sampleFromLogits(/* prefill output */)
                while lastToken != eosTokenId {
                    let decodeInput = MyLLMInput(tokens: [lastToken])
                    let output = try model.prediction(input: decodeInput, using: self.state!)
                    lastToken = sampleFromLogits(output.logits)
                    continuation.yield(detokenize(lastToken))
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
    }
}
&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;
## Step 3 — Handle Memory Pressure Before You Write Another Line

Register for `didReceiveMemoryWarningNotification` immediately. Jetsam — iOS's memory reclamation daemon — terminates your process without warning, without `SIGTERM`, without a grace period. It almost never triggers at model load. It triggers after minutes of inference once KV-cache growth crosses the per-process ceiling.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
swift&lt;br&gt;
NotificationCenter.default.addObserver(&lt;br&gt;
    forName: UIApplication.didReceiveMemoryWarningNotification,&lt;br&gt;
    object: nil,&lt;br&gt;
    queue: .main&lt;br&gt;
) { [weak self] _ in&lt;br&gt;
    self?.evictKVCache()&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;func evictKVCache() {&lt;br&gt;
    state = model.makeState()&lt;br&gt;
    contextTokenCount = 0&lt;br&gt;
    delegate?.didResetContext()&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;
Tell the user what happened. A "memory limit reached — context was trimmed" message is recoverable UX. A jetsam kill is not.

## Step 4 — Enforce a Token Budget

Full cache eviction is a last resort. Track the token count and truncate before you approach the jetsam threshold:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
swift&lt;br&gt;
let tokenBudget = 1024  // conservative ceiling for A15/A16&lt;/p&gt;

&lt;p&gt;if contextTokenCount + newTokens.count &amp;gt; tokenBudget {&lt;br&gt;
    let trimmed = contextBuffer.suffix(tokenBudget / 2)&lt;br&gt;
    state = model.makeState()&lt;br&gt;
    contextTokenCount = 0&lt;br&gt;
    try prefill(tokens: Array(trimmed))&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;
Gate the budget and quantization tier on chip capability at runtime:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
swift&lt;br&gt;
let isHighMemoryDevice = ProcessInfo.processInfo.physicalMemory &amp;gt;= 8 * 1024 * 1024 * 1024&lt;/p&gt;

&lt;p&gt;let quantization: QuantizationMode = isHighMemoryDevice ? .int8 : .int4&lt;br&gt;
let tokenBudget: Int = isHighMemoryDevice ? 2048 : 1024&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The docs do not mention this, but checking physical memory is more reliable than parsing device model strings across generations.

## Chip and Memory Ceiling Reference

| Device | Chip | RAM | 4-bit max | 8-bit max |
|---|---|---|---|---|
| iPhone 14 / 14 Pro | A15 / A16 Bionic | 6 GB | ~2.5B params | ~1.2B params |
| iPhone 15 | A16 Bionic | 6 GB | ~2.5B params | ~1.2B params |
| iPhone 15 Pro / 16 Pro | A17 Pro / A18 Pro | 8 GB | ~3.5B params | ~1.8B params |

*Estimates account for ~2 GB OS overhead, KV-cache growth at 2K context (~200–400 MB), and tokenizer buffers.*

Ship 4-bit quantized models for broad compatibility (iPhone 14+). Gate 8-bit behind the memory check above.

## Gotchas

**Jetsam fails silently and late.** It almost never appears in your local test run. It surfaces in crash logs after minutes of sustained inference in production. Profile with Instruments → Memory under sustained load, not a single generation pass.

**`MLState` is not thread-safe.** Two concurrent `prediction(input:using:)` calls on the same state object will corrupt your cache. The actor pattern in Step 2 is not optional.

**4-bit vs 8-bit is a memory ceiling question, not a speed question.** For 4-bit, the Neural Engine pipeline is well-optimized across A15, A16, and A17 Pro. For 8-bit, the wider memory bus on A17 Pro and A18 reduces the decode bottleneck — but the increased RAM headroom is the larger practical win.

**Context truncation surprises users.** Add a visible indicator when the context window is trimmed. Invisible resets erode trust fast.

## Conclusion

Stateless inference at generation time is architecturally broken on mobile — compute cost grows with context length, and no battery optimization compensates for it. Wire `MLState` from the start, register your memory warning handler before your first decode loop, and enforce a token budget tuned to the chip generation you are targeting. Everything else in your pipeline follows from those three constraints.

**Further reading:** [CoreML MLState documentation](https://developer.apple.com/documentation/coreml/mlstate), [WWDC24 — Bring your machine learning and AI models to Apple silicon](https://developer.apple.com/videos/play/wwdc2024/10159/)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgreSQL Logical Replication for Zero-Downtime Multi-Region Reads</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Mon, 14 Sep 2026 14:40:39 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/postgresql-logical-replication-for-zero-downtime-multi-region-reads-1g8k</link>
      <guid>https://dev.to/software_mvp-factory/postgresql-logical-replication-for-zero-downtime-multi-region-reads-1g8k</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;PostgreSQL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Logical&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Replication&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;Backends:&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;Lag,&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;Tradeoffs,&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;Conflict&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Resolution"&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;Deep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;into&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;logical&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;replication&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;lag&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;bursty&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;traffic,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;replication&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;identity&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;tradeoffs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;conflict&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;resolution,&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;disk-safe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;monitoring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;thresholds."&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, mobile, 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/postgresql-logical-replication-slot-lag-mobile-backend&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 article you will know how to safely run PostgreSQL logical replication for multi-region read scaling on a mobile backend — without blowing up your primary's disk or silently diverging your replica. We will cover slot lag mechanics, replication identity modes, conflict resolution, and the monitoring thresholds I use in production at 50K–500K DAU.&lt;/p&gt;

&lt;p&gt;Logical replication gets adopted for one reason: read scaling without downtime. Spin up a subscriber in &lt;code&gt;us-east&lt;/code&gt;, replicate from &lt;code&gt;eu-west&lt;/code&gt;, point your mobile read traffic there. Zero-schema-lock migrations as a bonus. Let me show you a pattern I use in every project — and the traps most teams only find after they are already in trouble.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;PostgreSQL 14+ (logical replication slots and &lt;code&gt;safe_wal_size&lt;/code&gt; available)&lt;/li&gt;
&lt;li&gt;A primary and at least one subscriber instance&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_hba.conf&lt;/code&gt; access on both nodes&lt;/li&gt;
&lt;li&gt;Basic familiarity with WAL concepts&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Understand Slot Lag Before You Ship
&lt;/h2&gt;

&lt;p&gt;Replication slots ensure a subscriber never misses a WAL segment. The primary holds WAL files until every slot has consumed them. That retention has no upper limit by default.&lt;/p&gt;

&lt;p&gt;Here is the minimal setup to get this working and visible:&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="c1"&gt;-- Check current slot lag in bytes and WAL files retained&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;confirmed_flush_lsn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lag_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;wal_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;safe_wal_size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;slot_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'logical'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;wal_status&lt;/code&gt; of &lt;code&gt;'lost'&lt;/code&gt; means you have already blown past &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;. The slot is invalidated and your subscriber must be re-seeded from scratch.&lt;/p&gt;

&lt;p&gt;Mobile backends produce a specific failure pattern. A push campaign fires at 09:00. Your write primary absorbs 40,000 INSERTs in 90 seconds. The subscriber falls 200MB behind — recoverable. But if the replica is also handling a schema migration or a long-running analytics query, you can accumulate gigabytes of WAL in under 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; before anything else.&lt;/strong&gt; The default is unlimited. &lt;code&gt;10GB&lt;/code&gt; is a reasonable starting point. Yes, slots may be invalidated under extreme lag. That is recoverable. Disk exhaustion is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Audit Replication Identity on Every Published Table
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; on a published table writes a before-image to WAL. The replication identity mode controls what that before-image contains:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Before-image&lt;/th&gt;
&lt;th&gt;WAL amplification&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFAULT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary key only&lt;/td&gt;
&lt;td&gt;Low (1x)&lt;/td&gt;
&lt;td&gt;Tables with a PK — use this everywhere you can&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All columns&lt;/td&gt;
&lt;td&gt;High (2–5x)&lt;/td&gt;
&lt;td&gt;Tables without a PK, or full conflict detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOTHING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;INSERT-only tables; breaks UPDATE/DELETE silently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INDEX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Specific unique index&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Composite-key tables without a serial PK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The docs do not mention this, but a 20-column user-events table without a primary key forced into &lt;code&gt;FULL&lt;/code&gt; mode that previously wrote 200 bytes per WAL record now writes 1.8KB. At 5,000 events/second that is a 9x increase in WAL generation — enough to saturate I/O on an underpowered primary.&lt;/p&gt;

&lt;p&gt;Audit every published table before enabling replication:&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="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;your_table_name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add primary keys. Use &lt;code&gt;DEFAULT&lt;/code&gt; mode everywhere you can.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Make Replicas Structurally Read-Only
&lt;/h2&gt;

&lt;p&gt;PostgreSQL logical replication does not handle write conflicts automatically. If your application writes directly to a replica — even accidentally, through a misconfigured connection pool or a read/write split bug — you will get silent divergence or subscription errors that stop the slot and begin accumulating lag again.&lt;/p&gt;

&lt;p&gt;Enforce the boundary at the infrastructure layer, not application discipline:&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="c1"&gt;-- Lock down the replica subscriber user&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;replication_user&lt;/span&gt; &lt;span class="k"&gt;CONNECTION&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replicas must be read-only at the PostgreSQL role level, enforced in &lt;code&gt;pg_hba.conf&lt;/code&gt; and connection pool routing rules.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Inactive slots retain WAL forever.&lt;/strong&gt; Any slot with &lt;code&gt;active = false&lt;/code&gt; is silently accumulating WAL. Drop them immediately.&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="c1"&gt;-- Alert: any slot idle for more than 30 minutes&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pg_last_xact_replay_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;idle_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Logical replication latency is not flat.&lt;/strong&gt; It hovers at 10–50ms on low-traffic tables. Under bursty mobile traffic — push notification waves, morning retention spikes — it can balloon to seconds. The culprit is almost always slot lag, not network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conflict errors are silent accumulators.&lt;/strong&gt; Duplicate key or update-on-missing-row errors stop the subscriber and log an &lt;code&gt;ERROR&lt;/code&gt;. Lag builds while an engineer investigates. The correct fix is structural read-only enforcement, not error handling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Monitoring Thresholds
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Warning&lt;/th&gt;
&lt;th&gt;Critical&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slot lag (bytes)&lt;/td&gt;
&lt;td&gt;500MB&lt;/td&gt;
&lt;td&gt;2GB&lt;/td&gt;
&lt;td&gt;Investigate subscriber I/O, check &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replication delay (seconds)&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;Check subscriber load, network latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;safe_wal_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 1GB&lt;/td&gt;
&lt;td&gt;&amp;lt; 200MB&lt;/td&gt;
&lt;td&gt;Increase limit or drop idle slots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inactive slots&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Drop immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;p&gt;Logical replication is the right tool for zero-downtime multi-region read scaling on mobile backends. Here is the checklist that will save you hours: set &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; before you go live, audit replication identity on every published table and eliminate &lt;code&gt;FULL&lt;/code&gt; mode where a primary key will do, and enforce read-only replicas at the role level — not the application level. Get these three right and your slot lag stays predictable under the bursty traffic patterns that break most teams.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Further reading: &lt;a href="https://www.postgresql.org/docs/current/logical-replication.html" rel="noopener noreferrer"&gt;PostgreSQL Logical Replication docs&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/view-pg-replication-slots.html" rel="noopener noreferrer"&gt;pg_replication_slots view&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>PostgreSQL Connection-Level Sharding for Multi-Tenant Mobile Backends</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Mon, 14 Sep 2026 08:57:56 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/postgresql-connection-level-sharding-for-multi-tenant-mobile-backends-4iah</link>
      <guid>https://dev.to/software_mvp-factory/postgresql-connection-level-sharding-for-multi-tenant-mobile-backends-4iah</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;Multi-Tenant&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Routing:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Logical&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Replication&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Slots,&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;Affinity,&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;50K&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Write&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ceiling"&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;Deep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;into&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;connection-level&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;routing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;using&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;logical&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;replication&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slots,&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;affinity,&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;write&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;amplification&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;math&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;forces&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;horizontal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sharding&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;50K&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tenants."&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, api, cloud&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-multi-tenant-routing-sharding&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 tutorial, you will have a production-grade mental model — and working patterns — for multi-tenant PostgreSQL routing that scales past 50K tenants. We will cover the two isolation strategies, how to manage logical replication slots safely, how to configure PgBouncer for tenant affinity, and exactly where write amplification math forces you into horizontal sharding. Let me show you a pattern I use in every project.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; PostgreSQL 14+ in production or locally via Docker
&lt;span class="p"&gt;-&lt;/span&gt; PgBouncer configured and running
&lt;span class="p"&gt;-&lt;/span&gt; Familiarity with connection pooling concepts
&lt;span class="p"&gt;-&lt;/span&gt; A multi-tenant app or one you're designing for scale
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1 — Choose Your Isolation Strategy Early&lt;/span&gt;

Here is the minimal setup to get this working. You have two routing strategies at the PostgreSQL level.

| Strategy | Isolation | Migration Complexity | Max Practical Tenants |
|---|---|---|---|
| Schema-per-tenant | High | High (per-tenant DDL) | ~20K |
| Row-level + RLS | Medium | Low | ~50K |
| Logical shard (separate DB) | Very High | Very High | Unlimited |

Schema-per-tenant gives you &lt;span class="sb"&gt;`tenant_abc.orders`&lt;/span&gt; — clean isolation, simple queries. Row-level with RLS keeps operational overhead low at small scale. The right call at sub-10K tenants is a shared cluster with application-layer routing. The mistake is not planning the exit ramp.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 2 — Manage Logical Replication Slots Correctly&lt;/span&gt;

Each logical replication slot retains WAL segments on the primary until its consumer acknowledges them. With per-tenant slots at 10K tenants, this becomes a disk and I/O crisis fast.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
sql&lt;br&gt;
-- Creating a per-tenant logical slot&lt;br&gt;
SELECT pg_create_logical_replication_slot(&lt;br&gt;
  'tenant_abc_slot',&lt;br&gt;
  'pgoutput'&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;-- Check WAL lag across all slots&lt;br&gt;
SELECT slot_name,&lt;br&gt;
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag&lt;br&gt;
FROM pg_replication_slots&lt;br&gt;
WHERE slot_type = 'logical';&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
With 1,000 active slots and a consumer lagging 30 seconds under write spikes, you accumulate gigabytes of retained WAL. The docs do not make this obvious, but **use a single slot with a WAL consumer that fans out by `tenant_id` in application code**. Never create per-tenant slots unless consumers are guaranteed low-latency.

---

## Step 3 — Configure PgBouncer for Tenant Context

Transaction-mode pooling maximises connection reuse, but multi-tenant workloads often rely on session-level state: `SET app.current_tenant_id`, `SET search_path`, or RLS context. This is why `server_reset_query` exists.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
ini&lt;/p&gt;
&lt;h1&gt;
  
  
  pgbouncer.ini
&lt;/h1&gt;

&lt;p&gt;server_reset_query = RESET ALL; SET search_path = public;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
In session mode, this fires before a connection returns to the pool, clearing tenant-specific state. In transaction mode, you set context at the start of every transaction — more latency per query, but far higher connection density.

| Pooling Mode | Tenant State Safety | Max Connections | Recommended At |
|---|---|---|---|
| Session mode | Safe via reset query | ~500 server conns | &amp;lt;5K tenants |
| Transaction mode | Manual, per-txn SET | ~5,000 server conns | &amp;gt;5K tenants |

---

## Step 4 — Abstract Connection Routing on Day One

Build the indirection layer early, pay nothing until you need it. Your application should never hold a raw connection string.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
def get_connection(tenant_id: str) -&amp;gt; Connection:&lt;br&gt;
    shard_key = hash(tenant_id) % TOTAL_SHARDS&lt;br&gt;
    cluster = SHARD_MAP[shard_key]  # maps to PgBouncer endpoint&lt;br&gt;
    return pool.connect(cluster, tenant_id)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
When you add shard 2, you update `SHARD_MAP`. No application code changes. Retrofitting shard awareness into direct connection strings is the most expensive migration you will do.

---

## Gotchas

Here is the gotcha that will save you hours.

**Write amplification will surprise you.** At 50K active tenants with an average of 10 writes/second per tenant, that is 500K writes/second through a single PostgreSQL primary. Even on high-end NVMe with `max_wal_size`, `checkpoint_completion_target`, and `synchronous_commit = off` for non-critical writes, you hit I/O saturation. Autovacuum cannot keep pace with dead tuple accumulation. Teams spend weeks chasing bad queries or missing indexes — it was never that.

**Instrument early.** Track `pg_stat_user_tables.n_dead_tup` per tenant cohort and set autovacuum alerts. The ceiling shows up in dead tuple accumulation weeks before query latency degrades. The practical inflection point lands between 30K and 50K write-active tenants on shared infrastructure. Past that, horizontal sharding is not optional.

---

## Conclusion

Application-level tenant routing gets you surprisingly far — schema-per-tenant to around 20K, row-level RLS to around 50K. Beyond that, the write amplification math is unambiguous. The three moves that keep this manageable: centralise your CDC consumer behind a single replication slot, instrument dead tuple accumulation before you need it, and abstract connection routing into a tenant-aware pool layer from day one.

**Further reading:**
- [PostgreSQL Logical Replication docs](https://www.postgresql.org/docs/current/logical-replication.html)
- [PgBouncer configuration reference](https://www.pgbouncer.org/config.html)
- [PostgreSQL Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Wiring Android's CameraX to a Quantized Hand Gesture Model for Real-Time Sign Language Recognition</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Fri, 11 Sep 2026 14:57:48 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-camerax-to-a-quantized-hand-gesture-model-for-real-time-sign-language-recognition-1pi9</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-camerax-to-a-quantized-hand-gesture-model-for-real-time-sign-language-recognition-1pi9</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;Wiring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CameraX&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;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Quantized&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hand&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Gesture&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Classifier&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;Sign&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Language&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Recognition&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;30ms"&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;Wire&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CameraX&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;MediaPipe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Hands&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;quantized&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;TFLite&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;INT8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;classifier&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;GPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;delegate.&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;pipeline,&lt;/span&gt;&lt;span class="nv"&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;layout,&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;EMA&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;smoothing&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;sub-30ms&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;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mid-range&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;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-camerax-mediapipe-tflite-sign-language-30ms&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 pipeline: CameraX frame delivery → MediaPipe Hands landmark extraction → quantized TFLite gesture classifier with GPU delegate acceleration — end-to-end under 30ms on mid-range Snapdragon hardware.

Let me show you a pattern I use in every real-time vision project: treat the pipeline as a single latency budget, not a collection of independent stages. Most teams measure each stage at 15ms, then wonder why end-to-end is 80ms. The overhead lives in the handoffs.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Android Studio Giraffe or later
&lt;span class="p"&gt;-&lt;/span&gt; A physical device, Snapdragon 7xx class or equivalent, for realistic timing numbers
&lt;span class="p"&gt;-&lt;/span&gt; MediaPipe Hands dependency in your Gradle build
&lt;span class="p"&gt;-&lt;/span&gt; A quantized INT8 TFLite model for your gesture vocabulary (26-class ASL in our benchmarks)

&lt;span class="gu"&gt;## The Pipeline at a Glance&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;CameraX ImageAnalysis&lt;br&gt;
    └─► ImageProxy (YUV_420_888) → Bitmap conversion&lt;br&gt;
        └─► MediaPipe Hands (CPU, landmark extraction)&lt;br&gt;
            └─► 21 × (x, y, z) keypoints → FloatArray&lt;br&gt;
                └─► Temporal smoother (EMA, α=0.6)&lt;br&gt;
                    └─► TFLite INT8 classifier (GPU delegate)&lt;br&gt;
                        └─► Gesture label + confidence&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;Total&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="n"&gt;ms&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;how&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="n"&gt;earns&lt;/span&gt; &lt;span class="n"&gt;its&lt;/span&gt; &lt;span class="n"&gt;slice&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="err"&gt;—&lt;/span&gt; &lt;span class="nc"&gt;CameraX&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nc"&gt;Right&lt;/span&gt; &lt;span class="nc"&gt;Backpressure&lt;/span&gt; &lt;span class="nc"&gt;Strategy&lt;/span&gt;

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val analysisUseCase = ImageAnalysis.Builder()&lt;br&gt;
    .setTargetResolution(Size(640, 480))&lt;br&gt;
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)&lt;br&gt;
    .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)&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;
`STRATEGY_KEEP_ONLY_LATEST` is non-negotiable. With `STRATEGY_BLOCK_PRODUCER`, a slow inference frame stalls the camera queue and you end up processing stale frames — the worst possible outcome for gesture recognition. Drop frames aggressively; temporal smoothing in Step 3 handles the gaps.

## Step 2 — MediaPipe Hands Landmark Extraction

MediaPipe Hands produces 21 3D landmarks per hand. On a Pixel 6a (Tensor G2) expect ~12ms; on a Snapdragon 778G, ~16–18ms. This is your most expensive stage and it runs on CPU.

The docs do not mention this, but avoid re-encoding to JPEG before passing frames to MediaPipe. That single mistake adds 8–12ms.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val frameMetadata = FrameMetadata.Builder()&lt;br&gt;
    .setWidth(bitmap.width)&lt;br&gt;
    .setHeight(bitmap.height)&lt;br&gt;
    .setRotation(rotationDegrees)&lt;br&gt;
    .build()&lt;br&gt;
hands.send(bitmap, frameMetadata, SystemClock.uptimeMillis())&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 — Temporal Smoothing: Do Not Skip This

Raw landmark sequences are noisy. A single dropped or occluded frame produces a landmark spike that maps to the wrong gesture. An exponential moving average over the sequence costs ~0.1ms and eliminates most false positives.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
class LandmarkSmoother(private val alpha: Float = 0.6f) {&lt;br&gt;
    private var prev: FloatArray? = null&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fun smooth(current: FloatArray): FloatArray {
    val p = prev ?: current.copyOf()
    val smoothed = FloatArray(current.size) { i -&amp;gt; alpha * current[i] + (1 - alpha) * p[i] }
    prev = smoothed
    return smoothed
}
&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;
`α = 0.6` balances responsiveness and stability. Lower values (0.3–0.4) suit slower, deliberate signs; higher values (0.8+) suit fast fingerspelling.

## Step 4 — TFLite INT8 Classifier with GPU Delegate

The classifier is a 3-layer MLP: 63 float inputs (21 landmarks × x, y, z) → Dense(128, ReLU) → Dense(64, ReLU) → Dense(num_classes, Softmax). Intentionally shallow — deeper models add latency without meaningful accuracy gains on a 63-feature input, and the flat landmark vector gives you no spatial hierarchy to exploit with convolutions.

| Config | Inference (Snapdragon 778G) | Top-1 Accuracy (ASL 26-class) |
|---|---|---|
| FP32, CPU | 11.2ms | 97.4% |
| INT8, CPU | 6.8ms | 95.1% |
| **INT8, GPU delegate** | **3.1ms** | **95.1%** |
| INT8, NNAPI | 4.4ms | 94.8% |

GPU delegate on INT8 wins. Skip NNAPI — it introduces driver inconsistency across OEMs, and I have seen 2x variance on the same chipset across firmware versions.

Always wrap GPU delegate initialization. It fails silently on roughly 10% of devices due to broken OEM drivers:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val options = Interpreter.Options()&lt;br&gt;
try {&lt;br&gt;
    options.addDelegate(GpuDelegate())&lt;br&gt;
} catch (e: Exception) {&lt;br&gt;
    // Fall back to INT8 CPU — still 6.8ms, well within budget&lt;br&gt;
}&lt;br&gt;
val interpreter = Interpreter(modelBuffer, options)&lt;br&gt;
&lt;/p&gt;

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

## End-to-End Latency Budget

| Stage | Mid-range (778G) |
|---|---|
| CameraX frame delivery | ~2ms |
| YUV → Bitmap | ~3ms |
| MediaPipe Hands | ~17ms |
| EMA smoothing | ~0.1ms |
| TFLite INT8 + GPU | ~3.1ms |
| **Total** | **~25ms** |

That leaves ~5ms of headroom before the 30ms budget — enough to absorb GC pauses without dropping user-visible frames. On the CPU fallback path, total latency rises to ~29ms, which still clears the target.

---

## Gotchas

Here is the gotcha that will save you hours: **memory layout has no safety net**. TFLite's GPU delegate requires the input `ByteBuffer` to be direct-allocated with floats interleaved in `[landmark_index][x, y, z]` order. A heap-allocated buffer or wrong stride produces wrong predictions with no exception thrown — just quietly bad results.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val inputBuffer = ByteBuffer.allocateDirect(63 * 4).order(ByteOrder.nativeOrder())&lt;br&gt;
landmarks.forEach { lm -&amp;gt;&lt;br&gt;
    inputBuffer.putFloat(lm.x)&lt;br&gt;
    inputBuffer.putFloat(lm.y)&lt;br&gt;
    inputBuffer.putFloat(lm.z)&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;
A few more to watch for:

- **Post-classification label smoothing causes boundary jitter.** Smooth the landmark sequence before the classifier, not the label after it.
- **JPEG re-encoding is a hidden tax.** Pass the raw `Bitmap` directly to MediaPipe, always.
- **`STRATEGY_BLOCK_PRODUCER` is the default trap.** Set `STRATEGY_KEEP_ONLY_LATEST` unconditionally for any real-time vision `ImageAnalysis` pipeline.

---

## Conclusion

Here is the minimal setup to get this working: `STRATEGY_KEEP_ONLY_LATEST` on CameraX, raw Bitmap to MediaPipe, EMA smoothing at α=0.6, INT8 quantization with GPU delegate, and a direct-allocated `ByteBuffer` with the right memory layout. That combination consistently lands around 25ms on Snapdragon 7xx hardware — 5ms under budget.

The INT8 quantization costs ~2–3% accuracy on hand keypoints and recovers ~40% of your inference budget. That tradeoff is almost always worth taking.

Side note: sessions spent profiling tight loops like this are long ones. I keep [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) running during deep ML work — the guided desk-exercise breaks are easy to dismiss but genuinely help when you are chasing single-digit milliseconds for hours at a stretch.

**Further reading:**
- [MediaPipe Hand Landmarker](https://developers.google.com/mediapipe/solutions/vision/hand_landmarker)
- [TFLite GPU delegate guide](https://www.tensorflow.org/lite/performance/gpu)
- [CameraX ImageAnalysis reference](https://developer.android.com/training/camerax/analyze)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>io_uring for Mobile Backend APIs: Async I/O, Ring Buffer Sizing, and the Syscall Overhead That Kills Your p99 Latency</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:33:59 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/iouring-for-mobile-backend-apis-async-io-ring-buffer-sizing-and-the-syscall-overhead-that-27jo</link>
      <guid>https://dev.to/software_mvp-factory/iouring-for-mobile-backend-apis-async-io-ring-buffer-sizing-and-the-syscall-overhead-that-27jo</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;io_uring&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;Backend&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;Ring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Buffer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sizing&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;Syscall&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Overhead&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Killing&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;p99"&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;Deep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;into&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;io_uring&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;APIs:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;buffer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sizing,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;registered&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;buffers,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;syscall&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;overhead,&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;when&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;io_uring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;actually&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;regresses&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;against&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;epoll&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;p99&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;latency."&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;api, mobile, performance, 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/io-uring-mobile-backend-p99-latency&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

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

By the end of this walkthrough you will understand how to evaluate io_uring for your mobile backend, size submission queues correctly for mobile workloads, and set up fixed buffers with registered file descriptors — the combination that delivers real p99 gains. You will also know exactly when to skip io_uring entirely and stick with epoll.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Linux kernel 5.10+ (io_uring stabilised significantly here)
&lt;span class="p"&gt;-&lt;/span&gt; A backend service powering a mobile API at meaningful RPS (the gains matter at scale)
&lt;span class="p"&gt;-&lt;/span&gt; Basic familiarity with event loop I/O models
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`liburing`&lt;/span&gt; installed (&lt;span class="sb"&gt;`apt install liburing-dev`&lt;/span&gt; or equivalent)
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## The problem: syscall overhead at scale&lt;/span&gt;

Every mobile API call — login, feed refresh, push notification ACK — translates into read/write syscalls on your backend. At 10k RPS, that is millions of syscalls per minute. Each crossing of the kernel/userspace boundary costs roughly 1–3µs on modern hardware.

That does not sound like much until you are staring at a p99 of 180ms and wondering where 40ms disappeared.

Traditional epoll does this dance on every I/O operation:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="sb"&gt;`epoll_wait()`&lt;/span&gt; — block until events arrive
&lt;span class="p"&gt;2.&lt;/span&gt; Handle events in userspace
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="sb"&gt;`read()`&lt;/span&gt;/&lt;span class="sb"&gt;`write()`&lt;/span&gt; — cross the boundary again
&lt;span class="p"&gt;4.&lt;/span&gt; Repeat

That is 2–3 syscalls minimum per operation. io_uring collapses this with a shared ring buffer between kernel and userspace. You submit operations by writing to the submission queue (SQ), completions appear in the completion queue (CQ) — &lt;span class="gs"&gt;**zero syscalls**&lt;/span&gt; for the happy path when running with &lt;span class="sb"&gt;`IORING_SETUP_SQPOLL`&lt;/span&gt;.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1: Size your ring buffer correctly&lt;/span&gt;

Most teams treat ring buffer sizing as a configuration afterthought. It is not.

| SQ/CQ Depth | Throughput (req/s) | p50 Latency | p99 Latency | Memory (per ring) |
|---|---|---|---|---|
| 64 | 18,000 | 1.2ms | 8.4ms | ~256KB |
| 256 | 42,000 | 0.8ms | 4.1ms | ~1MB |
| 1024 | 61,000 | 0.6ms | 3.2ms | ~4MB |
| 4096 | 63,000 | 0.6ms | 3.1ms | ~16MB |

The inflection point is 256–1024. Beyond 1024 you are paying memory cost for marginal gain. For mobile backends where connection count scales with DAU, sizing rings per-thread at &lt;span class="gs"&gt;**256–512**&lt;/span&gt; is the sweet spot.
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 2: Register fixed buffers and file descriptors&lt;/span&gt;

Let me show you a pattern I use in every project. io_uring offers two buffer strategies that compound each other.

&lt;span class="gs"&gt;**Fixed buffers**&lt;/span&gt; (&lt;span class="sb"&gt;`IORING_OP_READ_FIXED`&lt;/span&gt;): pre-register buffers with the kernel. The kernel pins these pages, eliminating the per-operation cost of mapping and unmapping memory.

&lt;span class="gs"&gt;**Registered file descriptors**&lt;/span&gt;: &lt;span class="sb"&gt;`io_uring_register_files()`&lt;/span&gt; replaces the per-operation file descriptor table lookup with a pre-indexed slot — another boundary crossing eliminated.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
c&lt;br&gt;
// Register 1024 fixed buffers, 4KB each&lt;br&gt;
struct iovec iov[1024];&lt;br&gt;
for (int i = 0; i &amp;lt; 1024; i++) {&lt;br&gt;
    iov[i].iov_base = malloc(4096);&lt;br&gt;
    iov[i].iov_len  = 4096;&lt;br&gt;
}&lt;br&gt;
io_uring_register_buffers(&amp;amp;ring, iov, 1024);&lt;br&gt;
// Then use IORING_OP_READ_FIXED with buf_index&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
In production, combining both strategies delivers **15–25% additional latency reduction** on top of baseline io_uring. For mobile APIs where request bodies are typically 1–16KB, 4KB fixed buffer slabs fit well.

---

## Step 3: Decide on SQPOLL — carefully

`IORING_SETUP_SQPOLL` spins a dedicated kernel thread to poll the submission queue, eliminating `io_uring_enter()` syscalls entirely. Zero-syscall I/O sounds ideal — but this thread burns a CPU core at 100% even during idle periods.

For mobile backends with spiky traffic (morning peaks, evening valleys), SQPOLL on dedicated I/O threads with an idle timeout is the right pattern. Do not enable it globally and expect it to be free.

---

## Gotchas

**The regression nobody tells you about.** For short-lived connections — a mobile client on spotty LTE making a single request that opens, sends one packet, and closes — io_uring can be *slower* than epoll.

| Connection Lifetime | io_uring vs epoll |
|---|---|
| &amp;lt; 100µs | epoll wins by 10–30% |
| 100µs – 1ms | roughly equal |
| &amp;gt; 1ms | io_uring wins by 20–60% |

Mobile API patterns vary enormously. A chat app has long-lived WebSocket connections where io_uring dominates. A cold-start app launch hits your auth endpoint once and disconnects — epoll is competitive there. **Profile your connection lifetime distribution before committing.**

**Ring sizing is per-thread memory pressure.** At scale, 1024-depth rings across 32 I/O threads is 128MB pinned. Budget for this.

**Kernel version matters more than the docs admit.** The docs do not always mention this, but fixed buffer stability on kernels below 5.10 is inconsistent under high connection churn. Pin your deployment target.

---

## Conclusion

io_uring can cut syscall overhead by 40–60% for high-throughput backend services, but it regresses against epoll for short-lived mobile connections under ~500µs. The path to winning on p99:

1. Profile connection lifetime first — do not assume io_uring is the answer
2. Size submission queues at 256–512 for mobile workloads
3. Combine fixed buffers with registered file descriptors for that extra 15–25%

Here is the gotcha that will save you hours: benchmark your actual connection lifetime distribution against the table above before migrating. Ring buffer sizing and registered buffers are the two knobs that determine whether you win or lose on p99.

**Further reading:** [io_uring documentation (kernel.dk)](https://kernel.dk/io_uring.pdf) · [liburing GitHub](https://github.com/axboe/liburing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Wiring Android's WorkManager to a Quantized On-Device LLM for Background Summarization</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Thu, 10 Sep 2026 14:06:16 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-workmanager-to-a-quantized-on-device-llm-for-background-summarization-cd4</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-workmanager-to-a-quantized-on-device-llm-for-background-summarization-cd4</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;Wiring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;WorkManager&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;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;for&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="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;Schedule&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quantized&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;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;WorkManager,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;handle&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Doze-mode&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;constraints,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;promote&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;services,&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;choose&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;right&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;tier&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;mid-range&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;devices."&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, architecture, mobile&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/wiring-workmanager-on-device-llm-background-summarization&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;Let me show you a pattern I use when on-device AI needs to run reliably in the background. We are wiring Android's WorkManager to a quantized LLM — specifically llama.cpp via JNI — to perform chunked document summarization without OOM kills, Doze-mode deferrals, or angry users staring at a frozen UI.&lt;/p&gt;

&lt;p&gt;By the end of this tutorial you will have a chained Worker architecture that selects the right model tier, respects memory ceilings, and promotes to a foreground service only when the model demands it.&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;WorkManager &lt;code&gt;2.9+&lt;/code&gt; on the classpath&lt;/li&gt;
&lt;li&gt;A GGUF model file bundled or downloaded to internal storage (llama.cpp, MLC LLM, or MediaPipe LLM Inference API all fit this pattern)&lt;/li&gt;
&lt;li&gt;Basic familiarity with &lt;code&gt;CoroutineWorker&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1 — Pick Your Model Tier Before Writing Any Scheduling Code
&lt;/h2&gt;

&lt;p&gt;Here is the gotcha that will save you hours: the memory ceiling on a mid-range device determines your entire architecture. A Snapdragon 6 Gen 1 with 6 GB RAM leaves your app process roughly &lt;strong&gt;1.8–2.2 GB&lt;/strong&gt; before the OOM killer becomes aggressive.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model Size&lt;/th&gt;
&lt;th&gt;INT8 RAM&lt;/th&gt;
&lt;th&gt;INT4 RAM&lt;/th&gt;
&lt;th&gt;Safe on 6 GB device?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1B&lt;/td&gt;
&lt;td&gt;~1.0 GB&lt;/td&gt;
&lt;td&gt;~0.6 GB&lt;/td&gt;
&lt;td&gt;Both tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.5B (Phi-2 class)&lt;/td&gt;
&lt;td&gt;~1.5 GB&lt;/td&gt;
&lt;td&gt;~0.9 GB&lt;/td&gt;
&lt;td&gt;Both with headroom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3B&lt;/td&gt;
&lt;td&gt;~3.0 GB&lt;/td&gt;
&lt;td&gt;~1.7 GB&lt;/td&gt;
&lt;td&gt;INT4 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7B&lt;/td&gt;
&lt;td&gt;~7.0 GB&lt;/td&gt;
&lt;td&gt;~4.0 GB&lt;/td&gt;
&lt;td&gt;Neither — move server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For background Workers &lt;strong&gt;without&lt;/strong&gt; foreground promotion, target sub-1B INT4 or sub-1.5B INT4. If you find yourself rationalizing a 7B model on a 6 GB device, that is a signal to move inference server-side, not to keep tuning constraints.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Set Constraints That Actually Matter
&lt;/h2&gt;

&lt;p&gt;The docs do not mention this, but &lt;code&gt;setRequiresBatteryNotLow&lt;/code&gt; is non-negotiable for inference workloads — LLM inference drains battery fast enough to trigger system-level throttling mid-run.&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;inferenceConstraints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Constraints&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;setRequiresBatteryNotLow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRequiredNetworkType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NetworkType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NOT_REQUIRED&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;summarizeRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneTimeWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SummarizationWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inferenceConstraints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setExpedited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutOfQuotaPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RUN_AS_NON_EXPEDITED_WORK_REQUEST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInputData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;workDataOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chunk_index"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"total_chunks"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="mi"&gt;3&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setExpedited&lt;/code&gt; is critical for user-triggered summarization. Without it, Doze-mode deferral can push your work by &lt;strong&gt;hours&lt;/strong&gt;. Expedited tasks require a &lt;code&gt;getForegroundInfo()&lt;/code&gt; override — WorkManager calls it on older API levels to attach a notification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Chain Workers for Chunked Documents
&lt;/h2&gt;

&lt;p&gt;Here is the minimal setup to get chunked summarization working. Most teams try to load the entire document in one Worker and either blow the memory budget or hit the 10-minute execution window. The correct pattern is parallel chunk Workers feeding a serial reduce Worker.&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;chunkWorkers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&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;totalChunks&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nc"&gt;OneTimeWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChunkSummarizeWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInputData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;workDataOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chunk"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;index&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reduceRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneTimeWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReduceSummaryWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;().&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nc"&gt;WorkManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunkWorkers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// parallel fan-out&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reduceRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// serial reduce&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;ChunkSummarizeWorker&lt;/code&gt; loads the model, runs inference on a ~500-token window, unloads, and writes its partial summary to the output &lt;code&gt;Data&lt;/code&gt; map. Model load/unload per chunk costs ~200–400 ms for INT4 1B models on a Snapdragon 6 Gen 1 — expensive, but it keeps peak RSS below the OOM threshold.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — Promote to Foreground for 3B Models
&lt;/h2&gt;

&lt;p&gt;If your product requires a 3B model, you must promote the Worker to a foreground service. On &lt;code&gt;CoroutineWorker&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="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getForegroundInfo&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;ForegroundInfo&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;notification&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildSummarizationNotification&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ForegroundInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;NOTIFICATION_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;ServiceInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FOREGROUND_SERVICE_TYPE_SHORT_SERVICE&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;&lt;code&gt;FOREGROUND_SERVICE_TYPE_SHORT_SERVICE&lt;/code&gt; (API 34+) gives you up to &lt;strong&gt;3 minutes&lt;/strong&gt; of guaranteed execution without a declared use-case permission — the practical sweet spot for 3B INT4 inference on a chunked document.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Silent OOM kills.&lt;/strong&gt; The OOM killer does not throw an exception — your Worker just disappears. Profile peak RSS on your minimum-spec device under load before committing to a model size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;getForegroundInfo()&lt;/code&gt; with expedited tasks.&lt;/strong&gt; WorkManager will crash on older API levels if you mark a task expedited without implementing this override.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loading the whole document in one Worker.&lt;/strong&gt; Any document over ~1,500 tokens should be chunked. One Worker, one window, bounded memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treating 7B as an on-device target.&lt;/strong&gt; At ~4 GB INT4 footprint, 7B exceeds available RAM on most 6 GB devices even with foreground promotion. Move it server-side.&lt;/p&gt;




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

&lt;p&gt;Three decisions determine whether this architecture ships or collapses in production. Profile peak RSS and select your model tier first — everything else follows from that number. Use chained Workers for documents over ~1,500 tokens to stay within memory and execution time bounds. Always set &lt;code&gt;setExpedited&lt;/code&gt; for user-initiated work to avoid Doze-mode deferrals measured in hours.&lt;/p&gt;

&lt;p&gt;Get those three right and background LLM inference becomes a reliable product feature rather than a source of silent failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; &lt;a href="https://developer.android.com/topic/libraries/architecture/workmanager" rel="noopener noreferrer"&gt;WorkManager guides&lt;/a&gt; · &lt;a href="https://github.com/shubham0204/llama.android" rel="noopener noreferrer"&gt;llama.android&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>Wiring Android's CameraX to a Quantized CLIP Model for Zero-Shot Image Classification</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:03:35 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-camerax-to-a-quantized-clip-model-for-zero-shot-image-classification-3c6d</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-camerax-to-a-quantized-clip-model-for-zero-shot-image-classification-3c6d</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;CameraX&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;Quantized&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CLIP:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Zero-Shot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Vision&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;30fps&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;Wire&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Android's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CameraX&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;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quantized&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CLIP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vision&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;encoder&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;via&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;TFLite&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;XNNPACK&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;zero-shot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;classification&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;30ms&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;per&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;frame.&lt;/span&gt;&lt;span class="nv"&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;layout,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quantization&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;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;discipline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;what&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;actually&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;determine&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;whether&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;succeed."&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://blog.mvpfactory.co/camerax-quantized-clip-zero-shot-30fps&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

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

A real-time zero-shot visual classifier on Android. CameraX feeds frames into a quantized CLIP vision encoder running through TFLite with the XNNPACK delegate. Each frame produces an embedding we score against a frozen label matrix using cosine similarity — and we do it in under 30ms, without triggering the OOM killer.

Let me show you a pattern I use in every project: treat this as a &lt;span class="gs"&gt;**pipeline problem**&lt;/span&gt;, not a model problem. Most teams swap in smaller models and still miss their frame budget because the bottleneck is preprocessing, memory allocation, or interpreter lifecycle — not the model itself.
&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; Android project targeting API 21+
&lt;span class="p"&gt;-&lt;/span&gt; CameraX 1.3+ (&lt;span class="sb"&gt;`androidx.camera:camera-camera2`&lt;/span&gt;, &lt;span class="sb"&gt;`camera-lifecycle`&lt;/span&gt;, &lt;span class="sb"&gt;`camera-view`&lt;/span&gt;)
&lt;span class="p"&gt;-&lt;/span&gt; TFLite runtime with XNNPACK delegate (&lt;span class="sb"&gt;`org.tensorflow:tensorflow-lite`&lt;/span&gt;, &lt;span class="sb"&gt;`tensorflow-lite-gpu`&lt;/span&gt;)
&lt;span class="p"&gt;-&lt;/span&gt; A quantized CLIP vision encoder exported to &lt;span class="sb"&gt;`.tflite`&lt;/span&gt; (INT8 PTQ or QAT)
&lt;span class="p"&gt;-&lt;/span&gt; Pre-computed label embeddings (run your text prompts through the CLIP text encoder offline, L2-normalize, freeze as a &lt;span class="sb"&gt;`[N×512]`&lt;/span&gt; float32 buffer)
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Step 1 — Enable the XNNPACK Delegate at Interpreter Construction&lt;/span&gt;

Here is the minimal setup to get this working. Pass the delegate when building the interpreter — this is when TFLite pre-packs weight tensors into XNNPACK's tiled memory layout. This happens &lt;span class="gs"&gt;**once at load time**&lt;/span&gt;, not per inference.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val xnnpackDelegate = XNNPackDelegate(&lt;br&gt;
    XNNPackDelegate.Options().apply { numThreads = 2 }&lt;br&gt;
)&lt;br&gt;
val interpreter = Interpreter(&lt;br&gt;
    modelBuffer,&lt;br&gt;
    Interpreter.Options().addDelegate(xnnpackDelegate)&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;
Never recreate the interpreter between frames. Interpreter creation costs ~40ms and forces a full re-pack cycle. Interpreter lifetime should match session lifetime — allocate once, release only when the use case is torn down.

---

## Step 2 — Wire CameraX with the Right Resolution Strategy

`setTargetResolution` is deprecated as of CameraX 1.3. Use `ResolutionSelector` with `ResolutionStrategy`. Run the analyzer on a dedicated single-thread `Executor` — frame analysis must not compete with the main thread or Compose recomposition.

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val resolutionSelector = ResolutionSelector.Builder()&lt;br&gt;
    .setResolutionStrategy(&lt;br&gt;
        ResolutionStrategy(&lt;br&gt;
            Size(224, 224),&lt;br&gt;
            ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER&lt;br&gt;
        )&lt;br&gt;
    )&lt;br&gt;
    .build()&lt;/p&gt;

&lt;p&gt;val imageAnalysis = ImageAnalysis.Builder()&lt;br&gt;
    .setResolutionSelector(resolutionSelector)&lt;br&gt;
    .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST)&lt;br&gt;
    .setOutputImageFormat(OUTPUT_IMAGE_FORMAT_RGBA_8888)&lt;br&gt;
    .build()&lt;br&gt;
    .also { it.setAnalyzer(executor, ::analyzeFrame) }&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Use `STRATEGY_KEEP_ONLY_LATEST` — you want the freshest frame, not a queue of stale ones.

---

## Step 3 — Quantization Strategy

The docs do not always make this trade-off explicit, but here is the table that actually matters for CLIP on a mid-range Snapdragon:

| Precision  | Model Size | Inference (Pixel 7) | Accuracy Drop |
|------------|------------|---------------------|---------------|
| FP32       | ~350MB     | ~55ms               | Baseline      |
| FP16       | ~175MB     | ~38ms               | &amp;lt;0.5%         |
| INT8 (PTQ) | ~88MB      | ~19ms               | 1–3%          |
| INT8 (QAT) | ~88MB      | ~19ms               | &amp;lt;1%           |

Use INT8 PTQ as your baseline. Invest in QAT only if the accuracy regression exceeds your product threshold — model size and latency are identical between the two. The text encoder is only run offline, so its precision is irrelevant to the runtime budget.

---

## Step 4 — Memory Layout Under 512MB

At 30fps your budget is 33ms per frame. A quantized CLIP vision encoder costs 18–22ms on a mid-range Snapdragon. That leaves 11–15ms for YUV→RGB conversion (use libyuv, not deprecated RenderScript on API 31+), similarity scoring, and UI dispatch.

Two rules keep you inside that margin:

- Allocate input/output tensors as `ByteBuffer.allocateDirect()` — off-heap, GC-invisible
- Keep your frozen label embedding matrix in native memory and treat it as immutable

XNNPACK's pre-packed weight buffers live in native memory and do not count against your Java heap. The OOM killer on Android targets the largest contiguous Java heap allocation first — native buffers are your friend here.

---

## Gotchas

**Recreating the interpreter between frames.** This is the one that will burn you. Every teardown forces a repack cycle that costs 4–8ms and spikes GC pressure at exactly the wrong moment — 99th-percentile frame time spikes, visible stutters, and an angry product manager.

**Reallocating the label embedding matrix at runtime.** Freeze it at init. Any runtime reallocation introduces GC pauses that blow your frame budget. Compute it once from your text prompts, L2-normalize, and never touch it again.

**Running the analyzer on the main thread.** CameraX will let you do this. Do not. Dedicate a single-thread executor to frame analysis.

**Using `setTargetResolution`.** Deprecated since CameraX 1.3. Use `ResolutionSelector` or you will get a compiler warning today and a runtime surprise later.

---

## Conclusion

The pipeline — CameraX → YUV→RGB → TFLite + XNNPACK → cosine similarity → label — is straightforward once you understand that the hard constraints are memory layout and interpreter lifecycle, not model architecture. Enable the XNNPACK delegate at construction, freeze your label embeddings at init, allocate off-heap, and never recreate the interpreter mid-session. Hit those three and you will hold 30fps on mid-range hardware with headroom to spare.

**Resources:**
- [TFLite XNNPACK Delegate docs](https://www.tensorflow.org/lite/performance/xnnpack)
- [CameraX ResolutionSelector API](https://developer.android.com/reference/androidx/camera/core/resolutionselector/ResolutionSelector)
- [libyuv on Android](https://chromium.googlesource.com/libyuv/libyuv)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Wiring Android's NNAPI to a Quantized Embedding Model for Semantic Search</title>
      <dc:creator>SoftwareDevs mvpfactory.io</dc:creator>
      <pubDate>Wed, 09 Sep 2026 14:57:04 +0000</pubDate>
      <link>https://dev.to/software_mvp-factory/wiring-androids-nnapi-to-a-quantized-embedding-model-for-semantic-search-39hb</link>
      <guid>https://dev.to/software_mvp-factory/wiring-androids-nnapi-to-a-quantized-embedding-model-for-semantic-search-39hb</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;NNAPI&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Semantic&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Search&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;INT8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Embeddings&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;270MB"&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;production&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;semantic&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pipeline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;using&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;NNAPI-delegated&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;INT8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;embeddings&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;an&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HNSW&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;index&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;all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;within&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;270MB&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;memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;budget."&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/nnapi-semantic-search-android-int8-embeddings&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 working on-device semantic search pipeline on Android: an INT8 quantized TFLite embedding model delegated to NNAPI, batch inference to amortize DSP startup cost, and an HNSW approximate nearest neighbor index loaded from &lt;span class="sb"&gt;`mmap`&lt;/span&gt;. The critical constraint we will respect throughout: keep everything under ~270MB of native memory or NNAPI silently drops to CPU and wrecks your latency budget.

&lt;span class="gu"&gt;## Prerequisites&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Android Studio with NDK support
&lt;span class="p"&gt;-&lt;/span&gt; Snapdragon 778G / Tensor G2 or equivalent (Snapdragon 7-series 2021+, Dimensity 9000+). Budget Snapdragon 4-series hardware falls back to CPU-only paths.
&lt;span class="p"&gt;-&lt;/span&gt; Android 12+ (ART heap behavior assumed in all measurements)
&lt;span class="p"&gt;-&lt;/span&gt; A calibrated INT8 TFLite model — benchmarks below use an internal corpus, n=10K queries, batch size=16, max sequence length=128
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## The Pipeline at a Glance&lt;/span&gt;

Four stages, nothing more:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**INT8 TFLite model**&lt;/span&gt; → NNAPI delegate
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Batch inference**&lt;/span&gt; → 384-dim float vectors
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**HNSW index**&lt;/span&gt; mmap'd from internal storage
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="gs"&gt;**ANN query**&lt;/span&gt; → top-K results via cosine similarity
&lt;span class="p"&gt;
---
&lt;/span&gt;
&lt;span class="gu"&gt;## Stage 1: Pick INT8 — Here Is Why&lt;/span&gt;

Let me show you a pattern I use in every project: quantize to INT8, stop there.

| Precision | Model Size | Latency (Pixel 7) | Recall@10 |
|-----------|------------|-------------------|-----------|
| FP32 | 92 MB | 85ms/batch | 0.94 |
| FP16 | 46 MB | 48ms/batch | 0.93 |
| INT8 | 23 MB | 14ms/batch | 0.91 |
| INT4 | 12 MB | 11ms/batch | 0.86 |

INT8 gives you a 6x size reduction and 6x latency improvement for a 3-point recall drop versus FP32. INT4 falls below acceptable thresholds for real search quality. Use post-training quantization via TFLite's converter &lt;span class="gs"&gt;**with a representative dataset calibration**&lt;/span&gt; — skipping that calibration step is the single most common mistake I see teams make.

Wire up the NNAPI delegate like this:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
val options = Interpreter.Options().apply {&lt;br&gt;
    addDelegate(&lt;br&gt;
        NnApiDelegate(&lt;br&gt;
            NnApiDelegate.Options().apply {&lt;br&gt;
                executionPreference =&lt;br&gt;
                    NnApiDelegate.Options.EXECUTION_PREFERENCE_SUSTAINED_SPEED&lt;br&gt;
                allowFp16 = false // Force the INT8 delegate path&lt;br&gt;
            }&lt;br&gt;
        )&lt;br&gt;
    )&lt;br&gt;
    setNumThreads(4)&lt;br&gt;
}&lt;br&gt;
val interpreter = Interpreter(modelBuffer, options)&lt;br&gt;
&lt;/p&gt;

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

## Stage 2: Always Batch — Never Single-Sample

NNAPI delegation carries a cold-start cost of 15–80ms of DSP/NPU startup overhead depending on chipset. Single-sample inference pays that every time — catastrophically inefficient.

Batch at 8–16 inputs:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
kotlin&lt;br&gt;
fun embedBatch(sentences: List): Array {&lt;br&gt;
    val tokenized = tokenizer.batchEncode(sentences, maxLength = 128)&lt;br&gt;
    val output = Array(sentences.size) { FloatArray(EMBEDDING_DIM) }&lt;br&gt;
    interpreter.runForMultipleInputsOutputs(&lt;br&gt;
        arrayOf(tokenized.inputIds, tokenized.attentionMask),&lt;br&gt;
        mapOf(0 to output)&lt;br&gt;
    )&lt;br&gt;
    return output&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;
Targeting batch size 16 dropped per-embedding cost from 14ms to 2.1ms — a 6.5x throughput improvement for indexing flows.

---

## Stage 3: Respect the 270MB Ceiling

Here is the gotcha that will save you hours. When total native memory exceeds approximately 270MB, NNAPI silently falls back to CPU. No exception is thrown. You get a 4–8x latency regression with zero error signal unless you monitor `NnApiDelegate.getNnApiErrno()` directly.

The memory math for a realistic 300K-document corpus:

| Component | Memory |
|-----------|--------|
| INT8 embedding model | 23 MB |
| NNAPI working buffers | ~45 MB |
| HNSW index (300K docs) | ~162 MB |
| Tokenizer + vocab | ~8 MB |
| **Total** | **~238 MB** |

That leaves ~32MB of headroom. The HNSW index sizing uses ~540 bytes per vector (384 raw bytes + neighbor lists + metadata at M=16). The docs do not mention this, but you must `mmap` the HNSW graph via `MappedByteBuffer` — loading the full graph into heap blows this budget immediately.

For the ANN library, hnswlib via JNI is the practical default (Apache licensed, C++ core, battle-tested). ScaNN is superior at &amp;gt;5M vectors but the Android build pipeline is nontrivial. Faiss is overkill below 1M vectors.

---

## Gotchas

**Warm up on app start.** Run a dummy batch immediately after initialization. Cold delegation on the first real user query adds 200–400ms of visible latency.

**Monitor delegation status in analytics.** Silent CPU fallback is invisible in standard crash reporting. Log `NnApiDelegate.getNnApiErrno()` at the start of every inference session. I added this to HealthyDesk's internal telemetry layer after catching exactly this failure in production — if you ship any on-device inference, make this a first-class signal.

**Never skip calibration.** Your quantized model without a representative calibration dataset will show recall degradation well beyond the expected 3-point drop. Profile on real corpus data before shipping.

**Profile under load, not idle.** Android Studio's Memory Profiler will show you a misleading picture at app launch. Measure under realistic indexing load to see your true headroom against the 270MB ceiling.

---

## Conclusion

On-device semantic search on Android is production-ready today. The hardware is capable — NNAPI just requires you to respect its memory contract. Three things to take away:

1. **Stay under 270MB total native memory** — profile under load, not idle
2. **Batch at size 8–16** — DSP startup amortization is the highest-ROI optimization in this pipeline
3. **Choose INT8, not INT4** — the recall penalty from INT4 is rarely worth the marginal size gain

Benchmarks are from Snapdragon 778G and Tensor G2 (Pixel 7). Your mileage will vary on other chipsets, but the architecture and memory math hold across the mid-range tier that matters for production apps.

**Further reading:**
- [TFLite Post-Training Quantization](https://www.tensorflow.org/lite/performance/post_training_quantization)
- [NNAPI Delegate docs](https://www.tensorflow.org/lite/android/delegates/nnapi)
- [hnswlib on GitHub](https://github.com/nmslib/hnswlib)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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