<?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: OPEYEMI OLUWAGBEMIGA</title>
    <description>The latest articles on DEV Community by OPEYEMI OLUWAGBEMIGA (@opeyemi_oluwagbemiga_a213).</description>
    <link>https://dev.to/opeyemi_oluwagbemiga_a213</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%2F2923359%2Fbaae4694-a878-414c-a633-5bff52b9ce5c.png</url>
      <title>DEV Community: OPEYEMI OLUWAGBEMIGA</title>
      <link>https://dev.to/opeyemi_oluwagbemiga_a213</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/opeyemi_oluwagbemiga_a213"/>
    <language>en</language>
    <item>
      <title>OFFSET Pagination Doesn’t Scale. Here’s What Does</title>
      <dc:creator>OPEYEMI OLUWAGBEMIGA</dc:creator>
      <pubDate>Fri, 03 Jul 2026 10:38:27 +0000</pubDate>
      <link>https://dev.to/opeyemi_oluwagbemiga_a213/offset-pagination-doesnt-scale-heres-what-does-1hfp</link>
      <guid>https://dev.to/opeyemi_oluwagbemiga_a213/offset-pagination-doesnt-scale-heres-what-does-1hfp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgp7t60hzk9q1572jvnc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgp7t60hzk9q1572jvnc.png" alt="Figure 1 — Graph showing the comparison between three pagination strategies (Local DB)" width="800" height="265"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1 — Graph showing the comparison between three pagination strategies (Local DB)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You are building a web app, and it comes to a point where you have to list all items (properties, user transactions, documents, etc.), you can’t just throw a huge pile on your user; rather, you paginate, which is a better UX to help them navigate better and find things easily.&lt;/p&gt;

&lt;p&gt;But what comes to the mind of a developer building an application that needs pagination? The old SQL syntax &lt;code&gt;LIMIT&lt;/code&gt; and &lt;code&gt;OFFSET&lt;/code&gt;&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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;FROM&lt;/span&gt; &lt;span class="n"&gt;tracks&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;id&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;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&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;This simply tells the database engine to start from row 20 and fetch me the next 10 music tracks, and let vibe.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LIMIT 10 OFFSET 50000;&lt;/code&gt;&lt;br&gt;
This tells the database engine to start from row 50000 and fetch me the next 10 music tracks, and let vibe.&lt;/p&gt;

&lt;p&gt;I bet at this point neither your user nor your database engine at this point is vibing.&lt;/p&gt;
&lt;h2&gt;
  
  
  THE BOTTLENECK OF TRADITIONAL PAGINATION
&lt;/h2&gt;

&lt;p&gt;A database engine can not teleport straight to row 50,000 and fetch the next 10 rows. What happens is that the database reads the first 50,010 rows and picks the last 10 rows. Can you see how expensive such a query is? 50,010 rows scan just to return 10 rows.&lt;/p&gt;

&lt;p&gt;So as your page number/depth increases, your query execution time increases linearly — O(n). Page 1 loads in 10ms, Page 100 in 170ms, Page 50000 in 1300ms.&lt;/p&gt;
&lt;h2&gt;
  
  
  SOLUTION: CURSOR PAGINATION
&lt;/h2&gt;

&lt;p&gt;This is simply like the real vibe, as each request for a new page takes roughly the same time, irrespective of the page number/depth — O(1). Page 1 and page 50,000 respond in under 10 milliseconds. That flat green line in Figure 1 is cursor pagination refusing to slow down, no matter how deep you go.&lt;/p&gt;

&lt;p&gt;But the truth is that cursor pagination doesn’t really have to do with pages; it has to do with the cursor (this indicates the ID of the last item the user saw).&lt;/p&gt;

&lt;p&gt;To make this work, you need a primary key/ID that is chronologically sortable and strictly unique. BigSerial can handle this for a single server. But Snowflake ID and its variation UUID7 also handle this gracefully for a distributed system (as they embed the exact milliseconds of creation into the ID).&lt;/p&gt;

&lt;p&gt;Because the ID is sorted, the database’s B-Tree index can instantly jump to that exact location on the disk. It skips the 50,000 rows instantly without reading them. The time complexity becomes O(1) — constant, lightning-fast response times, no matter how deep the user scrolls.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Implementation: Raw SQL
&lt;/h3&gt;

