<?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: Alex DevOps engineer</title>
    <description>The latest articles on DEV Community by Alex DevOps engineer (@oleksandr_devops).</description>
    <link>https://dev.to/oleksandr_devops</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%2F3907876%2F9865b7b5-fe2d-4576-867f-becdf82a1fef.png</url>
      <title>DEV Community: Alex DevOps engineer</title>
      <link>https://dev.to/oleksandr_devops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleksandr_devops"/>
    <language>en</language>
    <item>
      <title>Running an AI interviewer fully on-device with Gemma 3 1B — and engineering around what a 1B model can't do</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Wed, 08 Jul 2026 20:35:35 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/running-an-ai-interviewer-fully-on-device-with-gemma-3-1b-and-engineering-around-what-a-1b-model-40ll</link>
      <guid>https://dev.to/oleksandr_devops/running-an-ai-interviewer-fully-on-device-with-gemma-3-1b-and-engineering-around-what-a-1b-model-40ll</guid>
      <description>&lt;p&gt;I built an Android app that runs a mock DevOps job interview with an AI interviewer that lives &lt;strong&gt;entirely on your phone&lt;/strong&gt;. No cloud API, no OpenAI key, no round-trip to a server. The language model — Gemma 3 1B — is downloaded once and every inference after that runs fully offline, on the subway, on a plane, with airplane mode on.&lt;/p&gt;

&lt;p&gt;The headline is "on-device LLM." But the honest story is the opposite of the hype: the model was the &lt;em&gt;easy&lt;/em&gt; part. The real work was engineering a wall of determinism &lt;strong&gt;around&lt;/strong&gt; the model — because a 1B model, running on a phone CPU, cannot be trusted to do the things you'd casually hand to GPT-4.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DevOps Interview AI&lt;/strong&gt; is a native Android app that puts you in a mock interview against one of four AI personas, each a different seniority level:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Interviewer&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Emma — Recruiter&lt;/td&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;Cultural fit, CV review, salary expectations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Olivia — HR&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Behavioural questions, team dynamics, soft skills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marcus — Senior DevOps&lt;/td&gt;
&lt;td&gt;Hard&lt;/td&gt;
&lt;td&gt;CI/CD, Kubernetes, IaC, observability, SRE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;David — CTO&lt;/td&gt;
&lt;td&gt;Expert&lt;/td&gt;
&lt;td&gt;Architecture, scalability, engineering trade-offs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You pick an interviewer, join a Google Meet-style session (interviewer avatar tile, live subtitles, your camera in the corner), and answer five questions by voice. Speech-to-text turns your answer into text, the model asks the next question, text-to-speech speaks it back. At the end you get a scored evaluation. All of it offline.&lt;/p&gt;

