<?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: Prince Singh</title>
    <description>The latest articles on DEV Community by Prince Singh (@sudoexplain).</description>
    <link>https://dev.to/sudoexplain</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%2F4222661%2Fa8119838-3e30-469f-a252-9f446e45e7cd.png</url>
      <title>DEV Community: Prince Singh</title>
      <link>https://dev.to/sudoexplain</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sudoexplain"/>
    <language>en</language>
    <item>
      <title>Slow SQL query: find a missing index with EXPLAIN</title>
      <dc:creator>Prince Singh</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/sudoexplain/slow-sql-query-find-a-missing-index-with-explain-1hpp</link>
      <guid>https://dev.to/sudoexplain/slow-sql-query-find-a-missing-index-with-explain-1hpp</guid>
      <description>&lt;h2&gt;
  
  
  #The doubt
&lt;/h2&gt;

&lt;p&gt;A page that lists a customer's orders was instant in development. Months later it takes four seconds, and the database CPU is high every time someone opens it. A colleague says "add an index", runs one command, and the page becomes instant again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same query, same data, same database. What did an index change?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It changed how the database &lt;strong&gt;finds&lt;/strong&gt; the rows. Without an index, it has to read all of them.&lt;/p&gt;

&lt;p&gt;In this post&lt;/p&gt;

&lt;p&gt;By the end you will know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What a &lt;strong&gt;full table scan&lt;/strong&gt; is and why it gets slower as data grows&lt;/li&gt;
&lt;li&gt;How to &lt;strong&gt;diagnose&lt;/strong&gt; a slow query by reading its query plan&lt;/li&gt;
&lt;li&gt;How to create the right index, including multi-column ones&lt;/li&gt;
&lt;li&gt;Why an index is sometimes ignored, and what indexes cost&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  #Full table scan versus index
&lt;/h2&gt;

&lt;p&gt;Think of a 500-page book with no index at the back. To find every mention of "Smith", you read all 500 pages. With an index, you look up "Smith" and turn straight to pages 12 and 340. A database table works the same way.&lt;/p&gt;

&lt;p&gt;The query is simple:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no index on &lt;code&gt;customer_id&lt;/code&gt;, the database has only one way to answer: read &lt;strong&gt;every row&lt;/strong&gt; in the table and keep the ones that match. This is a &lt;strong&gt;full table scan&lt;/strong&gt; (a &lt;em&gt;sequential scan&lt;/em&gt; in PostgreSQL). With 1,000 rows, that is instant. With 10 million, it reads 10 million rows to return the 15 orders that customer 42 has.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;index&lt;/strong&gt; is a separate, sorted structure, usually a B-tree (a tree that keeps values in order, so a lookup takes a few steps), that maps a column's values to where the rows are. The database jumps straight to &lt;code&gt;customer_id = 42&lt;/code&gt; and reads only those rows. The work no longer grows with the size of the table, only slowly with its depth.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/slow-query-missing-index/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. The scan reads 10 million rows to find customer 42's 15 orders. The index lookup touches a handful.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  #Why the index is so much faster
&lt;/h2&gt;

&lt;p&gt;Think of looking up a name in a phone book. You do not read from page one. You open the middle, see you are too early in the alphabet, and jump forward. Each jump throws away most of the remaining pages.&lt;/p&gt;

&lt;p&gt;A B-tree index does the same, in levels. Each level is a short, sorted list of &lt;strong&gt;directions&lt;/strong&gt; , like "ids 1 to 100: go this way". Start with a tiny example: customer ids 1 to 1,000, and lists of 10 directions each.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;top level&lt;/strong&gt; has 10 directions: "ids 1 to 100 are down this branch, 101 to 200 down that one," and so on up to 1,000.&lt;/li&gt;
&lt;li&gt;Inside the branch for 1 to 100, the &lt;strong&gt;middle level&lt;/strong&gt; has 10 more directions: "1 to 10, 11 to 20, 21 to 30," and so on.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;bottom level&lt;/strong&gt; holds the 10 exact ids of one range, each with the location of its rows in the table.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To find customer 42, the database picks the branch for 1 to 100, then the one for 41 to 50, then finds 42 in a list of 10. That is &lt;strong&gt;3 small lists&lt;/strong&gt; , about 30 directions at most. Without the index it would read up to 1,000 rows.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/slow-query-missing-index/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2. A tiny index of 1,000 ids. Looking up customer 42 takes three steps.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now grow the table. Each extra level multiplies the reach by 10. Four levels cover 10,000 ids, five cover 100,000. A thousand times more ids costs only 3 more steps, while a scan would read a thousand times more rows.&lt;/p&gt;

&lt;p&gt;Real lists are bigger than 10. A typical one holds hundreds of directions (500 is a fair guess, depending on the column size). Three levels then reach 500 x 500 x 500 = 125 million rows. So the 10-million-row table from Figure 1 needs only about three steps, and so does a table ten times bigger.&lt;/p&gt;

&lt;p&gt;That is the whole fix. The scan read 10 million rows, and the index reads three small lists and then the 15 matching orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  #How to find a slow query and read EXPLAIN
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Find the slow queries.&lt;/strong&gt; Turn on the slow-query log. In PostgreSQL, set &lt;code&gt;log_min_duration_statement = 500&lt;/code&gt;, which logs every query that takes longer than 500 milliseconds. &lt;code&gt;pg_stat_statements&lt;/code&gt; is an extension, an add-on module that ships with PostgreSQL. It ranks queries by total time, but it is not on by default. First add its name to the &lt;code&gt;shared_preload_libraries&lt;/code&gt; setting, which lists the extensions loaded when the server starts, and restart the server. Then enable it with &lt;code&gt;CREATE EXTENSION pg_stat_statements;&lt;/code&gt;. Often one or two queries account for most of the load. If no single query is slow but one page runs hundreds of them, that is &lt;a href="https://sudoexplain.dev/posts/n-plus-one-query-problem/" rel="noopener noreferrer"&gt;the N+1 query problem&lt;/a&gt;, not a missing index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Ask the database how it runs the query.&lt;/strong&gt; Put the word &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; in front of your query:&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;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database does not return the orders. It runs the query, measures it, and prints its &lt;strong&gt;query plan&lt;/strong&gt; : the steps it took to find the rows. Here is an illustrative result for our table with no index:&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 orders (cost=0.00..250000.00 rows=12 width=64)
                    (actual time=0.1..4200.5 rows=15 loops=1)
  Filter: (customer_id = 42)
  Rows Removed by Filter: 9999985
Execution Time: 4201.2 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it from the top, one line at a time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Seq Scan on orders&lt;/code&gt;:&lt;/strong&gt; the database read the &lt;code&gt;orders&lt;/code&gt; table from start to end. This is the full table scan from earlier. On a big table, it is the warning sign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Filter: (customer_id = 42)&lt;/code&gt;:&lt;/strong&gt; for each row, it checked whether &lt;code&gt;customer_id&lt;/code&gt; is 42.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Rows Removed by Filter: 9999985&lt;/code&gt;:&lt;/strong&gt; it threw away almost 10 million rows. Only 15 matched (&lt;code&gt;rows=15&lt;/code&gt;). This is the wasted work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Execution Time: 4201.2 ms&lt;/code&gt;:&lt;/strong&gt; the whole query took about 4 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can ignore the &lt;code&gt;cost&lt;/code&gt; numbers for now. They are the database's own guess in made-up units, useful only for comparing plans with each other.&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; really runs the statement. For an &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt;, the data changes. Wrap it so you can undo it: &lt;code&gt;BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Compare the estimate with reality.&lt;/strong&gt; The plan shows two row counts: how many rows the database &lt;em&gt;expected&lt;/em&gt; (&lt;code&gt;rows=12&lt;/code&gt; in the first line) and how many it &lt;em&gt;got&lt;/em&gt; (&lt;code&gt;rows=15&lt;/code&gt;). Here they are close, which is good.&lt;/p&gt;

&lt;p&gt;Why check this? Before running a query, the database cannot know how many rows will match. It &lt;strong&gt;guesses&lt;/strong&gt; , using &lt;strong&gt;statistics&lt;/strong&gt; (its notes on how values are spread in each table), and picks the plan that looks cheapest for that guess. If the guess is wrong, the plan can be wrong too.&lt;/p&gt;

&lt;p&gt;For example, suppose the notes are out of date and the database expects 12 rows, but 500,000 come back. A plan that is perfect for 12 rows can be very slow for 500,000. Adding an index will not fix that, because the real problem is the bad guess.&lt;/p&gt;

&lt;p&gt;So a big gap between expected and actual rows tells you the statistics are stale. Running &lt;code&gt;ANALYZE&lt;/code&gt; on the table refreshes them. This is a separate command from &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, despite the shared word. If the two counts are close and you still see a &lt;code&gt;Seq Scan&lt;/code&gt; on a big table, the index is what is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 1: create the index
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CONCURRENTLY&lt;/code&gt; builds the index without blocking writes to the table, which matters on a live system. Without it, inserts and updates wait while the index is built. On a busy table with a 5-minute build (illustrative), nobody can place an order, and the waiting requests can drain &lt;a href="https://sudoexplain.dev/posts/connection-pool-exhausted/" rel="noopener noreferrer"&gt;the connection pool&lt;/a&gt;. The price is time: it reads the table twice and waits for running transactions to finish, so it takes longer than a plain &lt;code&gt;CREATE INDEX&lt;/code&gt;. That is a fair trade on a live system. (It also has one catch, covered in "When the fix goes wrong".)&lt;/p&gt;

&lt;p&gt;The plan after the index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Scan using idx_orders_customer_id on orders
                    (actual time=0.03..0.09 rows=15 loops=1)
  Index Cond: (customer_id = 42)
Execution Time: 0.12 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 4,201 ms to 0.12 ms, with no change to the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 2: match the index to the whole query
&lt;/h2&gt;

&lt;p&gt;Real queries filter and sort on several columns:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;AND&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;'OPEN'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;multi-column index&lt;/strong&gt; serves this in one pass, but &lt;strong&gt;column order matters&lt;/strong&gt;. Put the columns you compare with &lt;code&gt;=&lt;/code&gt; first, then the one you sort by:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_status_created&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An index works from the &lt;strong&gt;left&lt;/strong&gt; , like a phone book sorted by last name, then first name. You can look up "Smith", or "Smith, Anna", but not every "Anna" without reading the whole book. In the same way, this index helps queries that filter on &lt;code&gt;customer_id&lt;/code&gt;, or on &lt;code&gt;customer_id&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;. It usually cannot help a query on &lt;code&gt;status&lt;/code&gt; alone. PostgreSQL 18 can sometimes skip over the first column, but only when it has few distinct values, so do not rely on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 3: write the query so the index can be used
&lt;/h2&gt;

&lt;p&gt;An index on &lt;code&gt;created_at&lt;/code&gt; is sorted by the &lt;strong&gt;stored&lt;/strong&gt; values. If your query changes the column first, the database cannot use that order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the index cannot help: date() changes every value first&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-01'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of the phone book again. It is sorted by name. If you ask for "everyone whose name has 5 letters", the book cannot help. You must read every entry and count. The database does the same here. &lt;code&gt;date()&lt;/code&gt; turns each raw timestamp, such as &lt;code&gt;2026-03-01 14:25:09&lt;/code&gt;, into a plain date, &lt;code&gt;2026-03-01&lt;/code&gt;. The index holds the raw timestamps, not these dates, so the database must convert every row first and then compare.&lt;/p&gt;

&lt;p&gt;Leave the column alone and compare it with a range instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the index can jump straight to this range&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-01'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-02'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to &lt;code&gt;lower(email) = 'a@example.com'&lt;/code&gt;. If you must query that way, create an index on the same expression:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_email_lower&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A search that starts with a wildcard, such as &lt;code&gt;name LIKE '%smith'&lt;/code&gt;, has the same problem, because the index cannot jump to "anything ending in smith". That needs a different kind of index, a &lt;strong&gt;GIN index&lt;/strong&gt; with PostgreSQL's &lt;code&gt;pg_trgm&lt;/code&gt; extension, which we leave for another post.&lt;/p&gt;