&lt;p&gt;Here is the exact architectural blueprint for implementing this purely in SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: The Initial Query (Page 1)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the user first opens the app, you do not have a cursor yet. You just ask for the 10 most recent rows.&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;-- Fetching the first batch of data&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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;FROM&lt;/span&gt; &lt;span class="n"&gt;tracks&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;id&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Your API sends these 10 rows to the frontend. The frontend looks at the id of the very last item in that list (let’s say it is &lt;code&gt;018dc336–1234–7567–89ab-cdef01234567&lt;/code&gt;) and saves it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: The Cursor Query (Next Page)&lt;/strong&gt;&lt;br&gt;
When the user scrolls to the bottom of the screen, the frontend makes a new request and passes that saved ID back to your backend as the cursor.&lt;/p&gt;

&lt;p&gt;Instead of using &lt;code&gt;OFFSET&lt;/code&gt;, you use a &lt;code&gt;WHERE&lt;/code&gt; clause to ask the database for strictly older rows (less than) the cursor if sorting from newest to oldest.&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;-- Fetching the next batch using the cursor&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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;FROM&lt;/span&gt; &lt;span class="n"&gt;tracks&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;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'018dc336–1234–7567–89ab-cdef01234567'&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;id&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this is blazingly fast: The database engine looks at the &lt;code&gt;WHERE id &amp;lt;…&lt;/code&gt;clause. It traverses the B-Tree index, does a direct index seek directly to &lt;code&gt;018dc336–1234–7567–89ab-cdef01234567&lt;/code&gt;, and just scoops up the next 10 blocks of data. Zero discarded rows. Zero wasted I/O.&lt;/p&gt;

&lt;h3&gt;
  
  
  APPLICATION
&lt;/h3&gt;

&lt;p&gt;This is what social media platforms use when you scroll through your feeds, it gives you the illusion of endless scrolling until you hit your very first post. It is also used in bank apps when browsing through transaction history. Discord uses it while you scroll upward for older messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  LIMITATION
&lt;/h3&gt;

&lt;p&gt;Cursor pagination has one rigid constraint, and that is you cannot jump to a specific page. There is no “go to page 40.” You can only go next.&lt;/p&gt;

&lt;p&gt;For infinite scroll interfaces, this is not a limitation at all, as nobody wants to jump to page 40 of their Twitter feed. But for certain use cases, page numbers are genuinely necessary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A financial auditor who needs to jump to a specific date range in a transaction report&lt;/li&gt;
&lt;li&gt;An e-commerce product search page
Do not panic about going to the old friend OFFSET PAGINATION, because there is a better shortcut that still uses &lt;code&gt;OFFSET&lt;/code&gt; but in a more intuitive way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgh70upm4yyuutfejxwkh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgh70upm4yyuutfejxwkh.png" alt="Figure 2 — Amazon search page uses page number" width="800" height="396"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2 — Amazon search page uses page numbers&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  DEFERRED JOIN
&lt;/h2&gt;

&lt;p&gt;The traditional OFFSET PAGINATION reads through 50,010 full rows (each row has id, created_at, audio file url, cover image url, etc.). That’s a lot of fat data to scan and throw.&lt;/p&gt;

&lt;p&gt;But with a deferred join, you force the database engine to scan only the lightweight index, which is the skinny version of your data containing only the ID. Once you have the 10 IDs you actually need, you use an &lt;code&gt;INNER JOIN&lt;/code&gt; to fetch the full row data for just those 10 records.&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;--Step 1: The inner query ONLY reads the lightweight 'id' from the index (50,010 of them).&lt;/span&gt;
 &lt;span class="c1"&gt;-- Step 2: The outer query joins those 10 IDs back to the main table.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;main_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;main_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;main_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cover_image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;main_table&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;FROM&lt;/span&gt; &lt;span class="n"&gt;tracks&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;main_table&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tracks&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;id&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;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;50000&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;skinny_index&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;main_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;skinny_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;main_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;As you can see in Figure 1, deferred join (orange line) is significantly faster than naive offset at deep pages, though it still degrades as pages get deeper, unlike offset pagination, which degrades so badly. It is the ultimate compromise: page numbers for the UX, optimised index scan for the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHICH SHOULD ONE USE
&lt;/h2&gt;

&lt;p&gt;Cursor pagination for infinite scroll, social media feeds, chat history, and notification page&lt;/p&gt;

&lt;p&gt;Deferred join for admin dashboards, search results&lt;/p&gt;

