<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://websites.lvovsky.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://websites.lvovsky.com/" rel="alternate" type="text/html" /><updated>2026-09-07T20:09:07+10:00</updated><id>https://websites.lvovsky.com/feed.xml</id><title type="html">Ben Lvovsky</title><subtitle>Essays on software engineering — architecture, teams, and the incentives we build around them.</subtitle><author><name>Ben Lvovsky</name></author><entry><title type="html">“It Takes Too Long for Serverless” — Usually Doesn’t</title><link href="https://websites.lvovsky.com/serverless/" rel="alternate" type="text/html" title="“It Takes Too Long for Serverless” — Usually Doesn’t" /><published>2026-03-11T00:00:00+11:00</published><updated>2026-03-11T00:00:00+11:00</updated><id>https://websites.lvovsky.com/serverless-thinking-patterns</id><content type="html" xml:base="https://websites.lvovsky.com/serverless/"><![CDATA[<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>&quot;It Takes Too Long for Serverless&quot; — Usually Doesn&apos;t | Ben Lvovsky</title>
<!-- This page carries no layout, so jekyll-seo-tag never runs on it. -->
<meta name="description" content="The 15-minute limit is a signal the problem needs rethinking, not the infrastructure scaling up. Five scenarios, three patterns.">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Ben Lvovsky">
<meta property="og:title" content="&quot;It Takes Too Long for Serverless&quot; — Usually Doesn&apos;t">
<meta property="og:description" content="The 15-minute limit is a signal the problem needs rethinking, not the infrastructure scaling up. Five scenarios, three patterns.">
<meta property="og:url" content="https://websites.lvovsky.com/serverless/">
<meta property="og:image" content="https://websites.lvovsky.com/assets/og/serverless-thinking-patterns.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://websites.lvovsky.com/serverless/">
<!-- Hand-rolled because jekyll-seo-tag never runs here; mirrors what it emits
     for the markdown posts. Keep in step with those if seo-tag changes shape. -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "\"It Takes Too Long for Serverless\" — Usually Doesn't",
  "description": "The 15-minute limit is a signal the problem needs rethinking, not the infrastructure scaling up. Five scenarios, three patterns.",
  "url": "https://websites.lvovsky.com/serverless/",
  "datePublished": "2026-03-11T00:00:00+11:00",
  "dateModified": "2026-03-11T00:00:00+11:00",
  "image": "https://websites.lvovsky.com/assets/og/serverless-thinking-patterns.png",
  "author": { "@type": "Person", "name": "Ben Lvovsky" },
  "mainEntityOfPage": { "@type": "WebPage", "@id": "https://websites.lvovsky.com/serverless/" }
}
</script>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png">
<meta name="theme-color" content="#0a0e1a">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    min-height: 100vh;
    background: #0a0e1a;
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
    color: #e2e8f0;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    font-size: 15px;
    line-height: 1.6;
  }
  /* Header */
  .header {
    background: linear-gradient(135deg, #0f1629 0%, #1a2744 100%);
    border-bottom: 1px solid #1e3a5f;
    padding: 28px 32px 22px;
  }
  .header-inner { max-width: 860px; margin: 0 auto; }
  .badge {
    display: inline-block;
    background: #f59e0b22;
    border: 1px solid #f59e0b44;
    color: #f59e0b;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.12em;
    padding: 4px 12px;
    border-radius: 4px;
    margin-bottom: 14px;
    text-transform: uppercase;
  }
  .header h1 { font-size: 26px; font-weight: 700; color: #f8fafc; letter-spacing: -0.02em; margin-bottom: 8px; line-height: 1.3; }
  .header h1 span { color: #f59e0b; }
  .header p { color: #94a3b8; font-size: 15px; line-height: 1.5; }

  /* Body */
  .body { max-width: 860px; margin: 0 auto; padding: 24px 32px; }

  /* Two mindsets */
  .mindsets { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 28px; }
  .mindset {
    border-radius: 6px;
    padding: 14px 18px;
    font-size: 14px;
    line-height: 1.65;
    border-left-width: 3px;
    border-left-style: solid;
  }
  .mindset.old { background: #1a0a0a; border-color: #7f1d1d #7f1d1d #7f1d1d #ef4444; border-style: solid; }
  .mindset.old .label { color: #ef4444; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; }
  .mindset.old p { color: #fca5a5; }
  .mindset.new { background: #0a1a0e; border-color: #14532d #14532d #14532d #22c55e; border-style: solid; }
  .mindset.new .label { color: #22c55e; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; }
  .mindset.new p { color: #86efac; }

  /* Tabs */
  .tabs-label { color: #8494a7; font-size: 12px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 10px; }
  .tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
  .tab {
    background: #0f1629;
    border: 1px solid #2d3f56;
    color: #8b9cb2;
    padding: 8px 14px;
    border-radius: 6px;
    cursor: pointer;
    font-size: 13px;
    font-weight: 500;
    font-family: inherit;
    display: flex;
    align-items: center;
    gap: 8px;
    transition: all 0.15s;
  }
  .tab:hover { border-color: #3b82f6; color: #93c5fd; }
  .tab.active { background: #1e3a5f; border-color: #3b82f6; color: #93c5fd; }

  /* Card */
  .card { background: #0d1424; border: 1px solid #1e3a5f; border-radius: 6px; overflow: hidden; margin-bottom: 24px; }
  .card-header {
    background: #0f1e38;
    padding: 14px 20px;
    border-bottom: 1px solid #1e3a5f;
    display: flex;
    align-items: center;
    gap: 12px;
  }
  .card-icon { font-size: 26px; }
  .card-title-block { flex: 1; }
  .card-title { font-size: 17px; font-weight: 700; color: #f1f5f9; }
  .card-tag {
    display: inline-block;
    background: #1e3a5f;
    color: #93c5fd;
    font-size: 11px;
    font-weight: 500;
    padding: 2px 8px;
    border-radius: 4px;
    margin-top: 4px;
    letter-spacing: 0.05em;
    text-transform: uppercase;
  }
  .card-times { display: flex; gap: 20px; text-align: center; align-items: center; }
  .time-old { color: #ef4444; font-size: 18px; font-weight: 700; }
  .time-new { color: #22c55e; font-size: 18px; font-weight: 700; }
  .time-label { color: #8b9cb2; font-size: 11px; font-weight: 500; text-transform: uppercase; }
  .time-arrow { color: #4a5e75; font-size: 18px; padding-top: 6px; }

  /* Two cols */
  .two-cols { display: grid; grid-template-columns: 1fr 1fr; }
  .col { padding: 18px 20px; }
  .col:first-child { border-right: 1px solid #1e3a5f; }
  .col-label { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 10px; }
  .col.myth .col-label { color: #ef4444; }
  .col.myth .col-quote { color: #fca5a5; font-size: 14px; margin-bottom: 10px; font-style: italic; line-height: 1.55; }
  .col.reality .col-label { color: #22c55e; }
  .col.reality .col-strong { color: #86efac; font-size: 14px; margin-bottom: 10px; font-weight: 600; line-height: 1.55; }
  .col-detail { color: #94a3b8; font-size: 13px; line-height: 1.65; }

  /* Flow */
  .flow { border-top: 1px solid #1e3a5f; padding: 14px 20px; background: #080d1a; }
  .flow-label { color: #8494a7; font-size: 12px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 10px; }
  .flow-steps { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
  .flow-step {
    background: #0f1e38;
    border: 1px solid #1e3a5f;
    border-radius: 6px;
    padding: 8px 14px;
    min-width: 120px;
  }
  .step-label { color: #8b9cb2; font-size: 11px; font-weight: 500; margin-bottom: 3px; }
  .step-fn { color: #60a5fa; font-size: 12px; font-weight: 600; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
  .step-ms { color: #22c55e; font-size: 11px; margin-top: 3px; font-family: 'JetBrains Mono', 'Fira Code', monospace; }
  .step-arrow { color: #4a5e75; font-size: 14px; }

  /* Insight */
  .insight {
    border-top: 1px solid #1e3a5f;
    padding: 10px 20px;
    background: #0a1429;
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .insight span { font-size: 16px; }
  .insight p { color: #fbbf24; font-size: 13px; font-style: italic; line-height: 1.5; }

  /* Patterns */
  .patterns-label { color: #8494a7; font-size: 12px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 14px; }
  .patterns { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 20px; }
  .pattern { border-radius: 6px; padding: 16px; border-top-width: 2px; border-top-style: solid; }
  .pattern-num { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
  .pattern-title { color: #f1f5f9; font-size: 14px; font-weight: 600; margin-bottom: 8px; }
  .pattern-desc { color: #94a3b8; font-size: 13px; line-height: 1.6; margin-bottom: 10px; }
  .pattern-example { padding: 6px 10px; border-radius: 4px; font-size: 12px; font-family: 'JetBrains Mono', 'Fira Code', monospace; }

  .footer {
    border-top: 1px solid #2d3f56;
    padding-top: 20px;
    color: #8494a7;
    font-size: 14px;
    text-align: center;
    line-height: 1.8;
    margin-bottom: 36px;
  }
  .footer span { color: #94a3b8; font-weight: 500; }

  /* Comments. Duplicated from assets/css/site.css rather than linked, because
     this page is deliberately self-contained — see the note in the head. */
  .comments { border-top: 1px solid #2d3f56; padding-top: 28px; margin-bottom: 40px; }
  .comments h2 {
    font-size: 15px;
    font-weight: 600;
    color: #f59e0b;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    margin-bottom: 8px;
  }
  .comments-note { color: #8494a7; font-size: 14px; margin-bottom: 22px; }
  .comments-note a { color: #60a5fa; text-decoration: none; }
  .comments-note a:hover { text-decoration: underline; }
  .comments .giscus, .comments .giscus-frame { min-height: 260px; }

  @media (max-width: 600px) {
    .mindsets, .two-cols, .patterns { grid-template-columns: 1fr; }
    .card-times { display: none; }
    .body { padding: 16px; }
    .header { padding: 20px 16px; }
  }
</style>
</head>
<body>

<div class="header">
  <div class="header-inner">
    <div class="badge">Architecture Thinking Patterns</div>
    <h1>"It Takes Too Long for Serverless" — <span>Usually Doesn't</span></h1>
    <p>The 15-min limit is a signal the problem needs rethinking — not the infrastructure scaling up.</p>
  </div>
</div>

<div class="body">

  <div class="mindsets">
    <div class="mindset old">
      <div class="label">❌ Container Thinking</div>
      <p>"This job takes X hours → we need a long-running container / VM / ACI to hold it."</p>
    </div>
    <div class="mindset new">
      <div class="label">✓ Serverless Thinking</div>
      <p>"Why does it take X hours? Move to write-time, fan-out, or checkpoint through a queue."</p>
    </div>
  </div>

  <div class="tabs-label">Click a scenario →</div>
  <div class="tabs" id="tabs"></div>

  <div class="card" id="card"></div>

  <div class="patterns-label">The 3 patterns that eliminate long-running jobs</div>
  <div class="patterns">
    <div class="pattern" style="background:#0d1424; border-color:#3b82f6; border: 1px solid #3b82f633; border-top: 2px solid #3b82f6;">
      <div class="pattern-num" style="color:#3b82f6">01</div>
      <div class="pattern-title">Shift to Write-Time</div>
      <div class="pattern-desc">Don't aggregate at read-time. Update running totals on every write. Reports become instant reads.</div>
      <div class="pattern-example" style="background:#0a0e1a; border:1px solid #3b82f622; color:#3b82f6">Tax YTD, inventory, analytics dashboards</div>
    </div>
    <div class="pattern" style="background:#0d1424; border: 1px solid #8b5cf633; border-top: 2px solid #8b5cf6;">
      <div class="pattern-num" style="color:#8b5cf6">02</div>
      <div class="pattern-title">Fan-Out Parallelism</div>
      <div class="pattern-desc">If a job has N independent units, run N functions in parallel. Wall time = time for one unit.</div>
      <div class="pattern-example" style="background:#0a0e1a; border:1px solid #8b5cf622; color:#8b5cf6">Report metrics, bulk emails, doc processing</div>
    </div>
    <div class="pattern" style="background:#0d1424; border: 1px solid #f59e0b33; border-top: 2px solid #f59e0b;">
      <div class="pattern-num" style="color:#f59e0b">03</div>
      <div class="pattern-title">Queue Checkpointing</div>
      <div class="pattern-desc">Each function processes one chunk and enqueues the next. Failures retry only their chunk.</div>
      <div class="pattern-example" style="background:#0a0e1a; border:1px solid #f59e0b22; color:#f59e0b">Data migrations, ETL, sequential workflows</div>
    </div>
  </div>

  <div class="footer">
    The 15-minute limit is not a bug. It's the architecture telling you:<br>
    <span>if your function needs more than 15 minutes, the function is doing too much.</span>
  </div><section class="comments">
  <h2>Comments</h2>
  <p class="comments-note">
    Signing in with GitHub posts your comment to
    <a href="https://github.com/benlvovsky/websites-comments/discussions">Discussions</a>.
    Or just <a href="mailto:ben@lvovsky.com?subject=%22It%20Takes%20Too%20Long%20for%20Serverless%22%20%E2%80%94%20Usually%20Doesn't">email me</a>.
  </p>
  <script src="https://giscus.app/client.js"
          data-repo="benlvovsky/websites-comments"
          data-repo-id="R_kgDOUDjt1g"
          data-category="Announcements"
          data-category-id="DIC_kwDOUDjt1s4DEJMf"
          data-mapping="pathname"
          data-strict="1"
          data-reactions-enabled="1"
          data-emit-metadata="0"
          data-input-position="top"
          data-theme="transparent_dark"
          data-lang="en"
          crossorigin="anonymous"
          async>
  </script>
  <noscript>
    <a href="https://github.com/benlvovsky/websites-comments/discussions">Read and post comments on GitHub Discussions.</a>
  </noscript>
</section>

</div>

<script>
const scenarios = [
  {
    id: "accounting", icon: "📊", title: "Year-End Tax & Disbursement Analysis", tag: "Finance / ERP",
    myth: "We need a container running for 10-12 hours to crunch a full year of transactions at quarter-end.",
    mythDetail: "Team spins up a heavy Docker container (or Azure Container Instance), loops through 2M+ rows, calculates tax brackets, disbursements, accruals — then crashes or times out at 11h 47min.",
    reality: "Store a running aggregate on every single write. By the time you need the report, it's already computed.",
    realityDetail: "An Azure Function triggers on every new transaction event (Service Bus / Cosmos DB change feed). It updates a lightweight 'YTD Summary' record: running totals, tax buckets, category sums. The year-end 'report' is now a 200ms read — not a 12-hour job.",
    steps: [
      { label: "Transaction saved", fn: "fn-transaction-write", ms: "~20ms" },
      { label: "Trigger fires", fn: "fn-tax-aggregator", ms: "~80ms" },
      { label: "YTD record updated", fn: "Cosmos DB upsert", ms: "~30ms" },
      { label: "Year-end report", fn: "fn-report-read", ms: "~200ms ✓" },
    ],
    oldTime: "10–12 hrs", newTime: "< 1 sec",
    insight: "The 'long job' was caused by deferred computation. Move the work to write-time, not read-time.",
  },
  {
    id: "documents", icon: "📄", title: "Bulk Document Processing & OCR", tag: "Document Pipeline",
    myth: "We need a long-running container to process 50,000 uploaded PDFs overnight.",
    mythDetail: "A scheduled Docker job wakes up at 2am, iterates all unprocessed PDFs in blob storage, runs OCR, extracts data, writes to DB. Takes 6 hours. If it crashes at hour 5 — start over.",
    reality: "Each upload triggers its own isolated function. 50,000 files = 50,000 tiny parallel executions.",
    realityDetail: "Azure Blob Storage trigger fires an Azure Function the moment each PDF lands. OCR runs, data extracted, status written. No batch. No restart risk. Parallel by default. Azure handles the concurrency.",
    steps: [
      { label: "PDF uploaded", fn: "Blob Storage", ms: "user action" },
      { label: "Blob trigger fires", fn: "fn-ocr-processor", ms: "~2-4 sec" },
      { label: "Text extracted", fn: "Azure AI Vision", ms: "~1-2 sec" },
      { label: "Record written", fn: "fn-db-writer", ms: "~50ms ✓" },
    ],
    oldTime: "6 hrs batch", newTime: "2–4 sec each",
    insight: "50,000 sequential = 6 hrs. 50,000 parallel = same 4 seconds. Serverless is embarrassingly parallel.",
  },
  {
    id: "reports", icon: "📈", title: "Monthly BI Report Generation", tag: "Analytics",
    myth: "Generating our executive report with 40 charts and cross-tabulations takes 45 minutes. Needs a container.",
    mythDetail: "One process: pull raw data → calculate 40 metrics → render charts → generate PDF → email. Any failure = full restart. Lambda/Function timeout kills it.",
    reality: "Fan out. Each metric is its own function. An orchestrator assembles results when all complete.",
    realityDetail: "Azure Durable Functions: one orchestrator kicks off 40 parallel fn-metric-X functions. Each completes in under a minute. Orchestrator collects all results, calls fn-render-pdf, done. Total wall time: the slowest single metric, not the sum of all.",
    steps: [
      { label: "Report requested", fn: "fn-orchestrator", ms: "kickoff" },
      { label: "40 metrics parallel", fn: "fn-metric-01…40", ms: "~30 sec each" },
      { label: "All results collected", fn: "Durable await", ms: "~35 sec" },
      { label: "PDF rendered & sent", fn: "fn-render-pdf", ms: "~10 sec ✓" },
    ],
    oldTime: "45 min serial", newTime: "~45 sec parallel",
    insight: "Serial 40×metric time → parallel max(metric time). Orchestration replaces long-running loops.",
  },
  {
    id: "migration", icon: "🗄️", title: "Large Database Migration", tag: "Data Engineering",
    myth: "We need a container running for days to migrate 50GB of legacy data to the new schema.",
    mythDetail: "One migration script, one connection, one Docker container. If Azure kills it or network hiccups — manually figure out what was done, resume. It's stateful spaghetti.",
    reality: "Paginate. Each function processes one page/batch and checkpoints progress to a state table.",
    realityDetail: "fn-migrate-batch takes a page token, processes 500 rows, writes them, then enqueues the next page token to a Storage Queue. If any function fails — only that 500-row page retries. Azure Queue guarantees at-least-once delivery. Fully resumable, zero manual intervention.",
    steps: [
      { label: "Kickoff: page 0", fn: "fn-migration-start", ms: "once" },
      { label: "Process 500 rows", fn: "fn-migrate-batch", ms: "~8 sec" },
      { label: "Checkpoint + next", fn: "Queue + State table", ms: "~20ms" },
      { label: "Repeat until done", fn: "Self-driving queue", ms: "auto ✓" },
    ],
    oldTime: "Days, fragile", newTime: "Same speed, resilient",
    insight: "Long-running ≠ fast. A self-driving queue processes the same data but survives any failure gracefully.",
  },
  {
    id: "notifications", icon: "🔔", title: "Sending 1M Personalised Notifications", tag: "Marketing / Ops",
    myth: "Sending personalised emails/SMS to 1 million users takes hours in a container loop.",
    mythDetail: "One process: SELECT all users → loop → personalise → send via SendGrid → sleep 10ms (rate limit) → repeat. Container must stay alive for hours. One API timeout corrupts the whole run.",
    reality: "Each message is an independent event on a queue. Functions consume at controlled concurrency.",
    realityDetail: "Load user IDs into Azure Service Bus. fn-notify picks up messages, personalises, and sends one at a time. Set max concurrency to respect API rate limits. Failed sends dead-letter automatically — reprocess just those. Parallelism is a dial you control.",
    steps: [
      { label: "IDs → Service Bus", fn: "fn-batch-enqueue", ms: "fast write" },
      { label: "Consume & personalise", fn: "fn-notify (×N)", ms: "~200ms each" },
      { label: "Send via API", fn: "SendGrid / Twilio", ms: "~300ms" },
      { label: "Failed → Dead Letter", fn: "Auto retry", ms: "no data loss ✓" },
    ],
    oldTime: "3–4 hrs loop", newTime: "Controlled parallel",
    insight: "A queue IS your loop. Concurrency becomes a config number, not an architectural constraint.",
  },
];

let active = 0;

function renderTabs() {
  const container = document.getElementById('tabs');
  container.innerHTML = scenarios.map((s, i) => `
    <button class="tab ${i === active ? 'active' : ''}" onclick="setActive(${i})">
      <span>${s.icon}</span><span>${s.tag}</span>
    </button>
  `).join('');
}

function renderCard() {
  const s = scenarios[active];
  const stepsHtml = s.steps.map((step, i) => `
    <div class="flow-step">
      <div class="step-label">${step.label}</div>
      <div class="step-fn">${step.fn}</div>
      <div class="step-ms">${step.ms}</div>
    </div>
    ${i < s.steps.length - 1 ? '<div class="step-arrow">→</div>' : ''}
  `).join('');

  document.getElementById('card').innerHTML = `
    <div class="card-header">
      <div class="card-icon">${s.icon}</div>
      <div class="card-title-block">
        <div class="card-title">${s.title}</div>
        <div class="card-tag">${s.tag}</div>
      </div>
      <div class="card-times">
        <div>
          <div class="time-old">${s.oldTime}</div>
          <div class="time-label">Container</div>
        </div>
        <div class="time-arrow">→</div>
        <div>
          <div class="time-new">${s.newTime}</div>
          <div class="time-label">Serverless</div>
        </div>
      </div>
    </div>
    <div class="two-cols">
      <div class="col myth">
        <div class="col-label">❌ The Myth</div>
        <div class="col-quote">"${s.myth}"</div>
        <div class="col-detail">${s.mythDetail}</div>
      </div>
      <div class="col reality">
        <div class="col-label">✓ The Reality</div>
        <div class="col-strong">${s.reality}</div>
        <div class="col-detail">${s.realityDetail}</div>
      </div>
    </div>
    <div class="flow">
      <div class="flow-label">Serverless execution flow</div>
      <div class="flow-steps">${stepsHtml}</div>
    </div>
    <div class="insight">
      <span>💡</span>
      <p>${s.insight}</p>
    </div>
  `;
}

function setActive(i) {
  active = i;
  renderTabs();
  renderCard();
}

renderTabs();
renderCard();
</script>
</body>
</html>]]></content><author><name>Ben Lvovsky</name></author><category term="architecture" /><category term="serverless" /><summary type="html"><![CDATA[The 15-minute limit is a signal the problem needs rethinking, not the infrastructure scaling up. Five scenarios, three patterns.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://websites.lvovsky.com/assets/og/serverless-thinking-patterns.png" /><media:content medium="image" url="https://websites.lvovsky.com/assets/og/serverless-thinking-patterns.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Vanilla Ice Cream Problem</title><link href="https://websites.lvovsky.com/vanilla/" rel="alternate" type="text/html" title="The Vanilla Ice Cream Problem" /><published>2025-12-10T00:00:00+11:00</published><updated>2025-12-10T00:00:00+11:00</updated><id>https://websites.lvovsky.com/vanilla-ice-cream-problem</id><content type="html" xml:base="https://websites.lvovsky.com/vanilla/"><![CDATA[<p>There’s a famous engineering story — the kind every senior engineer has heard at least once — about a Pontiac owner who complained to the manufacturer about a very strange problem.</p>

<p>Whenever he bought <strong>vanilla ice cream</strong>, his car wouldn’t start when he got back to it.</p>

<p>Chocolate? Fine.</p>

<p>Strawberry? Fine.</p>

<p>Vanilla? Dead car. Every time.</p>

<p>Most companies would’ve told him to stop drinking on the job. Pontiac sent an engineer instead.</p>

<p>Turns out vanilla was the store’s most popular flavour, so it sat in its own freezer right at the front. Grab it and go: two minutes, tops.</p>

<p>Every other flavour was further inside, at a different counter. Longer walk, longer wait.</p>

<p>That extra time let the hot engine cool down. On the quick vanilla trip there wasn’t enough of it. The fuel in the lines was still hot, hot enough to turn into vapour and block the flow to the engine. This is called <strong>vapour lock</strong>.</p>

<p><strong>The problem was never vanilla. It was the time between stopping the car and restarting it.</strong></p>

<p>Vanilla didn’t cause anything. It just happened to be standing at the front of the shop.</p>

<p><em>(Real story or engineering legend, nobody’s fully sure. Doesn’t change the lesson.)</em></p>

<hr />

<h2 id="three-things-we-always-mix-up">Three things we always mix up</h2>

<p>When something breaks, we tend to mix three very different things into one.</p>

<h3 id="1-facts">1. Facts</h3>

<p>What did we actually observe?</p>

<blockquote>
  <p>“When I buy vanilla, the car doesn’t start.”</p>
</blockquote>

<p>That’s a fact. We can reproduce it, measure it, test it. Unlike the other two, it doesn’t change depending on who is in the room.</p>

<h3 id="2-feelings">2. Feelings</h3>

<p>How does it make us feel?</p>

<blockquote>
  <p>“This is scary. I just bought this car and now something’s wrong with it.”</p>
</blockquote>

<p>Completely real, and completely useless as evidence. The worry is what makes people rush; it can’t tell you anything about the car.</p>

<h3 id="3-assumptions">3. Assumptions</h3>

<p>What do we <em>think</em> is causing it?</p>

<blockquote>
  <p>“Something about vanilla is messing with the engine.”</p>
</blockquote>

<p>A guess dressed up as an explanation. It might turn out to be right. It might turn out to be the thing you quietly stop mentioning.</p>

<p><strong>The trouble starts the moment we treat the guess as a fact.</strong></p>

<hr />

<h2 id="engineering-does-this-every-single-day">Engineering does this every single day</h2>

<p>A production system goes down.</p>

<blockquote>
  <p>“It’s the database.”</p>
</blockquote>

<blockquote>
  <p>“No, it’s the network.”</p>
</blockquote>

<blockquote>
  <p>“It started right after the deploy — so it must be the deploy.”</p>
</blockquote>

<p>Five minutes later, the team is arguing about <em>solutions</em> to a problem nobody has actually confirmed.</p>

<p>The only real fact on the table is usually something like:</p>

<blockquote>
  <p>“Requests started timing out around 10:35.”</p>
</blockquote>

<p>And that is all we actually know. Database, network, deploy, traffic spike, cache, race condition: every one of those is still a guess, just delivered with confidence.</p>

<p><strong>“We don’t know yet” is a perfectly good engineering answer.</strong> Pretending otherwise is how you spend four hours fixing something that was never broken.</p>

<hr />

<h2 id="dont-debug-the-explanation-debug-the-observation">Don’t debug the explanation. Debug the observation.</h2>

<p>This is the whole point of the vanilla story, and the hardest habit to build, because the explanation is always more interesting than the observation.</p>

<p>Someone says:</p>

<blockquote>
  <p>“It only happens with customer X.”</p>
</blockquote>

<p>Don’t rush to check customer X’s account like it’s cursed. Ask instead:</p>

<p><strong>What’s actually different when we process customer X?</strong></p>

<p>Bigger data? A different code path? A specific time of day? A setting only they use?</p>

<p>Customer X might simply be your vanilla ice cream. The pattern is real; the explanation might not be.</p>

<p>The engineer Pontiac sent didn’t investigate vanilla. He went shopping, three times, with a stopwatch. What he was really doing was looking for <strong>what co-varies with the failure</strong>, and the answer turned out to be elapsed time, which nobody had thought to write down because nobody thought it mattered.</p>

<p>The move is to take the thing that supposedly explains the failure and list everything that travels with it:</p>

<ul>
  <li><strong>Volume.</strong> Is their dataset an order of magnitude bigger than everyone else’s?</li>
  <li><strong>Shape.</strong> Do they have a null, an empty array, a 500-character name, a timezone nobody else uses?</li>
  <li><strong>Path.</strong> Do they hit a different endpoint, region, tenant, or feature flag?</li>
  <li><strong>Timing.</strong> Do they run their batch at the same minute as something else of yours?</li>
  <li><strong>Concurrency.</strong> Are they the only customer with two users clicking at once?</li>
  <li><strong>History.</strong> Were they migrated from the old system, and does anyone still know what that migration did?</li>
</ul>

<p>Now you have six testable statements instead of one unfalsifiable one. Any of them can be checked in an afternoon. “It’s customer X” can’t be checked at all, which is exactly why it survives so long in a chat thread.</p>

<hr />

<h2 id="the-client-everybody-knew-was-cursed">The client everybody knew was cursed</h2>

<p>I ran into this on a financial processing pipeline for short-term rentals. Transactions arrived on an SQS queue, and a Lambda function sat on that queue and drained it.</p>

<p>One client’s work took ages. Not failing — just crawling, for hours, while every other client went through in minutes.</p>

<p>And that client already had a reputation. They’d been a source of strange requests for months. Unrelated things broke on their account. So by the time this landed, <strong>“it’s them again” wasn’t a theory anyone had to argue for.</strong> It felt earned. It genuinely felt like a curse, and I believed it for longer than I’d like to admit.</p>

<p>That reputation was our vanilla ice cream. The pattern was completely real — it <em>was</em> always them — and the explanation was completely wrong.</p>

<p>What was actually different about them had nothing to do with who they were. It was <strong>how they submitted work.</strong> Every other client accumulated transactions per day, running totals rolling forward. This one submitted an entire year of them in one go, which hit the queue as a single enormous burst of messages.</p>

<p>So the load we had tested, repeatedly and carefully and many times over, covered every shape except that one. Small volumes: fine. Large volumes spread across days: fine. A year of financial transactions arriving all at once: never tested, because nobody had thought of it as a case that existed.</p>

<p>And that Lambda’s concurrency was capped at one. Not a decision anybody had made: it was whatever the function had been deployed with on day one, and nobody had revisited it since. One invocation at a time, every message waiting for the one in front of it.</p>

<p>And this is the part worth being precise about: <strong>nothing was slow.</strong> Every individual message was processed quickly, nowhere near any limit. There were simply an enormous number of them, and they went through the queue in single file. At normal volumes that was invisible, because one at a time was plenty and we never came close to the ceiling. With a year of transactions stacked up behind it, the serial approach <em>was</em> the problem.</p>

<p>When I finally sat down and worked through what actually co-varied with the slowness, it wasn’t the client. It was how many messages arrived at once, against how many could be worked on at once. One of those numbers was an order of magnitude past anything we had tested. The other was one.</p>

<p>I raised the concurrency from one to ten, so ten of them ran in parallel instead of one after another. That was the whole remedy, and because Lambda only runs what it’s given, normal operation cost us almost nothing: the extra capacity existed only when the queue was deep enough to need it.</p>

<p>Months of “that client is cursed,” and the answer was a concurrency setting nobody had ever looked at.</p>

<p>I’d like to report that the nickname retired along with the setting. It did not.</p>

<p>What’s uncomfortable in hindsight isn’t the mistake itself, it’s how <em>reasonable</em> we were being while we made it. Every previous failure on that account was real evidence — for the wrong conclusion.</p>

<p><strong>The test that would have settled it in an hour:</strong> take a different client’s data, reshape it into a year in a single run, and push it through. If that crawls too, the client is innocent and the volume is the culprit. Nobody ran it, because nobody thought there was anything to test.</p>

<hr />

<h2 id="the-test-is-the-whole-job">The test is the whole job</h2>

<p>Converting a theory into a check is the part that separates debugging from arguing. It usually means finding the one measurement that can only come out one way.</p>

<blockquote>
  <p>“It must be the deploy — the timeouts started right after it.”</p>
</blockquote>

<p>The deploy landed at 10:31. Timeouts began at 10:35. So did the hourly reconciliation job. Both are “right after.” Roll back the deploy and the timeouts should stop immediately; pause the job and they should stop at the top of the next hour. Whichever moves is your answer, and finding out takes about ten minutes. Arguing about which one <em>feels</em> more likely can comfortably fill an afternoon.</p>

<blockquote>
  <p>“It’s the database.”</p>
</blockquote>

<p>Is the database slow, or is it starved of connections because something upstream stopped returning them to the pool? Those look identical from the application and want opposite fixes. Measure at the database, not at the thing complaining about the database.</p>

<blockquote>
  <p>“It only happens in production.”</p>
</blockquote>

<p>Production is not a cause, it’s a bundle of differences: data volume, concurrency, config, network path, TLS termination, someone else’s traffic. Name them, then rule them out one at a time.</p>

<hr />

<h2 id="but-it-works-on-my-computer">But it works on my computer</h2>

<p>Which brings me to the phrase every team has heard, usually from the same person.</p>

<p>We had a developer who said <strong>“but it works on my computer”</strong> every single time a bug turned up in production. It became a running joke. Someone would open the incident channel and half the team was already typing it before he could.</p>

<p>Here’s the thing, though: <strong>he was right.</strong> Every time.</p>

<p>It <em>did</em> work on his computer. That isn’t a defence, it’s a measurement — and a genuinely useful one. It tells you the code is capable of working, so whatever is broken lives in the difference between his machine and production. It’s the most valuable sentence anyone can offer in the first five minutes of an incident, and we turned it into a punchline.</p>

<p>“It works on my computer” is exactly “chocolate is fine.” And chocolate <em>was</em> fine. That was the clue. Nobody laughed at the Pontiac owner for mentioning it; an engineer wrote it down and went shopping.</p>

<p>What we should have done, every time he said it, was ask the follow-up: <strong>then what’s different about yours?</strong> Java version, a stale local config, a database with fifty rows instead of fifty million, no concurrent traffic, a mock standing in for something real, a timezone set to his own.</p>

<p>Most of those differences are accidents. Some are <a href="/cloud-engineering-anti-practices/">designed in</a>, which is worse.</p>

<p>The joke got a laugh. The question would have got us an answer.</p>

<hr />

<h2 id="what-the-guessing-actually-costs">What the guessing actually costs</h2>

<p>The first wrong theory is cheap: you lose an afternoon. The habit is what gets expensive, because wrong explanations don’t stay in the incident channel. They get written down.</p>

<p>They become the runbook step everyone follows for the next two years. They become the alert that fires on the symptom you misdiagnosed. They become the retry someone added around the wrong call, the cache in front of a database that was never the problem, the nightly restart nobody dares remove because it “fixed” something in 2023.</p>

<p>Every one of those is a defence built against the vanilla.</p>

<p>And because the real cause was never found, it comes back. Different symptom, different quarter, different on-call engineer — who now has a runbook confidently pointing in the wrong direction, and a system carrying three years of scar tissue from the last four times this happened.</p>

<p>Nobody on the team experiences this as failure. It just feels like working on a hard system that breaks a lot.</p>

<hr />

<h2 id="a-simple-rule">A simple rule</h2>

<p>Separate, every time:</p>

<p><strong>FACT</strong> — What do we know happened?</p>

<p><strong>FEELING</strong> — What worry does it create?</p>

<p><strong>ASSUMPTION</strong> — What do we think caused it?</p>

<p>Then do the one thing most people skip: <strong>test the assumption.</strong></p>

<p>Don’t argue about whether it’s the database, the network, the deploy, or the vendor. Build a small test that can actually prove or kill the theory.</p>

<p>That’s debugging. Everything before it is opinion with confidence attached.</p>

<hr />

<h2 id="good-engineers-dont-guess-the-fastest--they-test-the-hardest">Good engineers don’t guess the fastest — they test the hardest</h2>

<p>They can turn:</p>

<blockquote>
  <p>“I think it’s the database.”</p>
</blockquote>

<p>into:</p>

<blockquote>
  <p>“Let’s test if the database is actually responsible.”</p>
</blockquote>

<p>And then — the hard part — accept it when they’re wrong. The goal was never to be right; it was to find out <strong>what’s actually happening.</strong></p>

<p><strong>This is really what troubleshooting culture is about.</strong></p>

<p>Not finding someone to blame. Not being the person with the fastest answer. It’s about building a habit, as a team, of separating facts from guesses and testing before deciding. Teams that do this learn faster. Teams that don’t will keep repeating the same guesses year after year, and calling it experience.</p>

<p>Whether or not the Pontiac story is 100% true, it points to something that is: the pattern was real, the explanation was wrong. Worth remembering next time your production system does something that makes no sense.</p>

<p><strong>Don’t blame the vanilla. Find out what’s actually different about it.</strong></p>]]></content><author><name>Ben Lvovsky</name></author><category term="debugging" /><category term="incidents" /><category term="engineering-culture" /><summary type="html"><![CDATA[A Pontiac owner's car wouldn't start, but only when he bought vanilla ice cream. The pattern was real; the explanation was wrong. That gap is where four-hour outages live.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://websites.lvovsky.com/assets/og/vanilla-ice-cream-problem.png" /><media:content medium="image" url="https://websites.lvovsky.com/assets/og/vanilla-ice-cream-problem.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How Jira Metrics Slowly Killed Creativity on My Team</title><link href="https://websites.lvovsky.com/jira-metrics/" rel="alternate" type="text/html" title="How Jira Metrics Slowly Killed Creativity on My Team" /><published>2024-08-01T00:00:00+10:00</published><updated>2024-08-01T00:00:00+10:00</updated><id>https://websites.lvovsky.com/jira-metrics-killed-creativity</id><content type="html" xml:base="https://websites.lvovsky.com/jira-metrics/"><![CDATA[<p>There’s a particular kind of engineer Jira rewards, and it isn’t always the one you want building your product.</p>

<p>I watched this happen firsthand. Over a couple of years, our team’s culture shifted from <strong>“let’s find the best way to solve this”</strong> to <strong>“let’s close the ticket.”</strong> The tool didn’t do this on its own, but the way we used it did. The damage wasn’t abstract: good people left. The people who stayed were, on average, less capable at the actual craft of engineering than the ones who walked away.</p>

<p>The uncomfortable part is that nobody intended this outcome. We thought we were improving planning, transparency, and accountability. What we were really doing was teaching engineers that visible activity mattered more than thoughtful engineering.</p>

<hr />

<h2 id="the-story-sizing-trap">The Story-Sizing Trap</h2>

<p>Story pointing is supposed to help teams plan. In practice, on my team, it became a performance metric in disguise.</p>

<p>Every task was sized, every sprint produced a velocity number, and that number was watched by people who didn’t write code but cared deeply about whether it went up or down.</p>

<p>Once that happens, the incentive structure is set.</p>

<p>Nobody explicitly said, <strong>“Stop thinking and start closing tickets.”</strong> But the system rewarded exactly that behaviour.</p>

<ul>
  <li>Research doesn’t close tickets.</li>
  <li>Evaluating three possible approaches before choosing the right one doesn’t close tickets.</li>
  <li>Refactoring something so that the next six months of work become easier doesn’t close tickets.</li>
  <li>Deep debugging rarely closes tickets quickly.</li>
</ul>

<p>Only a closed ticket closes a ticket.</p>

<p>As a result, the smart, curious engineers—the ones who wanted to understand <strong>why</strong> something was broken instead of merely hiding the symptom—started to look like underperformers. Their velocity appeared lower. Their story-point totals accumulated more slowly.</p>

<p>It didn’t matter that their fixes were more durable, or that their code required less rework later. The dashboard had no column for <strong>“prevented next quarter’s outage.”</strong></p>

<hr />

<h2 id="working-smart-became-invisible-working-hard-became-theater">Working Smart Became Invisible, Working Hard Became Theater</h2>

<p>There used to be a meaningful distinction between <strong>working hard</strong> and <strong>working smart</strong>.</p>

<p>Jira flattened that distinction.</p>

<p>Story points don’t measure judgment; they measure throughput. And throughput is surprisingly easy to game if you’re willing to slice work into small enough pieces.</p>

<p>That’s exactly what happened.</p>

<p>A subset of the team—often the more average technical performers—figured out that the game was no longer <strong>“solve problems”</strong> but <strong>“generate tickets.”</strong></p>

<p>A straightforward feature would be split into 30, 40, sometimes more than 100 tiny tasks, many barely worth the overhead of creating them.</p>

<p>Every one of those tickets could be closed. Every closed ticket became a visible unit of <strong>“hard work.”</strong> The burndown chart looked fantastic.</p>

<p>The software itself did not become significantly better, faster, or easier to maintain.</p>

<p>Meanwhile, an engineer who spent two days <a href="/vanilla/">understanding a nasty concurrency bug</a> instead of applying a five-minute band-aid appeared, on paper, to have done less work. They had a single ticket sitting in <strong>In Progress</strong> for days.</p>

<p>To anyone glancing at the board, that looked like underperformance.</p>

<p>In reality, it was often the opposite.</p>

<hr />

<h2 id="the-robots-stayed-the-thinkers-left">The Robots Stayed, the Thinkers Left</h2>

<p>This was the hardest part to watch.</p>

<p>The engineers who cared about doing the work <strong>well</strong>—the ones who wanted to research an approach, propose a better architecture, or simply have enough uninterrupted time to think—gradually became exhausted.</p>

<p>Some received mediocre performance reviews because their point totals didn’t compare favourably with the ticket farmers. Others simply grew tired of a system that punished depth and rewarded visible busywork.</p>

<p>Eventually, many of them left for teams that measured outcomes instead of ticket counts.</p>

<p>Who stayed?</p>

<p>Mostly the people who had adapted to the system rather than resisted it. Not bad people—just people who had correctly learned that the safest path to looking successful was to generate volume:</p>

<ul>
  <li>Break work into the smallest possible pieces.</li>
  <li>Avoid ambiguous problems because they don’t size cleanly.</li>
  <li>Stay away from research tasks because they don’t produce satisfying green checkmarks.</li>
  <li>Prefer predictable ticket flow over difficult engineering questions.</li>
</ul>

<p>Over time, the team’s average technical judgment declined even as its reported <strong>velocity increased</strong>.</p>

<p>We became extremely efficient at closing tickets and noticeably less effective at deciding which tickets were worth creating in the first place.</p>

<hr />

<h2 id="outsourcing-makes-this-worse-not-better">Outsourcing Makes This Worse, Not Better</h2>

<p>If ticket-gaming is a subtle problem on an in-house team, it becomes a structural one the moment you bring in an outside vendor.</p>

<p>I’ve seen this pattern repeatedly on outsourced projects, regardless of which country the vendor was based in.</p>

<p>The client-side stakeholders—usually directors or VPs several layers removed from the code—don’t read diffs and don’t sit in on debugging sessions. What they see is the Jira dashboard:</p>

<ul>
  <li>tickets opened,</li>
  <li>tickets closed,</li>
  <li>velocity trending upward quarter after quarter.</li>
</ul>

<p>That dashboard becomes the proof of value for a contract that may be costing hundreds of thousands of dollars per year.</p>

<p>Vendors optimising for <strong>contract renewal rather than codebase health</strong> learn very quickly that the dashboard is the product.</p>

<p>So the same behaviour appears again, only amplified:</p>

<ul>
  <li>A single feature is fragmented into dozens of trivial tickets.</li>
  <li>A simple bug fix becomes five separate tasks: <em>investigate, reproduce, implement, test, verify</em>.</li>
  <li>Each task is closable.</li>
  <li>Each closure becomes a data point in a status report.</li>
</ul>

<p>The metrics look impressive. The architecture quietly deteriorates underneath.</p>

<p>Technical debt accumulates because <strong>technical debt is not what the dashboard measures</strong>.</p>

<p>The result is a slow-motion divergence between the story the metrics tell and the story the codebase tells.</p>

<h3 id="year-one">Year One</h3>

<ul>
  <li>High ticket counts.</li>
  <li>Impressive velocity.</li>
  <li>Glowing status reports.</li>
  <li>Happy stakeholders.</li>
</ul>

<h3 id="year-three">Year Three</h3>

<ul>
  <li>The codebase is harder to change.</li>
  <li>Incidents happen more frequently.</li>
  <li>Onboarding new engineers takes far longer than it should.</li>
  <li>Simple changes require touching fragile, poorly understood components.</li>
</ul>

<p>Nobody can point to the exact moment things went wrong because the reporting system never showed a decline.</p>

<p>It couldn’t.</p>

<p>It was never measuring the thing that mattered.</p>

<p>This is why so many outsourced software projects either fail outright or quietly bleed value for years before anyone notices: <strong>ticket throughput was never a good proxy for a maintainable, working product.</strong></p>

<p>The vendor may not even be acting in bad faith. They are often responding rationally to a contract that rewards <strong>visible activity</strong> rather than <strong>engineering judgment</strong>.</p>

<p>Rational responses to bad incentives still produce bad outcomes.</p>

<hr />

<h2 id="jira-isnt-the-villainbut-it-isnt-innocent-either">Jira Isn’t the Villain—But It Isn’t Innocent Either</h2>

<p>To be fair, Jira didn’t force anyone to behave this way.</p>

<p>A healthy engineering culture with thoughtful leadership can absolutely use Jira while still protecting space for research, deep work, and technical judgment.</p>

<p>The failure was organisational, not purely technological.</p>

<p>But I also don’t think the tool is entirely neutral.</p>

<p>Jira’s design vocabulary—<strong>story points, velocity charts, burndown graphs, sprint reports</strong>—naturally encourages people to treat <strong>ticket throughput</strong> as a proxy for <strong>value delivered</strong>.</p>

<p>That proxy is wrong often enough, and quietly enough, that many organisations never realise they have optimised for the wrong thing until their strongest engineers are gone and the remaining team is composed of people who are excellent at Jira and only average at engineering.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p>If I were leading that team again, I wouldn’t ban Jira.</p>

<p>I would ban the assumption that Jira metrics tell you who is contributing the most.</p>

<p>Planning tools are useful for coordinating work. They are dangerous when they become <strong>surrogate performance-review systems</strong>.</p>

<p>I would explicitly create protected space for work that rarely looks impressive on a board:</p>

<ul>
  <li>research spikes,</li>
  <li>architectural exploration,</li>
  <li>deep debugging,</li>
  <li>refactoring,</li>
  <li>technical debt reduction,</li>
  <li>mentoring,</li>
  <li>and the freedom to occasionally be <strong>slow because you are being thorough</strong> rather than fast because you are being superficial.</li>
</ul>

<p>I would also treat unusually high ticket throughput as a <strong>signal to investigate</strong>, not automatically as evidence of exceptional performance.</p>

<p>Sometimes the most productive engineer on the team is the one with <strong>fewer tickets closed</strong>, because they are solving the problems that prevent everyone else from drowning in tickets later.</p>

<hr />

<h2 id="the-real-cost">The Real Cost</h2>

<p>The biggest cost of optimising for Jira metrics is not bad charts, inflated velocity, or bloated backlogs.</p>

<p>It is the gradual loss of the people who care about understanding systems deeply.</p>

<p>Those engineers are often the ones who:</p>

<ul>
  <li>challenge weak architectural decisions,</li>
  <li>prevent future outages,</li>
  <li>simplify complexity before it becomes unmanageable,</li>
  <li>and quietly raise the technical standards of everyone around them.</li>
</ul>

<p>They are also the people most likely to become frustrated by environments that reward <strong>visible busyness over thoughtful engineering</strong>.</p>

<p>If your dashboards celebrate ticket volume more than customer outcomes, system reliability, or long-term maintainability, you may already be training your team to optimise for the wrong thing.</p>

<p>And by the time the metrics finally stop looking good, the people who could have warned you about it may already be working somewhere else.</p>

<p>You’ll still have plenty of tickets getting closed.</p>

<p>What you may no longer have is a team that knows <strong>which tickets are worth solving, which problems are worth understanding, and which shortcuts will become next year’s disaster</strong>.</p>

<p>That is a much harder thing to measure than velocity.</p>

<p>And a much more expensive thing to lose.</p>]]></content><author><name>Ben Lvovsky</name></author><category term="engineering-culture" /><category term="metrics" /><category term="teams" /><summary type="html"><![CDATA[Over a couple of years our culture shifted from "let's find the best way to solve this" to "let's close the ticket." The good engineers left, velocity went up, and nothing in the reporting system ever showed a decline.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://websites.lvovsky.com/assets/og/jira-metrics-killed-creativity.png" /><media:content medium="image" url="https://websites.lvovsky.com/assets/og/jira-metrics-killed-creativity.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How Docker Compose Quietly Undoes a Cloud Migration</title><link href="https://websites.lvovsky.com/cloud-engineering-anti-practices/" rel="alternate" type="text/html" title="How Docker Compose Quietly Undoes a Cloud Migration" /><published>2024-04-20T00:00:00+10:00</published><updated>2024-04-20T00:00:00+10:00</updated><id>https://websites.lvovsky.com/cloud-engineering-anti-practices</id><content type="html" xml:base="https://websites.lvovsky.com/cloud-engineering-anti-practices/"><![CDATA[<p>The architecture diagram is immaculate. Managed database, managed broker, managed identity, infrastructure as code, the lot. It has been reviewed, approved, and put on a slide.</p>

<p>Then you open the repository and find <code class="language-plaintext highlighter-rouge">docker-compose.yml</code>. Postgres. RabbitMQ. Redis. A <code class="language-plaintext highlighter-rouge">.env</code> file somebody maintains by hand. A folder called <code class="language-plaintext highlighter-rouge">mocks</code> that has grown a maintainer.</p>

<p>Both of those things are true at the same time, and nobody experiences this as a contradiction. The diagram describes what we deploy. The Compose file describes what we develop. The gap between them is where <a href="/vanilla/">the interesting failures</a> live, and we only ever meet them in production, because production is the first place the two descriptions are forced to agree.</p>

<p>This is the pattern I keep running into, and I’ve stopped thinking of it as a tooling problem. <strong>It’s on-premises development wearing a cloud badge.</strong></p>

<p>The uncomfortable part — the part that took me a while to accept — is that this is not what inexperience looks like. Junior developers use whatever the onboarding doc tells them to use. <strong>The local-first habit belongs to the senior people</strong>, because it’s built out of a decade of genuinely hard-won instinct: run it yourself, keep it close, don’t depend on infrastructure you can’t restart. Every one of those instincts was correct in a data centre. Several of them are now the reason the thing we tested isn’t the thing we built.</p>

<p>Good judgement, applied to the wrong decade, is still going to hurt you.</p>

<p>What follows is the list of habits, and what I think we should do instead. The examples lean AWS because that’s where I’ve spent most of my time, but none of the reasoning is AWS-specific — swap in Azure or GCP service names and the argument is unchanged.</p>

<hr />

<h2 id="your-laptop-is-the-workstation-not-the-environment">Your Laptop Is the Workstation, Not the Environment</h2>

<p><em>“I do my dev locally — it’s faster.”</em></p>

<p>If your company pays for an AWS Dev account, that account <strong>is your development environment</strong>. Not a deployment target you visit at the end of the sprint. The environment.</p>

<p>Put it in the same region as your users and your corporate network. Developing in Melbourne against <strong>ap-southeast-4</strong>, the path is:</p>

<p><strong>Laptop → corporate network → AWS ap-southeast-4 (Melbourne) → Dev services</strong></p>

<p>The latency on that path is low enough that the cloud behaves much more like an internal development network than the phrase “remote server” leads people to expect. Most of the intuition people have about remote development was formed on a VPN to a data centre on another continent, and it has not been updated since. Sydney’s <strong>ap-southeast-2</strong> is close enough to be the fallback if a service you need isn’t in Melbourne yet; on Azure the equivalent is <strong>Australia Southeast</strong>.</p>

<p>Developing this way buys the single biggest advantage cloud development has to offer: <strong>you are building against the platform you are deploying to.</strong></p>

<p>If production uses DynamoDB, develop against DynamoDB. If production uses SQS, develop against SQS. If it’s Cosmos DB and Service Bus, use Cosmos DB and Service Bus. What you should not do is quietly substitute:</p>

<ul>
  <li>local PostgreSQL</li>
  <li>local MongoDB</li>
  <li>local RabbitMQ</li>
  <li>local Redis</li>
  <li>Docker Compose</li>
  <li>fake queues</li>
  <li>mocked cloud services</li>
</ul>

<p>None of that is faster development. It’s a miniature on-premises environment on your laptop, plus the hope that it behaves like the cloud.</p>

<p>Local still has its place — code with no cloud dependencies, offline work, quick experiments, and the cases where an official emulator genuinely does the job (DynamoDB Local is the honourable example: same API, same vendor, documented differences). The problem is <strong>local infrastructure as the default</strong>, chosen for no better reason than that the real infrastructure lives somewhere else.</p>

<blockquote>
  <p><strong>Local code, real cloud services, a Dev account in the right region.</strong></p>
</blockquote>

<p>Your laptop is the development <em>workstation</em>. The cloud Dev account is the development <em>environment</em>. Conflating those two is where most of this begins.</p>

<hr />

<h2 id="the-broker-you-test-is-the-broker-you-ship">The Broker You Test Is the Broker You Ship</h2>

<p><em>“Let’s use RabbitMQ locally — everyone knows it.”</em></p>

<p>Production uses Amazon SQS. The developer runs RabbitMQ. Those are two different systems, and no amount of interface abstraction changes that.</p>

<p>RabbitMQ is not a bad message broker. Using it as a stand-in for the broker you’re actually shipping is the bad part. Delivery guarantees differ. Ordering semantics differ — a standard SQS queue does not promise order at all, and a FIFO queue is a different product with its own throughput limits. Dead-lettering differs. Visibility timeouts have no RabbitMQ equivalent. Retry and back-off behaviour differs. Throttling under load differs.</p>

<p>Every one of those differences is invisible until the system is under real load, which is to say: until customers are the ones discovering it. Your abstraction layer will faithfully hide the differences right up to the moment they matter, and then it will hide the cause too.</p>

<p><strong>Do this instead:</strong> develop against the messaging service you’re shipping. And if RabbitMQ really is the right choice, choose it for production and run it properly — the anti-practice is the mismatch, not the technology.</p>

<hr />

<h2 id="moving-complexity-is-not-removing-it">Moving Complexity Is Not Removing It</h2>

<p><em>“Cloud functions are too complex — I’ll use Docker.”</em></p>

<p>Serverless runtimes have real local tooling, built by the platform vendors, specifically so you don’t have to do this. Lambda has the <strong>SAM CLI</strong> — <code class="language-plaintext highlighter-rouge">sam local invoke</code> runs your handler in the same runtime image Lambda uses. Azure Functions has <strong>Core Tools</strong>. They exist and they work.</p>

<p>Replacing the runtime with a hand-rolled Docker architecture because containers feel more familiar doesn’t remove complexity. It relocates it — out of a runtime that somebody else maintains, documents, patches and supports, and into a Dockerfile that you now own forever, alone, undocumented.</p>

<p><strong>Do this instead:</strong> use the official local tooling, and integrate against the real services in your Dev environment.</p>

<hr />

<h2 id="nothing-is-as-permanent-as-a-temporary-deployment">Nothing Is as Permanent as a Temporary Deployment</h2>

<p><em>“We’ll deploy this temporarily. We’ll fix it properly later.”</em></p>

<p>Watch the life of a temporary deployment:</p>

<p><strong>temporary → feature → dependency → production → legacy</strong></p>

<p>Not one step in that chain requires a decision. It happens on its own, by default, while everybody is busy with something else. The only conscious decision anyone ever makes is the first one — and it gets made with the word “temporarily” attached, precisely to lower the apparent stakes of making it.</p>

<p>By the time something else depends on it, “we’ll fix it properly later” has become a migration project with a budget and an owner who didn’t choose it.</p>

<p><strong>Do this instead:</strong> before you deploy the temporary thing, ask the only question that matters.</p>

<blockquote>
  <p><strong>“Would I be happy if this were still running in 18 months?”</strong></p>
</blockquote>

<p>If the answer is no, fix it now, while it’s cheap and nothing depends on it.</p>

<hr />

<h2 id="an-access-ticket-became-an-architecture">An Access Ticket Became an Architecture</h2>

<p><em>“I don’t have cloud access — that’s why I started locally.”</em></p>

<p>This one deserves genuine sympathy, because it usually isn’t the developer’s fault. Someone needed to ship, the access request was sitting in a queue, and they did the reasonable thing with what they had.</p>

<p>But look at what got substituted for what. The problem was:</p>

<blockquote>
  <p>“I can’t access AWS.”</p>
</blockquote>

<p>And the thing that got built was:</p>

<blockquote>
  <p>“Let’s not use AWS.”</p>
</blockquote>

<p>An access ticket became an architecture. The ticket would have taken a week. The architecture will outlive several people’s employment, and nobody will remember it started as a permissions delay.</p>

<p><strong>Do this instead:</strong> treat missing cloud access as the delivery blocker it is, and escalate it as one. Access problems get fixed in days when somebody insists. Architectures built to route around them get fixed in years, if ever.</p>

<hr />

<h2 id="comfort-decisions-in-an-architecture-decisions-clothes">Comfort Decisions in an Architecture Decision’s Clothes</h2>

<p><em>“I don’t understand the cloud service — the old tech feels safer. I already know Postgres.”</em></p>

<p>I believe you. That still isn’t an architecture decision.</p>

<p>The tell is that the reasoning describes the state of your knowledge rather than any property of the system you’re building. “Postgres suits this access pattern” is an architecture argument. “I already know Postgres” is a fact about you, dressed up as a fact about the system.</p>

<p>Comfort decisions deserve to be taken seriously — unfamiliar technology carries real risk, and pretending otherwise is how teams end up operating something nobody understands. But the honest version of the argument is <em>“I don’t know this yet,”</em> and the honest response to that is to go and learn it. Ask a colleague. Read the docs. Find a working example. Pair with someone who’s done it. Use AI to compress the learning curve, which is one of the things it is genuinely excellent at.</p>

<p><strong>Do this instead:</strong> spend two days learning the right technology rather than two years maintaining the wrong one. The exchange rate is not close.</p>

<hr />

<h2 id="it-works-is-a-statement-about-the-present-tense">“It Works” Is a Statement About the Present Tense</h2>

<p><em>“If it works, there’s no need to fix it.”</em></p>

<p>It is also entirely compatible with:</p>

<ul>
  <li>operational overhead nobody accounts for</li>
  <li>infrastructure you manage that someone else would manage for free</li>
  <li>elaborate workarounds for a capability the managed service now ships</li>
  <li>a runtime version approaching end of support</li>
  <li>exactly one person who understands it</li>
  <li>a migration cost that grows every quarter</li>
</ul>

<p>Sometimes the right answer really is to leave it alone. Stability has enormous value, and rewriting working systems for aesthetic reasons is its own anti-practice. The problem isn’t leaving things alone — it’s leaving them alone <em>by default</em>, without ever having looked.</p>

<p><strong>Do this instead:</strong> periodically ask:</p>

<blockquote>
  <p><strong>“Would we choose this architecture today?”</strong></p>
</blockquote>

<p><em>“No, but it’s fine”</em> is a perfectly good answer. <em>“No, and I hadn’t thought about it”</em> is the one to worry about.</p>

<hr />

<h2 id="the-model-wasnt-confused-the-prompt-was">The Model Wasn’t Confused. The Prompt Was.</h2>

<p><em>“I asked Copilot and it generated the whole thing.”</em></p>

<p>You asked for this:</p>

<blockquote>
  <p>“Make it run locally.”</p>
</blockquote>

<p>You got <strong>Postgres, RabbitMQ, Redis and a Docker Compose file.</strong></p>

<p>Of course you did. You asked for a local system and it built you a local system, competently, in about four seconds. The model is not confused about cloud architecture. <strong>The prompt encoded the wrong architecture and the model implemented it faithfully</strong>, which is the one thing we all claim to want from these tools.</p>

<p>Try telling it what you’re actually building:</p>

<blockquote>
  <p>“Build a cloud-native solution using Lambda, DynamoDB and SQS, with local development through the SAM CLI and integration against our Dev account.”</p>
</blockquote>

<p>Same tool, same four seconds, a fundamentally different result.</p>

<p><strong>Think first, prompt second.</strong> And since this is now most of how code gets written:</p>

<p><strong>You generated it → you understand it → you deploy it → you own it.</strong></p>

<p>If you can’t explain what it does and deploy it yourself, it is not ready to hand to somebody else to operate at 3am.</p>

<hr />

<h2 id="choosing-an-architecture-to-please-a-tool">Choosing an Architecture to Please a Tool</h2>

<p><em>“Let’s do a monorepo — it’s easier for AI agents.”</em></p>

<p>This one is new, and I’m flagging it precisely because it sounds modern enough to pass unchallenged in a design review. Nobody wants to be the person who objected to the AI-friendly option.</p>

<p>It still isn’t an architecture reason. Repository structure should follow how software is built, owned and released. If services are developed and deployed independently, a polyrepo with properly versioned shared packages is often simpler and safer. If components genuinely belong together and release together, a monorepo may be exactly right — plenty of them are.</p>

<p>Both are defensible. <em>“The AI likes it”</em> is not one of the defences. We spent twenty years learning to structure repositories around teams and release boundaries; it would be a strange time to start structuring them around a tool’s context window.</p>

<p><strong>Do this instead:</strong> choose repository structure for the architecture and the teams who own it. Tooling ergonomics — human or artificial — is a tiebreaker at most.</p>

<hr />

<h2 id="gitignore-is-not-a-security-architecture"><code class="language-plaintext highlighter-rouge">.gitignore</code> Is Not a Security Architecture</h2>

<p><em>“Secrets live in <code class="language-plaintext highlighter-rouge">.env</code> — it’s in <code class="language-plaintext highlighter-rouge">.gitignore</code>.”</em></p>

<p><code class="language-plaintext highlighter-rouge">.gitignore</code> is a list of files Git ignores. That is a much smaller promise than the one being relied on.</p>

<p>It does nothing about the copy on your disk, the copy in the backup, the copy pasted into Slack while you were helping someone debug, or the afternoon someone renames the file and the pattern silently stops matching.</p>

<p>The platforms solved this. AWS has IAM roles, Secrets Manager and Parameter Store. Azure has Managed Identity and Key Vault. A role your code assumes is strictly better than a secret, because there is no secret to leak — and long-lived access keys in a <code class="language-plaintext highlighter-rouge">.env</code> file are the AWS-specific version of this mistake.</p>

<p><strong>Do this instead:</strong> no credentials in source, config files or Compose files — including the ones that are “only for development.” Development credentials reach production surprisingly often, usually by being reused.</p>

<hr />

<h2 id="the-configuration-exists-in-exactly-one-place">The Configuration Exists in Exactly One Place</h2>

<p><em>“We’ll just click it up in the console.”</em></p>

<p>ClickOps genuinely is faster. It is faster right up until the first time you need to reproduce the environment, stand up a second one, recover from a disaster, explain why staging behaves differently from production, or work out who changed that setting and when.</p>

<p>At that point the configuration exists in exactly one place: the running system. The only way to read it is to click through the same forty console screens that created it, and the only record of intent is whatever people remember. CloudTrail will tell you <em>who</em> changed it; it will not tell you what they meant.</p>

<p>CloudFormation, CDK, Terraform — pick whichever suits the team.</p>

<p><strong>Do this instead:</strong> infrastructure should be reproducible from code. The console is an excellent tool for operating and inspecting infrastructure. <strong>IaC is the source of truth.</strong></p>

<hr />

<h2 id="we-move-fast-is-doing-a-lot-of-work">“We Move Fast” Is Doing a Lot of Work</h2>

<p><em>“Testing in prod is fine — we move fast.”</em></p>

<p>It isn’t fine, and that phrase is carrying an enormous amount of weight: <strong>no staging environment, no test coverage, and no rollback plan</strong>, compressed into three words that sound like a culture rather than an omission. Every deployment becomes a bet placed with customer data, and the customer finds out before you do.</p>

<p>Canary releases, progressive rollouts and feature flags are <em>not</em> testing in prod. They are controlled, monitored exposure of <strong>already-tested</strong> code, with a rollback path established before the first customer sees anything. The entire premise is that you already believe the code works and are limiting the cost of being wrong.</p>

<p>Conflating the two is how a legitimate release strategy becomes cover for skipping the testing — you get the vocabulary of sophisticated delivery practice and the risk profile of deploying on a Friday afternoon and going home.</p>

<p><strong>Do this instead:</strong> test before it ships. Use staged rollouts to limit blast radius at release, not as a substitute for having tested.</p>

<hr />

<h2 id="the-questions-nobody-asked">The Questions Nobody Asked</h2>

<p>Almost every habit above survives for the same reason: nobody said it out loud in a room with other engineers in it. Local Postgres doesn’t get proposed and defended. It gets committed on a Tuesday.</p>

<ul>
  <li>Am I using a local service because it’s genuinely needed, or because I’m avoiding the cloud?</li>
  <li>Could I use the organisation’s Dev account instead?</li>
  <li>Is the Dev environment in the right region?</li>
  <li>Am I using the same managed services I’ll use in production?</li>
  <li>Am I solving an access problem by creating an architecture problem?</li>
  <li>Am I choosing this technology because it’s right, or because I already know it?</li>
  <li>Did I give my AI assistant the right architectural context?</li>
  <li>Am I choosing a monorepo for architectural reasons, or because AI likes it?</li>
  <li>Can I explain and deploy the code I generated?</li>
  <li>Is my infrastructure reproducible from code?</li>
  <li>Would we choose this architecture today?</li>
</ul>

<p>None of these are hard questions. That is rather the point.</p>

<hr />

<h2 id="what-it-actually-costs">What It Actually Costs</h2>

<p>The obvious cost is the incident — the release that behaved differently in production because production was the first environment where the real services were involved. That one is at least visible. Somebody writes it up.</p>

<p>The expensive cost is quieter.</p>

<p>You paid for a cloud migration and you are still running an on-premises engineering culture. You are paying a cloud bill <em>and</em> maintaining Compose files, mocks, seed scripts, and a local environment that breaks for one developer every sprint for reasons nobody can reproduce. You have all the operational overhead you migrated to escape, plus the bill.</p>

<p>Your team’s expertise is accumulating in the wrong place. After two years, they are excellent at running Postgres and RabbitMQ locally and have never once dealt with a DynamoDB throughput exception, an SQS visibility timeout expiring mid-handler and redelivering the message, or an IAM role that works in Dev and not in Test. That knowledge only comes from using the thing, and every mock defers it.</p>

<p>And the moment when the mismatch surfaces is always the worst available moment. Not during development, when it’s a bug. During release, when it’s an incident — with an audience, and a rollback, and someone senior asking how this wasn’t caught.</p>

<p>The diagram was right the whole time. Nobody had run it.</p>

<p>We build cloud-native solutions. <strong>Let’s develop them that way.</strong></p>]]></content><author><name>Ben Lvovsky</name></author><category term="architecture" /><category term="cloud" /><category term="engineering-culture" /><summary type="html"><![CDATA[The architecture diagram says managed services. Every developer's laptop says Postgres, RabbitMQ, Redis and a Compose file. You can pay for a cloud migration and still develop on-premises — and the people doing it are usually the ones senior enough to know better.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://websites.lvovsky.com/assets/og/cloud-engineering-anti-practices.png" /><media:content medium="image" url="https://websites.lvovsky.com/assets/og/cloud-engineering-anti-practices.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>