<?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: Akshay Gupta</title>
    <description>The latest articles on DEV Community by Akshay Gupta (@akshay_gupta).</description>
    <link>https://dev.to/akshay_gupta</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%2F2321240%2Fee51f013-e1fc-4951-adf7-9802e99df96b.jpeg</url>
      <title>DEV Community: Akshay Gupta</title>
      <link>https://dev.to/akshay_gupta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/akshay_gupta"/>
    <language>en</language>
    <item>
      <title>Rebuilding My Music Page: SoundCloud-Inspired Waveforms and Neo-Brutalism</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Tue, 04 Aug 2026 06:20:08 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/rebuilding-my-music-page-soundcloud-inspired-waveforms-and-neo-brutalism-5119</link>
      <guid>https://dev.to/akshay_gupta/rebuilding-my-music-page-soundcloud-inspired-waveforms-and-neo-brutalism-5119</guid>
      <description>&lt;p&gt;In 2025, I wrote about &lt;a href="https://dev.to/blog/building-a-music-showcase-for-my-portfolio-a-developer-s-journey"&gt;building the first version of my portfolio music player&lt;/a&gt;. That player had real-time visualisations, a mini player, a fullscreen mode, a queue, and enough moving parts to feel like a tiny streaming app.&lt;/p&gt;

&lt;p&gt;At the end of that post, I left myself a future idea: build a SoundCloud-inspired waveform that shows the shape of the entire track and lets the listener seek through it.&lt;/p&gt;

&lt;p&gt;Turns out, I had accidentally written my own roadmap. 😄&lt;/p&gt;

&lt;p&gt;The new &lt;a href="https://akshaygupta.live/music" rel="noopener noreferrer"&gt;music page&lt;/a&gt; is a complete visual rebuild around that idea. The waveform is now the centre of the listening experience, the player stays within reach at the bottom of the viewport, and the whole thing speaks the same neo-brutalist language as the rest of this site.&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%2Fjvo1pfql69eg04124ddm.webp" 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%2Fjvo1pfql69eg04124ddm.webp" alt="The rebuilt music page on desktop: the active track expanded into an amber slab with its waveform, the remaining tracks listed below, and the persistent player bar pinned to the bottom of the viewport" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea behind the rebuild
&lt;/h2&gt;

&lt;p&gt;My portfolio has never been just a list of technologies I know. I want it to feel like a small piece of me: engineering, music, experiments, opinions, and the occasional thing I built simply because I wanted it to exist.&lt;/p&gt;

&lt;p&gt;Music and software scratch a similar itch for me. Both begin as an empty timeline. You arrange small pieces, listen or observe, remove what does not belong, and keep iterating until the whole thing feels right. The craft is not in adding the most layers. It is in knowing which layers earn their place.&lt;/p&gt;

&lt;p&gt;That became the ideology behind this rebuild:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The interface should serve the music.&lt;/strong&gt; The waveform needed to be useful, not a decorative animation running in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A personal site should feel personal.&lt;/strong&gt; Dropping in a generic player would work, but it would not say anything about how I design or build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity has to justify itself.&lt;/strong&gt; If a feature makes the page heavier without making listening better, it probably does not belong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure is part of the experience.&lt;/strong&gt; Fast playback, secure URLs, sensible bandwidth use, and graceful fallbacks are UX too, even when nobody sees them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is smaller than the old player in some ways. I removed the fullscreen experience, the mini player, and the live analyser visualiser. In their place is one stronger interaction: the track itself expands into a timeline you can read, click, and scrub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a waveform?
&lt;/h2&gt;

&lt;p&gt;A normal progress bar tells you where you are. A waveform gives you a small map of where you are going.&lt;/p&gt;

&lt;p&gt;You can see the quiet intro, the first drop, the breakdown, and the dense final section before hearing them. That is one of the things I have always liked about SoundCloud's listening experience: time is not represented as an empty line. The shape of the audio becomes part of the interface.&lt;/p&gt;

&lt;p&gt;I borrowed that idea, not the interface around it. My version needed to fit my own track list, queue behaviour, colour system, and responsive layout. When a track is selected, its row opens into a tall waveform with a reflected lower half. Playback progress fills the bars, hovering previews a timestamp, and dragging scrubs through the track. A second, slimmer waveform lives in the persistent player bar.&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%2Fcy5acktphyo4i9lonz6w.webp" 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%2Fcy5acktphyo4i9lonz6w.webp" alt="Close-up of an expanded track showing the waveform seeker: dark played bars, lighter idle bars, the reflected lower half, a hover tooltip reading 1:42, and the elapsed and total time underneath" width="800" height="198"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The music still lives in a private S3 bucket. The server lists the tracks, extracts metadata from the object names, and returns the catalogue to the browser. When a listener selects a track, a separate endpoint returns a one-hour signed CloudFront URL, with an S3 pre-signed URL as the fallback.&lt;/p&gt;

&lt;p&gt;The new part is the waveform data. It follows a parallel path that begins offline instead of in the browser:&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%2Facl5899nqjy1lgpwhuzg.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%2Facl5899nqjy1lgpwhuzg.png" alt="Flowchart" width="798" height="131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are two API responses for a reason. Every track row needs a duration, so those small numbers travel with the initial listing. The much larger peak string is only returned for the selected track, alongside the signed audio URL the player already needs. That keeps the first response lean and avoids adding another request when playback starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating the waveform without making the browser pay for it
&lt;/h2&gt;

&lt;p&gt;My first player used the Web Audio API to draw a live visualiser. That works well when the goal is to show what is playing &lt;em&gt;right now&lt;/em&gt;, but it does not give you the shape of the full track before playback reaches it.&lt;/p&gt;

&lt;p&gt;To draw a complete waveform in the browser, I would need to fetch and decode the entire audio file. The &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element would then stream its own copy for playback. In other words, I would pay for the same track twice in CloudFront egress and make the listener wait for work that never changes.&lt;/p&gt;

&lt;p&gt;The serverless runtime is not a good audio workstation either. It does not ship with a decoder such as ffmpeg, and decoding full tracks on demand would waste compute for a deterministic result.&lt;/p&gt;

&lt;p&gt;So I moved the expensive work offline.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pnpm peaks:generate&lt;/code&gt; script lists every track in S3, downloads up to four at a time, and asks ffmpeg to decode each file into mono, 32-bit float PCM at 11,025 Hz. It then divides the samples into 400 buckets and stores the loudest absolute sample in each bucket.&lt;/p&gt;

&lt;p&gt;The final reduction is roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bucket&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;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bucket&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;samplesInBucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;peaks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;samplesInBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bucket&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;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bucket&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="nx"&gt;quantized&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;peaks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;loudest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quantized&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each amplitude becomes one byte instead of a floating-point number. Those 400 bytes are base64-encoded and committed to &lt;code&gt;track-peaks.json&lt;/code&gt; with the exact duration. It is tiny, deterministic, cacheable data derived from the real track.&lt;/p&gt;

&lt;p&gt;There is also a deliberately boring fallback. If I upload a new track and forget to regenerate the peaks, the player draws a flat placeholder strip. It does not invent a fake waveform. The audio still works, and the missing analysis is obvious enough for me to fix later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing the seeker on canvas
&lt;/h2&gt;

&lt;p&gt;Once the browser receives the base64 string, it converts each byte back into an amplitude between &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;. The waveform component then works out how many bars fit in the available width and samples the 400 buckets across them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;barCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;barWidth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;barCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;amplitudes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;amplitudes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;barCount&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;barHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;availableHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;barCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;played&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;barHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;barWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;barHeight&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;I chose canvas because this is a dense, repeated graphic that redraws as playback and pointer position change. The canvas is scaled using &lt;code&gt;devicePixelRatio&lt;/code&gt; so the bars remain sharp on high-density screens, and a &lt;code&gt;ResizeObserver&lt;/code&gt; redraws it when the viewport or surrounding layout changes.&lt;/p&gt;

&lt;p&gt;The tall row waveform uses the top 68% for the main envelope and mirrors a softer version underneath. The bottom player bar uses the same component with a slimmer configuration and no reflection. One renderer, two presentations.&lt;/p&gt;

&lt;p&gt;Seeking is just a ratio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pointerX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pointer capture keeps drag-scrubbing alive even if the pointer slips outside the canvas. The same ratio positions the hover tooltip and divides the bars into played, hovered, and idle colours.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a click to audible playback
&lt;/h2&gt;

&lt;p&gt;The waveform is the visible star, but the ordinary playback sequence still has to be reliable:&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%2Fvajmdrnu7qfqdee8qtlx.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%2Fvajmdrnu7qfqdee8qtlx.png" alt="Sequence Diagram" width="799" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The React side is split across three hooks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;useAudioPlayback&lt;/code&gt; owns the &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element state, time, duration, volume, mute, and seeking.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useQueueManager&lt;/code&gt; handles the upcoming queue, reordering, and shuffle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useKeyboardShortcuts&lt;/code&gt; maps Space, arrow keys, and &lt;code&gt;M&lt;/code&gt; without hijacking keys from inputs, the track list, or the accessible seeker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bottom player bar and queue drawer render into &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; through portals. They are viewport-level controls, so they should not become trapped by a positioned ancestor or lose their stacking order beneath the fixed navigation.&lt;/p&gt;

&lt;p&gt;Small behaviour choices matter here. Selecting the current track toggles play instead of reloading it. Pressing previous after three seconds restarts the track; near the beginning it goes to the previous one. Volume and the last selected track survive a refresh in &lt;code&gt;localStorage&lt;/code&gt;, but a restored track never starts playing without the listener asking it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neo-brutalism, briefly
&lt;/h2&gt;

&lt;p&gt;The site was moving to a neo-brutalist visual system while I was rebuilding the player, and the music page became a good stress test for it.&lt;/p&gt;

&lt;p&gt;The rules are intentionally strict: square corners, thick ink borders, hard unblurred offset shadows, solid surfaces, uppercase display type, and fast stepped transitions. Signal amber is the main accent; violet marks remixes and alternate states. On the dark theme the shadows flip to white, while on the light theme they become black, so the offset remains visible on both canvases.&lt;/p&gt;

&lt;p&gt;This is more than adding chunky borders after the component is finished. The style changes how the interface communicates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The active track becomes an amber slab and physically steps away from the page on a hard shadow.&lt;/li&gt;
&lt;li&gt;The main play button is the loudest control because it deserves the strongest visual weight.&lt;/li&gt;
&lt;li&gt;Buttons move onto their own shadows when pressed, giving a mechanical response without a soft animation.&lt;/li&gt;
&lt;li&gt;The queue is a drawer with a clear edge, not a floating glass panel.&lt;/li&gt;
&lt;li&gt;Originals and remixes use blunt square labels that remain readable at a glance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI is expressive, but the UX still has to stay quiet. Only the active track expands. Secondary actions stay out of the way. The persistent player keeps the essential controls reachable without turning the page into a dashboard.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&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%2Ffq1ic8spahllkz4y3zod.webp" alt="mobile player" width="429" height="930"&gt;&lt;/th&gt;
&lt;th&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%2F5jnq75t5dldwe5rdxyjr.webp" alt="mobile queue" width="428" height="927"&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The expanded track and compact player bar on mobile&lt;/td&gt;
&lt;td&gt;The queue drawer sliding over the track list&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A canvas is not an accessibility tree
&lt;/h2&gt;

&lt;p&gt;Canvas gave me the rendering model I wanted, but it is invisible to a screen reader and cannot receive keyboard range input by itself. I did not want mouse and touch users to get the real seeker while everyone else got a couple of skip buttons.&lt;/p&gt;

&lt;p&gt;The component therefore includes a native &lt;code&gt;input[type='range']&lt;/code&gt; with the same current time, duration, and seek handler. It stays visually hidden until keyboard focus reaches it, exposes an accessible label, and announces values such as &lt;code&gt;1:24 of 3:52&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The track list also behaves like a listbox: Up and Down move between tracks, Home and End jump to the edges, and Enter or Space selects a track. Global player shortcuts ignore controls that already own those keys. That last detail prevents one Arrow Up press from moving track focus &lt;em&gt;and&lt;/em&gt; changing the volume.&lt;/p&gt;

&lt;p&gt;Accessibility is not a finishing coat. For a custom interaction like this, it has to be part of the component architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I traded away
&lt;/h2&gt;

&lt;p&gt;Precomputed peaks are not free magic. They introduce an offline step whenever the S3 library changes, and 400 buckets cannot capture every tiny transient in a long track. The data represents the envelope, not the exact PCM signal.&lt;/p&gt;

&lt;p&gt;Those are good trade-offs for this page. Four hundred bars are more than the layout can display at most viewport sizes, one byte per bucket is wonderfully small, and the waveform exists to help someone navigate a song—not edit it at sample level.&lt;/p&gt;

&lt;p&gt;I also stopped treating the player as a collection of modes. The old fullscreen and mini experiences were fun to build, but they duplicated controls and created more state to keep in sync. The fixed bottom bar now works on desktop and mobile, while the expanded row gives the selected track the visual space it needs.&lt;/p&gt;

&lt;p&gt;Deleting features can feel less exciting than adding them. In this case, it made the product clearer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precompute deterministic work.&lt;/strong&gt; If the answer only changes when the audio file changes, it does not belong in every listener's browser session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make one request carry its weight.&lt;/strong&gt; Returning peaks with the signed URL made the waveform ready without another network round trip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Borrow interaction ideas, not entire products.&lt;/strong&gt; The SoundCloud-inspired timeline was the useful idea; the surrounding experience still needed to be mine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design systems are behavioural.&lt;/strong&gt; Square shapes and hard shadows matter, but hierarchy, motion, pressed states, and disclosure are what make the style coherent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing code can improve the feature.&lt;/strong&gt; One strong waveform seeker replaced several visualiser modes and made the page easier to understand.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;This rebuild brought the music page closer to what I wanted the portfolio to be in the first place: a place where the engineering does not hide the creative work, and the creative work gives the engineering a reason to exist.&lt;/p&gt;

&lt;p&gt;The waveform is inspired by a familiar listening pattern, but everything around it—the offline peak pipeline, signed delivery, queue, keyboard behaviour, theme-aware canvas, and brutalist interaction language—was shaped for this site.&lt;/p&gt;

&lt;p&gt;You can &lt;a href="https://akshaygupta.live/music" rel="noopener noreferrer"&gt;try the player here&lt;/a&gt; or explore the implementation in the &lt;a href="https://github.com/gupta-akshay/portfolio-v2" rel="noopener noreferrer"&gt;portfolio repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy coding, and happy listening. 🎧&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>ui</category>
      <category>ux</category>
    </item>
    <item>
      <title>Hybrid Search in Elasticsearch: Combining BM25 and kNN with RRF</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Mon, 03 Aug 2026 17:11:58 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/hybrid-search-in-elasticsearch-combining-bm25-and-knn-with-rrf-2k3e</link>
      <guid>https://dev.to/akshay_gupta/hybrid-search-in-elasticsearch-combining-bm25-and-knn-with-rrf-2k3e</guid>
      <description>&lt;p&gt;Semantic search is good at matching meaning. Lexical search is good at matching the words a user actually typed.&lt;/p&gt;

&lt;p&gt;Those strengths overlap, but they are not identical.&lt;/p&gt;

&lt;p&gt;A search for &lt;code&gt;SAML error AADSTS50011&lt;/code&gt; contains an exact identifier that lexical matching should preserve. A search for “I return to the sign-in page after authenticating” may describe the same problem without using the words in the relevant support article. Dense embeddings can help with the second query, while BM25 can be decisive for the first.&lt;/p&gt;

&lt;p&gt;In my earlier article on &lt;a href="https://dev.to/akshay_gupta/building-smart-search-how-embeddings-and-knn-make-search-feel-human-3o45"&gt;embeddings and k-nearest-neighbor search&lt;/a&gt;, I focused on the semantic side. In the &lt;a href="https://dev.to/akshay_gupta/building-a-rag-powered-support-chatbot-in-24-hours-of-hackathon-5f7c"&gt;RAG support chatbot article&lt;/a&gt;, I combined text retrieval with vector scoring. This article takes the next step: produce lexical and semantic candidate lists independently, then fuse their ranks with Reciprocal Rank Fusion, or RRF.&lt;/p&gt;

&lt;p&gt;The examples below target the Elasticsearch 9.x retriever API documented on July 27, 2026. If you operate an 8.x cluster, check the documentation for your exact minor version before copying the request shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why BM25 and kNN belong in the same retrieval pipeline
&lt;/h2&gt;