&lt;p&gt;Offset Pagination for demos, small datasets under 100k rows (to prevent over-engineering)&lt;/p&gt;

&lt;p&gt;The chart at the top of this article is not theoretical. It is real query times measured against a 1 million row PostgreSQL transaction table seeded with financial transaction data.&lt;/p&gt;

&lt;p&gt;The local benchmark gives you the clean theoretical curve. But when I deployed the same demo against a live Aiven PostgreSQL instance, something more interesting happened, but at the end of the day, cursor pagination still stays nearly flat while offset shots upward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpa1v41yngb12urs1u5a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpa1v41yngb12urs1u5a.png" alt="Figure 3 — This comparison is from my live demo using Aiven PostgreSQL database" width="800" height="259"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3 — This comparison is from my live demo using Aiven PostgreSQL database&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a live interactive demo so you can see this difference yourself: the three pagination strategies, one million transaction records, and real query times updating as you click next.&lt;/p&gt;

&lt;p&gt;You can interact with the live demo here: &lt;a href="https://cursor-pagination-delta.vercel.app/" rel="noopener noreferrer"&gt;https://cursor-pagination-delta.vercel.app/&lt;/a&gt;&lt;br&gt;
Github Repo: &lt;a href="https://github.com/opesam42/cursor-pagination" rel="noopener noreferrer"&gt;https://github.com/opesam42/cursor-pagination&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you run yours, you might notice offset starts higher on page 1 than expected. That is PostgreSQL’s buffer cache warming, as the first query has to read from disk before the data pages are loaded into memory. Subsequent queries benefit from cached data.&lt;/p&gt;

&lt;p&gt;Author: Gbenga Opeyemi&lt;br&gt;
Website: &lt;a href="https://gbengaopeyemi.vercel.app/" rel="noopener noreferrer"&gt;https://gbengaopeyemi.vercel.app/&lt;/a&gt;&lt;br&gt;
Medium: &lt;a href="https://medium.com/@opesam42" rel="noopener noreferrer"&gt;https://medium.com/@opesam42&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/opesam42" rel="noopener noreferrer"&gt;https://github.com/opesam42&lt;/a&gt;&lt;br&gt;
LinkedIn: &lt;a href="https://www.linkedin.com/in/opeyemi-oluwagbemiga-2ba61423b/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/opeyemi-oluwagbemiga-2ba61423b/&lt;/a&gt;&lt;br&gt;
X: &lt;a href="https://x.com/gbengaopeyemi04" rel="noopener noreferrer"&gt;https://x.com/gbengaopeyemi04&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>software</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Why Standard Indexes Fail: The Architecture of the Covering Index</title>
      <dc:creator>OPEYEMI OLUWAGBEMIGA</dc:creator>
      <pubDate>Sat, 02 May 2026 06:16:39 +0000</pubDate>
      <link>https://dev.to/opeyemi_oluwagbemiga_a213/why-standard-indexes-fail-the-architecture-of-the-covering-index-4g0o</link>
      <guid>https://dev.to/opeyemi_oluwagbemiga_a213/why-standard-indexes-fail-the-architecture-of-the-covering-index-4g0o</guid>
      <description>&lt;p&gt;In my last article, I broke down how and why to use indexes wisely for efficient lookups and data retrieval by identifying fields to index and which not to index. Can read that &lt;a href="https://dev.to/opeyemi_oluwagbemiga_a213/slapping-secondary-indexes-on-random-fields-is-silently-killing-your-database-15p3"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But adding a standard index in some cases is half the battle.&lt;/p&gt;

&lt;p&gt;Adding an index to a column creates a separate, organized B-Tree for that column. And at the bottom of the tree (leaf node) is the pointer. That pointer tells the database engine where the full row lives on the heap. The heap is the main table where the full rows live. It is unsorted, massive, and slow to search as it forces a full-table scan. Indexes help to make data retrieval faster.&lt;/p&gt;

&lt;p&gt;Standard indexes make searching fast, but they introduce a hidden bottleneck. Let’s look at a real-world example to see how and when to use a Covering Index to fix it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5w7yvvdkt210gsazh89w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5w7yvvdkt210gsazh89w.png" alt="Spotify “Recently Played” Screen" width="550" height="524"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Spotify “Recently Played” Screen&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem: The “Recently Played” Feed
&lt;/h2&gt;

&lt;p&gt;Think of any music streaming platform like Spotify or Apple Music. Every time you open the app, it instantly fetches your most recently played tracks, around 10 tracks initially.&lt;/p&gt;