&lt;h2&gt;
  
  
  #When the fix goes wrong
&lt;/h2&gt;

&lt;p&gt;An index is not free, and it is not always used. These three cause the most surprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A failed &lt;code&gt;CONCURRENTLY&lt;/code&gt; build.&lt;/strong&gt; To avoid making inserts and updates wait while the index is built, you used &lt;code&gt;CONCURRENTLY&lt;/code&gt;, so building on a live table feels safe. But the build can still fail halfway, for example because of a deadlock (two operations waiting on each other) or a duplicate value in a unique index.&lt;/p&gt;

&lt;p&gt;When it fails, PostgreSQL does not clean up. It leaves a half-built index behind, marked &lt;strong&gt;invalid&lt;/strong&gt;. The planner ignores an invalid index, so your query stays slow. Worse, every insert, update and delete still has to keep that useless index up to date. You pay the cost and get nothing back.&lt;/p&gt;

&lt;p&gt;To spot it, run &lt;code&gt;d orders&lt;/code&gt; in &lt;code&gt;psql&lt;/code&gt;. It lists the table's indexes and marks the broken one &lt;code&gt;INVALID&lt;/code&gt;. Remove it with &lt;code&gt;DROP INDEX CONCURRENTLY idx_orders_customer_id;&lt;/code&gt;, which does not block writes, then run the build again.&lt;/p&gt;

&lt;p&gt;One more trap comes from &lt;strong&gt;migration tools&lt;/strong&gt; , the tools that apply your database changes (such as a new index) when you deploy. Many wrap each migration in a transaction, a group of steps that either all succeed or all roll back together. That is usually helpful, but &lt;code&gt;CONCURRENTLY&lt;/code&gt; cannot run inside one. It does its work in several separate steps and waits for other running queries between them, so it cannot be rolled back as a single unit. If you try, PostgreSQL stops with an error, and your deploy fails.&lt;/p&gt;

&lt;p&gt;The fix is to mark that one migration as "do not use a transaction". Most tools have an option for it: &lt;code&gt;executeInTransaction=false&lt;/code&gt; in Flyway, &lt;code&gt;runInTransaction: false&lt;/code&gt; in Liquibase, and &lt;code&gt;disable_ddl_transaction!&lt;/code&gt; in Rails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Wrong column order.&lt;/strong&gt; Your query filters only on &lt;code&gt;customer_id&lt;/code&gt;. A colleague builds the index as &lt;code&gt;(status, customer_id)&lt;/code&gt;, because both columns appear in other queries. It looks fine: &lt;code&gt;customer_id&lt;/code&gt; is in the index.&lt;/p&gt;

&lt;p&gt;But Fix 2 showed that an index works from the left, so it is sorted by &lt;code&gt;status&lt;/code&gt; first. Say &lt;code&gt;status&lt;/code&gt; has 3 values: &lt;code&gt;NEW&lt;/code&gt;, &lt;code&gt;OPEN&lt;/code&gt; and &lt;code&gt;DONE&lt;/code&gt;. The index then has three groups, and customer 42's orders are scattered, a few in each group. The database has no single place to jump to. It usually ignores the index and scans the whole table, so the page stays slow.&lt;/p&gt;

&lt;p&gt;Build it as &lt;code&gt;(customer_id, status)&lt;/code&gt; instead. Now all of customer 42's entries sit together. Either way, run &lt;code&gt;EXPLAIN&lt;/code&gt; after creating any index, to confirm the database really uses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. An index on a column where most rows share one value.&lt;/strong&gt; Take a &lt;code&gt;status&lt;/code&gt; column where 95% of 10 million orders are &lt;code&gt;DONE&lt;/code&gt; (illustrative). Querying &lt;code&gt;status = 'DONE'&lt;/code&gt; matches 9.5 million rows. Using the index means 9.5 million lookups, each followed by a read of the table. Reading the table once from start to end is cheaper, so the &lt;strong&gt;planner&lt;/strong&gt; (the part of the database that chooses how to run a query) scans anyway. The index is never used, but it slows every write and uses disk.&lt;/p&gt;

&lt;p&gt;The rare value is different. If only 1% of orders are &lt;code&gt;OPEN&lt;/code&gt;, an index helps a lot for those. A &lt;strong&gt;partial index&lt;/strong&gt; covers only those rows, so it stays small:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_open&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&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;'OPEN'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query must include &lt;code&gt;status = 'OPEN'&lt;/code&gt;, or the database cannot use it.&lt;/p&gt;

&lt;p&gt;Indexes like that cost you on every write. To find indexes that queries never use, PostgreSQL keeps a count of how often each one was used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- indexes never used since the statistics were last reset&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexrelname&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never drop an index that backs a primary key or unique constraint, even if it shows no uses, because it enforces a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Which should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slow lookup by one column&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A single-column index&lt;/strong&gt; on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter on several columns, plus sorting&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A multi-column index,&lt;/strong&gt; equality columns first, sort column last&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A function in the &lt;code&gt;WHERE&lt;/code&gt; clause&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rewrite the condition,&lt;/strong&gt; or create an &lt;strong&gt;expression index&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The plan shows a wildly wrong row estimate&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Run &lt;code&gt;ANALYZE&lt;/code&gt;&lt;/strong&gt; to refresh statistics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A write-heavy table with many indexes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Drop indexes&lt;/strong&gt; that queries never use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are not sure, run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on the slow query first. The plan tells you what to do, and guessing at indexes can slow your writes for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Without an index, the database &lt;strong&gt;reads every row&lt;/strong&gt; , so the cost grows with the table. An index lets it jump to the matching rows.&lt;/li&gt;
&lt;li&gt;A query that is fast on small data and slow on large data is most often a &lt;strong&gt;missing index&lt;/strong&gt;. A bad plan from stale statistics, or &lt;a href="https://sudoexplain.dev/posts/n-plus-one-query-problem/" rel="noopener noreferrer"&gt;an N+1 pattern&lt;/a&gt;, can look the same.&lt;/li&gt;
&lt;li&gt;Diagnose with the slow-query log and &lt;strong&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;&lt;/strong&gt; : look for a sequential scan and millions of rows removed.&lt;/li&gt;
&lt;li&gt;Build indexes to fit the query: &lt;strong&gt;equality columns first, sort column last&lt;/strong&gt; , and write conditions the index can use.&lt;/li&gt;
&lt;li&gt;Indexes are not free: they slow writes and use space. Build them with &lt;code&gt;CONCURRENTLY&lt;/code&gt; on live tables, and drop the ones nobody uses.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>HikariPool-1 Connection is not available: pool exhausted</title>
      <dc:creator>Prince Singh</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/sudoexplain/hikaripool-1-connection-is-not-available-pool-exhausted-21p8</link>
      <guid>https://dev.to/sudoexplain/hikaripool-1-connection-is-not-available-pool-exhausted-21p8</guid>
      <description>&lt;h2&gt;
  
  
  #The doubt
&lt;/h2&gt;

&lt;p&gt;The service runs fine all day. Then traffic picks up and every request starts to hang. CPU is low. The database looks nearly idle. There is no crash, just a growing wall of timeouts, and the logs say something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HikariPool-1 - Connection is not available, request timed out after 30000ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The database is not busy and the servers are not busy, so what is everyone waiting for?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are waiting for a &lt;strong&gt;database connection&lt;/strong&gt; , and there are none left to hand out. Picture a rack of rental bikes. When every bike is out, the next customer queues, even though the roads are empty.&lt;/p&gt;

&lt;p&gt;In this post&lt;/p&gt;

&lt;p&gt;By the end you will know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why apps keep a pool of connections, and what "exhausted" means&lt;/li&gt;
&lt;li&gt;Three ways a pool runs dry: leaks, hoarding and too many servers&lt;/li&gt;
&lt;li&gt;How to &lt;strong&gt;diagnose&lt;/strong&gt; it from pool metrics, a thread dump and the database itself&lt;/li&gt;
&lt;li&gt;How to fix it, and why the obvious fix (a bigger pool) often makes it worse&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  #First, what the pool is for
&lt;/h2&gt;

&lt;p&gt;Opening a connection to a database is slow. It involves a network handshake, often encryption, and a login. Doing that for every request would cost more than the query itself. So an application opens a fixed number of connections at startup and keeps them in a &lt;strong&gt;connection pool&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A request &lt;strong&gt;borrows&lt;/strong&gt; a connection, runs its queries, and &lt;strong&gt;returns&lt;/strong&gt; it. Calling &lt;code&gt;close()&lt;/code&gt; on a pooled connection does not really close it. It hands the connection back to the pool.&lt;/p&gt;

&lt;p&gt;If every connection is borrowed, the next request &lt;strong&gt;waits&lt;/strong&gt; for one to come back. If it waits longer than a timeout, it fails with the error above. The pool is &lt;strong&gt;exhausted&lt;/strong&gt; when the demand for connections is higher than the supply, for long enough that requests start to queue.&lt;/p&gt;

&lt;p&gt;The pool size is small on purpose. HikariCP, the usual Java pool, defaults to 10 connections. That is often enough, because a connection is only needed while a query runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Failure 1: a connection leak, and the fix
&lt;/h2&gt;

&lt;p&gt;The simplest cause is a connection that is borrowed and never returned. Look at this code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;PreparedStatement&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prepareStatement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"SELECT * FROM orders WHERE id = ?"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// if this line throws an exception...&lt;/span&gt;
&lt;span class="nc"&gt;ResultSet&lt;/span&gt; &lt;span class="n"&gt;rs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeQuery&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ...use rs...&lt;/span&gt;
&lt;span class="c1"&gt;// ...this line never runs&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whenever &lt;code&gt;executeQuery()&lt;/code&gt; throws, &lt;code&gt;close()&lt;/code&gt; is skipped, and that connection is lost to the pool for good. One error does not matter. But a database hiccup that causes a few hundred errors can drain a pool of 10 in seconds, and the pool never recovers, even after the database is healthy again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: always return the connection.&lt;/strong&gt; Let the language do the closing. &lt;code&gt;try&lt;/code&gt;-with-resources closes the connection even when an exception is thrown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
     &lt;span class="nc"&gt;PreparedStatement&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prepareStatement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
             &lt;span class="s"&gt;"SELECT * FROM orders WHERE id = ?"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ResultSet&lt;/span&gt; &lt;span class="n"&gt;rs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeQuery&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...use rs...&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// c is returned to the pool here, error or not&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use a framework such as Spring, the framework does this for you, as long as you do not hold a raw connection yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Failure 2: holding a connection while you wait, and the fix
&lt;/h2&gt;

&lt;p&gt;In Failure 1, connections were lost by mistake. Here nothing is lost: every connection is returned in the end. The problem is that it is &lt;strong&gt;held for much longer than needed&lt;/strong&gt; , and while one request holds it, nobody else can use it.&lt;/p&gt;

&lt;p&gt;Think of renting a bike to ride to the shop, and then leaving it parked outside a café for two hours. You did bring it back, but nobody else could ride it in that time.&lt;/p&gt;

&lt;p&gt;In code, this usually happens inside a &lt;strong&gt;transaction&lt;/strong&gt;. A transaction is a group of database queries that must all succeed or all fail together. To keep them together, the app uses one connection for the whole group, from the first query until the method ends. Look at this method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the transaction takes a connection here...&lt;/span&gt;
    &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...and holds it while waiting 2 s on a remote call&lt;/span&gt;
    &lt;span class="n"&gt;paymentClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markPaid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what happens, step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;save(order)&lt;/code&gt; runs a quick query. To do that, the method borrows a connection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge(order)&lt;/code&gt; calls the payment company over the network and waits 2 seconds. No database work happens now, but the transaction is not finished, so the connection stays borrowed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;markPaid(order)&lt;/code&gt; runs another quick query. Only then does the method end and the connection go back to the pool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The connection did real work for a few milliseconds, but it was held for about 2 seconds. For almost the whole time, it sat idle and nobody else could use it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/connection-pool-exhausted/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. The connection sits idle for the whole remote call.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now see why this drains the pool. It is simple arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one connection serves 1 request per 2 seconds
