<?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: Parsa Aftabi</title>
    <description>The latest articles on DEV Community by Parsa Aftabi (@parsaaftabi).</description>
    <link>https://dev.to/parsaaftabi</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%2F4173416%2F2b407209-b058-4dba-a4a2-8eaaea86c6f1.png</url>
      <title>DEV Community: Parsa Aftabi</title>
      <link>https://dev.to/parsaaftabi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/parsaaftabi"/>
    <language>en</language>
    <item>
      <title>Read the migration before production does: miglens, a local-AI migration-risk explainer in Go</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:14:59 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/read-the-migration-before-production-does-miglens-a-local-ai-migration-risk-explainer-in-go-1jm8</link>
      <guid>https://dev.to/parsaaftabi/read-the-migration-before-production-does-miglens-a-local-ai-migration-risk-explainer-in-go-1jm8</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;miglens reads a SQL migration and tells you what is risky about it before you run it: first with a deterministic rule scan, then in plain English from a local open-weight model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;miglens migrations/0042_orders_cleanup.sql
&lt;span class="nb"&gt;cat &lt;/span&gt;migration.sql | miglens &lt;span class="nt"&gt;-model&lt;/span&gt; llama3.2:1b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dangerous line in a migration is rarely a syntax error. It is the &lt;code&gt;DROP COLUMN&lt;/code&gt; that deletes data a still-running service needs, the &lt;code&gt;ADD COLUMN ... NOT NULL&lt;/code&gt; that fails on a table with two million rows, the &lt;code&gt;CREATE INDEX&lt;/code&gt; that quietly blocks writes for twenty minutes. Linters check style. miglens checks blast radius.&lt;/p&gt;

&lt;p&gt;Fitting this week's theme, a migration is where a codebase keeps its ghosts: dropped columns that old code still reaches for, dead tables nobody owns, constraints added in a hurry that haunt every deploy after. miglens finds those ghosts while they are still just lines in a file, before they start haunting production.&lt;/p&gt;

&lt;p&gt;Two layers, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rule scan (always runs, fully offline).&lt;/strong&gt; Fifteen rules with severity, line number, a plain explanation, and a safer way to do the same thing. Dropping a table or column is CRITICAL, whole-table writes without a WHERE and NOT NULL without a DEFAULT are HIGH, a non-concurrent index build is MEDIUM, and so on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI summary (optional, local).&lt;/strong&gt; The findings plus the migration go to a model running in Ollama on my own machine, which answers in a fixed shape: what could break, who it affects, a safer alternative, and a rollout order.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If Ollama is not running, miglens prints the full scan, a short note on how to enable the summary, and exits 0. The scan is the source of truth. The model only explains it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;examples/risky.sql&lt;/code&gt; packs five classic mistakes into one small migration. Real output from a local run with &lt;code&gt;llama3.2:1b&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./miglens -model llama3.2:1b examples/risky.sql
== examples/risky.sql ==
5 findings: 1 critical, 3 high, 1 medium

line 4  CRITICAL  drop-column
  &amp;gt; ALTER TABLE orders DROP COLUMN legacy_code;
  Dropping a column permanently deletes that data, and any running code that still reads or writes the column starts failing immediately.
  Safer: Ship code that ignores the column first (expand/contract), then drop the column in a later migration.

line 7  HIGH  add-column-not-null
  &amp;gt; ADD COLUMN discount_code text NOT NULL;
  Adding a NOT NULL column without a DEFAULT fails on tables that already have rows, and on newer Postgres versions it still takes a lock while the default is applied.
  Safer: Add the column nullable (or with a DEFAULT), backfill existing rows, then add the NOT NULL constraint.

line 9  MEDIUM  create-index-not-concurrent
  &amp;gt; CREATE INDEX idx_orders_customer_id ON orders (customer_id);
  CREATE INDEX without CONCURRENTLY blocks writes to the table until the index is built, which on a large table means downtime.
  Safer: Use CREATE INDEX CONCURRENTLY (outside a transaction) so writes keep flowing during the build.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the local model on the same findings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- AI summary (local model) ---
The worst realistic outcome could be that the database is permanently
locked and unusable for a certain period, causing disruptions to users,
services, and jobs that rely on the orders table.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest note, same as last time: the 1B model is loose. It gets the shape of the answer but not the precision, and the summary gets sharper with &lt;code&gt;llama3.2:3b&lt;/code&gt;. The tool does not care which one you run. That is the point of building on a local, swappable model. The deterministic scan underneath is exact either way: right lines, right severities, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;miglens is one Go module with zero dependencies, standard library only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cmd/miglens&lt;/code&gt;: flags and env config (&lt;code&gt;-model&lt;/code&gt;, &lt;code&gt;-host&lt;/code&gt;, &lt;code&gt;-no-ai&lt;/code&gt;, &lt;code&gt;OLLAMA_HOST&lt;/code&gt;, &lt;code&gt;OLLAMA_MODEL&lt;/code&gt;), file or stdin input&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/rules&lt;/code&gt;: the pattern engine: walks the SQL statement by statement, comments stripped with line numbers preserved, one finding per rule per statement&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/report&lt;/code&gt;: renders the scan: counts by severity, then each finding with its line, explanation, and safer alternative&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/prompt&lt;/code&gt;: builds the fixed-shape summary request, worded so the model reports impact instead of inventing root causes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/ollama&lt;/code&gt;: a thin client for Ollama's &lt;code&gt;/api/generate&lt;/code&gt;; unreachable server or missing model is a note, never a failed run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;53 unit tests cover the rule engine (including exact line and severity assertions on the shipped risky example, and zero findings on the safe one), the report renderer, the prompt builder, and the client against a stub Ollama server. &lt;code&gt;go build ./...&lt;/code&gt; and &lt;code&gt;go test ./...&lt;/code&gt; are green.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The layering came first, before any code. An AI summary of a migration is only worth reading if something exact sits underneath it, because a small local model will happily sound confident about the wrong line. So the deterministic scan owns the facts, and the model owns the explanation. If the model is down, vague, or not pulled yet, you lose adjectives, not correctness.&lt;/p&gt;

&lt;p&gt;The unglamorous problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SQL is not line-shaped.&lt;/strong&gt; Statements span lines, comments sit inside them, and a finding that points at the wrong line is worse than no finding. The engine strips comments while preserving line numbers, then reports the exact line where a pattern matched inside a multi-line statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules interact.&lt;/strong&gt; &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot run inside a transaction, so flagging "no transaction" on a file that uses CONCURRENTLY would be a false alarm. The missing-transaction rule stays quiet when CONCURRENTLY is present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure should teach.&lt;/strong&gt; A missing model or a stopped Ollama prints exactly how to fix it (&lt;code&gt;ollama serve&lt;/code&gt;, &lt;code&gt;ollama pull llama3.2:3b&lt;/code&gt;) and the run still ends with the full scan in hand.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;A production schema is a map of the business: table names, column names, the shape of the data, which parts are being changed and why. Sending a migration to a hosted AI API sends that map to someone else's computer, under someone else's retention policy, usually minutes before a deploy, when nobody is stopping to think about data policy.&lt;/p&gt;

&lt;p&gt;A local open-weight model removes the question entirely. The migration stays on the machine that wrote it. It costs nothing per call, works with the network cable unplugged, and the model can be swapped the day a better open one drops. And because the deterministic layer needs no model at all, the tool still earns its place in a CI pipeline on a box with no GPU and no Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;Reviewing a migration by hand means holding the whole table in your head: how big it is, what locks each statement takes, which deploy order makes it safe. miglens does that first pass in seconds and hands me five findings with line numbers instead of forty lines of raw SQL. It does not make the migration safe. It makes sure I am not the last thing standing between a &lt;code&gt;DROP COLUMN&lt;/code&gt; and production.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>go</category>
      <category>ai</category>
    </item>
    <item>
      <title>My logs never leave the box: logwhisper, a local-AI log explainer in Go</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:57:25 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/my-logs-never-leave-the-box-logwhisper-a-local-ai-log-explainer-in-go-3knk</link>
      <guid>https://dev.to/parsaaftabi/my-logs-never-leave-the-box-logwhisper-a-local-ai-log-explainer-in-go-3knk</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;logwhisper is a small Go CLI that reads production log lines and explains them in plain English: what happened, the likely cause, and the next thing to check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;logwhisper examples/production.log