&lt;p&gt;The query looks like this:&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;track_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;played_at&lt;/span&gt; 
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;listen_history&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'018dc336-1234...'&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;played_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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Method 1: No Index
&lt;/h2&gt;

&lt;p&gt;Without an index, the query is forced to do a full-table scan. Let’s say Spotify has 100 million records on its listen_history table, which means for each query.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;100 million rows will be scanned just to isolate this single user’s history&lt;/li&gt;
&lt;li&gt;Then it is sorted in memory.&lt;/li&gt;
&lt;li&gt;And then the top 10 i
s returned.
At scale, this doesn’t just increase response times. This manual RAM sort will choke your disk I/O, exhaust your connection pool, and eventually bring down your application with 500 server errors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5ml0lc9rbp8cho7xw7pp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5ml0lc9rbp8cho7xw7pp.png" alt="Result of using Simple SELECT query" width="800" height="276"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Execution Time roughly 217ms with no index as a result of the Parallel Sequential Scan with 1,000,000 rows scanned. (Nano Banana for sharpening text in the terminal)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Using Standard Index
&lt;/h2&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_user_history&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;listen_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;track_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;played_at&lt;/span&gt; 
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;listen_history&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'018dc336-1234...'&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;played_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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, an index is added on &lt;code&gt;user_id&lt;/code&gt;, and the database engine creates a new B-Tree for this column. So let's say a user has played 5000 different tracks after signing up. No need for a 100 million row scan. It takes &lt;code&gt;O(log n)&lt;/code&gt; time to traverse through the B-Tree to get the records and their pointers (where &lt;code&gt;n&lt;/code&gt; is the number of tracks played by the user).&lt;/p&gt;

&lt;p&gt;Is it faster, right? But here is the hidden catch.&lt;/p&gt;

&lt;p&gt;But the index only contains &lt;code&gt;user_id&lt;/code&gt;. The query is asking for &lt;code&gt;track_id&lt;/code&gt; and &lt;code&gt;played_at&lt;/code&gt;. Because these columns are missing from the index, the database engine must use the pointers to jump from the B-Tree to the main heap to get the missing data.&lt;/p&gt;

&lt;p&gt;You know what that means? 5000 physical jump to the Heap. And after the jump, sorting is done in the memory using the &lt;code&gt;played_at&lt;/code&gt; column, and then it returns the top 10. This physical jump is what’s called the Heap Fetch, and this destroys I/O performance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fettathe3y746p6kwyssr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fettathe3y746p6kwyssr.png" alt="Result of using Standard Indexes" width="800" height="287"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The standard index eliminates the Sequential Scan, but introduces a new bottleneck, which are the implicit Heap Fetch and heap sort in RAM. (Nano Banana for sharpening text in the terminal)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: Covering Index
&lt;/h2&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_user_recent_plays&lt;/span&gt; 
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;listen_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;played_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
&lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;track_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;track_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;played_at&lt;/span&gt; 
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;listen_history&lt;/span&gt; 
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'018dc336-1234...'&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;played_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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look closely at this query:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;user_id&lt;/code&gt; and the &lt;code&gt;played_at DESC&lt;/code&gt; are used to build the core structure of the B-Tree. With this, the data is sorted by &lt;code&gt;user_id&lt;/code&gt; and then by &lt;code&gt;played_at&lt;/code&gt; in descending order&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;INCLUDE&lt;/code&gt; keyword bolts the &lt;code&gt;track_id&lt;/code&gt; to the leaf nodes of the B-Tree&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So when the &lt;code&gt;SELECT&lt;/code&gt; query is run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The database engine jumps to the &lt;code&gt;user_id&lt;/code&gt; in the B-Tree.&lt;/li&gt;
&lt;li&gt;And because the tree is pre-sorted using the &lt;code&gt;played_at&lt;/code&gt;, no need to take all tracks and sorting; all the database engine does is to fetch the first 10 rows as it is exactly the 10 newest tracks.&lt;/li&gt;
&lt;li&gt;It grabs the track_id from the leaf node, and returns the data to the user.
It never jumps to the Heap. It never sorts data in memory. That is why it is called an Index-Only Scan, and it executes at the speed of memory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvi5ujdbiuiw5jg7z9379.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvi5ujdbiuiw5jg7z9379.png" alt="Result of using Covering Index" width="800" height="177"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Covering Index doesn’t require the Heap Fetch and in-memory sorting isn’t required, reducing execution time to a blistering 0.106ms.&lt;br&gt;
The magic. From a 217ms execution time to a 0.106ms execution time&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;NOTE: If you run this exact experiment locally immediately after doing a 1-million-row bulk insert, your initial Index-Only Scan might show a small number of Heap Fetches (e.g., Heap Fetches: 10). This is not a failure of the index. This happens because PostgreSQL hasn't had time to update its Visibility Map. The database checks the heap to ensure that another transaction hasn't deleted those specific 10 rows. Running a manual &lt;code&gt;VACUUM&lt;/code&gt; on the table updates the map and instantly drops the Heap Fetches back down to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  WHEN TO USE COVERING INDEX:
&lt;/h2&gt;