&lt;p&gt;Elasticsearch uses BM25 as its default text similarity. BM25 scores matches using term frequency, document length normalization, and inverse document frequency. Its configurable &lt;code&gt;k1&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; parameters default to &lt;code&gt;1.2&lt;/code&gt; and &lt;code&gt;0.75&lt;/code&gt; respectively. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity" rel="noopener noreferrer"&gt;The Elasticsearch similarity reference documents these defaults&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That makes lexical search a natural fit for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product names, error codes, ticket IDs, and acronyms&lt;/li&gt;
&lt;li&gt;uncommon terms that carry a lot of meaning&lt;/li&gt;
&lt;li&gt;queries where the exact wording matters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dense-vector kNN search works differently. A model converts the query and each document or passage into vectors, and Elasticsearch retrieves nearby indexed vectors according to the field's configured similarity. The query vector must have the same dimensions and be created by the same embedding model as the document vectors. &lt;a href="https://www.elastic.co/docs/solutions/search/vector/knn" rel="noopener noreferrer"&gt;Elasticsearch documents these requirements in its kNN search guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Semantic retrieval can help when the query and relevant text express similar intent with different words. It also introduces its own failure modes. An embedding may soften the importance of a precise identifier, and two passages can be semantically close without being interchangeable for the user's task.&lt;/p&gt;

&lt;p&gt;Hybrid search is useful because neither retriever has to impersonate the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why lexical-first rescoring is not the same thing
&lt;/h2&gt;

&lt;p&gt;A common first implementation runs a text query, then applies vector similarity through Elasticsearch's &lt;code&gt;rescore&lt;/code&gt; API. That can improve ordering inside a lexical candidate set, but the candidate generation remains one-sided.&lt;/p&gt;

&lt;p&gt;Elasticsearch applies a query rescorer only to the top documents returned by the initial query, as bounded by &lt;code&gt;window_size&lt;/code&gt;. The default rescore window is 10 documents. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/rescore-search-results" rel="noopener noreferrer"&gt;The rescore documentation defines this behavior&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inference:&lt;/strong&gt; a document that is absent from the initial lexical window cannot be introduced by the vector rescorer, even if it would rank highly in an independent semantic search. The vector signal can reorder the lexical candidates, but it cannot recover a semantic-only candidate outside that window.&lt;/p&gt;

&lt;p&gt;RRF starts from a different architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;BM25 produces its own ranked candidate list.&lt;/li&gt;
&lt;li&gt;kNN produces its own ranked candidate list.&lt;/li&gt;
&lt;li&gt;RRF combines the lists into one result set.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This gives both retrieval methods a route into the final ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Reciprocal Rank Fusion works
&lt;/h2&gt;

&lt;p&gt;RRF combines two or more ranked result lists without directly comparing their raw scores. For a document &lt;code&gt;d&lt;/code&gt;, the fused score is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RRF(d) = Σ 1 / (rank_constant + rank_i(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sum includes each child result list in which the document appears. A document near the top of both lists receives contributions from both. A document found by only one retriever can still enter the fused ranking.&lt;/p&gt;

&lt;p&gt;This rank-based approach matters because a BM25 score and a vector similarity score do not share a universal scale. RRF avoids pretending that &lt;code&gt;12.4&lt;/code&gt; from one scoring system is directly comparable with &lt;code&gt;0.83&lt;/code&gt; from another.&lt;/p&gt;

&lt;p&gt;Elasticsearch's RRF retriever exposes &lt;code&gt;rank_constant&lt;/code&gt; and &lt;code&gt;rank_window_size&lt;/code&gt;. The documented default for &lt;code&gt;rank_constant&lt;/code&gt; is 60, and a higher value gives lower-ranked results more influence. &lt;code&gt;rank_window_size&lt;/code&gt; defaults to 10, must be at least the final requested &lt;code&gt;size&lt;/code&gt;, and can improve relevance at an additional performance cost when increased. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever" rel="noopener noreferrer"&gt;The RRF retriever reference specifies these parameters and constraints&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;RRF removes the need to calibrate heterogeneous score ranges, but it does not remove tuning. Candidate depth, filters, retriever weights, and evaluation quality still matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the lexical and vector fields
&lt;/h2&gt;

&lt;p&gt;Here is a minimal index for a support knowledge base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;support-kb&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;span class="nl"&gt;"mappings"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&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;span class="nl"&gt;"source_type"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword"&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;span class="nl"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dense_vector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"dims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"similarity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cosine"&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;span class="p"&gt;}&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;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;The &lt;code&gt;384&lt;/code&gt; dimensions are only an example. Set &lt;code&gt;dims&lt;/code&gt; to the output size of your embedding model and reject vectors created by a different model or model version. Elasticsearch's &lt;code&gt;dense_vector&lt;/code&gt; mapping supports up to 4096 dimensions, indexes vectors by default, and defaults to cosine similarity for non-bit vectors when &lt;code&gt;similarity&lt;/code&gt; is not specified. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector" rel="noopener noreferrer"&gt;The dense-vector mapping reference documents these settings&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I prefer to specify &lt;code&gt;index&lt;/code&gt; and &lt;code&gt;similarity&lt;/code&gt; explicitly anyway. The mapping then records an intentional retrieval decision instead of relying on defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run BM25 and kNN as independent retrievers
&lt;/h2&gt;

&lt;p&gt;The current retriever API lets an RRF retriever contain a standard lexical retriever and a kNN retriever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;support-kb/_search&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;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_source"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"source_type"&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;span class="nl"&gt;"retriever"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"rrf"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"term"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"published"&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;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"retrievers"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"standard"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"multi_match"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cannot sign in after sso redirect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="s2"&gt;"title^3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="s2"&gt;"content"&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;span class="p"&gt;}&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;span class="p"&gt;}&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"knn"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"query_vector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="c"&gt;/* vector from the same embedding model */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"num_candidates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&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;span class="p"&gt;}&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;span class="nl"&gt;"rank_window_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rank_constant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;span class="p"&gt;}&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;The request follows Elastic's documented pattern of placing &lt;code&gt;standard&lt;/code&gt; and &lt;code&gt;knn&lt;/code&gt; child retrievers inside an &lt;code&gt;rrf&lt;/code&gt; retriever. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/retrievers-examples" rel="noopener noreferrer"&gt;Elastic provides the same two-retriever structure in its retriever examples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The numbers are illustrative, not universal recommendations. Their roles are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;size&lt;/code&gt; is the number of final hits requested.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k&lt;/code&gt; controls how many nearest neighbors the kNN retriever returns.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;num_candidates&lt;/code&gt; controls how many approximate vector candidates Elasticsearch considers per shard before selecting the top neighbors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rank_window_size&lt;/code&gt; limits how many results from each child retriever participate in fusion.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rank_constant&lt;/code&gt; controls how quickly a retriever's contribution falls with rank.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the kNN retriever, &lt;code&gt;k&lt;/code&gt; must not exceed &lt;code&gt;num_candidates&lt;/code&gt;. Increasing &lt;code&gt;num_candidates&lt;/code&gt; tends to improve the accuracy of approximate kNN search at a computational cost. The current reference also caps it at 10,000. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/knn-retriever" rel="noopener noreferrer"&gt;The kNN retriever reference documents these constraints&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do not tune one parameter in isolation. A large &lt;code&gt;k&lt;/code&gt; does not help fusion if &lt;code&gt;rank_window_size&lt;/code&gt; truncates the list much earlier, and a deep fusion window is wasted if a child retriever returns too few candidates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply eligibility filters consistently
&lt;/h2&gt;

&lt;p&gt;Access rules, tenant boundaries, language, publication state, and document lifecycle are part of retrieval correctness.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;filter&lt;/code&gt; placed at the RRF level in the example applies to every child retriever. Elasticsearch also prevents combining a top-level &lt;code&gt;query&lt;/code&gt; with &lt;code&gt;retriever&lt;/code&gt; in the same search request. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever" rel="noopener noreferrer"&gt;Both behaviors are documented in the RRF retriever reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Using one shared eligibility filter helps prevent a subtle error: allowing the lexical retriever and vector retriever to search different corpora. If one includes drafts or cross-tenant documents while the other does not, the fused output can be invalid even when the ranking formula is correct.&lt;/p&gt;

&lt;p&gt;For a RAG system, enforce authorization before fusion and before any passage reaches the model. Relevance is never a substitute for access control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with equal influence, then earn every weight
&lt;/h2&gt;

&lt;p&gt;The current RRF retriever supports weights on child retrievers. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/retrievers-examples" rel="noopener noreferrer"&gt;Elastic's retriever examples include weighted RRF requests&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That does not mean a production system should immediately assign &lt;code&gt;2.0&lt;/code&gt; to semantic search because it feels more sophisticated. Begin with equal influence and compare the result against a judged query set. Add weights only when the evidence supports a persistent imbalance.&lt;/p&gt;

&lt;p&gt;A useful evaluation set should contain different retrieval behaviors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exact identifiers, codes, and names&lt;/li&gt;
&lt;li&gt;paraphrased intent&lt;/li&gt;
&lt;li&gt;ambiguous short queries&lt;/li&gt;
&lt;li&gt;long natural-language questions&lt;/li&gt;
&lt;li&gt;queries constrained by tenant, language, or publication state&lt;/li&gt;
&lt;li&gt;queries with no relevant result&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Evaluate BM25 alone, kNN alone, and RRF on the same judgments. Elasticsearch's rank evaluation API supports metrics including precision at k, recall at k, mean reciprocal rank, discounted cumulative gain, normalized discounted cumulative gain, and expected reciprocal rank. &lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval" rel="noopener noreferrer"&gt;The rank evaluation API reference lists the supported metrics&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pick a metric that matches the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use recall at k when the next stage reranks a candidate set and missing a relevant document is expensive.&lt;/li&gt;
&lt;li&gt;Use reciprocal rank when the first relevant result should appear as early as possible.&lt;/li&gt;
&lt;li&gt;Use nDCG when judgments have multiple relevance grades and ordering across the list matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not claim that hybrid search improved relevance because a few hand-picked queries look better. Without explicit judgments and a consistent metric, that is an impression, not a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure retrieval cost as well as relevance
&lt;/h2&gt;

&lt;p&gt;Approximate kNN in Elasticsearch uses indexed vector structures for fast search. Elastic recommends keeping HNSW vector data in the node's page cache for efficient performance. &lt;a href="https://www.elastic.co/docs/solutions/search/vector/knn" rel="noopener noreferrer"&gt;The kNN search guide discusses approximate kNN and page-cache considerations&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Hybrid retrieval runs more work than either child retriever alone, then performs fusion. The actual latency impact depends on index size, shard layout, vector dimensions, candidate counts, filters, hardware, cache state, and concurrency. There is no honest universal overhead number.&lt;/p&gt;

&lt;p&gt;Measure at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;end-to-end search latency at representative concurrency&lt;/li&gt;
&lt;li&gt;latency by query class and filter selectivity&lt;/li&gt;
&lt;li&gt;timeout and error rates&lt;/li&gt;
&lt;li&gt;candidate depth and final result count&lt;/li&gt;
&lt;li&gt;retrieval-quality metrics on a stable judged set&lt;/li&gt;
&lt;li&gt;resource use on data nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use production-like data distributions. A test index that fits comfortably in memory may hide the behavior that dominates a larger deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical rollout sequence
&lt;/h2&gt;

&lt;p&gt;I would introduce RRF in small, observable steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Freeze the embedding contract.&lt;/strong&gt; Record the model, version, dimensions, and preprocessing used for indexed documents and queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build judgments.&lt;/strong&gt; Include lexical wins, semantic wins, hard negatives, and filtered cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Establish two baselines.&lt;/strong&gt; Measure BM25 and kNN independently before measuring fusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add equal-weight RRF.&lt;/strong&gt; Start with candidate depths that are operationally affordable, then vary one family of parameters at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test access filters.&lt;/strong&gt; Verify that every child retriever operates over the same eligible corpus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure under load.&lt;/strong&gt; Compare relevance and latency together, not in separate environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary the change.&lt;/strong&gt; Record which result IDs moved and whether important query classes regressed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If RRF improves one group of queries and hurts another, inspect the judgments before changing weights. The problem may be tokenization, a poor embedding, stale content, an overly broad field, or a filter mismatch. Fusion cannot repair a weak source index automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common implementation mistakes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Adding raw scores together
&lt;/h3&gt;

&lt;p&gt;BM25 and vector similarity scores have different meanings and ranges. A fixed arithmetic blend requires score normalization and careful evaluation. RRF uses ranks instead, which is why it is a practical starting point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Calling lexical rescoring “hybrid recall”
&lt;/h3&gt;

&lt;p&gt;Rescoring a lexical window uses a semantic signal, but it does not create an independent semantic candidate path. Use independent retrieval when semantic-only candidates need a chance to enter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tuning on anecdotes
&lt;/h3&gt;

&lt;p&gt;A few memorable searches are useful debugging cases, not an evaluation set. Keep held-out judgments for reporting after tuning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using inconsistent filters
&lt;/h3&gt;

&lt;p&gt;Apply eligibility rules to every retriever. This is especially important for multi-tenant search and RAG.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expanding every window
&lt;/h3&gt;

&lt;p&gt;Larger &lt;code&gt;num_candidates&lt;/code&gt;, &lt;code&gt;k&lt;/code&gt;, and &lt;code&gt;rank_window_size&lt;/code&gt; can increase work. Increase them only when measured relevance justifies the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural lesson
&lt;/h2&gt;

&lt;p&gt;Hybrid search works best when lexical and semantic retrieval remain independent long enough to contribute their own candidates.&lt;/p&gt;

&lt;p&gt;BM25 protects the value of exact language. kNN adds a path for meaning expressed with different words. RRF turns those rankings into a common decision without inventing a shared score scale.&lt;/p&gt;

&lt;p&gt;The important part is not the formula by itself. It is the discipline around it: one eligible corpus, a stable embedding contract, explicit judgments, current API behavior, and latency measured under realistic conditions.&lt;/p&gt;

&lt;p&gt;That is what turns “we added vector search” into a retrieval system you can reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever" rel="noopener noreferrer"&gt;Elasticsearch: RRF retriever&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/retrievers-examples" rel="noopener noreferrer"&gt;Elasticsearch: Retriever examples&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/solutions/search/vector/knn" rel="noopener noreferrer"&gt;Elasticsearch: kNN search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/knn-retriever" rel="noopener noreferrer"&gt;Elasticsearch: kNN retriever&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector" rel="noopener noreferrer"&gt;Elasticsearch: Dense vector field type&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity" rel="noopener noreferrer"&gt;Elasticsearch: Similarity settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/rescore-search-results" rel="noopener noreferrer"&gt;Elasticsearch: Rescore search results&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval" rel="noopener noreferrer"&gt;Elasticsearch: Search rank evaluation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>elasticsearch</category>
      <category>semanticsearch</category>
      <category>rag</category>
      <category>performance</category>
    </item>
    <item>
      <title>PostgreSQL HOT Updates: When UPDATE Avoids Touching Indexes</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Mon, 20 Jul 2026 06:52:17 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/postgresql-hot-updates-when-update-avoids-touching-indexes-2g0j</link>
      <guid>https://dev.to/akshay_gupta/postgresql-hot-updates-when-update-avoids-touching-indexes-2g0j</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the last article, we saw why an &lt;a href="https://dev.to/akshay_gupta/why-postgresql-index-only-scans-still-hit-the-heap-2a7p"&gt;index-only scan can still fetch from the heap&lt;/a&gt;. This time, let us follow the write path in the opposite direction.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;UPDATE&lt;/code&gt; in PostgreSQL does not overwrite a row in place. Under MVCC, it creates a new row version. That new version may also require fresh entries in every relevant index, even when the application appears to be changing one small field. PostgreSQL has an optimization for avoiding part of that work: &lt;strong&gt;heap-only tuples&lt;/strong&gt;, usually called &lt;strong&gt;HOT updates&lt;/strong&gt;. &lt;a href="https://www.postgresql.org/docs/18/storage-hot.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Heap-Only Tuples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;HOT is useful, but it is not a switch you turn on. It is an outcome that becomes possible when the update and the physical page layout meet two conditions. This article explains those conditions, shows how to measure HOT updates, and gives you a careful way to decide whether &lt;code&gt;fillfactor&lt;/code&gt; or index design deserves attention.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was verified against PostgreSQL 18, the current stable documentation on July 20, 2026. PostgreSQL 19 was still a development version on that date. Check the documentation for your deployed major version before applying changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why an UPDATE Can Become Index Work