10 connections serve 10 / 2 = 5 requests per second
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a pool of 10 can handle 5 requests per second, and no more. Say 6 requests arrive every second. Every second the pool falls one request further behind. Those requests wait in a queue, the queue only grows, and in the end they time out with the error from the start of this post.&lt;/p&gt;

&lt;p&gt;The strange part is that the database looks idle. It is not the busy one. The connections are just waiting for the payment service. A slow query or a lock wait inside the database has the same effect, because it also makes each request hold its connection for longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: keep transactions short.&lt;/strong&gt; Do the slow work outside the transaction, so no connection is held while you wait. Call the payment service first. Then open a short transaction that only writes the results, which takes a few milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// no connection is held during the remote call&lt;/span&gt;
    &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// a short @Transactional method&lt;/span&gt;
    &lt;span class="n"&gt;orderWriter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveOrderAndPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cautions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Put the short method in a separate class.&lt;/strong&gt; It is tempting to keep it in the same class, like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;paymentClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// called from inside the same class&lt;/span&gt;
        &lt;span class="n"&gt;saveOrderAndPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;saveOrderAndPayment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...no transaction starts here&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks right, but &lt;code&gt;@Transactional&lt;/code&gt; does nothing here, and Spring does not warn you. The reason is how it works. Spring does not change your method. It puts a &lt;strong&gt;stand-in&lt;/strong&gt; object in front of your class. When other code calls your service, the call goes through the stand-in, which opens the transaction, runs your method, and then commits.&lt;/p&gt;

&lt;p&gt;A call from inside the same class goes straight to the method. The stand-in is skipped, so no transaction opens. The two saves are no longer all-or-nothing: if the second one fails, the first one stays. The fix is to put &lt;code&gt;saveOrderAndPayment&lt;/code&gt; in a separate class, like &lt;code&gt;orderWriter&lt;/code&gt; above, so the call comes from outside and goes through the stand-in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Charging first changes what can go wrong.&lt;/strong&gt; If the save fails after the charge, the customer paid and there is no order. Protect against that with an idempotency key, a unique ID that lets the server recognise and ignore a repeated request, and a way to refund.&lt;/p&gt;

&lt;p&gt;The principle: &lt;strong&gt;a connection should be held only while the database is actually working&lt;/strong&gt;. Never across a network call, a file upload or a sleep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hidden version of this problem in Spring Boot.&lt;/strong&gt; Even if you keep your transactions short, Spring Boot can still hold the connection for you. It has a setting called &lt;code&gt;open-in-view&lt;/code&gt;, and it is &lt;strong&gt;on by default&lt;/strong&gt;. Its purpose is to make lazy loading work late: it keeps the database connection open for the whole web request, so that even a page template can still load more data.&lt;/p&gt;

&lt;p&gt;That means the connection is held from the first database query until the response is sent. It stays held while your code calls other services or builds the page, which is the same problem as before, without you writing any transaction.&lt;/p&gt;

&lt;p&gt;To turn it off, set this in your configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.jpa.open-in-view&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the connection goes back to the pool when the service method ends. The price is that lazy data can only be loaded &lt;strong&gt;inside&lt;/strong&gt; the service method. So load everything the page needs there, with a fetch join or a projection, as shown in the &lt;a href="https://sudoexplain.dev/posts/n-plus-one-query-problem/" rel="noopener noreferrer"&gt;N+1 query problem&lt;/a&gt; post.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Failure 3: more servers than the database can take, and the fix
&lt;/h2&gt;

&lt;p&gt;Pools exist &lt;strong&gt;per server&lt;/strong&gt;. If each of your servers has a pool of 20, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 5 servers x 20 = 100 connections
20 servers x 20 = 400 connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Databases have a connection limit of their own. PostgreSQL's &lt;code&gt;max_connections&lt;/code&gt; is typically 100 by default, for example. Scale out past it and new connections are refused, with errors like &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt;. The pool then fails to even create connections, and all servers break together.&lt;/p&gt;

&lt;p&gt;This is the cruel part: you added servers to cope with load, and that is what took the database down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: size the pools together.&lt;/strong&gt; Pick the pool size from the database's limit, not from hope. The total across all servers must stay well under it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database limit: 100
reserve for admin tools, migrations, monitoring: 20
left for the app: 80

8 servers -&amp;gt; pool of 10 each = 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give batch work, such as a nightly report, its own small pool. Otherwise a report that holds 5 connections for an hour can starve the web requests.&lt;/p&gt;

&lt;p&gt;A smaller pool is usually faster than a bigger one. HikariCP's pool-sizing guide argues that a small pool often performs best, and it suggests roughly twice the database's CPU core count (plus the number of disks) as a starting point. Treat that as the total across all servers, start small and measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you truly need thousands of connections, use a connection proxy.&lt;/strong&gt; A &lt;strong&gt;connection proxy&lt;/strong&gt; is a small program that sits between your apps and the database. Your apps connect to the proxy, not to the database. The proxy keeps only a few real database connections and lends them out when they are needed.&lt;/p&gt;

&lt;p&gt;Think of a receptionist. A thousand visitors can wait in the lobby, but only 50 fit in the meeting rooms. The receptionist sends each visitor in when a room is free. In the same way, 20 servers with 50 connections each, 1,000 in all, can share 50 real database connections, which fits under a limit of 100.&lt;/p&gt;

&lt;p&gt;PgBouncer is a well-known proxy for PostgreSQL. It has &lt;strong&gt;pooling modes&lt;/strong&gt; , which decide how long an app keeps a real connection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session mode:&lt;/strong&gt; the app keeps one for as long as it stays connected. Everything works as normal, but little is shared.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction mode:&lt;/strong&gt; the app gets one only while a transaction runs, and then it goes to someone else. This shares the most, but the next transaction may run on a different real connection. Anything stored on one connection is lost, such as settings made with &lt;code&gt;SET&lt;/code&gt;, temporary tables, &lt;code&gt;LISTEN&lt;/code&gt; and session-level locks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So choose the mode first, and test your app with it. Transaction mode works only if your app does not use the features that it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  #For all three: fail fast with a short timeout
&lt;/h2&gt;

&lt;p&gt;Whatever the cause, set a &lt;strong&gt;short connection timeout&lt;/strong&gt; (a few seconds rather than 30), for example &lt;code&gt;spring.datasource.hikari.connection-timeout=3000&lt;/code&gt;. When the pool is exhausted, you want requests to fail fast and free their threads, not queue for half a minute and pile up.&lt;/p&gt;

&lt;h2&gt;
  
  
  #How to diagnose it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Read the pool metrics.&lt;/strong&gt; Pools expose three numbers: connections &lt;strong&gt;active&lt;/strong&gt; (borrowed), &lt;strong&gt;idle&lt;/strong&gt; (free) and requests &lt;strong&gt;pending&lt;/strong&gt; (waiting). The signature of exhaustion is &lt;em&gt;active equals the maximum, idle is zero, pending is climbing&lt;/em&gt;. HikariCP publishes these through Micrometer (a metrics library) as &lt;code&gt;hikaricp.connections.active&lt;/code&gt;, &lt;code&gt;hikaricp.connections.idle&lt;/code&gt; and &lt;code&gt;hikaricp.connections.pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Take a thread dump.&lt;/strong&gt; A thread dump lists what every thread is doing right now. Capture one with &lt;code&gt;jcmd &amp;lt;pid&amp;gt; Thread.print&lt;/code&gt;, and take two or three a few seconds apart. If many request threads are sitting inside &lt;code&gt;HikariPool.getConnection&lt;/code&gt;, they are all queueing for a connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"http-nio-8080-exec-17" waiting on condition
    at com.zaxxer.hikari.pool.HikariPool.getConnection(...)
    at com.example.OrderRepository.save(OrderRepository.java:42)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Find who is holding connections.&lt;/strong&gt; Ask the database what each connection is doing. In PostgreSQL:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;state_change&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;longest&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare this with the pool numbers. If the pool says "all active" but the database shows mostly &lt;code&gt;idle&lt;/code&gt;, connections were borrowed and are not being used: a leak, or the app is doing other work while holding them. A large number in &lt;strong&gt;idle in transaction&lt;/strong&gt; means the app opened a transaction and is doing something else with it: Failure 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Make leaks name themselves.&lt;/strong&gt; HikariCP can log a stack trace when a connection is held too long. It is off by default, so turn it on in the pool config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# milliseconds
&lt;/span&gt;&lt;span class="py"&gt;spring.datasource.hikari.leak-detection-threshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, a connection borrowed for more than 20 seconds produces a warning that shows exactly where it was borrowed. Set the threshold above your slowest legitimate query, or it will warn about connections that are fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  #When the fix goes wrong
&lt;/h2&gt;

&lt;p&gt;Two pitfalls look like sensible reactions. Both make an outage worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Raising the pool size.&lt;/strong&gt; When requests wait for connections, a bigger pool looks like the obvious cure. But more connections do not make the database faster. Past a point they make it slower, because it spends time switching between them. And as Failure 3 showed, a bigger pool on every server can push the total past the database limit.&lt;/p&gt;

&lt;p&gt;If the real problem is a leak, a bigger pool only delays the failure. Say a bug leaks one connection per failed request, and 5 requests fail each second. A pool of 10 is gone in 2 seconds. A pool of 200 is gone in 200 / 5 = 40 seconds. The outage is the same, just later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do instead:&lt;/strong&gt; find out why connections are held, using the pool metrics and a thread dump. Then fix the leak or the long transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Retrying on pool timeout.&lt;/strong&gt; The error says "try again later", so a retry looks natural. But the retry asks the same full pool for a connection. Say the pool has 10 connections, all busy, and 100 requests are waiting. They time out, and each is retried twice more, so 3 attempts each. Up to 3 x 100 = 300 requests now queue for the same 10 connections. The queue gets longer exactly when the system is struggling, and the pool may never catch up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do instead:&lt;/strong&gt; do not retry on a pool timeout. If a retry is needed, do it at one layer only, with a delay that grows and a limit on attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Which should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pool at maximum, but the database is idle&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Look for hoarding&lt;/strong&gt; (slow calls inside transactions) and leaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connections in "idle in transaction"&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Shorten transactions.&lt;/strong&gt; Move remote calls outside them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exhaustion began after an error spike&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A leak on the error path.&lt;/strong&gt; Use &lt;code&gt;try&lt;/code&gt;-with-resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine at 5 servers, broken at 20&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Check the total&lt;/strong&gt; against the database limit. Consider a proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests pile up for 30 seconds before failing&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A shorter connection timeout,&lt;/strong&gt; so failures are quick&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are not sure where to begin, begin with the pool metrics and a thread dump. Together they show whether the problem is waiting, leaking or too many servers in about five minutes.&lt;/p&gt;