&lt;span class="nb"&gt;cat &lt;/span&gt;app.log | logwhisper &lt;span class="nt"&gt;-model&lt;/span&gt; llama3.2:3b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI part runs entirely on my own machine through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; and an open-weight model (Llama 3.2). No API key, no cloud call, no log line ever leaving the box.&lt;/p&gt;

&lt;p&gt;Who gets to be a little lazier because of it? The on-call version of me. The tool was born from a familiar 2 a.m. scene: an incident, a wall of log lines, and the slow work of reading them one by one until a pattern shows up. logwhisper does the first pass — the reading — so the human can start at the diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;A sample incident log ships in the repo (&lt;code&gt;examples/production.log&lt;/code&gt;): a Go service whose Postgres runs out of connection slots at 02:15 and starts failing requests. Here is logwhisper on it, running the small &lt;code&gt;llama3.2:1b&lt;/code&gt; model locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./logwhisper -model llama3.2:1b examples/production.log

5. "Slow query" - A query to retrieve orders was slow, taking 1,823 milliseconds to complete.
6. "Query failed" - A query to insert order items failed due to a connection error.
7. "Request failed" - A POST request to create order items failed due to a connection error.
10. "Connection pool waiting" - The connection pool is waiting for 37 requests to be fulfilled.
11. "Query failed" - A query to retrieve the balance of a specific user failed due to a password authentication error.
12. "Read replica disabled" - The read replica database is disabled, and the server is falling back to the primary database.
16. "Postgres connections still high" - There are still high levels of connections to Postgres.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest note: the 1B model mostly narrates line by line. It reads the log back to you in plain words, which is already useful at 2 a.m., but the real cause-and-effect synthesis gets noticeably better with &lt;code&gt;llama3.2:3b&lt;/code&gt;. The tool doesn't care which one you run — that's the point of building on a local, swappable model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;logwhisper is one Go module with zero dependencies — standard library only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cmd/logwhisper&lt;/code&gt; — flags and env config (&lt;code&gt;-model&lt;/code&gt;, &lt;code&gt;-host&lt;/code&gt;, &lt;code&gt;OLLAMA_HOST&lt;/code&gt;, &lt;code&gt;OLLAMA_MODEL&lt;/code&gt;), stdin or file input&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/chunk&lt;/code&gt; — splits long logs into pieces a small model's context window can actually hold (40 lines / 6000 bytes by default, never splits a line in half)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/prompt&lt;/code&gt; — a fixed What happened / Likely cause / Next check template, worded to stop a small model from inventing a root cause it can't see&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/ollama&lt;/code&gt; — a thin client for Ollama's &lt;code&gt;/api/generate&lt;/code&gt;, with an honest error when Ollama isn't running that tells you exactly how to start it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unit tests cover the chunking, the prompt builder, and the client (including an unreachable-server case and a stub Ollama server). &lt;code&gt;go build ./...&lt;/code&gt; and &lt;code&gt;go test ./...&lt;/code&gt; are green.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The design constraint came first: whatever I built, the model had to be open and local. Ollama made that part boring in the best way — pull a model, get an HTTP API on &lt;code&gt;localhost:11434&lt;/code&gt;, done.&lt;/p&gt;

&lt;p&gt;The interesting problems were the unglamorous ones:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context windows are small.&lt;/strong&gt; A real incident log doesn't fit in a 1B model's context. So logwhisper chunks by lines and bytes and explains piece by piece, labeling each part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small models bluff.&lt;/strong&gt; Ask a small model "what's the root cause?" and it will happily invent one. The prompt template separates observation ("what happened") from hypothesis ("likely cause") and tells the model to say when the log doesn't contain the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure should teach.&lt;/strong&gt; If Ollama is down, the tool exits with the exact commands to fix it (&lt;code&gt;ollama serve&lt;/code&gt;, &lt;code&gt;ollama pull llama3.2:3b&lt;/code&gt;) instead of a naked dial error.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Production logs are some of the most sensitive text a company produces: user IDs, IPs, query parameters, the exact shape of every failure. Pasting them into a hosted AI API means sending that text to someone else's computer, under someone else's retention policy — at 2 a.m., under incident pressure, when nobody is thinking about data policy.&lt;/p&gt;

&lt;p&gt;A local open-weight model removes the question entirely. The log stays on the box that produced it. It also costs nothing per call, works with the network cable unplugged, and the model can be swapped the day a better open one drops — I ran the same binary against the 1B and 3B Llama 3.2 builds without changing a line of code.&lt;/p&gt;

&lt;p&gt;Slow and steady is also how incidents actually get solved: read carefully, form a hypothesis, check one thing. logwhisper just takes the first, slowest step off my plate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;Reading a 200-line incident log cold takes me roughly ten minutes before I even start forming a hypothesis. logwhisper's pass takes seconds, and I start from a plain-English summary instead of raw text. It doesn't fix the incident — it gets me to the fixing part faster, with the log never leaving the machine.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>go</category>
      <category>ai</category>
    </item>
    <item>
      <title>The http.Server Setup I Use in Every Go Project — Now Open Source</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:03:05 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/the-httpserver-setup-i-use-in-every-go-project-now-open-source-4653</link>
      <guid>https://dev.to/parsaaftabi/the-httpserver-setup-i-use-in-every-go-project-now-open-source-4653</guid>
      <description>&lt;p&gt;Every Go API I ship starts life with the same block of code.&lt;/p&gt;

&lt;p&gt;Not the handlers, not the routing. The server setup. Timeouts, signal handling, the shutdown dance. I'd copy it from the last project, change a number or two, and move on. After the fifth or sixth time I stopped pretending it was project code. It's infrastructure, so I extracted it.&lt;/p&gt;

&lt;p&gt;It's called &lt;a href="https://github.com/parsaaftabi/graceful" rel="noopener noreferrer"&gt;graceful&lt;/a&gt;. Zero dependencies, about 200 lines of code you can read in one sitting. Here's what it does and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stdlib defaults are a footgun
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;http.Server&lt;/code&gt; ships with every timeout set to zero. Zero doesn't mean "sensible default". It means no timeout at all.&lt;/p&gt;

&lt;p&gt;A client that connects and sends its headers one byte at a time will hold that connection open forever. That's slowloris, and it's not exotic — it's the default behavior of the server most Go APIs run on. Same story on the write side: a handler writing to a dead connection can block indefinitely, because nothing bounds it.&lt;/p&gt;

&lt;p&gt;Every production server needs five things, and the standard library leaves all five at zero values:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Real timeouts on reads and writes&lt;/li&gt;
&lt;li&gt;Signal handling for SIGINT/SIGTERM&lt;/li&gt;
&lt;li&gt;Draining in-flight requests instead of dropping them&lt;/li&gt;
&lt;li&gt;Cleanup that runs in a sane order (DB pools, telemetry flush)&lt;/li&gt;
&lt;li&gt;A deadline on shutdown itself, so it can't hang forever&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is hard. It's just boilerplate you have to remember, every time, in exactly the right order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mux&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServeMux&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/healthz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;srv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;graceful&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;graceful&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithAddr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":8080"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;graceful&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithOnShutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;srv&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="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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;That's the whole thing. &lt;code&gt;Run&lt;/code&gt; blocks until the context is cancelled or a signal arrives, drains in-flight requests, runs your hooks, and returns. &lt;code&gt;ErrServerClosed&lt;/code&gt; never surfaces as an error, because it isn't one.&lt;/p&gt;

&lt;p&gt;The defaults I landed on after running this shape in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ReadHeaderTimeout&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;td&gt;Headers must arrive fast. This is the slowloris guard.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReadTimeout&lt;/td&gt;
&lt;td&gt;10s&lt;/td&gt;
&lt;td&gt;Bounds the full request read, body included.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WriteTimeout&lt;/td&gt;
&lt;td&gt;30s&lt;/td&gt;
&lt;td&gt;Bounds the full response write, handler time included.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IdleTimeout&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;Keep-alive connections don't linger forever.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ShutdownTimeout&lt;/td&gt;
&lt;td&gt;20s&lt;/td&gt;
&lt;td&gt;Requests and hooks finish inside this, or not at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of them is overridable with a &lt;code&gt;With...&lt;/code&gt; option. WriteTimeout is the one people get wrong most often — if you serve large exports or SSE, raise it first and ask questions later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutdown hooks, in order
&lt;/h2&gt;