&lt;/h2&gt;

&lt;p&gt;PostgreSQL uses multiversion concurrency control so concurrent transactions can see the row versions appropriate to their snapshots. An update therefore adds a new row version to the heap instead of replacing the existing version in place. Without HOT, the new version can also need new index entries, and obsolete row versions and index entries eventually need cleanup. &lt;a href="https://www.postgresql.org/docs/18/storage-hot.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Heap-Only Tuples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Consider a table that separates frequently changing state from stable lookup 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;queue_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fillfactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&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;jobs_queue_status_idx&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue_name&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;attempts&lt;/code&gt; and &lt;code&gt;locked_at&lt;/code&gt; columns are not referenced by either index. An update that changes only those columns may qualify for HOT:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&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;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;Changing &lt;code&gt;status&lt;/code&gt;, however, changes a column referenced by &lt;code&gt;jobs_queue_status_idx&lt;/code&gt;, so that update cannot use HOT for this table:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&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;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 important word is &lt;em&gt;may&lt;/em&gt;. Avoiding indexed columns is necessary, but it is not sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Conditions for HOT
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 18 documents two requirements for a HOT update:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The update must not modify a column referenced by a table index, excluding summarizing indexes. BRIN is the only summarizing index method included in core PostgreSQL.&lt;/li&gt;
&lt;li&gt;The heap page containing the old row must have enough free space for the new row version.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When both conditions hold, PostgreSQL does not need new entries in ordinary indexes for the updated row. Summarizing indexes may still need an update. PostgreSQL can also remove intermediate versions in a HOT chain during normal operation, including &lt;code&gt;SELECT&lt;/code&gt;, when those versions are no longer visible to anyone. &lt;a href="https://www.postgresql.org/docs/18/storage-hot.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Heap-Only Tuples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same-page rule explains why HOT is partly a physical-layout concern. Even an update to a completely unindexed column becomes non-HOT when the new tuple has to move to another heap page.&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%2Fifuajk5h3awor49rr7yb.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%2Fifuajk5h3awor49rr7yb.png" alt="Flowchart showing PostgreSQL’s HOT update decision" width="507" height="926"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is also why adding an index can have a write-side consequence that a read-only review misses. If the new index references a frequently updated column, those updates stop being HOT-eligible. Payload columns added with &lt;code&gt;INCLUDE&lt;/code&gt; are still referenced by the index, so changing one also fails the first HOT condition. That follows directly from PostgreSQL's rule that no column referenced by an index may be modified. &lt;a href="https://www.postgresql.org/docs/18/storage-hot.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Heap-Only Tuples&lt;/a&gt; and &lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Index-Only Scans and Covering Indexes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expression indexes deserve the same review. PostgreSQL stores the result of the expression in the index and recomputes it for inserts and non-HOT updates. If an update changes a column on which an indexed expression depends, it is not HOT-eligible. &lt;a href="https://www.postgresql.org/docs/18/indexes-expressional.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Indexes on Expressions&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What fillfactor Actually Changes
&lt;/h2&gt;

&lt;p&gt;For a table, &lt;code&gt;fillfactor&lt;/code&gt; is a percentage from 10 through 100, and the default is 100. With a lower value, PostgreSQL packs pages only to that percentage during inserts and reserves the remaining space for updated row versions on the same page. This makes HOT updates more likely. The trade-off is lower initial row density, so the table can occupy more pages. &lt;a href="https://www.postgresql.org/docs/18/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: CREATE TABLE storage parameters&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can set it when creating a table:&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;account_balances&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fillfactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or change the storage parameter for future writes:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;account_balances&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fillfactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&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;80&lt;/code&gt; is an example, not a universal target. A table that is rarely updated may benefit more from the default complete packing. A heavily updated table may benefit from reserved page space, but the right value depends on tuple width, update frequency, access patterns, and storage budget. PostgreSQL's documentation explicitly recommends complete packing for never-updated tables and says smaller values can be appropriate for heavily updated tables. &lt;a href="https://www.postgresql.org/docs/18/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: &lt;code&gt;fillfactor&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do not lower &lt;code&gt;fillfactor&lt;/code&gt; just because a table receives updates. First establish whether HOT eligibility is low, whether updates are actually important to the workload, and whether same-page space is the limiting condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure HOT Instead of Guessing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_stat_user_tables&lt;/code&gt; exposes three useful cumulative counters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;n_tup_upd&lt;/code&gt;: total updated rows, including HOT updates and updates whose successor landed on a new page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;n_tup_hot_upd&lt;/code&gt;: updated rows whose successor needed no new index entries.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;n_tup_newpage_upd&lt;/code&gt;: updated rows whose successor landed on a different heap page. PostgreSQL documents these as always non-HOT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-ALL-TABLES-VIEW" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: &lt;code&gt;pg_stat_all_tables&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start with this table-level view:&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;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&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;n_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_hot_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_newpage_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_tup_hot_upd&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;2&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;hot_update_percent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_tup_newpage_upd&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;2&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;newpage_update_percent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'jobs'&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;hot_update_percent&lt;/code&gt; and &lt;code&gt;newpage_update_percent&lt;/code&gt; are derived diagnostic ratios, not built-in PostgreSQL metrics. The table counters are cumulative, so record their values and a timestamp at the beginning and end of the measurement interval, then compare the deltas under the same workload. This remains reliable even when you do not know when the counters were last reset.&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;clock_timestamp&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;observed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_hot_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_newpage_upd&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.jobs'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&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;pg_stat_database.stats_reset&lt;/code&gt; is only a partial sanity check: it reports the most recent database-wide statistics reset, but it is not updated by a relation-specific call such as &lt;code&gt;pg_stat_reset_single_table_counters()&lt;/code&gt;. Do not use it as the start time for these table counters unless you also know that no table-specific reset occurred. &lt;a href="https://www.postgresql.org/docs/18/monitoring-stats.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: cumulative statistics&lt;/a&gt; and &lt;a href="https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADMIN-STATS" rel="noopener noreferrer"&gt;statistics reset functions&lt;/a&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;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stats_reset&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_database&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A low HOT percentage does not by itself identify the cause. It could mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the workload updates indexed columns;&lt;/li&gt;
&lt;li&gt;same-page free space is scarce;&lt;/li&gt;
&lt;li&gt;updated tuples are getting wider;&lt;/li&gt;
&lt;li&gt;the observation window contains different workload phases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;n_tup_newpage_upd&lt;/code&gt; counter helps separate one branch. If it is high, lack of same-page space is a strong lead. If it is low but HOT is also low, review which columns the workload updates and which columns every index references. This is a diagnostic inference from the documented counter definitions, not a guarantee about a specific workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit Indexes Against the Update Path
&lt;/h2&gt;

&lt;p&gt;Start by listing the table's index definitions:&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;indexname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;indexdef&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'jobs'&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;indexname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare those definitions with the columns your application changes most often. The practical question is not simply, "Is this index used?" It is also, "Does this index make a common update ineligible for HOT?"&lt;/p&gt;

&lt;p&gt;That does not mean removing a useful index to chase a higher HOT ratio. An index may be essential for latency, constraints, or operational queries. The goal is to make the trade-off visible. The earlier &lt;a href="https://dev.to/blog/postgresql-indexing-deep-dive-choosing-the-right-index"&gt;indexing deep dive&lt;/a&gt; covers how index shape serves reads; HOT adds the write path to that design review.&lt;/p&gt;

&lt;p&gt;A productive review usually follows this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify the high-volume &lt;code&gt;UPDATE&lt;/code&gt; statements and the columns they change.&lt;/li&gt;
&lt;li&gt;Map those columns to index keys, &lt;code&gt;INCLUDE&lt;/code&gt; columns, predicates, and expressions.&lt;/li&gt;
&lt;li&gt;Observe &lt;code&gt;n_tup_hot_upd&lt;/code&gt; and &lt;code&gt;n_tup_newpage_upd&lt;/code&gt; over a representative interval.&lt;/li&gt;
&lt;li&gt;Change one variable at a time, such as an unnecessary index or a tested table &lt;code&gt;fillfactor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Re-measure application latency, table size, I/O, WAL, and the HOT counters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fourth step matters. A higher HOT percentage is not the final objective; lower total workload cost is.&lt;/p&gt;

&lt;h2&gt;
  
  
  HOT Does Not Remove the Need for VACUUM
&lt;/h2&gt;

&lt;p&gt;HOT reduces index maintenance for eligible updates and lets PostgreSQL prune intermediate HOT-chain versions during normal access when they are no longer visible. It does not make MVCC cleanup or vacuuming optional. PostgreSQL still relies on routine vacuuming to recover or reuse space, maintain visibility information, and prevent transaction ID wraparound. &lt;a href="https://www.postgresql.org/docs/18/storage-hot.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Heap-Only Tuples&lt;/a&gt; and &lt;a href="https://www.postgresql.org/docs/18/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Routine Vacuuming&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;HOT and the visibility map also solve different problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HOT is a write optimization that can avoid new index entries and shorten cleanup of intermediate row versions.&lt;/li&gt;
&lt;li&gt;The visibility map helps an index-only scan decide whether it can skip a heap visibility check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An update, including a HOT update, changes the heap page. That activity can therefore affect whether later index-only scans find the page all-visible. This is one reason to evaluate read and write optimizations as a system instead of maximizing a single counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Small, Evidence-Driven Playbook
&lt;/h2&gt;

&lt;p&gt;For an update-heavy table, use this sequence:&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;-- 1. Establish the table's update mix.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_hot_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_newpage_upd&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.jobs'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Review every index definition.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexdef&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'jobs'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Check the configured table storage options.&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;reloptions&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.jobs'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If common updates touch indexed columns, review whether each index is worth that write cost. If common updates avoid indexed columns but &lt;code&gt;n_tup_newpage_upd&lt;/code&gt; is high, a lower &lt;code&gt;fillfactor&lt;/code&gt; may be worth testing. If the table is read-mostly, preserving page density may matter more than reserving space for rare updates.&lt;/p&gt;

&lt;p&gt;Keep the test representative and do not invent a benchmark target. Compare the same workload before and after, include storage growth in the result, and remember that cumulative counters need a clear observation window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thought
&lt;/h2&gt;

&lt;p&gt;PostgreSQL indexing is not only about making &lt;code&gt;SELECT&lt;/code&gt; faster. Every index also participates in the economics of &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;HOT updates are PostgreSQL's way of avoiding unnecessary index entries when the logical change and physical page layout permit it. The useful workflow is straightforward: measure the update mix, inspect which columns your indexes reference, use &lt;code&gt;n_tup_newpage_upd&lt;/code&gt; to investigate page movement, and treat &lt;code&gt;fillfactor&lt;/code&gt; as a measured trade-off rather than a magic setting.&lt;/p&gt;

&lt;p&gt;The next time an update-heavy table becomes expensive, look beyond the SQL statement. The answer may be sitting in the relationship between its indexes and the free space on one heap page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/storage-hot.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Heap-Only Tuples&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS" rel="noopener noreferrer"&gt;PostgreSQL 18: &lt;code&gt;CREATE TABLE&lt;/code&gt; storage parameters and &lt;code&gt;fillfactor&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-ALL-TABLES-VIEW" rel="noopener noreferrer"&gt;PostgreSQL 18: &lt;code&gt;pg_stat_all_tables&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/indexes-expressional.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Indexes on Expressions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Index-Only Scans and Covering Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Routine Vacuuming&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why PostgreSQL Index-Only Scans Still Hit the Heap</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Sat, 18 Jul 2026 07:47:38 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/why-postgresql-index-only-scans-still-hit-the-heap-2a7p</link>
      <guid>https://dev.to/akshay_gupta/why-postgresql-index-only-scans-still-hit-the-heap-2a7p</guid>
      <description>&lt;p&gt;In the previous posts in this PostgreSQL performance series, we covered &lt;a href="https://dev.to/akshay_gupta/postgresql-query-performance-tuning-tips-3aof"&gt;query tuning&lt;/a&gt;, learned how to &lt;a href="https://dev.to/akshay_gupta/reading-and-interpreting-postgresql-query-plans-a-friendly-guide-4g87"&gt;read query plans&lt;/a&gt;, and walked through &lt;a href="https://dev.to/akshay_gupta/why-postgresql-index-only-scans-still-hit-the-heap-2a7p"&gt;PostgreSQL index types and covering indexes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is one important detail hiding behind all three topics.&lt;/p&gt;

&lt;p&gt;You can build the right covering index, get an &lt;code&gt;Index Only Scan&lt;/code&gt;, and still see PostgreSQL visit the table thousand of times.&lt;/p&gt;

&lt;p&gt;The clue is this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Heap Fetches: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An index-only scan describes what the plan &lt;em&gt;can&lt;/em&gt; return from the index. It does not guarantee that the executor will avoid every heap page. Whether PostgreSQL can skip those heap visits depends heavily on the table's &lt;strong&gt;visibility map&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This article explains that connection and gives you a practical way to diagnose it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was verified against the PostgreSQL 18 documentation, the current stable documentation in July, 2026. Check the matching documentation for your deployed major version before changing any production settings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What an Index-Only Scan Actually Promises
&lt;/h2&gt;

&lt;p&gt;PostgreSQL needs two things before it can chose an index-only scan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The index must support index-only scans.&lt;/li&gt;
&lt;li&gt;Every column required by the query must be available from the index.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;B-tree indexes always support index-only scans. GiST and SP-GiSt support them for some operator classes, while GIN does not because its entries generally cannot reconstruct the complete original value. &lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: Index-Only Scans and Covering Indexes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Consider this 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;SELECT&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;total_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&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;12345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A covering index can provide every referenced column:&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_covering&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;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_amount&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;customer_id&lt;/code&gt; is the search key. &lt;code&gt;total_amount&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; are payload columns stored in the index. They do not participate in the B-tree search or change the uniqueness semantics of a unique index. &lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: &lt;code&gt;INCLUDE&lt;/code&gt; payload columns&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That makes an index-only scan &lt;em&gt;possible&lt;/em&gt;. PostgreSQL still has another question to answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this row version visible to the current transaction?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why Visibility Forces Heap Access
&lt;/h2&gt;

&lt;p&gt;PostgreSQL uses Multi-Version Concurrency Control (MVCC). Different transactions can legitimately see different row versions. Index entries do not store enough visibility information to answer that question on their own. The visibility information lives with the tuple in the heap. &lt;a href="https://www.postgresql.org/docs/18/mvcc.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: Concurrency Control&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fetching every matching heap tuple would defeat much of the benefit of an index-only scan, so PostgreSQL maintains a compact structure called the &lt;strong&gt;visibility map&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Each heap page has two visibility-map bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All-visible&lt;/strong&gt; means every tuple on that page is visible to all current and future transactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All-frozen&lt;/strong&gt; means every tuple on that page has also been frozen, so a future vacuum does not need to revisit the page until it changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The map is conservative. &lt;code&gt;VACUUM&lt;/code&gt; sets visibility bits only when it can prove the condition is true. A data-modifying operation clears the relevant bit when it changes a page. &lt;a href="https://www.postgresql.org/docs/18/storage-vm.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: Visibility Map&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During an index-only scan, PostgreSQL follows this decision:&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%2Focjvcd5xenjrq9yiqxzv.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%2Focjvcd5xenjrq9yiqxzv.png" alt="PostgreSQL index-only scan decision" width="513" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The visibility map is much smaller than the heap it describes, so it is far more likely to remain cached. When the all-visible bit is set, PostgreSQL can avoid the heap access. When it is clear, PostgreSQL must inspect the heap tuple even if every selected column already exists in the index. &lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: How index-only scans use the visibility map&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is why the plan can say &lt;code&gt;Index Only Scan&lt;/code&gt; while &lt;code&gt;Heap Fetches&lt;/code&gt; remain greater than zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reproducible Diagnostic Workflow
&lt;/h2&gt;

&lt;p&gt;Start with the query plan, then work outward. Do not tune autovacuum from a single metric in isolation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Confirm the Plan and Read &lt;code&gt;Heap Fetches&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;EXPLAIN&lt;/code&gt; with runtime and buffer information:&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;total_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&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;12345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Only Scan using idx_orders_customer_covering on orders
  Index Cond: (customer_id = 12345)
  Heap Fetches: &amp;lt;count&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interpret the output carefully:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Index Only Scan&lt;/code&gt; confirms that the index contains the columns required by the query.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Heap Fetches: 0&lt;/code&gt; means all matching tuples were on pages PostgreSQL could trust as all-visible during this execution.&lt;/li&gt;