&lt;p&gt;If the pool drains because queries themselves are slow, look at the slow queries first. &lt;a href="https://sudoexplain.dev/posts/slow-query-missing-index/" rel="noopener noreferrer"&gt;A missing index&lt;/a&gt; is a common cause, and &lt;code&gt;EXPLAIN&lt;/code&gt; shows how the database runs a query. If one page runs hundreds of small queries, each request holds its connection for longer, and the &lt;a href="https://sudoexplain.dev/posts/n-plus-one-query-problem/" rel="noopener noreferrer"&gt;N+1 query problem&lt;/a&gt; is the likely cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A pool is a small, fixed set of connections that requests borrow and return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exhausted&lt;/strong&gt; means demand for connections outlasts supply, so requests queue and then time out.&lt;/li&gt;
&lt;li&gt;Three causes: a &lt;strong&gt;leak&lt;/strong&gt; (never returned), &lt;strong&gt;hoarding&lt;/strong&gt; (held during slow work) and &lt;strong&gt;too many servers&lt;/strong&gt; for the database limit.&lt;/li&gt;
&lt;li&gt;Diagnose with pool metrics (active at max, pending rising), a thread dump, and the connection states in the database.&lt;/li&gt;
&lt;li&gt;Fix it by always returning connections, keeping transactions short and sizing all pools under the database limit. A bigger pool is not the fix.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>java</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Stale cache and cache stampede: why servers disagree</title>
      <dc:creator>Prince Singh</dc:creator>
      <pubDate>Sun, 01 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/sudoexplain/stale-cache-and-cache-stampede-why-servers-disagree-2713</link>
      <guid>https://dev.to/sudoexplain/stale-cache-and-cache-stampede-why-servers-disagree-2713</guid>
      <description>&lt;h2&gt;
  
  
  #The doubt
&lt;/h2&gt;

&lt;p&gt;Imagine an online shop. You change a product's price from $20 to $25 and save it. You open the product page and see $25. Good.&lt;/p&gt;

&lt;p&gt;Then a customer opens the very same page and still sees &lt;strong&gt;$20&lt;/strong&gt;. They refresh. Now it says $25. They refresh again. $20.&lt;/p&gt;

&lt;p&gt;Nothing is broken in the usual sense. There is no error, no crash, and the database holds the right price. The site is just giving different answers to the same question, and it seems to change its mind at random.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The price is saved correctly, so why do some people still see the old one?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer is a &lt;strong&gt;stale cache&lt;/strong&gt;. It only makes sense after a few basics, so we start there. I won't assume you know any of these words.&lt;/p&gt;

&lt;p&gt;In this post&lt;/p&gt;

&lt;p&gt;By the end you will know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What a cache is, and why almost every app uses one&lt;/li&gt;
&lt;li&gt;Why a cache that works on one server goes wrong on two&lt;/li&gt;
&lt;li&gt;How to &lt;strong&gt;diagnose&lt;/strong&gt; a stale cache with a few requests&lt;/li&gt;
&lt;li&gt;Three ways to &lt;strong&gt;fix&lt;/strong&gt; it, and what each one costs&lt;/li&gt;
&lt;li&gt;How a fix can fail under real traffic, and how to stop it&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  #First, the basics
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;cache&lt;/strong&gt; is a saved copy of an answer, kept close by so you can reuse it without the slow trip to where it came from. Think of a notebook on your desk: you look a phone number up in the library once, write it down, and read it from the notebook after that.&lt;/p&gt;

&lt;p&gt;For an app, the library is the database, the notebook is the cache, and the cache usually lives in the server's memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getPrice(productId):
    if cache has productId:
        return cache[productId] # fast: no trip to the database
    price = database.readPrice(productId) # slow: the trip
    cache[productId] = price # write it in the notebook
    return price

function setPrice(productId, newPrice):
    database.writePrice(productId, newPrice)
    cache[productId] = newPrice # keep the notebook up to date
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;setPrice&lt;/code&gt;. It updates the database &lt;strong&gt;and&lt;/strong&gt; its own notebook. On a single server that is perfect, because there is one notebook and one writer, so the two can never disagree.&lt;/p&gt;

&lt;p&gt;A cached copy is &lt;strong&gt;stale&lt;/strong&gt; when the real data has changed but the copy has not. A stale answer is not an error. It looks completely normal, which is what makes it hard to spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  #One server has one notebook. Two servers have two.
&lt;/h2&gt;

&lt;p&gt;Real apps rarely run on one server, so you run several copies behind a &lt;strong&gt;load balancer&lt;/strong&gt; , which hands each incoming request to one of them. (New to that? The &lt;a href="https://sudoexplain.dev/posts/sessions-jwt-and-load-balancers/" rel="noopener noreferrer"&gt;sessions post&lt;/a&gt; explains it from scratch.)&lt;/p&gt;

&lt;p&gt;Here is the catch. Each server is a separate running program, so each one has &lt;strong&gt;its own memory&lt;/strong&gt; and therefore &lt;strong&gt;its own notebook&lt;/strong&gt;. They do not share it, and they do not know what is in each other's.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The problem: two notebooks that disagree
&lt;/h2&gt;

&lt;p&gt;Watch what happens when you change the price with two servers running:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/stale-caches-explained/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. Server A updates its own notebook and the database. Server B never hears about it, so it keeps serving the old price.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The admin saves $25. The load balancer sends that request to &lt;strong&gt;Server A&lt;/strong&gt;. Server A writes $25 to the database and to its own cache.&lt;/li&gt;
&lt;li&gt;A customer opens the product page. The load balancer sends this request to &lt;strong&gt;Server B&lt;/strong&gt;. Server B has $20 in its notebook, so it answers $20 without ever asking the database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Server B did nothing wrong. It followed the code exactly. It just has no way to know that Server A changed anything.&lt;/p&gt;

&lt;p&gt;This explains the random flipping: each refresh may land on a different server, and each server tells its own version of the truth. Like the session bug, this one &lt;strong&gt;never shows up on your laptop&lt;/strong&gt; , because on your laptop there is only one notebook.&lt;/p&gt;

&lt;h3&gt;
  
  
  #See it yourself
&lt;/h3&gt;

&lt;p&gt;You can reproduce it in a few lines of Java. Two "servers" each get their own cache, and they share one "database":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.HashMap&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Map&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StaleCache&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;computeIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;database:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&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;price&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;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&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;price&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Server&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Server&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 20&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 20&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 25&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 20 &amp;lt;- stale&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save it as &lt;code&gt;StaleCache.java&lt;/code&gt; and run &lt;code&gt;java StaleCache.java&lt;/code&gt; (this needs Java 11 or newer). The last line prints 20 even though the database now holds 25.&lt;/p&gt;

&lt;h2&gt;
  
  
  #How to diagnose it
&lt;/h2&gt;

&lt;p&gt;When users say "I see old data, but only sometimes", suspect this. Here is how to confirm it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Notice the pattern.&lt;/strong&gt; The data is correct in the database. The wrong answer comes and goes with refreshes. It started after you added a second server, or it disappears when you run just one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Find out which server answers.&lt;/strong&gt; Have each server add a &lt;strong&gt;header&lt;/strong&gt; to its responses with its own name, for example &lt;code&gt;X-Served-By: server-a&lt;/code&gt;. A header is a labelled extra line of information sent along with the page. Then ask for the same page several times. The tool below is &lt;code&gt;curl&lt;/code&gt;, a command-line program that makes web requests for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5 6&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-si&lt;/span&gt; https://example.com/api/products/42 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"x-served-by|price"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Read the output.&lt;/strong&gt; A stale cache looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x-served-by: server-a
{"id":42,"price":25}
x-served-by: server-b
{"id":42,"price":20}
x-served-by: server-a
{"id":42,"price":25}
x-served-by: server-b
{"id":42,"price":20}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Over HTTP/2 and HTTP/3, header names are sent in lowercase, which is why &lt;code&gt;grep -i&lt;/code&gt; ignores the case.)&lt;/p&gt;

&lt;p&gt;The same address gives two different answers, and the answer depends on &lt;strong&gt;which server replied&lt;/strong&gt;. Server A always says 25. Server B always says 20. That pattern is the fingerprint of a stale cache in one server's memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check the source of truth.&lt;/strong&gt; That is the one place that holds the real data, here the database. Look at the price there. If it says 25, the data is fine and one of the notebooks is out of date.&lt;/p&gt;

&lt;p&gt;Note&lt;/p&gt;

&lt;p&gt;If the answers are random and do &lt;strong&gt;not&lt;/strong&gt; line up with the server names, the cause is something else, such as two requests racing each other. The giveaway for a stale cache is that each server is consistently wrong in its own way.&lt;/p&gt;

&lt;p&gt;Also check for caches in front of your servers, in the browser or a CDN (a network of servers that keeps copies of pages close to users). Look at the &lt;code&gt;Cache-Control&lt;/code&gt; and &lt;code&gt;Age&lt;/code&gt; response headers. A response with a large &lt;code&gt;Age&lt;/code&gt; came from such a cache, not from your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 1: let copies expire
&lt;/h2&gt;

&lt;p&gt;The simplest fix is to give every cached copy a &lt;strong&gt;time to live&lt;/strong&gt; , usually shortened to &lt;strong&gt;TTL&lt;/strong&gt;. It is a timer that says "trust this copy for 60 seconds, then throw it away and ask the database again."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getPrice(productId):
    entry = cache[productId]
    if entry exists and now &amp;lt; entry.expiresAt:
        return entry.price
    price = database.readPrice(productId)
    cache[productId] = { price: price, expiresAt: now + 60 seconds }
    return price
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;setPrice&lt;/code&gt; must store an expiry time in the same way when it writes to the cache.)&lt;/p&gt;

&lt;p&gt;Server B's $20 now lives for at most a minute. After that it fetches 25 from the database and the servers agree again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; for up to one TTL, people can still see the old price. A shorter timer means fresher data but more trips to the database, which is the thing the cache was meant to avoid. Expiry is a good fit when slightly old data is harmless, such as a view count. It is a poor fit for a price someone just corrected.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 2: check a version number
&lt;/h2&gt;

&lt;p&gt;Instead of guessing how long a copy stays good, ask whether it is still good. Keep a &lt;strong&gt;version number&lt;/strong&gt; next to the data in the database: a counter that goes up by one every time the data changes. Before trusting its notebook, a server compares versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;versionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product:42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# cheap: one small number
&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# someone changed it elsewhere
&lt;/span&gt;    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# reload the fresh copy
&lt;/span&gt;    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking one small number is cheap. Reloading the whole product on every request is not. The version number tells you exactly when a reload is needed, so stale answers become much rarer, and you only pay for the full read when something really changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; every request still makes one small trip to the database to read the version. You save the heavy work but not the trip. If the trip itself is the problem, you need the next fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 3: tell the other servers (invalidation)
&lt;/h2&gt;

&lt;p&gt;The third idea is to stop waiting for servers to find out, and &lt;strong&gt;tell them&lt;/strong&gt;. When a server changes the data, it announces the change, and every server throws away its old copy. Removing a cached copy because it is no longer true is called &lt;strong&gt;invalidation&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function setPrice(productId, newPrice):
    database.writePrice(productId, newPrice)
    announce("product:" + productId + " changed") # every server hears this

on hearing "product:42 changed":
    cache.remove(42) # next read fetches fresh data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The announcement travels over a &lt;strong&gt;message channel&lt;/strong&gt; , which is a shared pipe that servers can send short notes into and listen to. Many systems provide one, so you rarely build it yourself.&lt;/p&gt;

&lt;p&gt;There is a close relative of this idea: instead of each server having its own notebook, keep &lt;strong&gt;one shared notebook&lt;/strong&gt; that all servers read and write, in a separate fast store. This is the same move as the shared session store in the sessions post. There is only one copy, so there is nothing to disagree with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; more moving parts. Messages can be lost if a server is restarting at the wrong moment, and a shared store is one more thing that must stay running. So real systems usually combine this with a TTL as a safety net: even if an announcement is missed, the copy dies on its own within a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  #When the fix itself breaks: the cache stampede
&lt;/h2&gt;

&lt;p&gt;Fix 1 has a hidden problem that only shows up under real traffic. Every copy gets the same timer, so popular items &lt;strong&gt;expire at the same moment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Picture the product page everyone is viewing. Its cached copy expires at 12:00:00. In the next second a thousand requests arrive, and every one of them finds the cache empty. Each one asks the database for the same thing at the same time.&lt;/p&gt;