&lt;p&gt;When Kubernetes sends SIGTERM, the order matters. Stop accepting new connections first. Let in-flight requests finish. Then close things, in the order you registered them.&lt;/p&gt;

&lt;p&gt;Hooks in graceful run sequentially after the listener drains. Each one gets the shutdown context, which is cancelled at the ShutdownTimeout deadline. If a hook fails, it's logged and the rest still run — one misbehaving cleanup shouldn't block closing the database pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;p&gt;No router. No middleware chain. No config files, no framework, no opinions about your handlers. It's a server lifecycle wrapper and nothing else, which is exactly why it's 200 lines and has zero dependencies. If you need more than this, you don't need a bigger version of graceful — you need a framework, and that's a different decision.&lt;/p&gt;

&lt;p&gt;The full &lt;code&gt;*http.Server&lt;/code&gt; is exposed via &lt;code&gt;HTTPServer()&lt;/code&gt; if you need ConnState hooks or HTTP/2 tuning, and &lt;code&gt;WithTLS&lt;/code&gt; takes a cert and key (passing only one is a config error, caught at startup, not at 3am).&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/parsaaftabi/graceful
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/parsaaftabi/graceful" rel="noopener noreferrer"&gt;https://github.com/parsaaftabi/graceful&lt;/a&gt; — issues and PRs welcome. If your timeout defaults differ from mine, I'm genuinely curious what you run and why. That's the part of this setup that never stops being project-specific.&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>PostgreSQL EXPLAIN ANALYZE: What I Actually Look At in Query Plans</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Fri, 16 Oct 2026 23:18:51 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/postgresql-explain-analyze-what-i-actually-look-at-in-query-plans-542f</link>
      <guid>https://dev.to/parsaaftabi/postgresql-explain-analyze-what-i-actually-look-at-in-query-plans-542f</guid>
      <description>&lt;h1&gt;
  
  
  PostgreSQL EXPLAIN ANALYZE: What I Actually Look At in Query Plans
&lt;/h1&gt;

&lt;p&gt;Every slow query I have ever chased started the same way: someone guessing. "Probably needs an index." "The join is too big." "Postgres is slow today." Guessing is how you add three indexes, change nothing, and learn nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; ends the guessing. It runs the query and tells you exactly where the time went. Here is how I read one, in the order I actually read it, and what each part is telling me.&lt;/p&gt;

&lt;p&gt;One warning first, because it bites people: &lt;code&gt;ANALYZE&lt;/code&gt; executes the statement. On a &lt;code&gt;SELECT&lt;/code&gt; that is fine. On an &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt;, wrap it in a transaction and roll back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forget the rollback once and you will remember it forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Always add BUFFERS
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; tells you time. &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; tells you time plus where the data came from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Buffers: shared hit=42 read=8190
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hit&lt;/code&gt; means the page was already in Postgres's cache. &lt;code&gt;read&lt;/code&gt; means it went to disk. A query with 8000 reads and 42 hits is not slow because the plan is bad. It is slow because nothing it needs is cached. That is a completely different problem from a bad join, and it changes everything you do next. Without &lt;code&gt;BUFFERS&lt;/code&gt; you cannot tell them apart, so I never run a plan without it.&lt;/p&gt;

&lt;p&gt;If you want the disk time broken out too, &lt;code&gt;track_io_timing&lt;/code&gt; needs to be on, but the hit/read split alone answers most questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Read bottom-up, and multiply by loops
&lt;/h2&gt;

&lt;p&gt;A plan is a tree. Postgres executes the leaves first and the results flow upward, so the most expensive thing is usually near the bottom, not the top. Start at the bottom.&lt;/p&gt;

&lt;p&gt;Two mechanics people get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time is cumulative.&lt;/strong&gt; The &lt;code&gt;actual time&lt;/code&gt; on a node includes all its children. To get what a node itself cost, subtract the children. The node with the biggest exclusive time is where your query is actually dying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loops multiply.&lt;/strong&gt; This line is the classic killer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-&amp;gt;  Index Scan using orders_user_id_idx on orders  (actual time=0.012..0.031 rows=3 loops=184200)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That index scan looks cheap: 0.03 milliseconds. But it ran 184,200 times, once per outer row. That is 184,200 tiny index probes, which is 184,200 rounds of random I/O. The per-loop number lies to you. The total is what you pay.&lt;/p&gt;

&lt;p&gt;Whenever I see a high loop count on an inner node, I already know the shape of the fix before reading further: the outer side is too big for a nested loop, or the inner side needs to be cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Estimated rows vs actual rows: the gap is the signal
&lt;/h2&gt;

&lt;p&gt;Every node shows two row counts: what the planner guessed (&lt;code&gt;rows=&lt;/code&gt;) and what actually came back (&lt;code&gt;actual rows=&lt;/code&gt;). The single most informative number in the whole plan is the ratio between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seq Scan on events  (actual rows=4 loops=1)
   Filter: (created_at &amp;gt; '2026-10-01')
   Rows Removed by Filter: 987331
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The planner thought this filter would match a lot of rows. It matched 4. When the estimate is off by orders of magnitude, the planner picks the wrong strategy for the wrong data shape, and everything downstream is built on that lie.&lt;/p&gt;

&lt;p&gt;Do not tune the query first. Fix the estimate. Nine times out of ten it is stale statistics: run &lt;code&gt;ANALYZE&lt;/code&gt; on the table and look again. If the stats are fresh and the gap persists, the query is shaped in a way statistics cannot model: a function applied to the column (&lt;code&gt;WHERE lower(email) = ...&lt;/code&gt;), a &lt;code&gt;LIKE '%...%'&lt;/code&gt; with a leading wildcard, or a predicate the planner has no stats for. Then you change the query shape or add an expression index, not another prayer.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Seq Scan is not automatically the villain
&lt;/h2&gt;

&lt;p&gt;A sequential scan on a 200-row lookup table is the correct plan. Postgres knows it, and you should let it. The seq scan becomes the villain in exactly one situation: it is scanning a large table and throwing almost all of it away.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Rows Removed by Filter&lt;/code&gt; is the field that tells you. If a seq scan reads a million rows and the filter removes 999,996 of them, that is either a missing index or an index the query cannot use. Check whether the filtered column has an index. If it does and Postgres still chose the seq scan, the estimate was probably wrong (see section 3) or the filter is not sargable, like a function call wrapping the column.&lt;/p&gt;

&lt;p&gt;Small table, full scan: fine. Big table, filter discards everything: that is your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Nested loops: the silent killer
&lt;/h2&gt;

&lt;p&gt;Nested loop joins are perfect when the outer side is small. Ten outer rows, index probe each, done. They become catastrophic when the outer side grows and nobody notices, because the plan shape does not change, only the loop count does. A query that ran fine for a year starts timing out, and the plan looks "the same" except &lt;code&gt;loops=&lt;/code&gt; went from 50 to 500,000.&lt;/p&gt;

&lt;p&gt;My rule: if the outer side returns more than a few thousand rows, I want a hash join or a merge join, not a nested loop doing random I/O per row. The fix is usually upstream, make the outer side smaller (a better filter, a better estimate), rather than forcing the join type.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Hash joins, sorts, and work_mem
&lt;/h2&gt;

&lt;p&gt;When a hash join or a sort does not fit in &lt;code&gt;work_mem&lt;/code&gt;, Postgres spills to disk. The plan tells you plainly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sort Method: external merge  Disk: 18432kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or for a hash join, multiple batches instead of one. Disk is orders of magnitude slower than memory, and this is the easiest big win in query tuning: &lt;code&gt;SET LOCAL work_mem = '256MB'&lt;/code&gt; for that session or transaction and re-run. If the plan flips to an in-memory quicksort and the time collapses, you found it.&lt;/p&gt;