&lt;li&gt;A nonzero count means PostgreSQL had to visit heap tuples for visibility checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the query under representative load and parameters. One fast execution against a warm cache does not prove that the index is healthy for the full workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Check Table Maintenance and Write Activity
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pg_stat_user_tables&lt;/code&gt; exposes estimated live and dead tuple counts, insert activity since the last vacuum, and timestamps and counts for manual and automatic vacuum runs. &lt;a href="https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-ALL-TABLES-VIEW" rel="noopener noreferrer"&gt;PostgreSQL documentation: &lt;code&gt;pg_stat_all_tables&lt;/code&gt;&lt;/a&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;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&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;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_ins&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_upd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_tup_del&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;n_ins_since_vacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_vacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;vacuum_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This view gives you signals, not a verdict. Its tuple counts are estimates, and cumulative counters can span a long period. Use them to answer practical questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the table receiving frequent updates or deletes?&lt;/li&gt;
&lt;li&gt;Has autovacuum run recently?&lt;/li&gt;
&lt;li&gt;Are inserts accumulating between vacuum runs?&lt;/li&gt;
&lt;li&gt;Does a high-heap-fetch period line up with write bursts?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A write-heavy table can continuously clear all-visible bits. In that case, a covering index may still reduce the amount of data PostgreSQL reads, but a truly heap-free scan may be an unrealistic steady-state goal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Inspect the Visibility Map Directly
&lt;/h3&gt;

&lt;p&gt;For deeper diagnosis, PostgreSQL ships the &lt;code&gt;pg_visibility&lt;/code&gt; extension. It can report the number of all-visible and all-frozen pages recorded in a relation's visibility map. Access is restricted to superusers and roles with the documented statistics privileges. &lt;a href="https://www.postgresql.org/docs/18/pgvisibility.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: &lt;code&gt;pg_visibility&lt;/code&gt;&lt;/a&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;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_visibility&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;pg_visibility_map_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public.orders'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To calculate the share of heap pages currently marked all-visible:&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;WITH&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt; &lt;span class="k"&gt;AS&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;all_visible&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_visibility_map_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public.orders'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt; &lt;span class="k"&gt;AS&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;relpages&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_pages&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.orders'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&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;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;all_visible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;all_visible&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;2&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;all_visible_percent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;heap&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;pg_class.relpages&lt;/code&gt; is a planner estimate updated by &lt;code&gt;VACUUM&lt;/code&gt;, &lt;code&gt;ANALYZE&lt;/code&gt;, and some DDL commands, so treat the percentage as diagnostic rather than exact. &lt;a href="https://www.postgresql.org/docs/18/catalog-pg-class.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: &lt;code&gt;pg_class&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the all-visible percentage is low and heap fetches are high, the visibility map is a strong lead. If it is high but the query still performs poorly, continue investigating selectivity, buffer reads, index size, cache behavior, and the number of rows returned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Manual &lt;code&gt;VACUUM&lt;/code&gt; as a Test, Not the Permanent Fix
&lt;/h2&gt;

&lt;p&gt;A controlled manual vacuum can help confirm the diagnosis:&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;VACUUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then rerun the same &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; and compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Heap Fetches&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;heap blocks read and hit&lt;/li&gt;
&lt;li&gt;total execution time across several representative executions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Routine &lt;code&gt;VACUUM&lt;/code&gt; maintains the visibility map. It also removes dead row versions that are no longer needed and makes their space reusable. PostgreSQL can run ordinary &lt;code&gt;VACUUM&lt;/code&gt; alongside normal reads and writes, although it still creates I/O and CPU load. &lt;code&gt;VACUUM FULL&lt;/code&gt; is a different operation that rewrites the table and takes a much stronger lock, so it is not the routine solution for visibility-map coverage. &lt;a href="https://www.postgresql.org/docs/18/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: Routine Vacuuming&lt;/a&gt; and &lt;a href="https://www.postgresql.org/docs/18/sql-vacuum.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: &lt;code&gt;VACUUM&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If heap fetches fall after &lt;code&gt;VACUUM&lt;/code&gt; and rise again as writes continue, you have learned something useful: the index is capable of serving the query, but the maintenance cadence and workload keep invalidating page visibility.&lt;/p&gt;

&lt;p&gt;Running manual &lt;code&gt;VACUUM&lt;/code&gt; forever is usually the wrong operational answer. The next step is to understand why autovacuum is not keeping pace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tune Autovacuum Per Table, Based on Evidence
&lt;/h2&gt;

&lt;p&gt;Autovacuum decides when to vacuum using a base threshold plus a scale factor tied to table size. In PostgreSQL 18, the defaults for update/delete-triggered vacuuming are &lt;code&gt;autovacuum_vacuum_threshold = 50&lt;/code&gt; and &lt;code&gt;autovacuum_vacuum_scale_factor = 0.2&lt;/code&gt;. These settings can be overridden per table. &lt;a href="https://www.postgresql.org/docs/18/runtime-config-vacuum.html#RUNTIME-CONFIG-AUTOVACUUM" rel="noopener noreferrer"&gt;PostgreSQL 18 documentation: Automatic Vacuuming&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a large table, a percentage-based threshold can still represent many changed rows. If measurements show that vacuum consistently starts too late, lower the table-specific scale factor instead of immediately changing the cluster-wide default:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_scale_factor&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;span class="mi"&gt;02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those values are examples, not universal recommendations. A lower threshold causes more frequent vacuum work. Measure the effect on query latency, I/O, CPU, dead tuples, and autovacuum duration before and after the change.&lt;/p&gt;

&lt;p&gt;You can inspect the effective table options with:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reloptions&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.orders'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a vacuum is running, &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt; reports its current phase and block progress for manual vacuum and autovacuum workers. &lt;a href="https://www.postgresql.org/docs/18/progress-reporting.html#VACUUM-PROGRESS-REPORTING" rel="noopener noreferrer"&gt;PostgreSQL documentation: Vacuum Progress Reporting&lt;/a&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;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;heap_blks_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;heap_blks_scanned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;heap_blks_vacuumed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;index_vacuum_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_progress_vacuum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If vacuum runs frequently but still falls behind, the trigger may not be the only issue. Check whether workers are saturated across many tables, whether cost-based delays are too restrictive for the workload, and whether long-running transactions prevent dead tuples from becoming removable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Misdiagnoses
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "The Planner Chose &lt;code&gt;Index Only Scan&lt;/code&gt;, So the Heap Is Never Read"
&lt;/h3&gt;

&lt;p&gt;The node name describes the data source available to the executor. MVCC visibility can still require heap visits. Read &lt;code&gt;Heap Fetches&lt;/code&gt; before declaring success.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Adding More &lt;code&gt;INCLUDE&lt;/code&gt; Columns Will Remove Heap Fetches"
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;INCLUDE&lt;/code&gt; solves column coverage. It does not solve visibility. Adding payload columns also duplicates data in the index, increases its size, and can slow searches or fail if an index tuple exceeds the index type's size limit. &lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: Covering-index cautions&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  "&lt;code&gt;ANALYZE&lt;/code&gt; Updates the Visibility Map"
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ANALYZE&lt;/code&gt; collects planner statistics. &lt;code&gt;VACUUM&lt;/code&gt; maintains the visibility map. &lt;code&gt;VACUUM (ANALYZE)&lt;/code&gt; performs both operations, but it is the vacuum work that changes visibility-map coverage. &lt;a href="https://www.postgresql.org/docs/18/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL documentation: Routine Vacuuming&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  "High Heap Fetches Always Mean Autovacuum Is Broken"
&lt;/h3&gt;

&lt;p&gt;Freshly modified pages are expected to lose their all-visible status. On a hot table, write activity can clear bits faster than vacuum can set them again. The right conclusion may be that index-only scans are only partially beneficial for this workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Zero Heap Fetches Means the Query Is Fast"
&lt;/h3&gt;

&lt;p&gt;It means the scan avoided heap visibility checks for that execution. The query can still read a large index range, return too many rows, perform expensive work above the scan node, or compete for I/O and CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Checklist
&lt;/h2&gt;

&lt;p&gt;When a covering index does not deliver the result you expected:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; with representative parameters.&lt;/li&gt;
&lt;li&gt;Confirm that the plan uses &lt;code&gt;Index Only Scan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Record &lt;code&gt;Heap Fetches&lt;/code&gt;, returned rows, buffer reads, and execution time.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;pg_stat_user_tables&lt;/code&gt; for write activity and recent vacuum history.&lt;/li&gt;
&lt;li&gt;Inspect all-visible coverage with &lt;code&gt;pg_visibility&lt;/code&gt; when privileges allow it.&lt;/li&gt;
&lt;li&gt;Use a controlled manual &lt;code&gt;VACUUM&lt;/code&gt; to test the visibility-map hypothesis.&lt;/li&gt;
&lt;li&gt;If the evidence supports it, tune autovacuum for that table and monitor the trade-off.&lt;/li&gt;
&lt;li&gt;Reconsider the covering index if the table changes too often for index-only scans to avoid meaningful heap work.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;A covering index is only half of an index-only scan.&lt;/p&gt;

&lt;p&gt;The index must contain the required columns, but PostgreSQL must also prove that each matching tuple is visible to the query. The visibility map lets it make that decision without reading the heap. Write activity clears the map's all-visible bits, and &lt;code&gt;VACUUM&lt;/code&gt; restores them when it is safe.&lt;/p&gt;

&lt;p&gt;So when &lt;code&gt;Heap Fetches&lt;/code&gt; starts climbing, do not immediately add another index. Read the plan, inspect maintenance history, measure visibility-map coverage, and tune vacuum only when the evidence points there.&lt;/p&gt;

&lt;p&gt;That is the difference between creating a covering index and operating one successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/indexes-index-only-scans.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Index-Only Scans and Covering Indexes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/storage-vm.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Visibility Map&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Routine Vacuuming&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/runtime-config-vacuum.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Automatic Vacuuming Configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/pgvisibility.html" rel="noopener noreferrer"&gt;PostgreSQL 18: &lt;code&gt;pg_visibility&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/monitoring-stats.html" rel="noopener noreferrer"&gt;PostgreSQL 18: Cumulative Statistics Views&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/18/progress-reporting.html#VACUUM-PROGRESS-REPORTING" rel="noopener noreferrer"&gt;PostgreSQL 18: Vacuum Progress Reporting&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>I just published Postgres MCP Server in Go!</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Sat, 04 Jul 2026 06:22:20 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/i-just-published-postgres-mcp-server-in-go-10ab</link>
      <guid>https://dev.to/akshay_gupta/i-just-published-postgres-mcp-server-in-go-10ab</guid>
      <description>&lt;p&gt;I open sourced a project I have been building on the side: a Go MCP server that connects Claude Code (or Cursor) directly to a live PostgreSQL database.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/gupta-akshay/postgres-mcp" rel="noopener noreferrer"&gt;github.com/gupta-akshay/postgres-mcp&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem it solves
&lt;/h2&gt;

&lt;p&gt;Most "AI plus database" workflows still look like this: copy SQL out of a chat window, paste it into a DB client, run it, copy the output back. It breaks flow, and the assistant never sees your actual schema, so it guesses.&lt;/p&gt;

&lt;p&gt;MCP fixes the connection problem. This server is what sits on the other end for Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;The server exposes nine tools over MCP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema introspection - real tables, columns, indexes, constraints&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;execute_sql&lt;/code&gt; - run queries directly (read only in restricted mode)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;explain_query&lt;/code&gt; - EXPLAIN ANALYZE, including against a hypothetical index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_top_queries&lt;/code&gt; - pull slow queries from &lt;code&gt;pg_stat_statements&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Index advisors - recommend indexes using a greedy Database Tuning Advisor built on &lt;code&gt;hypopg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;analyze_db_health&lt;/code&gt; - vacuum, XID wraparound, replication lag, invalid indexes, and more, checked in parallel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means you can ask "why is this query slow" and the assistant actually runs the EXPLAIN, checks the stats, and can simulate an index before anyone touches the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Go
&lt;/h2&gt;

&lt;p&gt;The project is inspired by the Python &lt;a href="https://github.com/crystaldba/postgres-mcp" rel="noopener noreferrer"&gt;crystaldba/postgres-mcp&lt;/a&gt;. I rebuilt it from scratch in Go so it ships as a single ~15 MB static binary. No Python runtime, no dependency chasing. &lt;code&gt;docker build&lt;/code&gt;, point Claude Code at it, done.&lt;/p&gt;

&lt;p&gt;Restricted mode wraps every call in a read only transaction, so write protection comes from Postgres itself, not string matching on the query text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to look
&lt;/h2&gt;

&lt;p&gt;The repo has the full setup instructions, the Docker config, and the test suite (unit, integration, and end to end against a real Postgres container with &lt;code&gt;pg_stat_statements&lt;/code&gt; and &lt;code&gt;hypopg&lt;/code&gt;). CI fails under 95% coverage.&lt;/p&gt;

&lt;p&gt;If you spend real time in Claude Code or Cursor and also spend real time worrying about Postgres performance, take a look: &lt;a href="https://github.com/gupta-akshay/postgres-mcp" rel="noopener noreferrer"&gt;github.com/gupta-akshay/postgres-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote up the build in more depth on my &lt;a href="https://www.akshaygupta.live/blog/postgres-mcp-server" rel="noopener noreferrer"&gt;blog&lt;/a&gt; and on &lt;a href="https://dev.to/akshay_gupta/postgres-mcp-in-go-giving-claude-code-a-live-line-to-your-database-1m7m"&gt;dev.to&lt;/a&gt; if you want the architecture and testing details.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mcp</category>
      <category>go</category>
      <category>ai</category>
    </item>
    <item>
      <title>PostgreSQL Indexing Deep Dive - Choosing the Right Index</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Sun, 21 Jun 2026 06:49:29 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/postgresql-indexing-deep-dive-choosing-the-right-index-239c</link>
      <guid>https://dev.to/akshay_gupta/postgresql-indexing-deep-dive-choosing-the-right-index-239c</guid>
      <description>&lt;p&gt;In the earlier posts of this series, we looked at &lt;a href="https://dev.to/akshay_gupta/postgresql-query-performance-tuning-tips-3aof"&gt;practical query tuning tips&lt;/a&gt; and how to &lt;a href="https://dev.to/akshay_gupta/reading-and-interpreting-postgresql-query-plans-a-friendly-guide-4g87"&gt;read and interpret query plans&lt;/a&gt;. A recurring theme in both was: "add an index here." But "add an index" is a bit like saying "use the right tool" — the interesting part is &lt;em&gt;which&lt;/em&gt; one.&lt;/p&gt;

&lt;p&gt;PostgreSQL ships with several index types, each tuned for a different kind of data and query. Picking the wrong one means PostgreSQL quietly ignores your index and goes back to a sequential scan. In this post, we'll walk through the main index types, when each shines, and the special index variations (composite, partial, covering, expression) that often matter more than the type itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting the Scene: Schema and Sample Data
&lt;/h2&gt;

&lt;p&gt;We'll reuse the same schema from the previous posts, with one small addition — a &lt;code&gt;metadata&lt;/code&gt; JSONB column and a &lt;code&gt;tags&lt;/code&gt; array on &lt;code&gt;orders&lt;/code&gt;, so we can explore the more exotic index types.&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&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;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers&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;order_date&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;total_amount&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Insert sample customers&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_name&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;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'Customer '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'customer'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'@example.com'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000000&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;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Insert sample orders&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_amount&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;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;INT&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;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'gift'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'priority'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fragile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bulk'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]],&lt;/span&gt;
  &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'channel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'web'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mobile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RANDOM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000000&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;s&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Remember to run &lt;code&gt;ANALYZE&lt;/code&gt; after a big bulk load. Without fresh statistics, the planner is guessing, and it may skip an index you just built.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How PostgreSQL Decides to Use an Index
&lt;/h2&gt;

&lt;p&gt;One thing to get straight first: &lt;strong&gt;an index is an offer, not a command.&lt;/strong&gt; PostgreSQL's planner compares the estimated cost of using an index against a sequential scan and picks the cheaper one. An index on a low-selectivity column (one where most rows match) is often &lt;em&gt;slower&lt;/em&gt; than just reading the whole table, because random index lookups plus heap fetches cost more than one big sequential read.&lt;/p&gt;

&lt;p&gt;So the question isn't "should this column have an index?" but "does my query filter to a small enough slice that an index lookup beats a scan?"&lt;/p&gt;

&lt;h2&gt;
  
  
  B-tree: The Default Workhorse
&lt;/h2&gt;