&lt;p&gt;The database slows down under the sudden load. Requests start timing out, so nobody manages to refill the cache, and the pile-up grows. This is called a &lt;strong&gt;cache stampede&lt;/strong&gt; : a crowd of requests all rushing to rebuild the same missing copy at once. The cache was meant to protect the database, and at that moment it does the opposite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How you would notice.&lt;/strong&gt; The database is calm, then load spikes sharply at the instant popular items expire, and responses get slow for a while. If traffic is steady, charts of database load show regular spikes, spaced one TTL apart.&lt;/p&gt;

&lt;p&gt;There are two common fixes, and real systems usually use both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Let only one request rebuild.&lt;/strong&gt; The first request to find the copy missing takes a &lt;strong&gt;lock&lt;/strong&gt; , which is a marker that lets only one request through at a time. It reloads the data. The others either wait a moment, or are handed the old copy until the new one is ready.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function getPrice(productId):
    entry = cache[productId]
    if entry exists and now &amp;lt; entry.expiresAt:
        return entry.price

    if lock.tryAcquire("price:" + productId): # only one request wins
        price = database.readPrice(productId)
        cache[productId] = { price: price, expiresAt: now + ttl }
        lock.release("price:" + productId)
        return price

    return entry.price if entry exists # others serve the old copy
    # (or wait briefly, then read the cache again)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Spread out the expiry times.&lt;/strong&gt; Instead of exactly 60 seconds, use 60 seconds plus a random few. Copies made at the same moment now die at slightly different moments, so the reloads trickle in instead of arriving as one wave.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ttl = 60 seconds + random(0 to 10 seconds) # "jitter"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock must be shared by all servers, for example kept in the shared store. A lock held in one server's memory only protects that server. Deleting a popular copy (Fix 3) causes the same stampede, so the lock helps there too. After a deletion there is no old copy to serve, so the other requests must wait for the rebuild.&lt;/p&gt;

&lt;p&gt;The cost of the lock is a little more code, and one more thing that can go wrong, such as a lock never released after a crash, so locks are given their own short expiry. The cost of jitter is nothing at all. That is why the TTL in real systems almost always carries some randomness.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Which should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Being a little out of date is harmless (counters, trending lists)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Expiry (TTL).&lt;/strong&gt; Simple, nothing clever to get wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The data must be correct, and one small database read per request is fine&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Version number.&lt;/strong&gt; Correct as of the version check, still cheaper than reloading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many servers, very high traffic, and each trip to the database hurts&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Invalidation or a shared cache,&lt;/strong&gt; plus a TTL as a backstop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You are not sure whether you even need a cache&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No cache.&lt;/strong&gt; Measure first, because a cache you do not need is a bug waiting to happen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are not sure, start with a short TTL. It is the easiest to understand and the easiest to remove. Move to versions or invalidation when "a minute out of date" turns out to matter to real users.&lt;/p&gt;

&lt;p&gt;Two more things are worth looking up once this makes sense. Two updates arriving at the same moment can leave a wrong value in the cache for good, which is a &lt;strong&gt;race condition&lt;/strong&gt;. And a cache with no size limit slowly fills the server's memory. Both are common in production, and each deserves a post of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;cache&lt;/strong&gt; is a saved copy that makes things faster. It is only safe while the copy matches the original.&lt;/li&gt;
&lt;li&gt;A cache in &lt;strong&gt;one server's memory&lt;/strong&gt; is private to that server. Add a second server and you have two copies that can disagree.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;stale&lt;/strong&gt; answer looks completely normal, which is why the bug feels random instead of broken.&lt;/li&gt;
&lt;li&gt;Diagnose it by asking the same question many times and noting &lt;strong&gt;which server&lt;/strong&gt; answers. Each server being consistently different is the fingerprint.&lt;/li&gt;
&lt;li&gt;Fix it by making copies &lt;strong&gt;expire&lt;/strong&gt; , by &lt;strong&gt;checking a version&lt;/strong&gt; , or by &lt;strong&gt;telling every server&lt;/strong&gt; when data changes. Each trades freshness against speed and complexity.&lt;/li&gt;
&lt;li&gt;Fixes have failure modes of their own. When popular copies expire together, a &lt;strong&gt;stampede&lt;/strong&gt; hits the database. Let only one request rebuild, and &lt;strong&gt;add random jitter&lt;/strong&gt; to expiry times.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>caching</category>
      <category>scaling</category>
      <category>web</category>
    </item>
    <item>
      <title>N+1 query problem in Hibernate and JPA: find it and fix it</title>
      <dc:creator>Prince Singh</dc:creator>
      <pubDate>Sun, 01 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/sudoexplain/n1-query-problem-in-hibernate-and-jpa-find-it-and-fix-it-37go</link>
      <guid>https://dev.to/sudoexplain/n1-query-problem-in-hibernate-and-jpa-find-it-and-fix-it-37go</guid>
      <description>&lt;h2&gt;
  
  
  #The doubt
&lt;/h2&gt;

&lt;p&gt;An orders page loads in 50 milliseconds on your machine, where the test database holds ten orders. Months later, in production with five thousand, it takes ten seconds. The database is busy answering a flood of requests. Yet when you look at the slow-query log, which lists queries that took too long, &lt;strong&gt;nothing is slow&lt;/strong&gt; : every query finishes in a millisecond or two.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every single query is fast, so why is the page slow?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because there is not one query. There are five thousand and one. It is like making 5,000 separate trips to the shop for one item each, instead of taking a single shopping list.&lt;/p&gt;

&lt;p&gt;In this post&lt;/p&gt;

&lt;p&gt;By the end you will know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What the &lt;strong&gt;N+1 problem&lt;/strong&gt; is and how loading related data (eager or lazy) creates it without any visible sign in the code&lt;/li&gt;
&lt;li&gt;Why it stays hidden in development and arrives with data growth&lt;/li&gt;
&lt;li&gt;How to &lt;strong&gt;diagnose&lt;/strong&gt; it, even though no single query is slow&lt;/li&gt;
&lt;li&gt;The fixes (fetch joins, batching, projections) and how each can backfire&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  #First, what an ORM does
&lt;/h2&gt;

&lt;p&gt;Your data is stored in a database. Your Java code works with Java objects. They are two different shapes, so something has to translate between them.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;ORM&lt;/strong&gt; (object-relational mapper) is that translator. You ask for an &lt;code&gt;Order&lt;/code&gt; in Java. The ORM writes the database query for you, runs it, and hands you back a ready &lt;code&gt;Order&lt;/code&gt; object. Without an ORM, you would write the query yourself and copy every value into the object by hand.&lt;/p&gt;

&lt;p&gt;The catch is that you never see the queries it writes. Keep that in mind, because this post is about what happens when those hidden queries pile up.&lt;/p&gt;

&lt;p&gt;You will see two names for this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JPA&lt;/strong&gt; is the written set of rules for how Java and a database are matched up. It is only rules, not code that runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hibernate&lt;/strong&gt; is the ORM that follows those rules and does the actual work. Spring Boot uses it by default, so most Java projects already have it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  #Then, related data: eager and lazy
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;Order&lt;/code&gt; belongs to a &lt;code&gt;Customer&lt;/code&gt;. When you load an order, should the ORM also load its customer? There are two choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Eager:&lt;/strong&gt; load the customer straight away, together with the order, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy:&lt;/strong&gt; load only the order. Load the customer later, at the moment your code first asks for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lazy exists to save work. A customer has its own addresses, orders and more, so loading all of that with every order could read a lot of data you never use.&lt;/p&gt;

&lt;p&gt;You choose with a setting on the link between the two classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ManyToOne&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FetchType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LAZY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Customer&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the part that surprises people. If you write nothing, JPA picks &lt;strong&gt;eager&lt;/strong&gt; for this kind of link. You might expect eager to be safe, because the customer arrives with the order. But when you load a &lt;em&gt;list&lt;/em&gt; of orders, the ORM sends one query for the orders, and then, to fill in each customer immediately, one more query per order, all inside that same call.&lt;/p&gt;

&lt;p&gt;So eager does not help. Lazy does not cause a problem by itself, though. If your code never asks for the customers, lazy sends no extra queries, and it saves work.&lt;/p&gt;

&lt;p&gt;The trouble starts when your code &lt;strong&gt;does&lt;/strong&gt; ask, for many orders in a row. A page that lists orders with each customer's name asks for every customer, and each ask sends its own query. Lazy only decides &lt;em&gt;when&lt;/em&gt; those queries are sent, not &lt;em&gt;how many&lt;/em&gt;. Eager sends the same number, just earlier.&lt;/p&gt;

&lt;p&gt;Here is lazy at work. Loading the orders does not load their customers. A customer is fetched only when you call &lt;code&gt;order.getCustomer()&lt;/code&gt;. So look at what happens when a page shows the customer's name for every order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1 query: loads all the orders&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findAll&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// each new customer here sends 1 more query&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomer&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop looks innocent. There is no query in sight. But each call to &lt;code&gt;getCustomer()&lt;/code&gt; quietly sends one:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- 1 query&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&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;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- one per customer&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&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;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&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;31&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/n-plus-one-query-problem/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. One query for the list, then one round trip per row.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the &lt;strong&gt;N+1 problem&lt;/strong&gt; : &lt;strong&gt;1&lt;/strong&gt; query to get the list, plus &lt;strong&gt;N&lt;/strong&gt; more, one for each customer. If there are 5,000 different customers, the page sends 5,001 queries.&lt;/p&gt;

&lt;p&gt;One small detail: if two orders belong to the same customer, the ORM remembers that customer and does not ask the database again. So N is the number of &lt;em&gt;different&lt;/em&gt; customers. With thousands of customers, that is still thousands of queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Why it appears only in production
&lt;/h2&gt;

&lt;p&gt;The cost grows with the data, and the code never changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Orders on the page&lt;/th&gt;
&lt;th&gt;Queries&lt;/th&gt;
&lt;th&gt;At about 2 ms each&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10 (development)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;22 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;501&lt;/td&gt;
&lt;td&gt;1 second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;5,001&lt;/td&gt;
&lt;td&gt;10 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each query is a &lt;strong&gt;network round trip&lt;/strong&gt; to the database. Even when the database answers in a fraction of a millisecond, the trip costs more. Thousands of tiny trips add up to a slow page.&lt;/p&gt;

&lt;p&gt;A request that runs thousands of queries also holds its database connection for longer. Under load, that can use up the connection pool, as explained in &lt;a href="https://sudoexplain.dev/posts/connection-pool-exhausted/" rel="noopener noreferrer"&gt;connection pool exhausted&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is why the bug ships dormant. In development, the database often runs on the same machine, so trips cost almost nothing, and a handful of rows is fast. It only wakes up when real data arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  #How to diagnose it
&lt;/h2&gt;

&lt;p&gt;The usual tool, the slow-query log, finds nothing, because no individual query is slow. Instead, look at &lt;strong&gt;how many&lt;/strong&gt; queries one request makes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Print the SQL.&lt;/strong&gt; Turn on SQL logging for one request, for example &lt;code&gt;logging.level.org.hibernate.SQL=DEBUG&lt;/code&gt; in Spring Boot. Load the page. If you see the same statement repeated with only the ID changing, that is the pattern:&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;select&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;customers&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="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;customers&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="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;customers&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="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hundreds&lt;/span&gt; &lt;span class="k"&gt;more&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Count with Hibernate statistics.&lt;/strong&gt; Setting &lt;code&gt;spring.jpa.properties.hibernate.generate_statistics=true&lt;/code&gt; makes Hibernate collect statement counts, which you can read from its &lt;code&gt;Statistics&lt;/code&gt; object or see in the session metrics log. A page that runs thousands of statements is your suspect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Ask the database how often each query ran.&lt;/strong&gt; The database can keep a tally of every query it receives. In PostgreSQL this is done by an extension called &lt;code&gt;pg_stat_statements&lt;/code&gt;. It treats queries that differ only in their values as one query, writing the value as a placeholder like &lt;code&gt;$1&lt;/code&gt;. So 5,000 lookups of different customers show up as a single line with a count of 5,000.&lt;/p&gt;