&lt;p&gt;The stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Kotlin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Jetpack Compose + Material 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI engine&lt;/td&gt;
&lt;td&gt;Google MediaPipe Tasks GenAI 0.10.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Gemma 3 1B, INT4 quantized (&lt;code&gt;.task&lt;/code&gt; bundle)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice&lt;/td&gt;
&lt;td&gt;Android &lt;code&gt;SpeechRecognizer&lt;/code&gt; (STT) + &lt;code&gt;TextToSpeech&lt;/code&gt; (TTS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Room + DataStore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;ViewModel + StateFlow, strict package boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why on-device at all?
&lt;/h2&gt;

&lt;p&gt;The motivation is privacy, and it's not a marketing checkbox — it's the whole point. Interview practice is where you're most exposed: you fumble answers, you second-guess your seniority, you say things you'd never put in front of a recruiter. Sending that stream to a third-party API felt wrong. On-device means the interview data physically cannot leave the phone. No telemetry, no cloud sync, nothing to leak.&lt;/p&gt;

&lt;p&gt;The side effects are nice too: zero inference cost, no rate limits, and it works with no connection at all. But privacy is the reason the whole thing runs the way it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a gated model into a client app without leaking a token
&lt;/h2&gt;

&lt;p&gt;Here's the first real problem. Gemma 3 1B is distributed on Hugging Face behind a license gate (&lt;code&gt;litert-community/Gemma3-1B-IT&lt;/code&gt;). An anonymous download gets an HTTP 401 — you need an access token. And you &lt;em&gt;cannot&lt;/em&gt; ship a personal Hugging Face token inside an APK: anyone who unzips the APK can extract it and reuse it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.task&lt;/code&gt; bundle is also ~530 MB, so bundling it as an app asset is a non-starter (nobody ships a half-gigabyte APK).&lt;/p&gt;

&lt;p&gt;The solution splits distribution in two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;source&lt;/strong&gt; app is one repository.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;model bundle&lt;/strong&gt; is published as a &lt;strong&gt;public GitHub Release asset&lt;/strong&gt; in a &lt;em&gt;separate&lt;/em&gt; repo. GitHub Release assets are served over plain HTTPS with no credential — no token to embed, no gate to pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On first launch, the app streams the model straight from that release into its private storage:&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="cm"&gt;/**
 * Streams the Gemma 3 1B `.task` bundle from a GitHub Release asset straight
 * into Context.getFilesDir ...
 *
 * Hosted as a Release asset (not the original Hugging Face repo) because
 * litert-community/Gemma3-1B-IT is gated behind license acceptance — an
 * anonymous request gets HTTP 401, and embedding a personal HF access token
 * in a client APK would let anyone who unzips the APK extract and reuse it.
 *
 * Writes to a `.part` file first and renames atomically on success, so a
 * half-downloaded file is never mistaken for a ready model.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters more than it looks. The "is the model ready?" check is deliberately dumb — a single file-exists lookup:&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;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;MODEL_FILE_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gemma-2b-it-gpu-int4.task"&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;isModelReady&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="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;File&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="n"&gt;filesDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MODEL_FILE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the download wrote directly to that filename and the process got killed at 60%, &lt;code&gt;isModelReady()&lt;/code&gt; would return &lt;code&gt;true&lt;/code&gt; for a corrupt file, and the engine would crash on load forever. Downloading to &lt;code&gt;model.task.part&lt;/code&gt; and renaming atomically on completion means the real filename only ever exists as a complete file. Crash-safety from one &lt;code&gt;rename()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;(An aside on that filename: it says &lt;code&gt;gemma-2b&lt;/code&gt;, but the model is Gemma 3 1B. MediaPipe's &lt;code&gt;LlmInferenceOptions&lt;/code&gt; doesn't care what the model is called — it loads whatever &lt;code&gt;.task&lt;/code&gt; sits at the path. So the filename is just a fixed interface contract that the downloader, the splash bootstrap check, and the engine all agree on. I left the legacy name rather than pretend it means anything.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyel8btc2h64l9wrkois5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyel8btc2h64l9wrkois5.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading the model: CPU, on purpose
&lt;/h2&gt;

&lt;p&gt;MediaPipe makes the actual load short:&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;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmInferenceOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;setModelPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_TOKENS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// 2048 — input + output combined&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxTopK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TOP_K&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPreferredBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CPU&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="n"&gt;inference&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFromOptions&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="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MAX_TOKENS&lt;/code&gt; is the whole budget, not just output.&lt;/strong&gt; MediaPipe rejects the entire request if the &lt;em&gt;input&lt;/em&gt; alone (system prompt + conversation history) exceeds it. On a 2048-token budget, an interview's growing history is a real constraint you have to manage, not an afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I force the CPU backend.&lt;/strong&gt; The GPU path was unstable on the devices I tested, and a 1B model runs perfectly well on CPU/XNNPACK. Chasing the GPU wasn't worth the crashes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Streaming tokens so it doesn't feel frozen
&lt;/h2&gt;

&lt;p&gt;A 1B model on a phone CPU is not instant. If you wait for the full response, the UI looks hung. So generation streams token-by-token through a &lt;code&gt;callbackFlow&lt;/code&gt;, and the subtitles fill in live:&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;fun&lt;/span&gt; &lt;span class="nf"&gt;generateResponseStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&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;Flow&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;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;callbackFlow&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;listener&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProgressListener&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;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partial&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;trySend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// emit each delta as it lands&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;close&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;future&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;future&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="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;onFailure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;close&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="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nc"&gt;Runnable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;awaitClose&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;flowOn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measuring &lt;em&gt;first-token latency&lt;/em&gt; (not total time) turned out to be the number that actually predicts whether the app feels alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson: don't trust the 1B model with anything you can compute yourself
&lt;/h2&gt;

&lt;p&gt;This is the part I want other people building on small on-device models to hear.&lt;/p&gt;

&lt;p&gt;A 1B model is not a small GPT-4. It's a text predictor that will happily forget which question number it's on, introduce itself with the wrong name, or invent a technical question that's off-topic. So the rule I converged on is: &lt;strong&gt;anything that has to be correct, compute in Kotlin. Only ask the model to do the part that genuinely needs generation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The greeting is not generated.&lt;/strong&gt; The interviewer's name and title must always be right, so a plain string builds it:&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="c1"&gt;// A 1B model can't be trusted to introduce itself reliably, and the greeting&lt;/span&gt;
&lt;span class="c1"&gt;// is a hard product requirement.&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;buildGreeting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interviewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InterviewerProfile&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="s"&gt;"Hello, I am ${interviewer.name}, your ${interviewer.title} interviewer today."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The question counter lives in Kotlin, not in the model's head.&lt;/strong&gt; The &lt;code&gt;ViewModel&lt;/code&gt; owns "we're on question 3 of 5" and "all answers are in, now evaluate." Each turn injects that state as an explicit control block, because the model cannot reliably count its own turns:&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="c1"&gt;// The question counter and the evaluation switch are decided in Kotlin, not by&lt;/span&gt;
&lt;span class="c1"&gt;// the model — a 1B model cannot reliably count its own turns. Each call injects&lt;/span&gt;
&lt;span class="c1"&gt;// the current state as an explicit control block so Gemma always knows exactly&lt;/span&gt;
&lt;span class="c1"&gt;// what to do next.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Technical questions come from a bank, not the model.&lt;/strong&gt; For the Hard/Expert interviewers, the exact question text is pulled from a curated &lt;code&gt;QuestionBank&lt;/code&gt; and handed to the model to present (lightly rephrased at most). This keeps generations short, on-topic, and actually about CI/CD and Kubernetes instead of whatever a 1B model free-associates into.&lt;/p&gt;

&lt;p&gt;And the prompt itself is hand-built in Gemma's turn format, priming the model with an acknowledgement turn so it stays in character:&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="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${SOT}user\n$systemPrompt$EOT\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${SOT}model\nUnderstood. I am ready to conduct the interview.$EOT\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ...conversation history, then the control block + user's answer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern generalizes: &lt;strong&gt;the model generates prose; deterministic code owns state, identity, and control flow.&lt;/strong&gt; That division is what makes a 1B model feel like a product instead of a toy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small delightful detail: two interviewers, one voice — on purpose
&lt;/h2&gt;

&lt;p&gt;Voices are on-device TTS. Marcus (Senior DevOps) and David (CTO) deliberately share the &lt;em&gt;exact&lt;/em&gt; same male voice and pitch:&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="c1"&gt;// Marcus shares David's exact male voice (MALE_SECONDARY + 0.78 pitch) by&lt;/span&gt;
&lt;span class="c1"&gt;// requirement — keep these two lines identical to David's below.&lt;/span&gt;
&lt;span class="n"&gt;voicePitch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.78f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;voiceProfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VoiceProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MALE_SECONDARY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads like a bug in a diff — two profiles with identical voice config — so the comment exists to stop a future me (or a reviewer) from "fixing" it. Product requirements that look like mistakes need a paper trail in the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture &amp;amp; process
&lt;/h2&gt;

&lt;p&gt;The package layout enforces clean boundaries — &lt;code&gt;features&lt;/code&gt; / &lt;code&gt;core&lt;/code&gt; (LLM, voice, download) / &lt;code&gt;domain&lt;/code&gt; (models) / &lt;code&gt;data&lt;/code&gt; (storage) — with no Android framework imports leaking into the domain models. Development follows a strict Git Flow (&lt;code&gt;main&lt;/code&gt; → &lt;code&gt;develop&lt;/code&gt; → &lt;code&gt;feature/*&lt;/code&gt;), annotated release tags, task IDs on every commit, and GitHub Actions for CI. Currently at v1.7.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  License &amp;amp; privacy
&lt;/h2&gt;

&lt;p&gt;The app is licensed under the &lt;strong&gt;Business Source License 1.1&lt;/strong&gt; (© 2026 Alex Korchenko) — not MIT, and I'd rather be accurate than slap an "open source" badge on it. What's genuinely public is what a user needs: the &lt;strong&gt;APK&lt;/strong&gt; and the &lt;strong&gt;model bundle&lt;/strong&gt; are distributed openly, no login or token required. The privacy guarantee is the important promise, and it's structural, not a policy: interview data never leaves the device because there is no server for it to go to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-device LLM ≠ small cloud LLM.&lt;/strong&gt; Budget your context window as input &lt;em&gt;plus&lt;/em&gt; output, pin a stable backend, and measure first-token latency, not total time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never embed a credential in a client to reach a gated model.&lt;/strong&gt; Re-host the artifact somewhere public (a GitHub Release asset works great) and stream it in — download to &lt;code&gt;.part&lt;/code&gt;, rename atomically, and let a file-exists check be your "ready" signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute everything you can; only generate what you must.&lt;/strong&gt; Names, counters, control flow, and canned questions belong in your own code. A 1B model is a prose engine wrapped in deterministic scaffolding — the scaffolding is the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comment the requirements that look like bugs.&lt;/strong&gt; Two identical voice configs, a legacy filename — a one-line comment saves the next person from "fixing" a deliberate choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flashy line is "AI interviewer in your pocket, fully offline." The engineering truth is quieter: a small model plus a lot of boring, deterministic Kotlin around it.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm &lt;strong&gt;Alex&lt;/strong&gt; — a DevOps/engineer who builds things end-to-end and writes about the parts that don't make it into the README. This project came out of wanting realistic interview practice without handing my worst answers to someone else's server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📱 App + model (public releases): &lt;strong&gt;&lt;a href="https://github.com/AI-DevOps-Interview-Avatar/devops-interview-app" rel="noopener noreferrer"&gt;AI-DevOps-Interview-Avatar&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;💼 LinkedIn: &lt;a href="https://www.linkedin.com/in/alex-d-732900177/" rel="noopener noreferrer"&gt;alex-d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X: &lt;a href="https://x.com/mainoceanm" rel="noopener noreferrer"&gt;@mainoceanm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📄 CV: &lt;a href="https://1drv.ms/w/c/a7697c18c9894c2b/IQA0Nj8hGiNRQ65CBsNq7KssAXbs3CJRfJZ3pGYIktdcq50?e=VTGBcB" rel="noopener noreferrer"&gt;résumé on OneDrive&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:mainoceanm@gmail.com"&gt;mainoceanm@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Building on a small on-device model too? I'd love to compare notes on what you had to compute yourself — drop it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>How a rotating refresh token turned into a 401 storm on Cloudflare Workers</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Wed, 08 Jul 2026 16:25:03 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/how-a-rotating-refresh-token-turned-into-a-401-storm-on-cloudflare-workers-1bel</link>
      <guid>https://dev.to/oleksandr_devops/how-a-rotating-refresh-token-turned-into-a-401-storm-on-cloudflare-workers-1bel</guid>
      <description>&lt;p&gt;We run a Cloudflare Workers + D1 backend with rotating refresh tokens. One afternoon production started sending a steady stream of 401s on &lt;code&gt;POST /v1/auth/refresh&lt;/code&gt; — not a spike, a &lt;em&gt;storm&lt;/em&gt;: dozens of alerts, spread out, never stopping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8p8h7b08vojmwe8a0zj3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8p8h7b08vojmwe8a0zj3.png" alt=" " width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The obvious suspect (a rotation race between two browser tabs sharing one refresh cookie) turned out to only be part of the story. The actual fix took three small, independent changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The frontend called refresh on &lt;em&gt;every&lt;/em&gt; page load, even with a still-valid access token — turning ordinary browsing into constant unnecessary rotation.&lt;/li&gt;
&lt;li&gt;A rejected refresh never cleared the httpOnly cookie, so a browser with a dead session kept replaying the same doomed request forever.&lt;/li&gt;
&lt;li&gt;Anonymous visitors (no session at all) were still calling refresh and getting a guaranteed 401 on every view.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this needed a new backend mechanism — just removing assumptions that had quietly stopped being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;This is &lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt;, a coffee e-commerce platform built entirely on Cloudflare's edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt; — a &lt;a href="https://developers.cloudflare.com/workers/" rel="noopener noreferrer"&gt;Cloudflare Workers&lt;/a&gt; API written in &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; with &lt;code&gt;@hono/zod-openapi&lt;/code&gt;, backed by Cloudflare &lt;strong&gt;D1&lt;/strong&gt; (SQLite at the edge). It owns auth, users and products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; — a Nuxt 4 storefront (Vue 3, Pinia, TailwindCSS) deployed to Cloudflare Pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot&lt;/strong&gt; — a Telegram bot, also on Workers, sharing the same D1 database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auth uses a short-lived &lt;strong&gt;access token&lt;/strong&gt; (in memory on the client) plus a rotating &lt;strong&gt;refresh token&lt;/strong&gt; kept in an &lt;code&gt;httpOnly&lt;/code&gt;, cross-origin cookie. Because the cookie is &lt;code&gt;httpOnly&lt;/code&gt; and set on a different origin than the storefront, &lt;strong&gt;the browser can send it but JavaScript can never read it.&lt;/strong&gt; Remember that constraint — it's the seed of the whole storm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storm
&lt;/h2&gt;

&lt;p&gt;The symptom was a flat, unrelenting line of &lt;code&gt;401 Unauthorized&lt;/code&gt; on &lt;code&gt;POST /v1/auth/refresh&lt;/code&gt;. Not correlated with a deploy, not correlated with traffic peaks — just a constant background hum of failed refreshes, each one firing an alert.&lt;/p&gt;

&lt;p&gt;Latency and error budgets were fine for &lt;em&gt;real&lt;/em&gt; endpoints. The 401s were "successful failures": the Worker was doing exactly what it was told, rejecting refresh requests that could never succeed. The bug wasn't in the rejection — it was in &lt;em&gt;who kept asking&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1 — stop refreshing on every page load
&lt;/h2&gt;

&lt;p&gt;The Pinia auth store refreshed the session on initialization, unconditionally. Every navigation that re-hydrated the store fired a refresh, even when the access token had 40 minutes of life left. On a rotating-token scheme that's not just wasteful — every refresh &lt;strong&gt;rotates&lt;/strong&gt; the token, so two quick navigations could race each other.&lt;/p&gt;

&lt;p&gt;The fix is a threshold: only refresh when the access token is actually near expiry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_THRESHOLD_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;needsAccessTokenRefresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_THRESHOLD_MS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now ordinary browsing rides the existing access token and only refreshes in the last minute of its lifetime, instead of on every mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2 — clear the dead cookie when refresh is rejected
&lt;/h2&gt;

&lt;p&gt;Here's the nasty one. When the backend rejected a refresh (expired/rotated/revoked token), it returned a 401 — but left the &lt;code&gt;httpOnly&lt;/code&gt; refresh cookie in place. The browser, having no way to inspect that cookie, had no idea it was dead. So on the next page load it dutifully sent the same doomed cookie again... and again... forever. One dead session = an infinite private 401 stream.&lt;/p&gt;

&lt;p&gt;The backend now clears the cookie on the rejected path, so the browser stops resending it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clearRefreshTokenCookie&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../lib/refresh-cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// inside the refresh handler, on the failure branch:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearRefreshTokenCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// tell the browser to drop the dead cookie&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;401&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 &lt;code&gt;Set-Cookie&lt;/code&gt; with an expired/&lt;code&gt;Max-Age=0&lt;/code&gt; value turns "replay forever" into "fail once, then go quiet."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3 — don't let anonymous visitors refresh at all
&lt;/h2&gt;

&lt;p&gt;Because the refresh cookie is invisible to JavaScript, the client couldn't tell a logged-out visitor apart from a logged-in one at boot time — so it optimistically tried to refresh for &lt;em&gt;everyone&lt;/em&gt;, including first-time anonymous visitors who never had a session. Each of those is a guaranteed 401.&lt;/p&gt;

&lt;p&gt;The client can't read the cookie, but it &lt;em&gt;can&lt;/em&gt; remember that a session once existed in this browser. A tiny &lt;code&gt;localStorage&lt;/code&gt; marker does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The refresh cookie is httpOnly on a cross-origin API, so the client can't see&lt;/span&gt;
&lt;span class="c1"&gt;// it — this marker records "a session once existed in this browser" instead, so&lt;/span&gt;
&lt;span class="c1"&gt;// anonymous visitors never fire a doomed POST /v1/auth/refresh on page load.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HAD_SESSION_MARKER_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brewly-had-session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hadSessionInThisBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HAD_SESSION_MARKER_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker is set when a session is stored and removed in &lt;code&gt;clearSession()&lt;/code&gt; (right next to fix #2 on the client side). At boot, the store only attempts a refresh if &lt;code&gt;hadSessionInThisBrowser()&lt;/code&gt; is true. Anonymous visitors now make zero refresh calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rotation race (the suspect that wasn't the whole story)
&lt;/h2&gt;

&lt;p&gt;The original hypothesis — two tabs sharing one refresh cookie, each rotating it and invalidating the other — was real, but it was a &lt;em&gt;contributor&lt;/em&gt;, not the root cause. Once fixes 1–3 collapsed the volume of refresh calls, the race window shrank dramatically: fewer calls, fewer concurrent rotations. (We also added a short rotation grace so a just-rotated token isn't instantly rejected for an in-flight second request — but that's a smaller, separate story.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 401 storm is a "who's asking" problem as often as a "why rejected" problem.&lt;/strong&gt; The rejection logic was correct the whole time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;httpOnly&lt;/code&gt; cross-origin cookies are invisible to the client — design for that.&lt;/strong&gt; If the client can't see the cookie, give it a separate signal (a marker) so it can reason about session state without guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always clear a credential the server just rejected.&lt;/strong&gt; Otherwise the client will replay it until the heat death of the browser tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh on need, not on mount.&lt;/strong&gt; Rotation schemes punish over-eager refreshing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three small, independent changes — no new backend architecture, no schema migration. Just deleting assumptions that had quietly stopped being true.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm &lt;strong&gt;Alex&lt;/strong&gt; — a DevOps engineer building &lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt;, a coffee e-commerce platform that runs entirely on Cloudflare's edge (Workers, D1, Pages). I write about edge architecture, auth, and the debugging stories that come with shipping to production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 Live project: &lt;strong&gt;&lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;brewly.online&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🎥 YouTube — building the Brewly Telegram bot on Cloudflare Workers: &lt;a href="https://www.youtube.com/watch?v=Wm5E8TSZRhw" rel="noopener noreferrer"&gt;watch the walkthrough&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💼 LinkedIn: &lt;a href="https://www.linkedin.com/in/alex-d-732900177/" rel="noopener noreferrer"&gt;alex-d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X: &lt;a href="https://x.com/mainoceanm" rel="noopener noreferrer"&gt;@mainoceanm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📄 CV: &lt;a href="https://1drv.ms/w/c/a7697c18c9894c2b/IQA0Nj8hGiNRQ65CBsNq7KssAXbs3CJRfJZ3pGYIktdcq50?e=VTGBcB" rel="noopener noreferrer"&gt;résumé on OneDrive&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:mainoceanm@gmail.com"&gt;mainoceanm@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Questions or war stories about edge auth? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>devops</category>
      <category>backend</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Shipping at the Edge: Migrating a Coffee Subscription Platform to Cloudflare Workers</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Fri, 22 May 2026 13:10:47 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/shipping-at-the-edge-migrating-a-coffee-subscription-platform-to-cloudflare-workers-3ip4</link>
      <guid>https://dev.to/oleksandr_devops/shipping-at-the-edge-migrating-a-coffee-subscription-platform-to-cloudflare-workers-3ip4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Building a D2C platform isn't just about the product; it's about the resilience of the delivery chain. I've been working on Brewly Store, a coffee subscription service, and recently decided to tear down our traditional setup to embrace a more modern, edge-first architecture.&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feq184jhv53ei492cxt8x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feq184jhv53ei492cxt8x.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://brewly-frontend.pages.dev/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;brewly-frontend.pages.dev&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;The Tech Stack 🛠:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Runtime: Cloudflare Workers (using the Hono framework).&lt;/p&gt;

&lt;p&gt;Language: TypeScript.&lt;/p&gt;

&lt;p&gt;Infrastructure: AWS EKS for core services.&lt;/p&gt;

&lt;p&gt;Delivery: A full GitOps pipeline using ArgoCD and GitHub Actions.&lt;/p&gt;

&lt;p&gt;Why the Migration?&lt;br&gt;
We moved from a containerized AWS ECR setup to Cloudflare Workers. Why?&lt;/p&gt;

&lt;p&gt;Latency: Moving logic closer to the user.&lt;/p&gt;

&lt;p&gt;Scalability: Handling subscription spikes without managing scaling groups.&lt;/p&gt;

&lt;p&gt;Developer Experience: Hono provides a lightweight, expressive way to handle routing that feels incredibly fast.&lt;/p&gt;

&lt;p&gt;The Security-First Mindset 🔒:&lt;br&gt;
As a DevOps specialist, I believe infrastructure is only as good as its security. For Brewly, I’ve implemented:&lt;/p&gt;

&lt;p&gt;GPG-signed commits to ensure the integrity of our code.&lt;/p&gt;

&lt;p&gt;Strict secret management using tools like Kleopatra and PwPush.&lt;/p&gt;

&lt;p&gt;GitOps-driven deployments where ArgoCD ensures the cluster state matches our repository exactly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons Learned from the Trenches:&lt;/strong&gt;&lt;br&gt;
The biggest challenge wasn't the code—it was the shift in thinking from "servers" to "distributed functions." Debugging edge cases in a serverless environment requires a robust observability stack, which we are currently refining within our EKS cluster.&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.youtube.com/@devopseng" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyt3.googleusercontent.com%2FfKKUnpAd3y9pScxLrZS41AiKqwnvWlOsRm-dRhzviopMBg17y_EODownVf5D-A0tWH4nU40TRRs%3Ds900-c-k-c0x00ffffff-no-rj" height="900" class="m-0" width="900"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.youtube.com/@devopseng" rel="noopener noreferrer" class="c-link"&gt;
            DevOps Journey UA - YouTube
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Welcome to the channel! I'm Alex, a DevOps Specialist. On this channel, I share hands-on experience in building modern IT infrastructure, automation, and implementing BigTech standards in real-world projects.

What to expect:
Cloud Solutions: Deep dives into AWS (EKS, ECR, Secrets Manager) and transitioning to Serverless architecture (Cloudflare Workers, D1).

Containerization: Everything about Docker, Kubernetes, and CI/CD pipeline optimization.

Security First: Practical advice on secret management, GPG-signed commits, and infrastructure security.

IT Productivity: Reviews of task managers (Jira, Linear, ClickUp) and building effective workflows for developers.

Why subscribe?
I don't just talk about theory—I show the "end-to-end" process: how to migrate a real service from AWS to Cloudflare, how to set up automated NAS backups, or how to organize a team's work in Jira. All content is based on my daily work with Linux (Ubuntu) and CLI-driven environments.

          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.youtube.com%2Fs%2Fdesktop%2F5af4fee3%2Fimg%2Ffavicon.ico" width="48" height="48"&gt;
          youtube.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


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