&lt;p&gt;If you create an index without specifying a type, you get a B-tree. It's the right choice the overwhelming majority of the time. B-trees handle equality (&lt;code&gt;=&lt;/code&gt;) and range (&lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;lt;=&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;gt;=&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;) queries, &lt;code&gt;ORDER BY&lt;/code&gt;, and &lt;code&gt;IN&lt;/code&gt; lists — anything where data has a natural sort 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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&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;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;12345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;using&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;cost&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;span class="mi"&gt;42&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;46&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&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;span class="mi"&gt;045&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;053&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&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;12345&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;Planning&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;824&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;067&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;B-trees also power range scans and sorted output. This query can read straight from the index in order, skipping a sort step entirely:&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;12345&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;order_date&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;&lt;strong&gt;Use B-tree for&lt;/strong&gt;: scalar columns (integers, text, timestamps, numerics), equality and range filters, sorting, and primary/foreign keys. When in doubt, it's a B-tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composite (Multicolumn) Indexes and Column Order
&lt;/h2&gt;

&lt;p&gt;When you frequently filter on more than one column together, a composite index can serve the whole predicate at once.&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_date&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;order_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;12345&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch — and it trips up a lot of people — is &lt;strong&gt;column order matters&lt;/strong&gt;. A composite index on &lt;code&gt;(customer_id, order_date)&lt;/code&gt; can be used for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;WHERE customer_id = 12345&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WHERE customer_id = 12345 AND order_date &amp;gt; '2024-01-01'&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…but it is &lt;strong&gt;much&lt;/strong&gt; less useful for &lt;code&gt;WHERE order_date &amp;gt; '2024-01-01'&lt;/code&gt; alone, because &lt;code&gt;order_date&lt;/code&gt; is the &lt;em&gt;second&lt;/em&gt; column. Think of a phone book sorted by (last name, first name): great for finding "Smith, John", useless for finding everyone named "John".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb&lt;/strong&gt;: put the column(s) you filter by equality first, and the column(s) you filter by range (or sort by) last. This is sometimes called the "equality first, range last" principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Covering Indexes with &lt;code&gt;INCLUDE&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;An index-only scan (covered in the tuning post) lets PostgreSQL answer a query entirely from the index without touching the table. You can extend this with &lt;code&gt;INCLUDE&lt;/code&gt; columns — extra payload stored in the index leaf nodes that isn't part of the searchable key.&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_covering&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;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_amount&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;/code&gt;&lt;/pre&gt;

&lt;/div&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="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&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;12345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="k"&gt;Only&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_covering&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;cost&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;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;46&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;746&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;750&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&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;12345&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Fetches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;Planning&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;660&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Heap Fetches: 0&lt;/code&gt; line is the prize — PostgreSQL never visited the table. The difference between &lt;code&gt;INCLUDE&lt;/code&gt; and just adding the columns to the key is that &lt;code&gt;INCLUDE&lt;/code&gt; columns don't bloat the searchable B-tree structure and don't have to be sortable, but they're available to satisfy &lt;code&gt;SELECT&lt;/code&gt; lists.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Heap Fetches&lt;/code&gt; rely on the visibility map being up to date. If you see a high heap-fetch count on an index-only scan, the table likely needs a &lt;code&gt;VACUUM&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Partial Indexes: Index Only What You Query
&lt;/h2&gt;

&lt;p&gt;If your queries always target a subset of rows, a partial index covers just that slice — smaller on disk, cheaper to maintain, and faster to scan.&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_pending&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;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;12345&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;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL can use this index only when the query's &lt;code&gt;WHERE&lt;/code&gt; clause implies the index predicate (here, &lt;code&gt;status = 'pending'&lt;/code&gt;). Because the index holds roughly a quarter of the rows, it's a fraction of the size of a full index — a big win on large, skewed tables where you only ever query the "hot" rows (active records, unprocessed jobs, undeleted rows, etc.).&lt;/p&gt;

&lt;h2&gt;
  
  
  Expression (Functional) Indexes
&lt;/h2&gt;

&lt;p&gt;A plain index on a column can't help a query that wraps that column in a function — the function defeats the index. This is one of the most common "why isn't my index used?" mysteries:&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;-- This will NOT use a plain index on email&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="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;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'customer42@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to index the &lt;em&gt;expression&lt;/em&gt; itself:&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_customers_lower_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&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;Now the same query uses the index. The rule: index the exact expression your queries use. This applies to date truncation (&lt;code&gt;date_trunc('day', order_date)&lt;/code&gt;), casts, concatenations, and any deterministic function.&lt;/p&gt;

&lt;h2&gt;
  
  
  GIN: Indexing "Many Values per Row"
&lt;/h2&gt;

&lt;p&gt;B-trees assume one comparable value per column. But what about a JSONB document, an array, or a full-text document where a single row contains &lt;em&gt;many&lt;/em&gt; searchable values? That's where &lt;strong&gt;GIN (Generalized Inverted Index)&lt;/strong&gt; comes in. It builds an inverted map from each contained element back to the rows holding it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSONB containment:&lt;/strong&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_metadata&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;metadata&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"channel": "mobile"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Scan&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;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2307&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;21143&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;330200&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;46&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;274&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;213&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;945&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;334109&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;Recheck&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"channel": "mobile"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;14709&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;idx_orders_metadata&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&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;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;2224&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;330200&lt;/span&gt; &lt;span class="n"&gt;width&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;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;965&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;965&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;334109&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"channel": "mobile"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;Planning&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;609&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;219&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;956&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Array membership:&lt;/strong&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_tags&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;tags&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'priority'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Scan&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;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1691&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;19513&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;83&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;249067&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;086&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;249923&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;Recheck&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{priority}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="n"&gt;Heap&lt;/span&gt; &lt;span class="n"&gt;Blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;14709&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;Bitmap&lt;/span&gt; &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;idx_orders_tags&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&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;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;1629&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;249067&lt;/span&gt; &lt;span class="n"&gt;width&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;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;291&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;291&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;249923&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{priority}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="n"&gt;Planning&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;993&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;67&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;231&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Full-text search&lt;/strong&gt; (with &lt;code&gt;tsvector&lt;/code&gt;) and &lt;strong&gt;trigram search&lt;/strong&gt; (with the &lt;code&gt;pg_trgm&lt;/code&gt; extension, great for &lt;code&gt;LIKE '%foo%'&lt;/code&gt; and fuzzy matching) also rely on GIN:&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&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;idx_customers_name_trgm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%Customer 99%'&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;Use GIN for&lt;/strong&gt;: JSONB, arrays, full-text search, and trigram/&lt;code&gt;LIKE&lt;/code&gt; matching. The trade-off is that GIN indexes are slower to update and larger than B-trees, so they suit read-heavy, search-style workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  GiST: Geometry, Ranges, and Nearest-Neighbour
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GiST (Generalized Search Tree)&lt;/strong&gt; is a framework for indexing data that doesn't fit a linear order — geometric shapes, ranges, and "distance" queries. If you use PostGIS for spatial data, you're using GiST. It also handles range types and the &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; (overlap) operator.&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;-- Suppose orders had a valid_period range column&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;idx_orders_period&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_period&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;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;valid_period&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'[2024-01-01, 2024-02-01)'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;tstzrange&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GiST also enables exclusion constraints (e.g. "no two bookings can overlap for the same room") and &lt;code&gt;ORDER BY location &amp;lt;-&amp;gt; point&lt;/code&gt; nearest-neighbour queries. For the trigram case above, GiST is an alternative to GIN. GIN searches faster but builds slower. GiST is the reverse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use GiST for&lt;/strong&gt;: geometric/spatial data (PostGIS), range overlap queries, exclusion constraints, and nearest-neighbour search.&lt;/p&gt;

&lt;h2&gt;
  
  
  SP-GiST: Space-Partitioned Trees for Clustered Data
&lt;/h2&gt;

&lt;p&gt;GiST has a sibling: &lt;strong&gt;SP-GiST (Space-Partitioned GiST)&lt;/strong&gt;. Where GiST builds balanced trees, SP-GiST builds non-balanced ones — quadtrees, k-d trees, and radix trees (tries). The idea is to repeatedly split the search space into partitions that don't have to be the same size. That fits data which clusters into non-overlapping regions: 2D points, IP address ranges, and text that shares common prefixes.&lt;/p&gt;