&lt;p&gt;It is switched off by default. To turn it on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;pg_stat_statements&lt;/code&gt; to &lt;code&gt;shared_preload_libraries&lt;/code&gt; in the PostgreSQL settings, then restart PostgreSQL.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;CREATE EXTENSION pg_stat_statements;&lt;/code&gt; once in your database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then reset the tally, load the slow page once, and list the queries by how many times they ran:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements_reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;-- start counting from zero&lt;/span&gt;

&lt;span class="c1"&gt;-- now load the page once, then run:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reset may need extra privileges. If you cannot run it, load the page twice and compare the counts. The result looks like this (illustrative):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; calls | query
-------+-----------------------------------------
  5000 | select ... from customers where id = $1
     1 | select ... from orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One page load ran one query for the orders and 5,000 for the customers. That huge count on a cheap query is the N+1 problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Look at an APM trace.&lt;/strong&gt; APM means application performance monitoring, a tool that records what each request did. Examples are Datadog, New Relic and Elastic APM, or OpenTelemetry with the free Jaeger viewer. They show one request as a tree of &lt;strong&gt;spans&lt;/strong&gt; , the timed steps inside it. Open the trace of the slow page. A request with hundreds of identical, tiny database spans is a textbook N+1.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 1: fetch everything in one query
&lt;/h2&gt;

&lt;p&gt;Tell the ORM to load the orders and their customers together, in one query, instead of asking for each customer later. This is called a &lt;strong&gt;fetch join&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"select o from Order o left join fetch o.customer"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findAllWithCustomer&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sends one query that returns every order with its customer already attached. Looping over the orders now sends no more queries. In Spring Data you can get the same effect with &lt;code&gt;@EntityGraph(attributePaths = "customer")&lt;/code&gt;. Five thousand and one queries become one.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 2: fetch in batches
&lt;/h2&gt;

&lt;p&gt;When you cannot join, tell the ORM to load related rows in groups. Instead of one query per customer, it asks for many at a time:&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="n"&gt;spring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jpa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hibernate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_batch_fetch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five thousand queries become roughly a hundred. It is not as good as one, but it is a one-line change, and it applies to every association without rewriting queries. Hibernate also has a &lt;code&gt;@BatchSize&lt;/code&gt; annotation for finer control.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 3: load only what the page needs
&lt;/h2&gt;

&lt;p&gt;Often the page does not need the whole &lt;code&gt;Customer&lt;/code&gt;. It only shows the order number and the customer's name. So ask the database for just those two values. This is called a &lt;strong&gt;projection&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;First, a small interface that lists the two values you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OrderSummary&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="nf"&gt;getOrderId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getCustomerName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a query that returns only those two values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"select o.id as orderId, c.name as customerName "&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"from Order o left join o.customer c"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderSummary&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findSummaries&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sends one query, and the database returns only two columns instead of full order and customer rows. It is usually the fastest option, because it moves the least data.&lt;/p&gt;

&lt;h2&gt;
  
  
  #When the fix goes wrong
&lt;/h2&gt;

&lt;p&gt;Each fix can create a new problem if you use it in the wrong place. These are the common ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Joining two lists in one query (Fix 1).&lt;/strong&gt; A fetch join is safe when each order has &lt;em&gt;one&lt;/em&gt; related object, such as one customer. But an order can also have &lt;em&gt;lists&lt;/em&gt;: several items, and several payments. Joining both lists in one query goes wrong.&lt;/p&gt;

&lt;p&gt;The database can only answer with a flat table, so it pairs every item with every payment. Take 2 orders, where each order has 3 items and 4 payments (illustrative). These are the rows for order 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order | item | payment
------+------+--------
  1 | pen | card-A
  1 | pen | card-B
  1 | pen | card-C
  1 | pen | card-D
  1 | book | card-A
  1 | book | card-B
  1 | book | card-C
  1 | book | card-D
  1 | lamp | card-A
  1 | lamp | card-B
  1 | lamp | card-C
  1 | lamp | card-D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order 2 has the same shape, so it adds 12 more rows. That is 3 × 4 = 12 rows for each order, 24 rows in all. But the real data is only 6 items and 8 payments, 14 pieces in total. Most rows are repeats: "pen" appears four times, once with each payment.&lt;/p&gt;

&lt;p&gt;It gets much worse as the lists grow. With 10 items and 10 payments per order, each order gives 10 × 10 = 100 rows, so 100 orders become 10,000 rows. Hibernate has to sort them all back into orders, and the page becomes slow. Often Hibernate refuses and throws &lt;code&gt;MultipleBagFetchException&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do instead.&lt;/strong&gt; Split the work into separate queries, so the two lists never meet. It takes three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Join only the items.&lt;/strong&gt; Load the orders together with their items, in one query. Each row is now just one item of one order, so 100 orders with 10 items each give 100 × 10 = 1,000 rows, not 10,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the payments out of that query.&lt;/strong&gt; The page still needs them, so they stay lazy: Hibernate loads an order's payments when your code first reads them. If each order did that on its own, you would be back to the N+1 problem, with 100 more queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch on batch fetching (Fix 2).&lt;/strong&gt; Now, when the code reads the payments of one order, Hibernate loads the payments of 50 orders in a single query. 100 orders need only 2 of these queries. The batch size counts orders, not rows, so 50 orders with 10 payments each means 500 rows per query.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For our 2 orders, the items query returns 6 rows, and the payments query returns 8 rows (both orders fit in one batch):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;items query payments query
order | item order | payment
------+------ ------+--------
  1 | pen 1 | card-A
  1 | book 1 | card-B
  1 | lamp 1 | card-C
  2 | mug 1 | card-D
  2 | cap 2 | card-E
  2 | bag 2 | card-F
                           2 | card-G
                           2 | card-H
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 2 queries and 6 + 8 = 14 rows, with no repeats, instead of 24 rows before. The gap grows quickly with size. With 100 orders, each with 10 items and 10 payments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query 1, the items:&lt;/strong&gt; 100 orders × 10 items = 1,000 rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries 2 and 3, the payments:&lt;/strong&gt; 50 orders × 10 payments = 500 rows each, 1,000 in total.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is 3 queries and about 2,000 rows, instead of 10,000 rows in one query.&lt;/p&gt;

&lt;p&gt;In short: a join saves queries but can multiply rows, and batching keeps the rows low but sends more queries. Joining one list and batching the other keeps both numbers small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Joining a list and paging (Fix 1).&lt;/strong&gt;  &lt;strong&gt;Paging&lt;/strong&gt; means showing a long list a little at a time, for example 20 orders per page. Normally the database does this for you: you ask for "the first 20 rows" and it stops after 20.&lt;/p&gt;

&lt;p&gt;That works when there is one row per order. But when you join a list, such as the items, there is one row per &lt;em&gt;item&lt;/em&gt;. Say each order has 3 items. The first 20 rows are then only about 7 orders, and the last of them is cut in half, with some of its items missing.&lt;/p&gt;

&lt;p&gt;The database cannot know how many rows make 20 whole orders, so Hibernate does not ask it to stop. It loads &lt;strong&gt;every&lt;/strong&gt; order with all of its items into memory, and then picks out 20 orders in Java. With 100,000 orders, that is hundreds of thousands of rows in memory just to show one page. Hibernate writes a warning in the log when it does this.&lt;/p&gt;

&lt;p&gt;Instead, do it in two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run one query for just the 20 orders on this page, with nothing joined.&lt;/li&gt;
&lt;li&gt;Run a second query that loads the items of those 20 orders.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the database only ever returns what the page needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Making everything &lt;code&gt;EAGER&lt;/code&gt;.&lt;/strong&gt; It looks like an easy shortcut: always load the customer together with the order, so nobody has to think about it. But as shown earlier, eager does not remove the extra queries. Loading a list of orders still sends one query per customer.&lt;/p&gt;

&lt;p&gt;The new problem is that eager applies &lt;strong&gt;everywhere&lt;/strong&gt;. Say one page lists 5,000 orders and shows only the order number and the total. It never uses the customer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With a lazy link, that page sends 1 query.&lt;/li&gt;
&lt;li&gt;With an eager link, it sends up to 5,001 queries, to load customers that nobody looks at.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same happens in every report, search and export that loads orders. So one setting can make pages slower that were fine before. Instead, keep the links lazy, and let each page fetch what it needs with Fix 1, 2 or 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Silencing &lt;code&gt;LazyInitializationException&lt;/code&gt;.&lt;/strong&gt; Remember that lazy data is fetched later, when your code first touches it. To fetch it, Hibernate needs its &lt;strong&gt;session&lt;/strong&gt; : the open connection between your code and the database. In many applications the session closes when the method that loaded the data finishes.&lt;/p&gt;

&lt;p&gt;If your code touches lazy data after that, there is no session left to ask the database, and Hibernate throws this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the session closes when findAll() returns&lt;/span&gt;
&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findAll&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// error: there is no session left to load the customer&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;getCustomer&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error message ends with "could not initialize proxy - no Session". It is annoying, so people silence it in one of two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Switch the link to &lt;code&gt;EAGER&lt;/code&gt;.&lt;/strong&gt; This brings back problem 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the session open for the whole request.&lt;/strong&gt; Spring Boot's "open session in view" setting does this, and it is on by default in web applications. Lazy loading then works anywhere, even in a controller or a page template, so a loop can quietly send hundreds of queries again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both hide the error and keep the N+1 problem. The error is actually telling you something useful: you are asking for data you did not load. Instead, fetch what you need before the method finishes, with Fix 1, 2 or 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A huge batch size (Fix 2).&lt;/strong&gt; The batch size is how many IDs go into one query. With a batch size of 50, loading 100 customers takes 2 queries, each ending in &lt;code&gt;WHERE id IN (...50 IDs...)&lt;/code&gt;. That is fine.&lt;/p&gt;

&lt;p&gt;It is tempting to think a bigger number is always better, because it means fewer queries. Say you set 5,000 and then load 5,000 customers. You get one query with a list of 5,000 IDs in it. That has problems of its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The query text is huge, and the database takes longer to read and plan it.&lt;/li&gt;
&lt;li&gt;Some databases refuse it. For example, Oracle allows at most 1,000 values in one &lt;code&gt;IN&lt;/code&gt; list.&lt;/li&gt;
&lt;li&gt;If a page needs only 3 customers, a very large batch size gives you no extra benefit, only a risk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with a modest number, in the tens, then measure the page with your real data and adjust.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Which should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One page needs a related object for every row&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Fetch join&lt;/strong&gt; or &lt;code&gt;@EntityGraph&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many associations, rarely all needed&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Batch fetching&lt;/strong&gt; (the global default size)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A list or report that shows a few columns&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;A projection&lt;/strong&gt; with only those columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Related data is a collection and you also need paging&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Two queries:&lt;/strong&gt; page the parent, then fetch the children by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A loop calls &lt;code&gt;findById&lt;/code&gt; for each row&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&lt;code&gt;findAllById&lt;/code&gt;&lt;/strong&gt; once with all the IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;N+1 also hides in collections, such as &lt;code&gt;order.getItems()&lt;/code&gt; inside a loop, and in hand-written loops that call a repository once per row. The fixes above apply to both.&lt;/p&gt;