&lt;p&gt;I do not raise &lt;code&gt;work_mem&lt;/code&gt; globally for this. It is per-operation memory, and a global bump gets multiplied by every concurrent sort and hash in the system. Session-local, for the query that needs it, is the disciplined move.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Index-only scans and heap fetches
&lt;/h2&gt;

&lt;p&gt;An index-only scan is Postgres's cheapest access path, but it has a catch: it still visits the table (the heap) for any row whose visibility is not confirmed in the visibility map. The plan shows this as &lt;code&gt;Heap Fetches&lt;/code&gt;. A high number means the table is not vacuumed often enough and the "index-only" scan is quietly doing heap I/O anyway.&lt;/p&gt;

&lt;p&gt;If you see an index-only scan with thousands of heap fetches on a table that updates a lot, the fix is not a better index. It is autovacuum tuning, or a manual &lt;code&gt;VACUUM&lt;/code&gt;, so the visibility map actually reflects reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. CTEs can be an optimization fence
&lt;/h2&gt;

&lt;p&gt;On Postgres 12 and later, a CTE that is referenced once gets inlined into the main query like a subquery, and the planner can optimize across it. Before 12, every CTE was materialized: fully computed, stored in a temp table, then read. That fence still exists on demand with the &lt;code&gt;MATERIALIZED&lt;/code&gt; keyword.&lt;/p&gt;

&lt;p&gt;When I see a plan where a CTE materializes millions of rows and the outer query filters down to a hundred, I check whether inlining would let the planner push the filter inside. On modern Postgres this usually happens automatically, but a &lt;code&gt;MATERIALIZED&lt;/code&gt; left over from an older version, or a CTE referenced twice, will still fence it. Small thing, big difference on large CTEs.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. My 60-second routine for any slow query
&lt;/h2&gt;

&lt;p&gt;This is the actual order I go in, every time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the node with the largest exclusive time (subtract children, multiply loops). That is the bottleneck. Everything else is context.&lt;/li&gt;
&lt;li&gt;On that node and its parents, compare estimated rows to actual rows. A big gap means fix statistics or query shape before touching anything else.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;BUFFERS&lt;/code&gt; on the expensive nodes. Mostly reads means a caching problem, not a plan problem.&lt;/li&gt;
&lt;li&gt;Look for disk: &lt;code&gt;external merge&lt;/code&gt;, hash batches, temp files. That is &lt;code&gt;work_mem&lt;/code&gt;, session-local.&lt;/li&gt;
&lt;li&gt;Check the join strategy against the outer row count. Nested loop with a huge outer side is the usual suspect for queries that "used to be fast."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most slow queries I have chased were not exotic. A wrong estimate, a missing index, or a nested loop that outgrew its welcome. The plan tells you which one in about a minute, if you read it bottom-up and trust the numbers over your first guess.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Redis in Production: What I Actually Configure</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:43:09 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/redis-in-production-what-i-actually-configure-51c9</link>
      <guid>https://dev.to/parsaaftabi/redis-in-production-what-i-actually-configure-51c9</guid>
      <description>&lt;h1&gt;
  
  
  Redis in Production: What I Actually Configure
&lt;/h1&gt;

&lt;p&gt;Redis is the piece of infrastructure everyone under-configures. Out of the box it's a cache with no memory limit, an eviction policy that fails writes instead of evicting, and persistence that can lose your last chunk of data. That's fine on a laptop. In production, the defaults will page you at 3am.&lt;/p&gt;

&lt;p&gt;Here's what I actually touch on every Redis I ship, and why. None of it is exotic — that's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. maxmemory + maxmemory-policy: the two lines that matter most
&lt;/h2&gt;

&lt;p&gt;Default Redis has no memory limit and &lt;code&gt;noeviction&lt;/code&gt;. When memory fills up, writes start failing with OOM errors while reads keep working. That's the worst kind of failure: half your app looks fine, the other half is quietly dropping writes.&lt;/p&gt;