&lt;p&gt;Covering indexes are powerful, but if misused, they can slow down write speeds. They should not be abused.&lt;/p&gt;

&lt;p&gt;Use them strictly for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Highly critical, high-frequency read queries (endpoints hit thousands of times a second).&lt;/li&gt;
&lt;li&gt;Queries that return a small, lightweight columns (Integers, UUIDs, Timestamps, Booleans)
. Never INCLUDE massive text blocks or JSON data.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>sql</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Slapping Secondary Indexes on Random Fields is Silently Killing Your Database.</title>
      <dc:creator>OPEYEMI OLUWAGBEMIGA</dc:creator>
      <pubDate>Tue, 03 Mar 2026 06:17:52 +0000</pubDate>
      <link>https://dev.to/opeyemi_oluwagbemiga_a213/slapping-secondary-indexes-on-random-fields-is-silently-killing-your-database-15p3</link>
      <guid>https://dev.to/opeyemi_oluwagbemiga_a213/slapping-secondary-indexes-on-random-fields-is-silently-killing-your-database-15p3</guid>
      <description>&lt;p&gt;Why do we add indexes to our SQL fields? To make the search faster, right?&lt;/p&gt;

&lt;p&gt;But do you know it has a massive downside? Writes become slower, forcing developers to be strategic about which fields should be labelled as secondary indexes.&lt;/p&gt;

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

&lt;p&gt;What is the magic behind indexes? Let's say we have a table of books called “book” with fields (id, title, author, pub_date, isbn).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT * FROM book WHERE author=”C.S. Lewis”&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This causes the database engine to search through the Heap (a physical unstructured file where all data in the database actually lives). Let's say we have a total of 1 million rows in the table and only 20 Lewis books. The database engine would scan through all the 1 million rows, even when it had gotten the total of 20 Lewis books, just to be sure it didn’t miss any.&lt;/p&gt;

&lt;p&gt;This is a Full Table Scan with &lt;em&gt;O(n)&lt;/em&gt; complexity. It is brutally slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE FIX
&lt;/h2&gt;

&lt;p&gt;When you add the field “author” as a secondary index&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CREATE INDEX idx_author ON book(author);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The database engine creates a separate, highly organized B-Tree structure. It stores the Author names in alphabetical order, paired with a pointer (physical addresses) to where the full row actually lives.&lt;/p&gt;

&lt;p&gt;So instead of scanning through all 1 million rows, the database traverses through the B-Tree in &lt;em&gt;O(log n)&lt;/em&gt; time to get the 20 records and their pointers. It then uses the pointers to get the data it needs from the heap. That’s how secondary indexes make reads faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  BUT WHY DO WRITES GET SLOWER?
&lt;/h2&gt;

&lt;p&gt;Adding or updating data no longer means just changing the heap data, but also the additional B-Tree created by the secondary indexes. So if you blindly add 5 secondary indexes to a table, every single &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; means writing to the Heap plus updating 5 separate B-Trees on the disk.&lt;/p&gt;

&lt;p&gt;That is the hidden cost. As a backend developer, your job isn’t to index everything; instead, your job is to understand the tradeoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  TIPS FOR SECONDARY INDEXES
&lt;/h2&gt;

&lt;p&gt;Do not index fields that are constantly updated, like page visits and view counts. This is to reduce the overhead of the database’s write performance.&lt;/p&gt;

&lt;p&gt;Only index fields that you actively use in your &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;JOIN&lt;/code&gt;, or &lt;code&gt;ORDER BY&lt;/code&gt; clauses.&lt;/p&gt;

</description>
      <category>database</category>
      <category>systemdesign</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