&lt;p&gt;If you are not sure, turn on SQL logging for your three most visited pages and count. You will probably find one. If one query really is slow, that is a different problem, such as &lt;a href="https://sudoexplain.dev/posts/slow-query-missing-index/" rel="noopener noreferrer"&gt;a missing index&lt;/a&gt;, and &lt;code&gt;EXPLAIN&lt;/code&gt; shows how the database runs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The ORM fetches related data one row at a time, either on first use ( &lt;strong&gt;lazy&lt;/strong&gt; ) or straight away ( &lt;strong&gt;eager&lt;/strong&gt; ), so a list can quietly send one query per row: the &lt;strong&gt;N+1 problem&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It stays hidden because small data is fast. The cost grows with row count, and the code never changes.&lt;/li&gt;
&lt;li&gt;No single query is slow, so the &lt;strong&gt;slow-query log misses it&lt;/strong&gt;. Count queries per request instead.&lt;/li&gt;
&lt;li&gt;Fix it with a &lt;strong&gt;fetch join&lt;/strong&gt; , &lt;strong&gt;batch fetching&lt;/strong&gt; or a &lt;strong&gt;projection&lt;/strong&gt; , depending on what the page needs.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>java</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Sessions, JWTs and load balancers explained</title>
      <dc:creator>Prince Singh</dc:creator>
      <pubDate>Sat, 31 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/sudoexplain/sessions-jwts-and-load-balancers-explained-3m57</link>
      <guid>https://dev.to/sudoexplain/sessions-jwts-and-load-balancers-explained-3m57</guid>
      <description>&lt;h2&gt;
  
  
  #The doubt
&lt;/h2&gt;

&lt;p&gt;Picture a website where you log in. You click "My orders", and it greets you by name. The site &lt;strong&gt;remembers who you are&lt;/strong&gt;. That memory is called a &lt;strong&gt;session&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now picture a busy website that is not run by one computer, but by three identical ones. A gatekeeper stands in front of them and sends each click to whichever computer is free. That gatekeeper is called a &lt;strong&gt;load balancer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is the puzzle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You log in. Your click goes to &lt;strong&gt;Computer 1&lt;/strong&gt; , which remembers you.&lt;/li&gt;
&lt;li&gt;You click "My orders". The gatekeeper sends this one to &lt;strong&gt;Computer 2&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Computer 2 was never told about your login. To it, you are a stranger.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;If one computer remembers me, how does the next one know who I am?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was my doubt. Big websites with millions of users clearly solve it, so the answer must exist. It only makes sense after a few basics, so we start there. I won't assume you know any of these words.&lt;/p&gt;

&lt;p&gt;In this post&lt;/p&gt;

&lt;p&gt;By the end you will know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What a session is, and what a JWT is&lt;/li&gt;
&lt;li&gt;What a load balancer does, and why it breaks naive sessions&lt;/li&gt;
&lt;li&gt;How to &lt;strong&gt;diagnose&lt;/strong&gt; the problem when it happens to you&lt;/li&gt;
&lt;li&gt;The ways to &lt;strong&gt;fix&lt;/strong&gt; it, and how a JWT fits in&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  #First, the problem HTTP leaves us with
&lt;/h2&gt;

&lt;p&gt;Your browser talks to a website using HTTP, the set of rules browsers and servers use to exchange pages and data. Each time your browser asks the server for something (a page, an image, your list of orders), that is one &lt;strong&gt;request&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;HTTP has no memory. Every request arrives as a stranger. The server answers it and forgets it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request 1: POST /login (username + password) -&amp;gt; "Welcome, Prince"
Request 2: GET /orders -&amp;gt; "Who are you?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server did not forget on purpose. Nothing in request 2 says it came from the person who logged in a moment ago. So after login, the browser has to &lt;strong&gt;prove who it is on every single request&lt;/strong&gt; , without typing the password again. A session and a JWT are two different ways to do that.&lt;/p&gt;

&lt;h3&gt;
  
  
  #Cookies in 30 seconds
&lt;/h3&gt;

&lt;p&gt;A session ID usually rides on a &lt;strong&gt;cookie&lt;/strong&gt; , and a JWT often does too, though it is also commonly sent in an &lt;code&gt;Authorization&lt;/code&gt; header. A cookie is a small piece of text that a website asks your browser to store. After that, the browser attaches it automatically to every request to that website.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Response: Set-Cookie: sid=9f3c2a...; HttpOnly; Secure; SameSite=Lax
Requests: Cookie: sid=9f3c2a...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those extra words are safety switches. &lt;code&gt;HttpOnly&lt;/code&gt; hides the cookie from the page's own JavaScript. &lt;code&gt;Secure&lt;/code&gt; sends it only over HTTPS (the encrypted, padlock version of HTTP). &lt;code&gt;SameSite&lt;/code&gt; limits when other websites can trigger it. Remember that the browser does the carrying. The only question is what you put inside the cookie.&lt;/p&gt;

&lt;h3&gt;
  
  
  #See it yourself
&lt;/h3&gt;

&lt;p&gt;You can look at a real one in two minutes. Open a website you are logged into, where logging back in is easy, and press &lt;code&gt;F12&lt;/code&gt; to open the browser's developer tools. Choose the &lt;strong&gt;Application&lt;/strong&gt; tab (called &lt;strong&gt;Storage&lt;/strong&gt; in Firefox), then &lt;strong&gt;Cookies&lt;/strong&gt; , then your site.&lt;/p&gt;

&lt;p&gt;Look for a cookie with a long, random-looking value. It is often named something like &lt;code&gt;session&lt;/code&gt;, &lt;code&gt;sid&lt;/code&gt; or &lt;code&gt;JSESSIONID&lt;/code&gt;. That value is the ticket number. Now delete that cookie and refresh the page. You are logged out, because the server can no longer match a ticket to a record. If nothing happens, the site keeps its login in a different cookie, so try another one.&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;

&lt;p&gt;A real session cookie or token works like a password for your account. Never paste one into an online tool, a chat or an email.&lt;/p&gt;

&lt;h2&gt;
  
  
  #What is a session?
&lt;/h2&gt;

&lt;p&gt;A session is a &lt;strong&gt;record the server keeps about you&lt;/strong&gt;. After you log in, the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates a record such as "this is user 42, logged in at 10:00"&lt;/li&gt;
&lt;li&gt;Gives that record a long random ID, for example &lt;code&gt;9f3c2a...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sends the ID to your browser in a cookie&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cookie holds only the ticket number. Think of a coat check: you hand over your coat and get a numbered ticket. The ticket is meaningless on its own. The coat stays in the cloakroom. On every later request the server takes the ticket, looks up the record, and knows who you are.&lt;/p&gt;

&lt;p&gt;Here is the idea in pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sessions = an empty table, kept in THIS server's memory

on POST /login:
    id = a random ticket number
    sessions[id] = { userId: 42 }
    send the browser a cookie: sid = id

on GET /orders:
    session = sessions[the sid cookie]
    if there is no such session:
        redirect to /login # unknown ticket: who are you?
    show the orders of session.userId
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice where the record lives: in a table inside that one server's memory. Keep that in mind, because it is the root of the whole problem.&lt;/p&gt;

&lt;p&gt;You may already have used this without noticing. In a Java web app, the servlet container (the program that runs it, such as Tomcat) does exactly this for you: calling &lt;code&gt;request.getSession()&lt;/code&gt; creates the record and sends a cookie called &lt;code&gt;JSESSIONID&lt;/code&gt;. By default, the table lives in that one server's memory.&lt;/p&gt;

&lt;p&gt;A session has a great property: &lt;strong&gt;the server is in control&lt;/strong&gt;. To log someone out, delete their record. To change their permissions, edit it. The next request sees the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  #What is a JWT?
&lt;/h2&gt;

&lt;p&gt;JWT stands for JSON Web Token (JSON is just a plain-text way of writing labelled values, as in the box below). It takes the opposite approach: the server keeps &lt;strong&gt;nothing&lt;/strong&gt; , and the browser carries &lt;strong&gt;the facts about you&lt;/strong&gt; , sealed so they cannot be changed.&lt;/p&gt;

&lt;p&gt;Think of a festival wristband with your ticket details printed on it and a tamper-proof seal. Any gate guard can read it and check the seal. Nobody needs to phone head office.&lt;/p&gt;