&lt;p&gt;The everyday win is prefix matching on text with the &lt;code&gt;^@&lt;/code&gt; (starts-with) operator:&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_customers_email_spgist&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;SPGIST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="n"&gt;text_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;^@&lt;/span&gt; &lt;span class="s1"&gt;'customer999'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;text_ops&lt;/code&gt; class also supports the ordinary comparison operators (&lt;code&gt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;), so the same index serves range and sort queries. For points there are two classes — &lt;code&gt;quad_point_ops&lt;/code&gt; (a quadtree, the default) and &lt;code&gt;kd_point_ops&lt;/code&gt; (a k-d tree) — and both support k-nearest-neighbour &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; ordering, just like GiST. The &lt;code&gt;inet_ops&lt;/code&gt; class indexes &lt;code&gt;inet&lt;/code&gt;/&lt;code&gt;cidr&lt;/code&gt; columns with the network containment operators (&lt;code&gt;&amp;lt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;So when do you pick SP-GiST over GiST? When your data partitions cleanly and the partitions don't overlap — points scattered on a map, IP subnets, strings walking down a prefix tree. GiST handles overlapping data (like bounding boxes that intersect); SP-GiST is built for the non-overlapping case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use SP-GiST for&lt;/strong&gt;: non-overlapping geometric data (points, quadtrees/k-d trees), &lt;code&gt;inet&lt;/code&gt;/&lt;code&gt;cidr&lt;/code&gt; network ranges, and text prefix matching.&lt;/p&gt;

&lt;h2&gt;
  
  
  BRIN: Tiny Indexes for Naturally Ordered Data
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;BRIN (Block Range Index)&lt;/strong&gt; is the lightweight outlier. Instead of indexing every row, it stores the min/max value for each &lt;em&gt;block range&lt;/em&gt; of the table. This makes BRIN indexes tiny — often kilobytes where a B-tree would be gigabytes — but they only help when the column's values are &lt;strong&gt;physically correlated with their storage order&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The classic fit is an append-only &lt;code&gt;order_date&lt;/code&gt; on a table where rows are inserted in date 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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_date_brin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;BRIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&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;order_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-30'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because rows from June are clustered together on disk, BRIN can skip every block range outside that window. If the data is &lt;em&gt;not&lt;/em&gt; correlated (e.g. &lt;code&gt;customer_id&lt;/code&gt;, which is random in our sample), BRIN is useless — it can't rule out any block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use BRIN for&lt;/strong&gt;: huge, append-only tables where the indexed column tracks insert order (timestamps, sequential IDs, log data). It trades a bit of precision for a massive size saving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash Indexes: Equality-Only
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hash indexes&lt;/strong&gt; support only the &lt;code&gt;=&lt;/code&gt; operator — no ranges, no sorting. Since PostgreSQL 10 they're crash-safe and replicated (before that they were best avoided). For simple equality on a large column, a hash index can be slightly smaller than a B-tree.&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_status_hash&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;HASH&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, a B-tree handles equality just as well &lt;em&gt;and&lt;/em&gt; supports ranges and sorting, so hash indexes are a niche choice. Reach for one only when you've measured a real benefit on equality-only lookups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the Right Index — A Cheat Sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Index Type&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Operators&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B-tree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalars, ranges, sorting (the default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;=&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;, &lt;code&gt;IN&lt;/code&gt;, &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONB, arrays, full-text, trigram &lt;code&gt;LIKE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@&amp;gt;&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, &lt;code&gt;@@&lt;/code&gt;, &lt;code&gt;ILIKE&lt;/code&gt; (with &lt;code&gt;pg_trgm&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GiST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Geometry, ranges, nearest-neighbour&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt;, overlap, exclusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SP-GiST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Non-overlapping points, IP ranges, text prefixes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;^@&lt;/code&gt;, &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;@&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BRIN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Huge append-only, correlated columns&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;=&lt;/code&gt;, range (block-level)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Equality-only on large columns&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Layer the variations on top: make it &lt;strong&gt;composite&lt;/strong&gt; if you filter on multiple columns, &lt;strong&gt;partial&lt;/strong&gt; if you only query a subset, &lt;strong&gt;covering&lt;/strong&gt; (&lt;code&gt;INCLUDE&lt;/code&gt;) to enable index-only scans, and &lt;strong&gt;expression&lt;/strong&gt;-based if your queries wrap the column in a function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost of Indexes: They Aren't Free
&lt;/h2&gt;

&lt;p&gt;Every index speeds up reads but slows down writes — each &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; has to maintain every index on the table. Indexes also consume disk and memory, and they can bloat over time just like tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find unused indexes&lt;/strong&gt; so you can drop the dead weight:&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;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table&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;AS&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;times_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&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;index_size&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&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 with &lt;code&gt;idx_scan = 0&lt;/code&gt; after a representative period is a strong candidate for removal — it's pure write overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find bloated / rebuild indexes&lt;/strong&gt;: heavy update/delete churn leaves indexes bloated. Rebuild without locking out writes using:&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;REINDEX&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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Always prefer &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; and &lt;code&gt;REINDEX ... CONCURRENTLY&lt;/code&gt; in production. They avoid the heavy locks that would otherwise block writes for the duration of the build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Indexing is where a lot of PostgreSQL performance lives, but it's not about indexing everything — it's about matching the index to the shape of your data and your queries. Start with a B-tree, reach for GIN/GiST/BRIN when your data outgrows a simple sort order, and use the composite/partial/covering/expression variations to make each index pull its weight. Then verify with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; (from the previous post) that the planner actually takes the offer — and prune the indexes that never get used.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL Documentation: &lt;a href="https://www.postgresql.org/docs/17/indexes.html" rel="noopener noreferrer"&gt;Indexes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL Documentation: &lt;a href="https://www.postgresql.org/docs/17/indexes-types.html" rel="noopener noreferrer"&gt;Index Types&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://use-the-index-luke.com/" rel="noopener noreferrer"&gt;Use The Index, Luke!&lt;/a&gt; — still the best free resource on how indexes actually work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy indexing! 🎉&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Postgres MCP in Go - Giving Claude Code a Live Line to Your Database</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Tue, 21 Apr 2026 06:36:13 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/postgres-mcp-in-go-giving-claude-code-a-live-line-to-your-database-1m7m</link>
      <guid>https://dev.to/akshay_gupta/postgres-mcp-in-go-giving-claude-code-a-live-line-to-your-database-1m7m</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Copy-pasting SQL from a chat window into a DB client and back again is how most "AI + database" workflows actually feel. 🙃 It breaks flow, loses context, and the assistant never sees your real schema - so it guesses.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; fixes that by letting an AI assistant speak to tools over a standard channel. This project is a Go MCP server that plugs Claude Code (or Cursor) directly into a live PostgreSQL database. Ask &lt;em&gt;"why is this query slow?"&lt;/em&gt; and the assistant can actually run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, peek at &lt;code&gt;pg_stat_statements&lt;/code&gt;, and even simulate an index with &lt;code&gt;hypopg&lt;/code&gt; before you ever touch the schema.&lt;/p&gt;

&lt;p&gt;It's inspired by the excellent Python &lt;a href="https://github.com/crystaldba/postgres-mcp" rel="noopener noreferrer"&gt;crystaldba/postgres-mcp&lt;/a&gt;, reimplemented from scratch in Go so the whole thing ships as a single ~15 MB static binary - no Python runtime, no CGo, no system libs to chase down.&lt;/p&gt;

&lt;p&gt;Check it out on Github - &lt;a href="http://github.com/gupta-akshay/postgres-mcp" rel="noopener noreferrer"&gt;http://github.com/gupta-akshay/postgres-mcp&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it can do
&lt;/h2&gt;

&lt;p&gt;The server exposes nine tools over MCP. The assistant picks whichever fits the question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What the AI can do with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;list_schemas&lt;/code&gt; / &lt;code&gt;list_objects&lt;/code&gt; / &lt;code&gt;get_object_details&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Understand your real tables, columns, indexes, constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;execute_sql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run arbitrary SQL (read-only when started in restricted mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;explain_query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EXPLAIN (ANALYZE)&lt;/code&gt; a query, optionally with a hypothetical index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_top_queries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pull the slowest queries from &lt;code&gt;pg_stat_statements&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_workload_indexes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Recommend indexes from real workload via a DTA greedy algorithm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_query_indexes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same, but for an explicit list of queries you care about&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_db_health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parallel health checks: vacuum, XID wraparound, connections, sequences, replication lag, buffer cache, invalid indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Translated into actual conversations, that means you can just say things like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Show me the 10 slowest queries right now, then simulate adding an index on &lt;code&gt;orders(user_id, status)&lt;/code&gt; and tell me if it would help."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;…and the assistant does the work instead of handing you a snippet to paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&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%2F3xqi0j0pl6rez9jsui6z.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%2F3xqi0j0pl6rez9jsui6z.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zooming into the server itself, each layer has one job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transport&lt;/strong&gt; - two MCP transports are supported. &lt;code&gt;stdio&lt;/code&gt; is the default: the assistant spawns the binary per session, which is great for local dev. &lt;code&gt;SSE&lt;/code&gt; runs the same binary as a long-lived HTTP server so multiple clients can share it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool registry&lt;/strong&gt; - each of the nine tools is registered with a typed input schema, so the assistant sees proper parameter hints and the server rejects malformed calls before they hit Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB layer&lt;/strong&gt; - a thin &lt;code&gt;Querier&lt;/code&gt; interface wraps a &lt;code&gt;pgxpool.Pool&lt;/code&gt;. Everything else in the codebase depends on the interface, not the pool, which is what makes the unit tests fast and deterministic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access modes&lt;/strong&gt; - &lt;code&gt;unrestricted&lt;/code&gt; for local hacking, &lt;code&gt;restricted&lt;/code&gt; for anything shared. Restricted mode wraps every call in a &lt;code&gt;READ ONLY&lt;/code&gt; transaction, so write protection is enforced by Postgres itself, not by hopeful string matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index advisor&lt;/strong&gt; - the &lt;code&gt;analyze_*_indexes&lt;/code&gt; tools implement a greedy Database Tuning Advisor: enumerate candidate indexes, ask the planner (via &lt;code&gt;hypopg&lt;/code&gt;) how much each one would save, keep the winner, repeat until cost stops dropping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks&lt;/strong&gt; - seven independent checks run in parallel goroutines and merge into a single report, so &lt;code&gt;analyze_db_health&lt;/code&gt; feels instant even against a real database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical tool call looks like this on the wire:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;assistant&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;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"explain_query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"sql"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SELECT * FROM orders WHERE user_id = $1 AND status = 'open'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hypothetical_indexes"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;]&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;span class="p"&gt;]&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;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;The server runs the &lt;code&gt;EXPLAIN&lt;/code&gt; inside a transaction that creates the hypothetical index, captures the plan, rolls back, and returns JSON the assistant can reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it locally
&lt;/h2&gt;

&lt;p&gt;Ship artifact is a single Docker image, so trying it out is basically three commands:&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;# 1. Build the image (static binary on Alpine, ~15 MB)&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; postgres-mcp:latest &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# 2. Point Claude Code at it - ~/.claude/settings.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--rm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"-e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DATABASE_URI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"-e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACCESS_MODE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--add-host=host.docker.internal:host-gateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"postgres-mcp:latest"&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;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"DATABASE_URI"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgresql://user:pass@host.docker.internal:5432/mydb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ACCESS_MODE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unrestricted"&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;span class="p"&gt;}&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;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3. Restart Claude Code - MCP servers are loaded at startup only.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For shared setups the same image runs as a long-lived SSE server behind &lt;code&gt;docker compose&lt;/code&gt;, and clients just point at &lt;code&gt;http://host:8000/sse&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two Postgres extensions make everything sing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pg_stat_statements&lt;/code&gt; - unlocks &lt;code&gt;get_top_queries&lt;/code&gt; and &lt;code&gt;analyze_workload_indexes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hypopg&lt;/code&gt; - lets &lt;code&gt;explain_query&lt;/code&gt; and both index advisors simulate an index without actually creating it. This is the magic bit: you get real planner cost numbers for an index that never existed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without these the server still works, it just turns off the tools that depend on them and tells the assistant why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing strategy
&lt;/h2&gt;

&lt;p&gt;This part was honestly as fun as the server itself. The suite is split three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit&lt;/strong&gt; - every sub-package tested against a &lt;code&gt;MockQuerier&lt;/code&gt; double. Pure Go, no Docker, runs in under a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration&lt;/strong&gt; - the real &lt;code&gt;pgx&lt;/code&gt; driver against a live Postgres, covering connection modes, restricted-mode write blocking, and JSON column decoding edge cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End-to-end&lt;/strong&gt; - every one of the nine MCP tools driven over the actual MCP protocol through an in-process client, against a Postgres container that has &lt;code&gt;pg_stat_statements&lt;/code&gt; and &lt;code&gt;hypopg&lt;/code&gt; baked in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CI fails the build if overall coverage drops below 95%. A &lt;code&gt;make test-db-up&lt;/code&gt; target spins up a disposable Postgres on port 5433 (so it doesn't fight your host DB on 5432), and &lt;code&gt;make test-integration&lt;/code&gt; wires the whole thing together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interfaces at the DB boundary pay for themselves fast.&lt;/strong&gt; The &lt;code&gt;Querier&lt;/code&gt; abstraction made the mock-based unit tests trivial and kept handler logic independent of pgx details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planner-assisted index advice is surprisingly powerful.&lt;/strong&gt; A greedy loop over &lt;code&gt;hypopg&lt;/code&gt; gets you recommendations that feel magical, because the planner is doing the hard thinking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static Go binaries are still underrated for tooling.&lt;/strong&gt; No Python env drift, no &lt;code&gt;pip install&lt;/code&gt;, no Node version matrix. &lt;code&gt;docker run&lt;/code&gt; and it just works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP is a nice little protocol.&lt;/strong&gt; Once you model your tools as typed function calls, integrating with any MCP-aware assistant is basically free.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Wiring an AI assistant directly to Postgres turns a chat window into something closer to a pair of senior hands on the keyboard ✨. EXPLAIN plans, index experiments, and health audits stop being chores you context-switch into - they're just part of the conversation.&lt;/p&gt;

&lt;p&gt;The whole thing is ~15 MB, has no runtime dependencies, and drops into any MCP-capable editor with a five-line JSON config. If you spend any real time inside Claude Code or Cursor and also spend any real time worrying about Postgres, this is the kind of glue that quietly earns its keep.&lt;/p&gt;

</description>
      <category>go</category>
      <category>postgres</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why I Ditched Sanity CMS for MDX (And Never Looked Back)</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Wed, 31 Dec 2025 09:07:35 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/why-i-ditched-sanity-cms-for-mdx-and-never-looked-back-3jhm</link>
      <guid>https://dev.to/akshay_gupta/why-i-ditched-sanity-cms-for-mdx-and-never-looked-back-3jhm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;"Simplicity is the ultimate sophistication." - Leonardo da Vinci&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sometimes the best technical decisions are the ones that remove complexity rather than add it. This is the story of how I migrated my blog from Sanity CMS to plain MDX files, and why it turned out to be one of the best decisions I've made for this portfolio.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: Why I Originally Chose Sanity
&lt;/h2&gt;

&lt;p&gt;When I first built this portfolio, Sanity CMS seemed like the obvious choice for managing blog content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual Editor&lt;/strong&gt;: A nice WYSIWYG interface for writing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured Content&lt;/strong&gt;: Schema-driven content modeling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time Collaboration&lt;/strong&gt;: Though I was the only author 😅&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDN-hosted Images&lt;/strong&gt;: Automatic image optimization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook Revalidation&lt;/strong&gt;: On-demand ISR when content changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked. But over time, the cracks started showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Breaking Point: Why I Decided to Leave
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Overhead for a Single Author
&lt;/h3&gt;

&lt;p&gt;I was running an entire CMS infrastructure for... myself. The Sanity Studio added routes, dependencies, and complexity that felt increasingly unnecessary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;src/sanity/
├── env.ts
├── lib/
│   ├── client.ts
│   ├── image.ts
│   └── queries.ts
├── schemaTypes/
│   ├── authorType.ts
│   ├── blockContentType.ts
│   ├── categoryType.ts
│   └── postType.ts
└── structure.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All this infrastructure for what could be a simple markdown file.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The External Dependency Problem
&lt;/h3&gt;

&lt;p&gt;Every time I wanted to write, I had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open my site&lt;/li&gt;
&lt;li&gt;Navigate to &lt;code&gt;/studio&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Wait for the Sanity Studio to load&lt;/li&gt;
&lt;li&gt;Write in their editor&lt;/li&gt;
&lt;li&gt;Hope the webhook fired correctly for revalidation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My content lived on someone else's servers. If Sanity changed their pricing, had an outage, or sunset a feature, I'd be scrambling.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Code Blocks Were a Pain
&lt;/h3&gt;

&lt;p&gt;As a developer writing technical content, code blocks are essential. Sanity's Portable Text format required custom serializers, and getting syntax highlighting right was always a battle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Old Sanity code block serializer - verbose and fragile&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CodeBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CodeBlockValue&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SyntaxHighlighter&lt;/span&gt;
      &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;oneDark&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;customStyle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.5rem 0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/SyntaxHighlighter&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With MDX, it's just... markdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;typescript
&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;greeting&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello, World!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Version Control? What Version Control?
&lt;/h3&gt;

&lt;p&gt;My code was in Git. My content was in Sanity. Two sources of truth, zero unified history. I couldn't easily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Review content changes in PRs&lt;/li&gt;
&lt;li&gt;Roll back a post to a previous version&lt;/li&gt;
&lt;li&gt;See what changed alongside code changes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Solution: MDX with Next.js
&lt;/h2&gt;

&lt;p&gt;MDX gives you the best of both worlds: Markdown's simplicity with React's power. Here's how I set it up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Install the Dependencies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @next/mdx @mdx-js/loader @mdx-js/react
pnpm add remark-gfm rehype-slug rehype-prism-plus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a class="mentioned-user" href="https://dev.to/next"&gt;@next&lt;/a&gt;/mdx&lt;/strong&gt;: Official Next.js MDX integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;remark-gfm&lt;/strong&gt;: GitHub Flavored Markdown (tables, strikethrough, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rehype-slug&lt;/strong&gt;: Auto-generates IDs for headings (for Table of Contents)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rehype-prism-plus&lt;/strong&gt;: Syntax highlighting with Prism.js&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Configure Next.js
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;createMDX&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@next/mdx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;remarkGfm&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remark-gfm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;rehypeSlug&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rehype-slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;rehypePrismPlus&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rehype-prism-plus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pageExtensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mdx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// ... other config&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withMDX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMDX&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;remarkPlugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;remarkGfm&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;rehypePlugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rehypeSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rehypePrismPlus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ignoreMissing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}]],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;withMDX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Create MDX Components
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;mdx-components.tsx&lt;/code&gt; file at the project root customizes how MDX elements render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// mdx-components.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MDXComponents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mdx/types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Image&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMDXComponents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MDXComponents&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;MDXComponents&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Custom heading with auto-generated ID&lt;/span&gt;
    &lt;span class="na"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h2&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h2&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;,
&lt;/span&gt;
    &lt;span class="c1"&gt;// Smart links: internal vs external&lt;/span&gt;
    &lt;span class="na"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Link&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_blank&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noopener noreferrer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/a&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="c1"&gt;// Accessible code blocks&lt;/span&gt;
    &lt;span class="na"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;pre&lt;/span&gt; &lt;span class="nx"&gt;tabIndex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;region&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Code snippet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/pre&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="p"&gt;),&lt;/span&gt;

    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Structure the Content
&lt;/h3&gt;

&lt;p&gt;Each blog post is now a simple &lt;code&gt;.mdx&lt;/code&gt; file with exported metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;content/
└── blog/
    ├── my-first-post.mdx
    ├── another-post.mdx
    └── this-post.mdx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the file structure is beautifully simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;export const metadata = {
  title: 'My Blog Post',
  slug: 'my-blog-post',
  publishedAt: '2025-01-01',
  categories: ['nextjs', 'react'],
  coverImage: '/images/blog/my-post.avif',
  author: {
    name: 'Akshay Gupta',
    avatar: '/images/blog-author.png'
  },
  excerpt: 'A short description of the post.'
}

&lt;span class="gu"&gt;## Introduction&lt;/span&gt;

Your markdown content goes here...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Build the MDX Utilities
&lt;/h3&gt;

&lt;p&gt;I created a small utility library to handle blog operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/mdx/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CONTENT_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBlogSlugs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONTENT_DIR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.mdx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;mdx$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBlogBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`@/content/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mdx`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONTENT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mdx`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; 
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;readingTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateReadingTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawContent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readingTime&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;h3&gt;
  
  
  Step 6: Render the Blog Page
&lt;/h3&gt;

&lt;p&gt;The dynamic route imports and renders MDX directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBlogBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Dynamic import of the MDX content&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MDXContent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`@/content/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mdx`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MDXContent&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/article&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slugs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getBlogSlugs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;slugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;slug&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;h2&gt;
  
  
  The Migration: What Changed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Removed (~12,000 lines deleted)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Entire &lt;code&gt;src/sanity/&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;Sanity Studio routes (&lt;code&gt;/studio&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Webhook revalidation endpoint&lt;/li&gt;
&lt;li&gt;Portable Text serializers&lt;/li&gt;
&lt;li&gt;8 Sanity-related npm packages&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Added (~7,500 lines added)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;7 MDX blog posts in &lt;code&gt;content/blog/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;MDX utilities in &lt;code&gt;src/lib/mdx/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Custom MDX components&lt;/li&gt;
&lt;li&gt;Prism.js syntax highlighting theme&lt;/li&gt;
&lt;li&gt;Cover images in &lt;code&gt;public/images/blog/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Net result&lt;/strong&gt;: ~4,700 fewer lines of code. Less code, fewer bugs, simpler maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Benefits I'm Enjoying Now
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Write Anywhere
&lt;/h3&gt;

&lt;p&gt;My favorite markdown editor, VS Code, Obsidian, or even &lt;code&gt;neovim&lt;/code&gt; in a pinch. No browser required.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Git-Native Content
&lt;/h3&gt;

&lt;p&gt;Every post is version controlled. I can see the full history, create branches for draft posts, and review content changes in PRs alongside code.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Blazing Fast Builds
&lt;/h3&gt;

&lt;p&gt;No API calls during build. Everything is local filesystem reads. The build is noticeably faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. True Ownership
&lt;/h3&gt;

&lt;p&gt;My content lives in my repo. No vendor lock-in, no surprise pricing changes, no external dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Better Code Blocks
&lt;/h3&gt;

&lt;p&gt;Prism.js with the Dracula theme, automatic language detection, and keyboard-accessible code regions. It just works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Look ma, beautiful syntax highlighting!&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. React Components in Markdown
&lt;/h3&gt;

&lt;p&gt;Need a custom callout? An interactive demo? Just import and use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;import { InteractiveDemo } from '@/components/Demo';

Here's a live demo:

&lt;span class="nt"&gt;&amp;lt;InteractiveDemo&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotchas and Solutions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. OpenGraph Images Need Node.js Runtime
&lt;/h3&gt;

&lt;p&gt;The OG image generator uses &lt;code&gt;fs&lt;/code&gt; to read MDX files, but Next.js image routes default to Edge runtime. Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/blog/[slug]/opengraph-image.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Reading Time Calculation
&lt;/h3&gt;

&lt;p&gt;With Sanity, I could query a computed field. With MDX, I calculate it from the raw content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateReadingTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&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="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;S&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="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```/g, '') // Remove code blocks
    .replace(/`&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="s2"&gt;`]*`&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// Remove inline code&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// Remove HTML&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; min read`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;words&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;h3&gt;
  
  
  3. Table of Contents
&lt;/h3&gt;

&lt;p&gt;Without a structured AST from Sanity, I extract headings with regex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractHeadings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headingRegex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;#&lt;/span&gt;&lt;span class="se"&gt;{1,4})\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/gm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headingRegex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;headings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;headings&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;h2&gt;
  
  
  Should You Make the Switch?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MDX is perfect if you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are a solo author or small team&lt;/li&gt;
&lt;li&gt;Write technical content with code blocks&lt;/li&gt;
&lt;li&gt;Want content in version control&lt;/li&gt;
&lt;li&gt;Value simplicity over features&lt;/li&gt;
&lt;li&gt;Are comfortable with markdown&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stick with a CMS if you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Have non-technical content editors&lt;/li&gt;
&lt;li&gt;Need complex workflows and approvals&lt;/li&gt;
&lt;li&gt;Require real-time collaboration&lt;/li&gt;
&lt;li&gt;Want a visual editing experience&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Moving from Sanity to MDX was like cleaning out a cluttered closet. The immediate benefit is obvious: less stuff, more space, easier to find things. But the real joy comes from the daily experience of just... writing.&lt;/p&gt;

&lt;p&gt;No dashboards. No loading spinners. No "syncing content." Just me, my editor, and markdown. The way blogging should be.&lt;/p&gt;

&lt;p&gt;The code for this entire blog system is open source at &lt;a href="https://github.com/gupta-akshay/portfolio-v2" rel="noopener noreferrer"&gt;github.com/gupta-akshay/portfolio-v2&lt;/a&gt;. Feel free to steal it. 🚀&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away." - Antoine de Saint-Exupéry&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>nextjs</category>
      <category>sanity</category>
      <category>mdx</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Snapshots, Brooms, and Arch Linux Chaos Control</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Tue, 02 Dec 2025 12:07:10 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/snapshots-brooms-and-arch-linux-chaos-control-hie</link>
      <guid>https://dev.to/akshay_gupta/snapshots-brooms-and-arch-linux-chaos-control-hie</guid>
      <description>&lt;p&gt;Arch gives you the steering wheel &lt;em&gt;and&lt;/em&gt; the broom. On my Arch setup, Limine boots a BTRFS root while Snapper snaps a before/after every time I poke &lt;code&gt;pacman&lt;/code&gt;. It's gorgeous, rollbacks for days, but the snapshots stack up faster than RGB stickers unless I sweep them out. So I wrote this simple &lt;code&gt;cleanup.sh&lt;/code&gt;, my tiny digital janitor.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I use Arch btw."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why I even bother
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pacman + Snapper hooks = instant safety net. If a wile build breaks stuff, I just reboot, pick the previous snapshot in Limine, and carry on.&lt;/li&gt;
&lt;li&gt;I settled on &lt;a href="https://cachyos.org/" rel="noopener noreferrer"&gt;CachyOS&lt;/a&gt; and &lt;a href="https://hydeproject.pages.dev/" rel="noopener noreferrer"&gt;HyDE&lt;/a&gt; on top of it, after trying and experimenting with multiple setups. Now CachyOS loves performance tweaks, which means I love experimenting. Snapshots let me go full mad scientist without sweating the fallout.&lt;/li&gt;
&lt;li&gt;Doing the cleanup myself keeps that classic Arch vibe: I decide what stays, what goes, and when my SSD gets to breathe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How my cleanup script keeps me sane
&lt;/h2&gt;

&lt;p&gt;You can keep the below script anywhere on your system, and yes, it wants root (&lt;code&gt;sudo ./cleanup.sh [KEEP]&lt;/code&gt;). Default keep count is 2, but bump it to whatever number keeps you cozy. The flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Are we legit?&lt;/strong&gt;: Bails if you're not root or if &lt;code&gt;btrfs&lt;/code&gt; tools are missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roll call&lt;/strong&gt;: Lists every &lt;code&gt;/.snapshots/&amp;lt;id&amp;gt;/snapshot&lt;/code&gt;, nicely sorted so we know what's oldest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick favourites&lt;/strong&gt;: Shows what stays, what goes, and then asks "you sure?" so you don't rage-delete your lifeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yeet + sync&lt;/strong&gt;: Deletes the dusty stuff, cleans empty directories, and syncs the filesystem so everything feels fresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Slide the whole script into your toolbox:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="c"&gt;# How many latest snapshots to keep (default: 2)&lt;/span&gt;
&lt;span class="nv"&gt;KEEP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Please run this script as root:"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  sudo &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; [KEEP]"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; btrfs &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error: 'btrfs' command not found. Is btrfs-progs installed?"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Scanning for snapshots under /.snapshots ..."&lt;/span&gt;
&lt;span class="nb"&gt;mapfile&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; SNAPSHOTS &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;
  btrfs subvolume list / &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9 ~ /^\.snapshots\/[0-9]+\/snapshot$/ {print $9}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt;,2n
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;TOTAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;SNAPSHOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; TOTAL &lt;span class="o"&gt;==&lt;/span&gt; 0 &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No snapshots found under /.snapshots."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Found &lt;/span&gt;&lt;span class="nv"&gt;$TOTAL&lt;/span&gt;&lt;span class="s2"&gt; snapshot subvolumes:"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SNAPSHOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo

&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; TOTAL &amp;lt;&lt;span class="o"&gt;=&lt;/span&gt; KEEP &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Total snapshots (&lt;/span&gt;&lt;span class="nv"&gt;$TOTAL&lt;/span&gt;&lt;span class="s2"&gt;) &amp;lt;= KEEP (&lt;/span&gt;&lt;span class="nv"&gt;$KEEP&lt;/span&gt;&lt;span class="s2"&gt;). Nothing to delete."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Old ones to delete = everything except the last KEEP entries&lt;/span&gt;
&lt;span class="nv"&gt;DELETE_COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; TOTAL &lt;span class="o"&gt;-&lt;/span&gt; KEEP &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="nv"&gt;TO_DELETE&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SNAPSHOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;:0:DELETE_COUNT&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TO_KEEP&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SNAPSHOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;:DELETE_COUNT&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Will KEEP the latest &lt;/span&gt;&lt;span class="nv"&gt;$KEEP&lt;/span&gt;&lt;span class="s2"&gt; snapshot(s):"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TO_KEEP&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo
echo&lt;/span&gt; &lt;span class="s2"&gt;"Will DELETE &lt;/span&gt;&lt;span class="nv"&gt;$DELETE_COUNT&lt;/span&gt;&lt;span class="s2"&gt; older snapshot(s):"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TO_DELETE&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo

read&lt;/span&gt; &lt;span class="nt"&gt;-rp&lt;/span&gt; &lt;span class="s2"&gt;"Proceed with deletion? [y/N] "&lt;/span&gt; ans
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ans&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;y|Y&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Aborted."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;relpath &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TO_DELETE&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;fullpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/&lt;/span&gt;&lt;span class="nv"&gt;$relpath&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Deleting subvolume: &lt;/span&gt;&lt;span class="nv"&gt;$fullpath&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  btrfs subvolume delete &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fullpath&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="c"&gt;# Try to remove the parent directory (/.snapshots/&amp;lt;id&amp;gt;) if it is empty&lt;/span&gt;
  &lt;span class="nv"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$relpath&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;rmdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Removed empty directory: &lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Syncing filesystem..."&lt;/span&gt;
btrfs filesystem &lt;span class="nb"&gt;sync&lt;/span&gt; /

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Cleanup complete."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this feels 100% Arch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I'm the one dialling in how many snapshots survive, not some mystery cron job.&lt;/li&gt;
&lt;li&gt;Limine keeps every snapshot bootable but leaves the housekeeping to me, which is exactly how I like it.&lt;/li&gt;
&lt;li&gt;No silent "optimization" services, just a bash script, a terminal prompt, and the knowledge my restore points are ones I actually care about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time you finish spicy upgrade on your Arch system, kick back for a second and run &lt;code&gt;cleanup.sh&lt;/code&gt;. Five seconds of sweeping, and boom: your Arch universe stays tidy, intentional, and totally yours.&lt;/p&gt;

</description>
      <category>archlinux</category>
      <category>cachyos</category>
    </item>
    <item>
      <title>Terminal Resume - ssh.akshaygupta.live</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Thu, 20 Nov 2025 14:50:02 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/terminal-resume-sshakshayguptalive-3mbk</link>
      <guid>https://dev.to/akshay_gupta/terminal-resume-sshakshayguptalive-3mbk</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Ever wish a resume said "hi" the same way you do? 🚀 This one does. Pop open iTerm (or whatever shell keeps you grounded), paste &lt;code&gt;ssh ssh.akshaygupta.live&lt;/code&gt;, and a neon figlet banner blooms like it's 1994. In a blink you're welcomed with the TL;DR, a friendly prompt, and zero browser chrome in sight. It feels like stepping into a dotfiles stash, only this one tells my career story.&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%2F4fobwbd0zr7aaaxzqm1d.gif" 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%2F4fobwbd0zr7aaaxzqm1d.gif" alt=" " width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Under the hood it's just thoughtful TypeScript, and a little flair. Here's the tour 🧭&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;terminal/server.ts&lt;/code&gt; spins up an &lt;code&gt;ssh2.Server&lt;/code&gt;, accepts every session and hands it off to a fresh &lt;code&gt;ResumeShell&lt;/code&gt; so nobody fights for history or width.&lt;/li&gt;
&lt;li&gt;The shell clears the screen, renders the marquee welcome (&lt;code&gt;renderWelcome&lt;/code&gt; mixes &lt;code&gt;figlet&lt;/code&gt;, &lt;code&gt;gradient-string&lt;/code&gt; and &lt;code&gt;boxen&lt;/code&gt;), and reacts to &lt;code&gt;window-change&lt;/code&gt; events so everything stays readable from 80 columns to ultrawide setups.&lt;/li&gt;
&lt;li&gt;Input flows through the &lt;code&gt;commander&lt;/code&gt; map, so verbs like &lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;skills&lt;/code&gt;, &lt;code&gt;experience&lt;/code&gt;, &lt;code&gt;education&lt;/code&gt;, &lt;code&gt;links&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt;, &lt;code&gt;clear&lt;/code&gt;, and &lt;code&gt;help&lt;/code&gt; are just plain object keys. Want a new section? Add another entry, done.&lt;/li&gt;
&lt;li&gt;Formatting magic lives in &lt;code&gt;terminal/renderer.ts&lt;/code&gt;, where &lt;code&gt;wrap-ansi&lt;/code&gt; keeps paragraphs tidy, &lt;code&gt;boxen&lt;/code&gt; frames each section, and &lt;code&gt;chalk&lt;/code&gt; paints the &lt;code&gt;akshay@terminal $&lt;/code&gt; prompt with that unmistakable gradient.&lt;/li&gt;
&lt;li&gt;All copy comes straight from &lt;code&gt;terminal/resumeData.ts&lt;/code&gt;, the same data powering &lt;code&gt;public/assets/akshay-cv.pdf&lt;/code&gt;, which means the PDF, website, and shell stay perfectly in sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the source code at &lt;a href="https://github.com/gupta-akshay/portfolio-v2" rel="noopener noreferrer"&gt;https://github.com/gupta-akshay/portfolio-v2&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it is deployed
&lt;/h2&gt;

&lt;p&gt;Setting it up locally is a five-minute coffee break ☕️: run &lt;code&gt;ssh-keygen -t ed25519 -f ~/.ssh/akshay_terminal_host -N ""&lt;/code&gt;, point &lt;code&gt;TERMINAL_SSH_HOST_KEY&lt;/code&gt; at that file, and start &lt;code&gt;pnpm terminal:ssh&lt;/code&gt;. It defaults to &lt;code&gt;0.0.0.0:2222&lt;/code&gt;, but you can tweak &lt;code&gt;TERMINAL_SSH_HOST&lt;/code&gt; and &lt;code&gt;TERMINAL_SSH_PORT&lt;/code&gt; if your network has Opinions.&lt;/p&gt;

&lt;p&gt;Production takes the same pragmatic path outlined in the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fly launch --no-deploy&lt;/code&gt; to generate the Fly.io scaffolding from &lt;code&gt;fly.toml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fly volumes create keys_volume --size 1&lt;/code&gt; so the Ed25519 host key survives restarts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fly secrets set TERMINAL_SSH_HOST_KEY=/app/keys/terminal_host_ed25519&lt;/code&gt; to wire up that volume.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fly deploy&lt;/code&gt; to ship the container that just runs &lt;code&gt;pnpm terminal:ssh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Allocate an IPv4 address, add an A record for &lt;code&gt;ssh.akshaygupta.live&lt;/code&gt;, and keep Cloudflare in gray-cloud mode so raw SSH hits the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not a Fly.io fan? Any tiny VM, Railway container, or Raspberry Pi that can run Node 18+ will do, just mount the host key somewhere durable and pass the same env vars.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh ssh.akshaygupta.live&lt;/code&gt; is a little nostalgia and a lot of signal ✨. It stays in lockstep with the canonical PDF, feels snappy even on spotty Wi-Fi, and is genuinely fun to demo in interviews or hallway chats. Swap in your GIF captures, keep &lt;code&gt;resumeData.ts&lt;/code&gt; updated, and you’ve got a living resume that greets people the same way you would, prompt first, attitude included.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ssh</category>
      <category>hobby</category>
    </item>
    <item>
      <title>Building Smart Search: How Embeddings and kNN Make Search Feel Human</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Sat, 05 Jul 2025 07:41:00 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/building-smart-search-how-embeddings-and-knn-make-search-feel-human-3o45</link>
      <guid>https://dev.to/akshay_gupta/building-smart-search-how-embeddings-and-knn-make-search-feel-human-3o45</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;“The real voyage of discovery consists not in seeking new landscapes, but in having new eyes.” – Marcel Proust&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Coffee? ✅ Chai? ✅ Excitement about building intelligent search? Double ✅&lt;/p&gt;

&lt;p&gt;Ever wondered how your favorite chatbot magically finds the perfect answer to your question, even if you didn't use the exact words? The secret sauce behind these smart search systems is a clever combination of something called embeddings and a neat technique known as k-nearest neighbor (kNN) search.&lt;/p&gt;

&lt;p&gt;Think of embeddings like your brain's ability to understand that "reset my password" and "forgot my login" basically mean the same thing. Embeddings translate words into numbers that capture their meaning, creating a sort of mathematical language that helps computers understand human intent.&lt;/p&gt;

&lt;p&gt;This system is part of a broader evolution from my earlier prototype: &lt;a href="https://dev.to/akshay_gupta/building-a-rag-powered-support-chatbot-in-24-hours-of-hackathon-5f7c"&gt;Building a RAG-powered Support Chatbot in 24 Hours of Hackathon&lt;/a&gt;. Back then, we hacked together a working retrieval-augmented generation (RAG) system with basic context retrieval. Since then, the approach has matured significantly; embedding quality, search efficiency, and real-time performance have all leveled up. Let’s dive into how this works now, at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's an Embedding, Anyway? 🤔
&lt;/h2&gt;

&lt;p&gt;Imagine you're searching for help with a forgotten password. Traditional search might not connect "reset password" to an article titled "recover your login credentials". But embeddings see the similarity immediately, it's like having a friend who always gets what you're saying, even if you're using different words.&lt;/p&gt;

&lt;p&gt;Here's how it works:&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="c1"&gt;# Simple example of embeddings
&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How do I reset my password?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_embedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Result: [0.23, -0.45, 0.12, ...] (170 numbers representing meaning)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating Embeddings (the Smart Way) 🏗️
&lt;/h2&gt;

&lt;p&gt;Here's the workflow we've built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clean the Data&lt;/strong&gt; 🧹 : Remove noise, extra formatting, and HTML tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch It Up&lt;/strong&gt; ⚡ : Process many documents at once to keep things fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink and Simplify&lt;/strong&gt; 📉 : Compress data to a compact, efficient 170-dimensional format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize It&lt;/strong&gt; ⚖️ : Standardize embeddings so they place nicely together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We don't just look at titles or content alone; we combine both to capture the full story, like reading a headline and the article itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing Embeddings Smartly 💾
&lt;/h2&gt;

&lt;p&gt;We use &lt;strong&gt;Elasticsearch&lt;/strong&gt; to store and query our embeddings efficiently. Elasticsearch is traditionally known for text-based search, but it now also supports &lt;strong&gt;dense vector&lt;/strong&gt; fields, a perfect fit for storing high-dimensional embeddings.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a &lt;code&gt;dense_vector&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;dense_vector&lt;/code&gt; type in Elasticsearch allows you in store an array of floating-point numbers (your embeddings) inside a document. You can then perform vector-based similarity searches on them. When you set index: true, Elasticsearch builds an &lt;strong&gt;HNSW (Hierarchical Navigable Small World)&lt;/strong&gt; graph structure over these vectors, enabling fast and approximate kNN searches.&lt;/p&gt;

&lt;p&gt;Each indexed document typically contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The original content (so users can read it).&lt;/li&gt;
&lt;li&gt;The 170-dimensional vector embedding.&lt;/li&gt;
&lt;li&gt;Metadata like url, category, and timestamps for filtering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's simplified example:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mappings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense_vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;170&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keyword&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By indexing these vectors, we are enabling lighting-fast retrieval of semantically similar documents across multiple use cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is k-Nearest Neighbor (KNN)?📍
&lt;/h3&gt;

&lt;p&gt;kNN is a search algorithm that finds the closest items to a query point in a multi-dimensional space. In our context, the query point is the user's question, represented as a 170-dimensional vector. kNN then searches the stored embeddings to find the k most similar vectors, i.e., the documents most relevant to the query.&lt;/p&gt;

&lt;p&gt;kNN is great for semantic search because it doesn’t rely on keywords. It looks at the &lt;strong&gt;distance&lt;/strong&gt; between vectors, so two texts that mean the same thing will still be found together even if they don't share vocabulary.&lt;/p&gt;

&lt;p&gt;We use &lt;strong&gt;HNSW (Hierarchical Navigable Small World)&lt;/strong&gt; for this, an efficient algorithm for approximate nearest neighbor searches, optimized for performance at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding What You Really Mean 🔍
&lt;/h2&gt;

&lt;p&gt;Here's how our search works against a user question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn your question info numbers&lt;/strong&gt; 🧠 : Your question becomes an embedding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find nearest matches&lt;/strong&gt; 🎯 : Quickly find similar embeddings using kNN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score results&lt;/strong&gt; 📊 : Rank based on how close the matches are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter and Fine-Tune&lt;/strong&gt; 🧹 : Ensure only the best, most relevant answers make it through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how the search happens:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_relevant_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_question&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_embedding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;high_priority_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;knn_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_priority_kb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.34&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;low_priority_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;knn_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_priority_kb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.54&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;combined_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;combine_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;high_priority_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;low_priority_results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rank_by_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;combined_results&lt;/span&gt;&lt;span class="p"&gt;)[:&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;h2&gt;
  
  
  Why Not Stick with Regular Search? 🤷‍♂️
&lt;/h2&gt;

&lt;p&gt;Traditional searches struggle with understanding synonyms and context. kNN with embeddings shine because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic Understanding&lt;/strong&gt;: Recognizes meaning, not just exact words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synonyms and Variations&lt;/strong&gt;: Easily connects different phrases with similar meanings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context Awareness&lt;/strong&gt;: Matches based on overall meaning, not keyword hits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast and Scalable&lt;/strong&gt;: Handles millions of items swiftly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why kNN Beats Cosine Similarity 🚀
&lt;/h2&gt;

&lt;p&gt;Cosine similarity works well for comparing a small number of vectors. But once you’re dealing with thousands, or millions, of documents, it becomes a bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cosine Similarity Limitations:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linear Time Complexity&lt;/strong&gt;: It compares the query with every document. Painfully slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory Intensive&lt;/strong&gt;: All embeddings need to be loaded into RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doesn’t Scale Well&lt;/strong&gt;: Every new document adds to the workload.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why kNN Wins:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sub-linear Time&lt;/strong&gt;: HNSW allows logarithmic-time searches—significantly faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approximate Matching&lt;/strong&gt;: 95% accuracy with 10x speed boost is a win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scales Beautifully&lt;/strong&gt;: Works efficiently even with 60k+ documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrated Filters&lt;/strong&gt;: Combine semantic search with metadata filters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data Volume&lt;/strong&gt;: ~60k documents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kNN Search Time&lt;/strong&gt;: &amp;lt;50ms per query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cosine Similarity&lt;/strong&gt;: 3-5 seconds on same dataset, too slow for real-time apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So while cosine similarity is great for academic demos, kNN with HNSW is what powers production-grade semantic search.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG System Integration: Where This Fits In 🔄
&lt;/h2&gt;

&lt;p&gt;This entire embedding and kNN infrastructure forms the &lt;strong&gt;retrieval layer&lt;/strong&gt; in a &lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt; system.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval&lt;/strong&gt;: The user’s query is embedded and passed through KNN search to fetch top-k context documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Augmentation&lt;/strong&gt;: These documents form the context that is passed to an LLM (like GPT or Claude).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation&lt;/strong&gt;: The LLM uses this context to answer the question accurately and with reference to known content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, this system helps the AI “know” what to say. Without it, even the smartest LLM would be guessing in the dark.&lt;/p&gt;

&lt;p&gt;From our hackathon RAG prototype to now, the major improvements include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Better semantic matching via custom-trained embeddings&lt;/li&gt;
&lt;li&gt;Much faster search with Elasticsearch HNSW&lt;/li&gt;
&lt;li&gt;Contextual ranking and filtering&lt;/li&gt;
&lt;li&gt;Higher relevance due to threshold tuning and boost factors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read more about the hackathon RAG prototype &lt;a href="https://dev.to/akshay_gupta/building-a-rag-powered-support-chatbot-in-24-hours-of-hackathon-5f7c"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up 🎉
&lt;/h2&gt;

&lt;p&gt;Embeddings and kNN have transformed search from simple matching to intelligent understanding. The future isn't about exact matches; it's about understanding what people mean and giving them exactly what they need, even when they don't quite know how to ask.&lt;/p&gt;

&lt;p&gt;That’s the magic behind modern search, and it's pretty cool! 🚀&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The future belongs to those who prepare for it today." – Malcolm X&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>programming</category>
      <category>ai</category>
      <category>python</category>
      <category>nlp</category>
    </item>
    <item>
      <title>Building a Music Showcase for My Portfolio: A Developer's Journey</title>
      <dc:creator>Akshay Gupta</dc:creator>
      <pubDate>Sun, 09 Mar 2025 18:13:14 +0000</pubDate>
      <link>https://dev.to/akshay_gupta/building-a-music-showcase-for-my-portfolio-a-developers-journey-4adl</link>
      <guid>https://dev.to/akshay_gupta/building-a-music-showcase-for-my-portfolio-a-developers-journey-4adl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Code is poetry, music is magic, and when they come together, something extraordinary happens.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hey there, fellow developers! 👋 Today, I want to share how I built the music section of my portfolio website. As someone who codes by day and produces electronic music by night, I wanted a space to showcase my tracks that was both functional and visually appealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Double Life: Developer by Day, Music Producer by Night
&lt;/h2&gt;

&lt;p&gt;When I'm not writing code for my day job, I'm often tinkering with synthesizers and drum machines, creating electronic music. It's a creative outlet that balances nicely with the logical thinking required in software development. I've been making music for several years now, and I wanted to integrate this passion into my portfolio website.&lt;/p&gt;

&lt;p&gt;The goal was simple: create a section where visitors could easily browse and play my tracks with a modern, responsive interface that works across all devices.&lt;/p&gt;

&lt;p&gt;Before we go in further details, you can try the page for yourself at &lt;a href="https://akshaygupta.live/music" rel="noopener noreferrer"&gt;https://akshaygupta.live/music&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AWS S3 + CloudFront for Audio Hosting
&lt;/h2&gt;

&lt;p&gt;One of the first decisions I had to make was where to host my audio files. I needed a solution that was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cost-effective for storing audio files&lt;/li&gt;
&lt;li&gt;Scalable if my library grows&lt;/li&gt;
&lt;li&gt;Fast for global users&lt;/li&gt;
&lt;li&gt;Secure with proper access controls&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After considering various options, I settled on AWS S3 for storage coupled with CloudFront for content delivery. Here's why:&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 for Storage
&lt;/h3&gt;

&lt;p&gt;S3 provides reliable, secure storage at a reasonable cost. I can easily upload new tracks, organise them in folders, and set appropriate permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  CloudFront for Delivery
&lt;/h3&gt;

&lt;p&gt;CloudFront creates a global CDN that caches my audio files at edge locations around the world, reducing latency for listeners regardless of their location. This is crucial for streaming audio without buffering issues.&lt;/p&gt;

&lt;p&gt;Here's simplified version of how I fetch the audio file URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAudioUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// If CloudFront is configured, use it&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLOUDFRONT_DOMAIN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;CLOUDFRONT_KEY_PAIR_ID&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;CLOUDFRONT_PRIVATE_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Generate signed CloudFront URL with 1-hour expiration&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getCloudfrontSignedUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleanDomain&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;keyPairId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CLOUDFRONT_KEY_PAIR_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;dateLessThan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error getting CloudFront URL:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Fallback to S3 pre-signed URL if CloudFront fails&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Using S3 Fallback URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&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;GetObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BUCKET_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ResponseContentDisposition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&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;I implemented a fallback mechanism that uses S3 pre-signed URLs if CloudFront isn't configured or fails. This ensures my music is always accessible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building a Custom Audio Player from Scratch
&lt;/h2&gt;

&lt;p&gt;I could have used an existing audio player library, but I wanted complete control over the UI, UX, and features. So I built a custom player from scratch using React and the Web Audio API.&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%2Fv874jdna91sb89vb05gm.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%2Fv874jdna91sb89vb05gm.png" alt="screenshot" width="799" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Component Architecture
&lt;/h3&gt;

&lt;p&gt;I structured the player with a modular approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AudioPlayer/
├── components/
│   ├── FullScreenPlayer.tsx
│   ├── MiniPlayer.tsx
│   ├── NowPlaying.tsx
│   ├── PlayerControls.tsx
│   ├── QueuePanel.tsx
│   ├── TrackList.tsx
│   └── Waveform.tsx
├── hooks/
│   ├── useAudioContext.ts
│   ├── useAudioPlayback.ts
│   ├── useQueueManager.ts
│   └── useVisualizer.ts
├── AudioPlayer.tsx
└── types.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation of concerns made the codebase more maintainable and allowed me to focus on specific features independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Features
&lt;/h3&gt;

&lt;p&gt;The player includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Play/pause, previous/next track controls&lt;/li&gt;
&lt;li&gt;Volume control with mute toggle&lt;/li&gt;
&lt;li&gt;Progress bar with seek functionality&lt;/li&gt;
&lt;li&gt;Track queue management with drag-and-drop reordering&lt;/li&gt;
&lt;li&gt;Shuffle mode&lt;/li&gt;
&lt;li&gt;Responsive design that adapts to mobile and desktop&lt;/li&gt;
&lt;li&gt;Full-screen mode with expanded visualizations.&lt;/li&gt;
&lt;li&gt;Mini-player for compact viewing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One of the most challenging aspects was ensuring smooth playback transitions between tracks. I had to carefully manage the audio element's state and handle various edge cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handlePlayPause&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Ensure audio context is running&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioContextRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;suspended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;audioContextRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;playPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playPromise&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;playPromise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Playback failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="c1"&gt;// Handle autoplay restrictions&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NotAllowedError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;setNeedsUserInteraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error toggling playback:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Magic Behind the Visualisations
&lt;/h2&gt;

&lt;p&gt;The most eye-catching feature of the player is undoubtedly the audio visualisations. I implemented two types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A waveform visualiser that shows the audio waveform in real-time.&lt;/li&gt;
&lt;li&gt;A mini circular visualiser that pulses with the music's intensity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Waveform Visualiser
&lt;/h3&gt;

&lt;p&gt;The waveform visualiser display's the audio's time-domain data as a smooth, animated wave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;drawWaveform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;canvasRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;analyserRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvasRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analyserRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequencyBinCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataArray&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Get time-domain data&lt;/span&gt;
  &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByteTimeDomainData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Clear canvas&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Draw waveform&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sliceWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&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;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;128.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;sliceWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Request next frame&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;drawWaveform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;canvasRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;analyserRef&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Mini Circular Visualiser
&lt;/h3&gt;

&lt;p&gt;The mini visualiser uses frequency data to create a pulsing circle that responds to the music's energy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;drawMiniVisualizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;miniCanvasRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;analyserRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;miniCanvasRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analyserRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequencyBinCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataArray&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Get frequency data&lt;/span&gt;
  &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByteFrequencyData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Calculate average frequency for scaling&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&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;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;average&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;average&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Draw pulsing circle&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;centerX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;centerY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;centerX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;centerY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Request next frame&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;drawMiniVisualizer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;miniCanvasRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;analyserRef&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To optimise performance, I implemented several techniques:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frame rate limiting to prevent excessive CPU usage&lt;/li&gt;
&lt;li&gt;Canvas size optimisation based on device capabilities&lt;/li&gt;
&lt;li&gt;Gradient caching to avoid recreating gradients on each frame&lt;/li&gt;
&lt;li&gt;Selective rendering based on visibility&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Cross-Browser and Cross-Device Compatibility
&lt;/h2&gt;

&lt;p&gt;One of the biggest challenges was ensuring the player worked consistently across different browsers and devices. Audio playback can be particularly tricky due to varying implementations of the Web Audio API and autoplay restrictions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safari and iOS Challenges
&lt;/h3&gt;

&lt;p&gt;Safari and iOS presented unique challenges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audio Context Limitations&lt;/strong&gt;: Safari requires user interaction before allowing audio context creation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoplay Restrictions&lt;/strong&gt;: iOS requires user interaction before any audio can play&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Audio API Differences&lt;/strong&gt;: Safari's implementation has subtle differences from Chrome and Firefox&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To address these issues, I implemented several workarounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Unlock audio context on user interaction&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unlockAudioContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioContextRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;suspended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;audioContextRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error unlocking AudioContext:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Listen for any user interaction&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unlockAudioContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unlockAudioContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unlockAudioContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unlockAudioContext&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;unlockAudioContext&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Handle Playback Errors
&lt;/h3&gt;

&lt;p&gt;I implemented robust error handling to gracefully recover from playback issues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error loading audio:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Try alternative URL or format if available&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallbackFormats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;tryNextFormat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unable to play this track. Please try another.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;After weeks of development and testing, I'm proud of the final result. The music page provides a seamless listening experience with a visually appealing interface that works across all modern browsers and devices.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&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%2Frb5h4g5etw2q9n0mvtjw.png" alt="phone-1" width="800" height="1587"&gt;&lt;/th&gt;
&lt;th&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%2Fdivp32aoobsbqheohqs6.png" alt="phone-2" width="800" height="1587"&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The player has become one of the most commented-on features of my portfolio by friends, with many of my developer peers often surprised that it's a custom implementation rather than a third-party widget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check Out the Code
&lt;/h2&gt;

&lt;p&gt;If you're interested in exploring the code further or using it as inspiration for your own projects, you can find it in my GitHub repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/gupta-akshay/portfolio-v2" rel="noopener noreferrer"&gt;https://github.com/gupta-akshay/portfolio-v2&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feel free to star the repo if you find it useful, and don't hesitate to reach out if you have any questions or suggestions!&lt;/p&gt;




&lt;h2&gt;
  
  
  Future Considerations
&lt;/h2&gt;

&lt;p&gt;While I'm happy with the current implementation, I have several ideas for future enhancements:&lt;/p&gt;

&lt;h3&gt;
  
  
  SoundCloud-Inspired UI/UX
&lt;/h3&gt;

&lt;p&gt;I've always been a fan of Soundcloud's intuitive interface and user experience. Future iterations might incorporate some of their best design patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Waveform visualisation with playback position indicator&lt;/li&gt;
&lt;li&gt;Comment placement directly on the waveform&lt;/li&gt;
&lt;li&gt;Continuous playback while browsing&lt;/li&gt;
&lt;li&gt;More prominent artist information and artwork&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Search and Filtering Capabilities
&lt;/h3&gt;

&lt;p&gt;As my music collection grows, I plan to implement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full-text search across track titles and metadata&lt;/li&gt;
&lt;li&gt;Filtering by genre, year, and type (remix, original, etc.)&lt;/li&gt;
&lt;li&gt;Sorting options (newest, most played, etc.)&lt;/li&gt;
&lt;li&gt;Playlist creation and management&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Open-Source NPM Package
&lt;/h3&gt;

&lt;p&gt;I've received requests from some of my peer-developers to make this player available as a reusable component. I'm considering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extracting the core functionality into a standalone package&lt;/li&gt;
&lt;li&gt;Creating a well-documented API for customization&lt;/li&gt;
&lt;li&gt;Supporting different themes and visualisation styles&lt;/li&gt;
&lt;li&gt;Adding plugin support for extending functionality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're interested in contributing to any of these future enhancements or have other ideas, please reach out!&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Building this music player was a fun challenge that allowed me to combine my passions for coding and music. It reinforced my belief that creating custom solutions, while more time-consuming, can result in better user experiences that perfectly match your specific needs.&lt;/p&gt;

&lt;p&gt;What custom components have you built for your portfolio? I'd love to hear about your experiences in the&amp;nbsp;comments&amp;nbsp;below!&lt;/p&gt;

&lt;p&gt;Happy coding (and music-making)! 🎧👨‍💻&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The best music is essentially there to provide you something to face the world with." — Bruce Springsteen&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>webdev</category>
      <category>webaudio</category>
      <category>react</category>
      <category>s3cloudfront</category>
    </item>
  </channel>
</rss>