&lt;p&gt;I always set &lt;code&gt;maxmemory&lt;/code&gt; — below what the box actually has, leaving headroom for fragmentation and the OS — and I pick the policy based on what that Redis instance is &lt;em&gt;for&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pure cache:&lt;/strong&gt; &lt;code&gt;allkeys-lru&lt;/code&gt;. Least-recently-used keys go first. Simple, correct for a cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions or mixed data with TTLs:&lt;/strong&gt; &lt;code&gt;volatile-lru&lt;/code&gt; (or &lt;code&gt;volatile-ttl&lt;/code&gt; if you'd rather evict nearest-expiry first).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue or anything you can't afford to lose:&lt;/strong&gt; &lt;code&gt;noeviction&lt;/code&gt; is actually right here. I'd rather have writes fail loudly than silently drop queued jobs. But then &lt;code&gt;maxmemory&lt;/code&gt; must be sized so it never fills, and you monitor it like a hawk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake I keep seeing: one Redis doing all three jobs under one policy. Separate your concerns — or at least know exactly which data you're willing to lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Persistence: decide what "losing it" means
&lt;/h2&gt;

&lt;p&gt;Redis gives you RDB snapshots, an AOF log, or nothing. The default is RDB snapshots only, which means you can lose everything since the last snapshot.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pure cache:&lt;/strong&gt; persistence off is fine, and it's faster. Losing a cache is a cold start, not an incident. Write that in the runbook so nobody panics at 4am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything you can't rebuild&lt;/strong&gt; (sessions you don't want to nuke, queues, rate-limit state): AOF with &lt;code&gt;appendfsync everysec&lt;/code&gt;. You lose at most a second of writes, and the performance cost is boring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Belt and suspenders:&lt;/strong&gt; run both. AOF wins on restart; RDB gives you fast point-in-time copies for disaster recovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you choose: test the restart. I've seen more Redis "data loss" caused by nobody ever testing failover than by actual crashes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Timeouts: reap the dead connections
&lt;/h2&gt;

&lt;p&gt;Idle client connections pile up. Every app server that connected once and never said goodbye stays on the list. &lt;code&gt;timeout 300&lt;/code&gt; closes idle clients (the default is &lt;code&gt;0&lt;/code&gt; — never — which is a slow leak). &lt;code&gt;tcp-keepalive 300&lt;/code&gt; lets the OS reap dead peers instead of holding their slots forever.&lt;/p&gt;

&lt;p&gt;If your &lt;code&gt;CLIENT LIST&lt;/code&gt; keeps growing week over week while traffic is flat, this is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. slowlog: your free query profiler
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;slowlog&lt;/span&gt;-&lt;span class="n"&gt;log&lt;/span&gt;-&lt;span class="n"&gt;slower&lt;/span&gt;-&lt;span class="n"&gt;than&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
&lt;span class="n"&gt;slowlog&lt;/span&gt;-&lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="m"&gt;128&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then actually read it: &lt;code&gt;SLOWLOG GET&lt;/code&gt;. What I'm looking for isn't one slow command — it's the &lt;em&gt;same&lt;/em&gt; command showing up hundreds of times. That's your N+1 wearing a Redis costume.&lt;/p&gt;

&lt;p&gt;Usual suspects: &lt;code&gt;KEYS *&lt;/code&gt; in production (use &lt;code&gt;SCAN&lt;/code&gt;), giant hashes fetched whole instead of with &lt;code&gt;HSCAN&lt;/code&gt;, and Lua scripts that quietly grew teeth over six months of "just one more line."&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Key discipline: TTLs or it didn't happen
&lt;/h2&gt;

&lt;p&gt;Every key gets a TTL unless you can articulate why not. Keys without expiry are how a cache becomes a memory leak with extra steps.&lt;/p&gt;

&lt;p&gt;Here's the trap: &lt;code&gt;volatile-lru&lt;/code&gt; and &lt;code&gt;volatile-ttl&lt;/code&gt; only evict keys &lt;em&gt;with&lt;/em&gt; a TTL. Keys without one are immortal — they will eventually OOM your instance no matter what policy you set. This is the single most common Redis misconfiguration I run into: policy set to &lt;code&gt;volatile-lru&lt;/code&gt;, half the keys with no TTL, surprise OOM at 3am.&lt;/p&gt;

&lt;p&gt;Two more habits: name keys like paths (&lt;code&gt;service:entity:id&lt;/code&gt;) so &lt;code&gt;SCAN&lt;/code&gt; is actually usable, and watch out for big keys. A 50MB hash is a latency bomb — on eviction, on replication, on &lt;code&gt;HGETALL&lt;/code&gt;. Keep hashes small; split them.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Disable the footguns
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;rename&lt;/span&gt;-&lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="n"&gt;FLUSHDB&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="n"&gt;rename&lt;/span&gt;-&lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="n"&gt;FLUSHALL&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody needs to flush a production database on a whim and a &lt;code&gt;redis-cli&lt;/code&gt;. Same treatment for &lt;code&gt;KEYS&lt;/code&gt; and &lt;code&gt;DEBUG&lt;/code&gt; in prod. Then the network basics: &lt;code&gt;protected-mode yes&lt;/code&gt;, bind to the private interface only, &lt;code&gt;requirepass&lt;/code&gt; set. Redis with no password on a reachable interface isn't a misconfiguration — it's an incident waiting for a scanner.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What I actually monitor
&lt;/h2&gt;

&lt;p&gt;You don't need forty metrics. The ones that have ever paged me usefully:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;used_memory vs maxmemory&lt;/strong&gt; (as a percentage — alert &lt;em&gt;before&lt;/em&gt; it matters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;evicted_keys rate&lt;/strong&gt; (a spike means the working set doesn't fit, or TTLs are missing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;instantaneous_ops_per_sec&lt;/strong&gt; (baseline it; deviations mean something changed upstream)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;connected_clients and blocked_clients&lt;/strong&gt; (blocked &amp;gt; 0 for a long time means something's stuck on &lt;code&gt;BLPOP&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rdb_last_bgsave_status / aof_last_rewrite_status&lt;/strong&gt; (persistence failing silently is the classic)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;keyspace hits/misses&lt;/strong&gt; (hit rate dropping means your cache isn't caching)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Replication: the minimum viable setup
&lt;/h2&gt;

&lt;p&gt;One replica. &lt;code&gt;replica-serve-stale-data yes&lt;/code&gt; — serve reads during failover instead of erroring everything. If you genuinely can't afford to lose writes during a partition, &lt;code&gt;min-replicas-to-write&lt;/code&gt; — with the honest caveat that you're trading away availability.&lt;/p&gt;

&lt;p&gt;For most services I run, an async replica plus good monitoring beats a fancy Sentinel setup nobody will maintain. If you need real HA with automatic failover, that's a different article — and honestly, probably a managed service.&lt;/p&gt;




&lt;p&gt;None of this is exotic. Redis in production is mostly about deciding what each instance is &lt;em&gt;for&lt;/em&gt;, setting boundaries (memory, TTLs, timeouts), and turning off the defaults that assume a laptop. Do that, and Redis disappears into the background — which is exactly where infrastructure belongs.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>backend</category>
      <category>devops</category>
      <category>database</category>
    </item>
    <item>
      <title>Docker Images I'd Actually Ship: A Production Checklist</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:17:04 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/docker-images-id-actually-ship-a-production-checklist-hel</link>
      <guid>https://dev.to/parsaaftabi/docker-images-id-actually-ship-a-production-checklist-hel</guid>
      <description>&lt;p&gt;Most Dockerfiles I've seen in the wild are development artifacts wearing a production costume. Someone took the image they debugged with, slapped a CMD on it, and called it a deploy. It works — right up until it doesn't: 1.2GB images, root processes, secrets baked into layers, and deploys that take ten minutes to pull.&lt;/p&gt;

&lt;p&gt;Building a production image is a different discipline. Here's my checklist, the stuff I actually do on every service I ship, PHP or Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-stage builds are the whole game
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this article, take this. A builder stage with your full toolchain, a runtime stage with almost nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ---- builder ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;php:8.3-cli&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; git unzip &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=composer:2 /usr/bin/composer /usr/bin/composer&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; composer.json composer.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;composer dump-autoload &lt;span class="nt"&gt;--optimize&lt;/span&gt;

&lt;span class="c"&gt;# ---- runtime ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; php:8.3-fpm&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /var/www&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app /var/www&lt;/span&gt;
&lt;span class="c"&gt;# ... config, permissions, user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Composer, git, build tools — none of that belongs in the image your users' requests hit. The runtime stage should contain your code, your runtime, and nothing else. This one change usually cuts image size by more than half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick your base image like an adult
&lt;/h2&gt;

&lt;p&gt;Three options, three personalities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alpine&lt;/strong&gt;: tiny, fast pulls. But musl instead of glibc, which means the occasional weird DNS or binary-compat surprise at 2am. Fine for Go. I'd think twice for PHP with compiled extensions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debian slim&lt;/strong&gt;: boring in the best way. glibc, everything behaves like your dev machine. Slightly bigger. This is my default for PHP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distroless / scratch&lt;/strong&gt;: no shell, no package manager, no attack surface to speak of. Perfect for a statically compiled Go binary. Terrifying to debug — which is the point, because there's nothing to debug &lt;em&gt;in&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My defaults: &lt;code&gt;php:8.3-fpm&lt;/code&gt; (debian-based) for PHP, &lt;code&gt;gcr.io/distroless/static&lt;/code&gt; for Go. Boring choices that don't page me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order your layers for the cache
&lt;/h2&gt;

&lt;p&gt;Docker caches layers top-down, and every COPY invalidates everything below it. So: dependencies first (they change rarely), source code last (it changes constantly).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.mod go.sum ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 go build &lt;span class="nt"&gt;-o&lt;/span&gt; /app/server ./cmd/server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;go mod download&lt;/code&gt; on its own layer means a code change doesn't re-download the world. Same idea with &lt;code&gt;composer.json&lt;/code&gt;/&lt;code&gt;composer.lock&lt;/code&gt; before the full source copy. Get this wrong and every deploy rebuilds everything from scratch while your CI bill quietly grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run as non-root. No exceptions.
&lt;/h2&gt;

&lt;p&gt;A container running as root that escapes is a host compromise. A container running as &lt;code&gt;nobody&lt;/code&gt; that escapes is a bad day. The fix is one line and some permission hygiene:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www-data:www-data /var/www/storage /var/www/bootstrap/cache
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; www-data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do it after everything that needs root (installs, chowns), as late as possible in the Dockerfile. If your app can't run without root, that's a bug in your app, not a reason to skip this.&lt;/p&gt;

&lt;h2&gt;
  
  
  .dockerignore — the file everyone forgets
&lt;/h2&gt;

&lt;p&gt;Without it, your build context includes &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;vendor/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;, your designer's 400MB of PSDs, and last year's backup. Slow builds, bloated contexts, and occasionally secrets you didn't mean to ship.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.git
vendor/
node_modules/
.env
*.md
docker-compose*.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five minutes, once. There's no excuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Healthchecks that actually check something
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=3s --start-period=10s --retries=3 \&lt;/span&gt;
  CMD wget -qO- http://localhost:8080/health || exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Port is open" is not a health check. Your &lt;code&gt;/health&lt;/code&gt; endpoint should verify what the app actually needs: can it reach the database, is Redis responding, is the disk writable. A container that accepts traffic while its DB connection is dead is worse than a container that's down — at least a down container gets restarted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets don't go in images. Ever.
&lt;/h2&gt;

&lt;p&gt;Build args, ENV lines with passwords, &lt;code&gt;.env&lt;/code&gt; files copied into the image — all of these end up in layer history, readable by anyone who can pull the image. I've seen production AWS keys in public images. Don't be that team.&lt;/p&gt;

&lt;p&gt;Secrets arrive at runtime: environment variables from your orchestrator, mounted secret files, a vault agent. The image should boot with zero credentials and complain loudly if they're missing. Fail fast on missing config is a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHP specifics
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave xdebug at home.&lt;/strong&gt; It's a dev extension. It slows every request and has no business in production. &lt;code&gt;--no-dev&lt;/code&gt; on composer, no dev extensions in the runtime stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune OPcache in the image&lt;/strong&gt;, not on the server. &lt;code&gt;opcache.enable=1&lt;/code&gt;, &lt;code&gt;opcache.validate_timestamps=0&lt;/code&gt; (you redeploy instead of editing files on the box, right?), generous &lt;code&gt;opcache.memory_consumption&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One process per container.&lt;/strong&gt; PHP-FPM in one container, nginx in another. Or skip the pair entirely — FrankenPHP serves a Laravel app from a single image with no FPM/nginx split, and it's genuinely simpler to operate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Go specifics
&lt;/h2&gt;

&lt;p&gt;This is where Docker shines brightest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.mod go.sum ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux go build &lt;span class="nt"&gt;-ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-s -w"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /server ./cmd/server

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/static:nonroot&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /server /server&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nonroot:nonroot&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/server"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A static binary, no libc, no shell, running as nonroot. The final image is ~15MB. Fifteen. Compare that to the 1GB PHP-FPM image you started with and tell me Go doesn't earn its reputation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The pattern repeats everywhere: single-stage images with dev dependencies and no &lt;code&gt;.dockerignore&lt;/code&gt; routinely land in the 1GB+ range. Rebuild the same app multi-stage — slim runtime, no dev deps, non-root, tuned OPcache — and it lands an order of magnitude smaller, typically well under 200MB. Pull time goes from minutes to seconds, which matters a lot when you're scaling up under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Multi-stage build. Boring base image. Deps before source in layer order. Non-root user. A real &lt;code&gt;.dockerignore&lt;/code&gt;. Healthchecks that check dependencies. Secrets only at runtime. No dev tools in the runtime stage.&lt;/p&gt;

&lt;p&gt;None of this is exotic. That's the point — production Docker isn't about clever tricks, it's about doing the boring things consistently. The teams that get paged at 3am are usually the ones that skipped the boring things.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>go</category>
      <category>php</category>
    </item>
    <item>
      <title>Laravel Octane in Production: What the Docs Don't Tell You</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Sun, 11 Oct 2026 10:57:56 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/laravel-octane-in-production-what-the-docs-dont-tell-you-482c</link>
      <guid>https://dev.to/parsaaftabi/laravel-octane-in-production-what-the-docs-dont-tell-you-482c</guid>
      <description>&lt;p&gt;Laravel Octane promises something that sounds too good: your Laravel app, running at speeds that make PHP-FPM look like it's wading through mud. Thousands of requests per second. Response times in single-digit milliseconds.&lt;/p&gt;

&lt;p&gt;It's real. I've seen it. But between the marketing and your production server there's a gap the docs gloss over, and it's full of memory leaks, stateful landmines, and 3am restarts. Here's what I actually learned running Octane for real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick your server like it matters, because it does
&lt;/h2&gt;

&lt;p&gt;Octane gives you three engines: FrankenPHP, Swoole, and RoadRunner. The docs present them as roughly equal choices. They're not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FrankenPHP&lt;/strong&gt; is what I'd hand to most teams. It's a Caddy-based app server, installs without drama, and worker mode just works. If your team already deploys PHP the boring way, this is the smallest jump with the biggest payoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RoadRunner&lt;/strong&gt; is the grown-up in the room. Written in Go, mature process model, great supervisor story. If you want boring reliability and you don't mind one more binary in your stack, it's hard to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swoole&lt;/strong&gt; is the fastest on paper and the one I'd least like to debug at 3am. It's a PHP extension that rewrites the rules of the runtime — coroutines, its own event loop, its own table implementation. When it works it's beautiful. When it doesn't, you're reading C-level stack traces wondering where your evening went.&lt;/p&gt;

&lt;p&gt;My take: start with FrankenPHP. Move to RoadRunner if you outgrow it or need its process model. Only reach for Swoole if you know exactly why you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your app is stateful and you forgot
&lt;/h2&gt;

&lt;p&gt;This is the big one. Under PHP-FPM, everything dies at the end of every request. Your sloppy singleton? Gone. That static property accumulating data? Gone. FPM is a garbage collector with a web server attached.&lt;/p&gt;

&lt;p&gt;Octane keeps your app booted in memory across requests. Suddenly all that state you were accidentally relying on FPM to clean up... doesn't get cleaned up.&lt;/p&gt;

&lt;p&gt;The classic hits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This looks innocent. Under Octane it's a memory leak with a login form.&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReportService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;addRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// grows forever, across requests, across users&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;Singletons bound with request-scoped data. Event listeners registered in a service provider that never get deregistered. Config values mutated at runtime (&lt;code&gt;config(['app.timezone' =&amp;gt; ...])&lt;/code&gt; in one request leaking into the next). Locale or timezone set per-request and never reset.&lt;/p&gt;

&lt;p&gt;The fix isn't complicated, but it requires a mindset shift: &lt;strong&gt;treat every request as sharing a room with every other request.&lt;/strong&gt; Audit your singletons. Prefer &lt;code&gt;bind()&lt;/code&gt; over &lt;code&gt;singleton()&lt;/code&gt; for anything request-scoped. And use Octane's lifecycle hooks — &lt;code&gt;Octane::tick()&lt;/code&gt;, request "flush" callbacks — to reset what must not survive.&lt;/p&gt;

&lt;p&gt;If your codebase is old and nobody knows what the singletons hold, be honest with yourself: Octane will find every one of them, in production, on a Friday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory leaks are not an if, they're a when
&lt;/h2&gt;

&lt;p&gt;Even a clean app leaks a little under a long-lived worker. A few kilobytes per request doesn't matter at 100 requests. At 100,000 requests on the same worker, it matters a lot.&lt;/p&gt;

&lt;p&gt;So don't run workers forever. Octane lets you cap it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/octane.php&lt;/span&gt;
&lt;span class="s1"&gt;'max_requests'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// recycle the worker after 500 requests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick a number and stop theorizing. 500 is a fine default. Watch worker memory in your monitoring — if it climbs steadily between recycles, you have a leak worth hunting. If it stays flat, your number is fine and you can raise it.&lt;/p&gt;

&lt;p&gt;And set up &lt;code&gt;octane:reload&lt;/code&gt; as part of your deploy, not as an afterthought. A deploy that doesn't recycle workers is a deploy that didn't fully happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency is a tool, not a lifestyle
&lt;/h2&gt;

&lt;p&gt;Octane can run tasks concurrently (&lt;code&gt;Octane::concurrently()&lt;/code&gt;), and it's genuinely useful for the fan-out pattern: three independent API calls, one response. I've replaced whole queue jobs with it where the work was fast and the user was waiting anyway.&lt;/p&gt;

&lt;p&gt;But don't turn your request lifecycle into a concurrency puzzle. If you need real background work with retries and failure handling, that's what queues are for. Octane doesn't replace Horizon. It replaces the part of your code that was waiting on I/O for no reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy it like a grown-up
&lt;/h2&gt;

&lt;p&gt;A few things that bite people on day one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supervisor (or systemd) is not optional.&lt;/strong&gt; Workers die. OOM-killer exists. Your process manager should restart them without you noticing. Here's the shape of it:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:octane]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/artisan octane:start --server=frankenphp --workers=auto --max-requests=500&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;www-data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--workers=auto&lt;/code&gt; is a starting point, not an answer.&lt;/strong&gt; Start there, then size workers to your actual CPU and memory. More workers than cores just adds context-switching overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OPcache + preloading still matter.&lt;/strong&gt; Octane doesn't make PHP's compilation cost disappear for free; keep your OPcache tuned like you would anywhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm your caches before traffic arrives.&lt;/strong&gt; First requests on fresh workers are the slowest. A deploy-time warmup script hitting your key routes saves you from a latency spike right when you're watching the dashboards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When I'd skip Octane entirely
&lt;/h2&gt;

&lt;p&gt;Not every app should run on it, and saying so isn't heresy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Low-traffic apps where FPM is already idle 99% of the time. You're adding operational complexity for numbers nobody will notice.&lt;/li&gt;
&lt;li&gt;Apps with heavy per-request memory (big file processing, huge collections) — long-lived workers amplify this instead of containing it.&lt;/li&gt;
&lt;li&gt;Codebases where nobody will fix the leaks Octane exposes. The server is honest; your app has to be too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Octane is a performance tool for apps that have outgrown FPM's model, not a magic flag you flip on everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;Octane is the real deal — the performance gains aren't marketing. But it's a different runtime model wearing Laravel's clothes, and it demands you actually understand what your app holds in memory.&lt;/p&gt;

&lt;p&gt;Do the singleton audit. Cap your worker lifetimes. Pick FrankenPHP unless you have a reason not to. Deploy with a process manager and a reload strategy from day one.&lt;/p&gt;

&lt;p&gt;Get those right and Octane is the easiest performance win in the Laravel ecosystem. Get them wrong and you'll learn about memory leaks the hard way — which, to be fair, is also a way to learn.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>performance</category>
      <category>backend</category>
    </item>
    <item>
      <title>PostgreSQL Connection Pooling in Go: What I Actually Tune in Production</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Sat, 10 Oct 2026 19:56:33 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/postgresql-connection-pooling-in-go-what-i-actually-tune-in-production-4mnj</link>
      <guid>https://dev.to/parsaaftabi/postgresql-connection-pooling-in-go-what-i-actually-tune-in-production-4mnj</guid>
      <description>&lt;p&gt;In my last post, lesson 3 was "your database is the bottleneck, not Go" — use pgx, size the pool deliberately. A few of you asked what "deliberately" actually means. Fair question.&lt;/p&gt;

&lt;p&gt;Here's every &lt;code&gt;pgxpool&lt;/code&gt; setting I touch in production, what it does, and the number I actually use. No theory — just the config running behind real traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The pool is the choke point, not your code
&lt;/h2&gt;

&lt;p&gt;Go will happily spin up 10,000 goroutines to serve requests. If your pool holds 20 connections, 9,980 of those goroutines are standing in line doing nothing.&lt;/p&gt;

&lt;p&gt;Every performance problem I've debugged in a Go API eventually led here. Not the handler, not the JSON serialization — the pool. It's the narrowest pipe in your system, and the defaults are not your friend. Every setting below is about controlling that pipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. MaxConns: do the math, per instance
&lt;/h2&gt;

&lt;p&gt;Each Postgres connection costs the server a backend process — a few MB of memory plus CPU spent context-switching. Past a few hundred connections, Postgres spends more time managing connections than running queries. More connections is not more throughput; it's more overhead.&lt;/p&gt;

&lt;p&gt;The formula I use, per API instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max_conns ≈ (2 × CPU cores) + 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 4-core box gets 12. Not 100. Not unlimited. 12.&lt;/p&gt;

&lt;p&gt;Why 2×? Web queries are I/O-bound — while one connection waits on disk, another uses the CPU. Two per core keeps the CPU busy without drowning Postgres in backends. The +4 is headroom for migrations, admin queries, and that one endpoint that fans out.&lt;/p&gt;

&lt;p&gt;The part everyone misses: &lt;strong&gt;multiply by instance count.&lt;/strong&gt; Three replicas × 12 = 36 connections hitting Postgres. Five services each with a pool of 20 = 100 backends before a single query runs. I size the pool backwards, from the database's perspective: database connection budget ÷ number of clients = per-instance MaxConns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// sized: (2 × 4 cores) + 4. Database budget ÷ instances.&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. MinConns: idle is not free
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MinConns&lt;/code&gt; keeps warm connections ready so a traffic burst doesn't pay connection-setup latency. Sounds great until you realize every idle connection still holds a Postgres backend process. &lt;code&gt;MinConns = 10&lt;/code&gt; across ten instances is 100 backends doing absolutely nothing at 3 AM.&lt;/p&gt;

&lt;p&gt;I keep it small — 2 to 5. Enough to absorb a burst, cheap enough to forget about. pgx ramps up to &lt;code&gt;MaxConns&lt;/code&gt; on demand anyway, and the ramp is fast. Warm is good; hoarding is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinConns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The two settings everyone ignores: lifetimes
&lt;/h2&gt;

&lt;p&gt;Long-lived connections go stale. Load balancers silently drop idle TCP connections. DNS changes don't propagate to connections that are already open. A &lt;code&gt;statement_timeout&lt;/code&gt; you changed on the server only applies to new connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConnLifetime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hour&lt;/span&gt;        &lt;span class="c"&gt;// recycle every connection hourly&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConnIdleTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt; &lt;span class="c"&gt;// reap connections idle this long&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MaxConnLifetime&lt;/code&gt; forces rotation — no connection lives long enough to go quietly stale. &lt;code&gt;MaxConnIdleTime&lt;/code&gt; reaps connections that are just sitting there holding a backend for no reason.&lt;/p&gt;

&lt;p&gt;One gotcha: don't set &lt;code&gt;MaxConnLifetime&lt;/code&gt; too aggressively. One minute on a busy pool means constant churn — TLS handshake and auth on every cycle. An hour is boring. Boring is good.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. HealthCheckPeriod: trust, but verify
&lt;/h2&gt;

&lt;p&gt;pgx validates idle connections before handing them out, but only as often as &lt;code&gt;HealthCheckPeriod&lt;/code&gt; allows. The default is one minute, which is fine for most setups.&lt;/p&gt;

&lt;p&gt;If your network is flaky — cloud load balancers love silently killing idle TCP — drop it to 30 seconds. It's a cheap ping. A dead connection handed to a live request is an expensive 500.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HealthCheckPeriod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Fail fast: the context deadline is your acquire timeout
&lt;/h2&gt;

&lt;p&gt;This is the setting that saves you during an outage. When the pool is exhausted, what should the 21st request do — wait forever, or fail fast?&lt;/p&gt;

&lt;p&gt;pgx v5 has no separate acquire-timeout knob. Connection acquisition respects the context deadline. &lt;strong&gt;Your context timeout is your acquire timeout.&lt;/strong&gt; Use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT name FROM users WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two seconds, then the request fails with a clear error instead of piling up behind a dead database. This is the difference between "the API is slow" and "the API is down for everyone, including the health check." Fail fast, return 503, let the load balancer route around you.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Watch the pool: &lt;code&gt;pool.Stat()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you're not watching the pool, you're guessing. pgx exposes everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pool: total=%d idle=%d acquired=%d max=%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalConns&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdleConns&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AcquiredConns&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConns&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I export these as Prometheus gauges and alert on exactly one thing: &lt;code&gt;AcquiredConns&lt;/code&gt; sitting at &lt;code&gt;MaxConns&lt;/code&gt;. That means the pool is saturated — every new request is queueing. It tells me to scale instances or find the slow query holding connections hostage, long before users notice.&lt;/p&gt;

&lt;p&gt;Two patterns I watch in dashboards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;IdleConns&lt;/code&gt; near zero with &lt;code&gt;TotalConns&lt;/code&gt; at &lt;code&gt;MaxConns&lt;/code&gt; — the pool is too small, or something is leaking connections.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AcquireCount&lt;/code&gt; climbing far faster than actual queries — something acquires and never releases. Nine times out of ten, it's a forgotten &lt;code&gt;rows.Close()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Mistakes I've actually made
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A pool per request.&lt;/strong&gt; Early on I called &lt;code&gt;pgxpool.New&lt;/code&gt; inside a handler "to be safe." Every request built a pool, every pool opened connections. The database fell over in minutes. One pool per process, created at startup, passed down. Ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;pool.Close()&lt;/code&gt;.&lt;/strong&gt; On shutdown, in-flight queries get killed mid-flight and the database logs a wall of errors. Close the pool in your graceful shutdown path — after &lt;code&gt;srv.Shutdown&lt;/code&gt;, before exit. (See lesson 6 of my last post.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepared statements behind PgBouncer in transaction mode.&lt;/strong&gt; pgx prepares statements by default; PgBouncer in transaction-pooling mode can't handle named prepared statements across transactions, and you'll get &lt;code&gt;prepared statement already exists&lt;/code&gt; errors at the worst possible time. The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultQueryExecMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryExecModeSimpleProtocol&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only needed if you run PgBouncer in transaction mode. If you don't know what that means, you probably don't — skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The config I actually ship
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/jackc/pgx/v5/pgxpool"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;NewPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;connStr&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parse db config: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinConns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConnLifetime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hour&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConnIdleTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HealthCheckPeriod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;

    &lt;span class="c"&gt;// Only if you run PgBouncer in transaction mode:&lt;/span&gt;
    &lt;span class="c"&gt;// config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol&lt;/span&gt;

    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewWithConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"create db pool: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Fail fast at startup if the database is unreachable.&lt;/span&gt;
    &lt;span class="n"&gt;pingCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pingCtx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ping database: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve connections. Two idle minimum. Hourly rotation. And a ping at startup, so a bad connection string fails the deploy — not the first request at 2 AM.&lt;/p&gt;




&lt;p&gt;Connection pooling is unglamorous work. Nobody demos a pool config. But it's the difference between an API that degrades gracefully and one that falls over the first time traffic spikes.&lt;/p&gt;

&lt;p&gt;What's your MaxConns, and how did you arrive at it? I'm curious whether anyone actually measures this or just copies 20 from a tutorial.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about backend engineering, Go, and the unglamorous work of keeping systems alive. Follow for more.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>backend</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>7 Lessons I Learned Building Production APIs in Go</title>
      <dc:creator>Parsa Aftabi</dc:creator>
      <pubDate>Fri, 09 Oct 2026 13:57:37 +0000</pubDate>
      <link>https://dev.to/parsaaftabi/7-lessons-i-learned-building-production-apis-in-go-35f9</link>
      <guid>https://dev.to/parsaaftabi/7-lessons-i-learned-building-production-apis-in-go-35f9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; After shipping several production systems in Go – coming from PHP/Laravel – these are the eight lessons that actually mattered: boring project structure, &lt;code&gt;context.Context&lt;/code&gt; everywhere, fixing the database before adding cache, deliberate Redis caching, request-ID logging, graceful shutdown, and thin handlers. Code included.&lt;/p&gt;




&lt;p&gt;When I first picked up Go, I came from PHP/Laravel land – where the framework holds your hand through everything. Go handed me a toolbox, pointed at the horizon, and said &lt;em&gt;"good luck."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few production systems later, here are the eight lessons that actually stuck. Not theory – the stuff I wish someone had told me on day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Boring project structure beats clever frameworks
&lt;/h2&gt;

&lt;p&gt;My first Go API had packages named &lt;code&gt;utils&lt;/code&gt;, &lt;code&gt;helpers&lt;/code&gt;, and &lt;code&gt;common&lt;/code&gt; – three junk drawers with the same junk. Don't do that.&lt;/p&gt;

&lt;p&gt;What works: organize by domain, keep &lt;code&gt;main.go&lt;/code&gt; thin, and resist importing a web framework until &lt;code&gt;net/http&lt;/code&gt; genuinely hurts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring is a feature. The next person reading your code (probably future you at 2 AM) will thank you.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;context.Context&lt;/code&gt; is not optional
&lt;/h2&gt;

&lt;p&gt;Every handler, every DB call, every outbound request should take a context. The day a slow downstream service hangs your entire API is the day you learn this the hard way. Learn it the easy way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetByID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. Timeouts, cancellation, and request-scoped values – all from threading &lt;code&gt;ctx&lt;/code&gt; through your call chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Your database is the bottleneck, not Go
&lt;/h2&gt;

&lt;p&gt;Go can handle tens of thousands of concurrent requests. Your Postgres connection pool cannot – at least not with default settings.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;pgx&lt;/code&gt; with a properly sized pool, and size it deliberately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"postgres://user:pass@localhost:5432/app?pool_max_conns=20&amp;amp;pool_min_conns=5"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb I follow: &lt;code&gt;max_connections ≈ (2 × CPU cores) + a small buffer&lt;/code&gt; per instance. And add the missing index &lt;em&gt;before&lt;/em&gt; you add Redis. Most "slow API" problems I've debugged were a missing index, not a missing cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cache deliberately with Redis – don't sprinkle it everywhere
&lt;/h2&gt;

&lt;p&gt;Caching everything is how you get stale data bugs that only appear on Tuesdays. I cache with two rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cache-aside only&lt;/strong&gt; – the app reads from cache, falls back to DB, and writes back. No magic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every cached key gets a TTL and an invalidation story.&lt;/strong&gt; If you can't explain when the key expires or gets invalidated, don't cache it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvuif872mvxo1awvsns92.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvuif872mvxo1awvsns92.webp" alt="Cache-aside pattern: App → Redis → (on miss) Postgres – write back" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// cache-aside in ~10 lines&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rdb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// cache hit&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// cache miss: go to source&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rdb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Structured logging with request IDs will save you
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;log.Println("something happened")&lt;/code&gt; is a confession, not logging. In production you need to trace one request across handlers, DB calls, and cache lookups. The cheapest way: a request-ID middleware plus structured logs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfiyq9w8amdfn1a9y0nt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfiyq9w8amdfn1a9y0nt.webp" alt="One request ID traveling through handler, service, and database layers" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// a private key type keeps request-scoped values collision-free across packages&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ctxKey&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;requestIDKey&lt;/span&gt; &lt;span class="n"&gt;ctxKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"requestID"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;requestID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Request-ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&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;requestIDKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The typed key matters: a plain &lt;code&gt;"requestID"&lt;/code&gt; string key can be silently overwritten by another package's middleware using the same string. Two extra lines, zero collisions.&lt;/p&gt;

&lt;p&gt;Then log with fields, not sentences: &lt;code&gt;logger.Info("order.created", "request_id", id, "order_id", orderID)&lt;/code&gt;. When something breaks at 3 AM, you'll grep one ID instead of reading 50,000 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Handle graceful shutdown from day one
&lt;/h2&gt;

&lt;p&gt;It's ten lines of code, and it prevents dropped requests on every deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;quit&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;quit&lt;/span&gt; &lt;span class="c"&gt;// wait for shutdown signal&lt;/span&gt;
&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"forced shutdown: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;In-flight requests finish, DB connections close cleanly, and your deploys stop causing mysterious 502s.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Keep handlers thin – they're translators, not business logic
&lt;/h2&gt;

&lt;p&gt;A handler should do three things: parse input, call a service, write output. The moment you see SQL queries or business rules inside a handler, extract them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2FbIbfm76gqywI99e9x6ni.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2FbIbfm76gqywI99e9x6ni.webp" alt="Layered architecture: Handler → Service → Repository" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// handler: translation layer only&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CreateUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CreateUserInput&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&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;in&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&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;All the real logic lives in &lt;code&gt;service&lt;/code&gt;. Handlers stay dumb, services stay testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Set server timeouts — the defaults are zero
&lt;/h2&gt;

&lt;p&gt;Go's &lt;code&gt;http.Server&lt;/code&gt; ships with &lt;strong&gt;no timeouts at all&lt;/strong&gt;. A client that drips one byte per minute can hold a connection open indefinitely — this is how Slowloris works, and how "the API just stopped responding" incidents begin. Timeouts are load-bearing config; review them the way you review your pool size.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;srv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Addr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="s"&gt;":8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReadTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c"&gt;// max time to read the whole request&lt;/span&gt;
    &lt;span class="n"&gt;WriteTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c"&gt;// max time to write the whole response&lt;/span&gt;
    &lt;span class="n"&gt;IdleTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="m"&gt;120&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// max idle time on keep-alive connections&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtlety worth knowing: &lt;code&gt;WriteTimeout&lt;/code&gt; starts counting when the headers are &lt;em&gt;read&lt;/em&gt;, not when your handler starts — so for long-lived streaming endpoints (SSE, downloads), set it generously or manage it per-handler.&lt;/p&gt;

&lt;p&gt;I extracted this exact setup into a tiny library — &lt;a href="https://github.com/parsaaftabi/graceful" rel="noopener noreferrer"&gt;graceful&lt;/a&gt;. Same defaults, plus SIGINT/SIGTERM handling and ordered shutdown hooks. Zero dependencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thread connecting all eight
&lt;/h2&gt;

&lt;p&gt;None of these are about Go-specific tricks. They're about &lt;strong&gt;respecting production&lt;/strong&gt;: timeouts, observability, clean boundaries, deliberate caching. Go just happens to make the right thing easy – if you let it.&lt;/p&gt;

&lt;p&gt;Which lesson hit home for you? And what's the one &lt;em&gt;you&lt;/em&gt; wish someone told you on day one? I'd genuinely like to steal it for the next post. 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I write about backend engineering, Go, and the unglamorous work of keeping systems alive. Follow for more.&lt;/em&gt;&lt;/p&gt;

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