&lt;p&gt;A JWT is three chunks of text joined by dots: &lt;code&gt;header.payload.signature&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;header&lt;/strong&gt; says how it was signed (not the same thing as an HTTP header, which comes later)&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;payload&lt;/strong&gt; holds the facts (called &lt;em&gt;claims&lt;/em&gt;), such as who you are and when the token expires&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;signature&lt;/strong&gt; is the seal, made with a secret key (a private key, or in simpler setups a shared secret) that only the login service holds
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1793490000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1793493600&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;sub&lt;/code&gt; is the subject (the user's ID), &lt;code&gt;iat&lt;/code&gt; is when the token was issued and &lt;code&gt;exp&lt;/code&gt; is when it expires, both written as seconds since 1970.&lt;/p&gt;

&lt;p&gt;When the token comes back, the server checks the seal. If it matches, the facts are trusted. If someone edited even one letter, the seal no longer matches and the token is rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# At login (once)
facts = { sub: "42", role: "admin", expires: one hour from now }
token = sign(facts, privateKey)

# On every request: fails if tampered with or expired
facts = verify(token, publicKey)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no lookup and no table. Just a calculation.&lt;/p&gt;

&lt;h3&gt;
  
  
  #How can a seal be checked but not forged?
&lt;/h3&gt;

&lt;p&gt;This is the part that confuses most people, so here is an analogy. Think of a &lt;strong&gt;postcard with an official stamp&lt;/strong&gt; on it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anyone who picks up the postcard can &lt;strong&gt;read&lt;/strong&gt; it. Nothing is hidden.&lt;/li&gt;
&lt;li&gt;Only the post office owns the &lt;strong&gt;stamp&lt;/strong&gt;. Nobody else can make a genuine one.&lt;/li&gt;
&lt;li&gt;The stamp is made from the message itself. Change one word, and the old stamp no longer fits, so the postcard is exposed as fake.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A JWT works the same way, and the two keys are the stamp and the stamp-checker:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Postcard analogy&lt;/th&gt;
&lt;th&gt;What it can do&lt;/th&gt;
&lt;th&gt;Who holds it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Private key&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The stamp itself&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Create&lt;/strong&gt; a seal&lt;/td&gt;
&lt;td&gt;Only the login service. Keep it secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Public key&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A checking device&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Check&lt;/strong&gt; a seal, but never make one&lt;/td&gt;
&lt;td&gt;Anyone. It is safe to hand out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the login service stamps tokens with its private key. Every other server holds the public key and can confirm "yes, a genuine stamp, and the words have not changed". Those servers can verify logins, but they cannot invent one.&lt;/p&gt;

&lt;p&gt;(Some systems use one shared secret for both jobs instead of a pair. Then every server that can check a token can also forge one. The idea is the same, just less safe to spread around.)&lt;/p&gt;

&lt;h3&gt;
  
  
  #One thing that surprises people
&lt;/h3&gt;

&lt;p&gt;A typical JWT is &lt;strong&gt;signed, not encrypted&lt;/strong&gt;. (Encrypted variants exist but are rare.) Those two words sound alike but mean different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted&lt;/strong&gt; means scrambled so that only the right key holder can read it, like a locked box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed&lt;/strong&gt; means readable by everyone, but protected against changes, like the stamped postcard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A JWT is the postcard, not the locked box. Its chunks are written in Base64, which is only a way of spelling any data with ordinary letters and digits. It is not a secret code. Anyone holding the token can read it. In Java, three lines are enough to open one up, no key needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\\."&lt;/span&gt;&lt;span class="o"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;];&lt;/span&gt;
&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlDecoder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;decode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// prints: {"sub":"42","role":"admin",...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seal stops people &lt;em&gt;changing&lt;/em&gt; the facts. It does not stop people &lt;em&gt;reading&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;Warning&lt;/p&gt;

&lt;p&gt;Never put passwords or sensitive data in a JWT payload. Anyone who gets the token can read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Stateful and stateless, in plain words
&lt;/h2&gt;

&lt;p&gt;You will see these two words everywhere, so let us pin them down.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stateful&lt;/strong&gt; means someone has to &lt;em&gt;remember&lt;/em&gt; things between requests. A session is stateful: the server remembers you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless&lt;/strong&gt; means nothing is remembered between requests. Each request carries everything needed to handle it. A JWT is the stateless style: the token carries the facts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two misconceptions are worth clearing up now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"JWT is the modern replacement and sessions are old."&lt;/em&gt; They are two answers to the same question: where does the memory live? Neither is free.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"A stateful app cannot be spread over many servers."&lt;/em&gt; It can, and this post shows how. The trick is to separate &lt;strong&gt;where the state lives&lt;/strong&gt; from &lt;strong&gt;which server answers the request&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  #One server is easy. Then you add a second.
&lt;/h2&gt;

&lt;p&gt;Everything above works perfectly with a single server. Real apps rarely stay that way, because one server means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;limited capacity, and it falls over under heavy load&lt;/li&gt;
&lt;li&gt;no backup if it crashes&lt;/li&gt;
&lt;li&gt;downtime every time you release a new version, because the app has to restart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you run several copies of the app and put a &lt;strong&gt;load balancer&lt;/strong&gt; in front of them. A load balancer is a traffic cop: it receives every request from users and hands each one to one of the servers, spreading the work out. Users see one website, but several servers do the work behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The problem: a session kept in one server's memory
&lt;/h2&gt;

&lt;p&gt;Now combine the two ideas. Our session code kept its table in &lt;strong&gt;one server's memory&lt;/strong&gt;. Watch what happens with two servers:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/sessions-jwt-and-load-balancers/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 1. The login lands on Server A, but the next request lands on Server B, which has never heard of it.&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You log in. The load balancer sends the request to &lt;strong&gt;Server A&lt;/strong&gt;. Server A creates session &lt;code&gt;S1&lt;/code&gt; in its own memory and gives your browser the cookie &lt;code&gt;sid=S1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;You click a link. The load balancer sends this request to &lt;strong&gt;Server B&lt;/strong&gt; , which is just as likely. Server B receives &lt;code&gt;sid=S1&lt;/code&gt;, looks in its own memory, finds nothing, and says: &lt;em&gt;"Who are you? Please log in."&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Server B did nothing wrong. It simply never saw your login. The result for users is baffling: they are logged out at random, or have to log in again every few clicks.&lt;/p&gt;

&lt;p&gt;This bug is cruel for one more reason: &lt;strong&gt;it never shows up on your laptop&lt;/strong&gt; , because on your laptop there is only one server.&lt;/p&gt;

&lt;h2&gt;
  
  
  #How to diagnose it
&lt;/h2&gt;

&lt;p&gt;When users report random logouts only in production, suspect this. Here is how to confirm it step by step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Notice the pattern.&lt;/strong&gt; Logins that work, then vanish for no reason? Does it happen more as you add servers? Does it go away if you run only one server? Those are strong signs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Find out which server answers.&lt;/strong&gt; Configure each server to add a &lt;strong&gt;header&lt;/strong&gt; to its responses with its own name, for example &lt;code&gt;X-Served-By: server-a&lt;/code&gt;. A header is a labelled extra line of information sent along with the page. Then log in once, save the cookie, and call a protected page several times. The tool below is &lt;code&gt;curl&lt;/code&gt;, a command-line program that makes web requests for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Log in once and save the cookie&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; jar.txt &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'user=demo&amp;amp;pass=demo'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/login &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null

&lt;span class="c"&gt;# Call a protected page six times and show who answered&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5 6&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; jar.txt &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null https://example.com/orders &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"^HTTP/|^x-served-by"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If sessions are stuck in one server's memory, you will see something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;span class="na"&gt;x-served-by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server-a&lt;/span&gt;
&lt;span class="s"&gt;HTTP/2 302&lt;/span&gt;
&lt;span class="na"&gt;x-served-by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server-b&lt;/span&gt;
&lt;span class="s"&gt;HTTP/2 200&lt;/span&gt;
&lt;span class="na"&gt;x-served-by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server-a&lt;/span&gt;
&lt;span class="s"&gt;HTTP/2 302&lt;/span&gt;
&lt;span class="na"&gt;x-served-by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server-b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; (success) comes from the server that created the session, and a &lt;code&gt;302&lt;/code&gt; (a redirect to the login page) comes from the other. That settles it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Rule out the lookalikes.&lt;/strong&gt; Make sure the cookie is actually being sent (check the browser's developer tools, Application tab), that it has not expired, and that the cookie's domain and &lt;code&gt;Secure&lt;/code&gt; settings match your site. Those cause random logouts too, but they would break on one server as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 1: sticky sessions
&lt;/h2&gt;

&lt;p&gt;The cheapest fix: tell the load balancer to &lt;strong&gt;always send the same user to the same server&lt;/strong&gt;. This is called a &lt;em&gt;sticky session&lt;/em&gt;. Once you land on Server A, you stay on Server A.&lt;/p&gt;

&lt;p&gt;It works, but it has real weaknesses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If Server A crashes or restarts, everyone stuck to it is logged out&lt;/li&gt;
&lt;li&gt;Deploying a new version restarts the servers, so users on a restarted server can lose their sessions. Some servers keep them across a clean restart only if you configure it, and a crash loses them either way&lt;/li&gt;
&lt;li&gt;The load can become uneven, because some users keep a server busy while others do not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sticky sessions are a bandage. They are fine for a small setup, and not much more.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 2: a shared session store
&lt;/h2&gt;

&lt;p&gt;The robust fix: &lt;strong&gt;take the table out of the server.&lt;/strong&gt; Keep sessions in one shared place that every server can read. A database works. So does a fast in-memory store like Redis, which is software built to hold small pieces of data in memory and share them over a network. Distributed caches such as Infinispan or Hazelcast do the same job. You do not need to know these yet. They are just examples. (Keeping copies of data in fast stores has its own failure mode: copies go out of date, as explained in &lt;a href="https://sudoexplain.dev/posts/stale-caches-explained/" rel="noopener noreferrer"&gt;stale cache and cache stampede&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: &lt;a href="https://sudoexplain.dev/posts/sessions-jwt-and-load-balancers/" rel="noopener noreferrer"&gt;see it on sudoexplain.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure 2. With a shared session store, either server can answer any request.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The change is tiny. The only thing that moves is where the table lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sessions = the shared store # was: a table in this server's memory

on login (any server):
    sessions.save(id, { userId: 42 }, expires in 30 minutes)

on a later request (any server):
    session = sessions.load(id)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Server A creates the session, Server B finds it, and everything works. Any server can answer any request, so the servers are &lt;strong&gt;interchangeable&lt;/strong&gt;. You can add, remove or restart them freely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This answers the question we started with.&lt;/strong&gt; The application is still stateful, because it remembers you. But the &lt;em&gt;state&lt;/em&gt; lives in the shared store, not in the web servers. The servers are effectively stateless, and that is what lets a load balancer spread requests around freely. Stateful system, stateless servers.&lt;/p&gt;

&lt;p&gt;Two details matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expiry must be shared too.&lt;/strong&gt; Store a "last active" timestamp in the shared record, and treat a session as expired when &lt;code&gt;now - lastActive &amp;gt; timeout&lt;/code&gt;. If each server tracked time on its own, one server could log you out while another still thinks you are logged in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is a cost.&lt;/strong&gt; Every request now makes a trip to the store, and the store has to be reliable, because if it goes down, nobody can be recognised. That is why these stores usually run as a group of machines holding copies of each other's data, so one failure does not lose everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A third option exists, &lt;em&gt;session replication&lt;/em&gt;, where servers copy sessions to each other. It works for small clusters, but it gets heavy as you grow, which is why a shared store is more common.&lt;/p&gt;

&lt;h2&gt;
  
  
  #Fix 3: stop storing sessions on the server at all (JWT)
&lt;/h2&gt;

&lt;p&gt;There is a completely different way to escape the problem: if the server keeps no session, there is nothing to share. That is exactly what a JWT does.&lt;/p&gt;

&lt;p&gt;With a JWT, the login hands the browser a signed token carrying the facts. On every request, &lt;strong&gt;any&lt;/strong&gt; server can check the signature using the key, and trust the facts. No shared table, no lookup, no request to a store.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You log in. The token is created and given to your browser.&lt;/li&gt;
&lt;li&gt;Your next request goes to Server B. It checks the signature. It matches. You are in.&lt;/li&gt;
&lt;li&gt;The request after that goes to Server A. Same check. Same result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No server needed to have seen your login. This is why tokens are popular when many servers, or many separate services, must all trust the same login. Remember the key pair from earlier: services only need the &lt;strong&gt;public&lt;/strong&gt; key to check tokens, so they can verify logins without being able to create fake ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  #The catch
&lt;/h3&gt;

&lt;p&gt;A JWT is valid until it expires, and the server is not looking anything up, so it cannot say "actually, this one is no good any more." That hurts when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a user logs out&lt;/li&gt;
&lt;li&gt;an account is disabled&lt;/li&gt;
&lt;li&gt;a role is removed&lt;/li&gt;
&lt;li&gt;a token is stolen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual answer is to make tokens &lt;strong&gt;short-lived&lt;/strong&gt; (5 to 15 minutes) and pair them with a &lt;strong&gt;refresh token&lt;/strong&gt;. When the short token expires, the client trades the refresh token for a new one. Usually the refresh token is checked against a &lt;strong&gt;server-side record&lt;/strong&gt; , so it can be revoked. You have rebuilt a small session, consulted every few minutes instead of every request.&lt;/p&gt;

&lt;p&gt;Two more cautions. If you keep the token where the page's own JavaScript can read it (such as &lt;code&gt;localStorage&lt;/code&gt;), a script-injection bug can steal it. An &lt;code&gt;HttpOnly&lt;/code&gt; cookie stops scripts from reading it, though it then needs protection against forged requests from other sites (&lt;code&gt;SameSite&lt;/code&gt; helps). And on every request the server must check the signature, the expiry and the algorithm it expects. Never accept a token that names its own algorithm as "none".&lt;/p&gt;

&lt;p&gt;So "stateless" never meant the state vanished. The honest version is: &lt;em&gt;JWTs keep state out of the hot path, and in exchange you get weaker, slower revocation.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  #Which should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One web app, one backend, users log in through a browser&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Sessions.&lt;/strong&gt; Simple, easy to revoke, nothing clever to get wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several servers behind a load balancer&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Sessions in a shared store&lt;/strong&gt; , or tokens. Avoid sessions in one server's memory (sticky sessions are a stopgap)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several services that must trust the same login&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Tokens (JWT).&lt;/strong&gt; Each service verifies locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile apps or third-party API clients&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Tokens.&lt;/strong&gt; Cookies are awkward to share across apps and third-party clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You must be able to kick a user out right now&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Sessions&lt;/strong&gt; , or very short tokens with a revocable refresh token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are not sure, start with sessions in a shared store. Move to tokens when you actually feel the pain of sharing login state across services, not because a blog post said tokens are modern.&lt;/p&gt;

&lt;h2&gt;
  
  
  #The takeaway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;HTTP forgets you, so every request needs proof of who you are.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;session&lt;/strong&gt; keeps the memory on the server. A &lt;strong&gt;JWT&lt;/strong&gt; puts the memory in a signed token.&lt;/li&gt;
&lt;li&gt;A session kept in &lt;strong&gt;one server's memory&lt;/strong&gt; breaks the moment a load balancer sends you to a different server.&lt;/li&gt;
&lt;li&gt;Fix it by &lt;strong&gt;moving the state out of the servers&lt;/strong&gt; (a shared store) or &lt;strong&gt;out of the server side entirely&lt;/strong&gt; (a JWT), rather than relying on pinning users to machines.&lt;/li&gt;
&lt;li&gt;"Stateless" does not mean the state disappeared. It means you chose where to keep it, and the moment you need to revoke something, it comes back.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>authentication</category>
      <category>web</category>
      <category>scaling</category>
    </item>
  </channel>
</rss>
