<?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: Emir</title>
    <description>The latest articles on DEV Community by Emir (@imkyssa).</description>
    <link>https://dev.to/imkyssa</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3822807%2F951a4c30-6014-48a0-aa2c-f5043659dd44.png</url>
      <title>DEV Community: Emir</title>
      <link>https://dev.to/imkyssa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/imkyssa"/>
    <language>en</language>
    <item>
      <title>Database Performance Bottlenecks: N+1 Queries, Missing Indexes, and Connection Pools</title>
      <dc:creator>Emir</dc:creator>
      <pubDate>Fri, 27 Mar 2026 02:56:37 +0000</pubDate>
      <link>https://dev.to/imkyssa/database-performance-bottlenecks-n1-queries-missing-indexes-and-connection-pools-2bhg</link>
      <guid>https://dev.to/imkyssa/database-performance-bottlenecks-n1-queries-missing-indexes-and-connection-pools-2bhg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Software engineering is more than just centering a div."&lt;/em&gt; — howtocenterdiv.com&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; You profiled Node.js, added Redis, switched runtimes. Still slow. The database was the problem the whole time. Fix N+1 queries and missing indexes before you touch anything else.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Same story every time. App is slow. Someone swaps the framework. Still slow. Someone adds Redis. Still slow. Finally somebody opens up the query plan and there it is — a sequential scan across 4 million rows because nobody put an index on user_id. Four seconds per request. Was sitting there the whole time.&lt;/p&gt;

&lt;p&gt;Nobody ever looks at the database first though. It's always "let's try Fastify" or "let's add a cache" because those feel like real engineering work. An index is one line of SQL. You can't demo one line of SQL in standup. A framework migration gets you a PR, a benchmark chart, maybe even a blog post. But it won't fix a missing index, obviously.&lt;/p&gt;

&lt;p&gt;And honestly? Most backend devs have never been taught to read a query plan. Bootcamps don't go there, tutorials barely mention EXPLAIN, and you end up figuring it all out at 2am during a production fire. After that you never forget it. But it shouldn't take a production incident to learn this stuff.&lt;/p&gt;




&lt;h2&gt;
  
  
  N+1 Queries
&lt;/h2&gt;

&lt;p&gt;We all learn about N+1 early on. We all still write one at some point:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM orders LIMIT 50&lt;/span&gt;&lt;span class="dl"&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;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE id = $1&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="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;51 round trips for 50 orders. PM says "can we do 500 per page?" and now it's 501. The page is slow because of a loop hitting the database on every iteration — nothing to do with Node being slow or not.&lt;/p&gt;

&lt;p&gt;What's really annoying is that N+1 hides in dev. You've got 10 rows locally, it returns instantly, you move on. Deploys, data piles up, and six months from now somebody files a Jira ticket about the orders page taking 8 seconds. By then everyone forgot this code existed.&lt;/p&gt;

&lt;p&gt;One JOIN:&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;orders&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="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&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;JOIN&lt;/span&gt; &lt;span class="n"&gt;users&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ORMs make it worse because the code looks clean:&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="c1"&gt;// Reads beautifully. Fires 51 queries.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&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;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;orders&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;user&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;// This does the same thing in 1 query.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about that first version looks wrong. Prisma, Sequelize, TypeORM, all of them will do this to you if you don't pay attention. The whole selling point of an ORM is that it hides SQL. But it hides the query count too, and that's the part that bites.&lt;/p&gt;

&lt;p&gt;Turn on query logging. Prisma has &lt;code&gt;log: ['query']&lt;/code&gt; on the client, Sequelize takes &lt;code&gt;logging: console.log&lt;/code&gt;. Go look at what gets generated. I've opened up codebases where one endpoint was doing 200+ queries because somebody nested includes three levels deep. Endpoint worked, was just painfully slow, and nobody thought to check the logs until users started complaining.&lt;/p&gt;

&lt;p&gt;GraphQL makes it uglier because resolvers fire per field, so a single query can spawn dozens of DB calls without anyone realizing. DataLoader was built for exactly this — batches and deduplicates calls within one tick. Retrofitting it later is painful so do it early if you can.&lt;/p&gt;




&lt;h2&gt;
  
  
  EXPLAIN ANALYZE
&lt;/h2&gt;

&lt;p&gt;Slow query? Don't touch the code yet. Run this first:&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;user_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 output tells you how Postgres actually executed the query. &lt;strong&gt;Seq Scan&lt;/strong&gt; is the bad one — it means Postgres read through the entire table, row by row, to find your data. On a table with 200 rows nobody cares. On a table with millions of rows that's where your response time went. &lt;strong&gt;Index Scan&lt;/strong&gt; is what you want — Postgres found an index and skipped straight to the relevant rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Seq Scan: reading 4,999,999 rows to find 1. painful.&lt;/span&gt;
&lt;span class="n"&gt;Seq&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;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;89234&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&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;1&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;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_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;span class="k"&gt;Rows&lt;/span&gt; &lt;span class="n"&gt;Removed&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4999999&lt;/span&gt;

&lt;span class="c1"&gt;-- One index fixes it&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_user_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;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Now look at the difference&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;using&lt;/span&gt; &lt;span class="n"&gt;idx_orders_user_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="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;user_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;4 seconds to 4 milliseconds on a production table with real data. Not theoretical.&lt;/p&gt;

&lt;p&gt;There's a third one you might run into: &lt;strong&gt;Bitmap Heap Scan&lt;/strong&gt;. Postgres builds a map of which disk pages probably contain matching rows and reads those instead of scanning everything. Not usually a problem, but if your query should return like 3 rows and you see a bitmap scan, dig into it. Index might not match well or Postgres has outdated info about the data in that table.&lt;/p&gt;

&lt;p&gt;Also look at &lt;strong&gt;estimated rows&lt;/strong&gt; versus &lt;strong&gt;actual rows&lt;/strong&gt; in the output. When those are wildly different (Postgres expected 10, actually got 50,000) the planner is making decisions on wrong assumptions. Fix it by running &lt;code&gt;ANALYZE tablename&lt;/code&gt; — different command from EXPLAIN ANALYZE, it just tells Postgres to rescan and update its internal statistics. I've seen terrible query plans go to perfectly fine after that one command.&lt;/p&gt;

&lt;p&gt;Do this for every query before it ships. Not just the complicated ones — the boring three-line SELECTs are the ones that slip through review without anyone running EXPLAIN, and those tend to be the worst performers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Indexes
&lt;/h2&gt;

&lt;p&gt;Indexes speed up reads but slow down writes. Every INSERT, UPDATE, DELETE also has to update every index on that table. I've debugged write latency issues that turned out to be nothing more than too many indexes on one table — 15 of them, most added "just in case" and never used by any query.&lt;/p&gt;

&lt;p&gt;Only index what you actually filter, join, or sort on. WHERE, JOIN, ORDER BY. If someone says "let's index this column in case we need it later" — push back. You probably won't need it, and writes just got slower for nothing.&lt;/p&gt;

&lt;p&gt;One thing that confuses people: a composite index on (user_id, created_at) is a completely different thing from two separate indexes on each column. Not interchangeable even though it feels like they should be. Your query does WHERE user_id = 42 ORDER BY created_at? The composite covers both filter and sort in one pass. Separate indexes — Postgres picks one for the WHERE, grabs the rows, sorts them in memory separately.&lt;/p&gt;

&lt;p&gt;Column order in composites matters a lot. (user_id, created_at) works for queries that filter on user_id alone. It does absolutely nothing for queries filtering only on created_at. Leftmost column has to be in the query. Works like a phone book — sorted by last name first, then first name within that. Easy to find all the Smiths. Impossible to efficiently find every John across all last names.&lt;/p&gt;

&lt;p&gt;Partial indexes are another trick not enough people know about. If you've got a table where 95% of the rows are 'completed' and you're only ever querying for the 'pending' ones, you can tell Postgres to only index those:&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;created_at&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;p&gt;Tiny index, covers exactly what you need, barely any write overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Connection Pools
&lt;/h2&gt;

&lt;p&gt;Postgres defaults to 100 max connections. Your app uses a pool — reusable connections instead of opening new ones per request. Most libraries default to 10 and nobody revisits that number after initial setup.&lt;/p&gt;

&lt;p&gt;Real traffic breaks this immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pool size: 10
Requests per second: 300
Each request holds a connection for 50ms

Connections needed: 300 * 0.05 = 15
You have 10. Five requests queued at any moment.
Waits compound. p99 latency climbs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;People reference the formula &lt;code&gt;(core_count * 2) + effective_spindle_count&lt;/code&gt;. Four-core SSD box gives roughly 9. But this is a starting point at best — you need to load test with realistic traffic to find what actually works.&lt;/p&gt;

&lt;p&gt;One thing people get wrong here: cranking the pool up doesn't help. Setting it to 50 per instance can actually make performance worse. Postgres has to context-switch between connections and each one takes memory on the server side. The sweet spot is usually lower than you'd expect. PostgreSQL wiki has a good writeup on why.&lt;/p&gt;

&lt;p&gt;Kubernetes makes it worse. 10 pods × 10 pool size = 100 connections just from your app. Already hitting the default limit. Then a rolling deploy starts, new pods open connections before old pods release theirs, and for a few seconds you're way over. Seen this crash databases.&lt;/p&gt;

&lt;p&gt;PgBouncer fixes this. Sits between your app and Postgres, multiplexes connections at the protocol level. Your 10 pods each think they've got 10 connections but PgBouncer maps all of that to maybe 20 actual Postgres connections. Set it up before you actually need it — debugging connection limits at 3am while Googling "FATAL: too many clients already" is not a fun time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Slow Query Log
&lt;/h2&gt;

&lt;p&gt;The single most underrated tool in Postgres and almost nobody has it turned on:&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;log_min_duration_statement&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 1000 is milliseconds. Logs every query over one second. You can lower it to 500 or 200 later but start at a second so you don't drown in output.&lt;/p&gt;

&lt;p&gt;Leave it on in production for a day then go read the file. You will find queries you completely forgot about. Some ancient cron job nobody's looked at since it was written, an admin panel endpoint that hits a full table scan twice a year when someone actually clicks the button.&lt;/p&gt;

&lt;p&gt;Here's the thing about optimization — most teams do it by guessing. "This query has three JOINs so it's probably the slow one." Then you check and the three-JOIN query runs in 2ms because the indexes are right. Meanwhile some tiny SELECT with no WHERE index has been quietly running 10,000 times a day, each one doing a seq scan. The log doesn't guess. It just records what happened.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt; goes even further if you want it. Extension that tracks execution count, total time, average time for every distinct query. Basically gives you a leaderboard of which queries eat the most database time. Takes five minutes to turn on.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Redis Actually Makes Sense
&lt;/h2&gt;

&lt;p&gt;After queries are cleaned up. After indexes are right. After the slow query log stopped turning up problems.&lt;/p&gt;

&lt;p&gt;Then sure, put Redis in front of it.&lt;/p&gt;

&lt;p&gt;Good fit for data that changes rarely but gets read all the time — sessions, feature flags, config, product catalogs. Bad fit as a patch over a slow query. The slow query doesn't go away. It still fires on cache misses: traffic spikes, cold starts after deploys, TTL expiration at 3am when nobody's monitoring.&lt;/p&gt;

&lt;p&gt;Check if you even need a separate service. Postgres materialized views are cached query results that live right in the database. Refresh them on a schedule or trigger. For dashboards and aggregated stats, they might be enough without adding Redis to your infrastructure.&lt;/p&gt;

&lt;p&gt;If you do go with Redis, figure out invalidation before writing any code. "We'll handle cache invalidation later" always ends with users seeing stale data and filing bugs nobody can reproduce because the cache expired between the report and the investigation. TTL, write-through, or both — pick one upfront.&lt;/p&gt;

&lt;p&gt;Fix the query first. Cache if the numbers justify it after.&lt;/p&gt;




&lt;h2&gt;
  
  
  Transactions
&lt;/h2&gt;

&lt;p&gt;Transactions hold locks. Longer the transaction, longer the lock, bigger the pileup:&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="c1"&gt;// Holds a lock while sendEmail does... whatever sendEmail does&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="nx"&gt;trx&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;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// could take 2+ seconds&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inventory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stock&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Email has no business being inside a database transaction. SMTP is slow, sometimes very slow, and during that entire time you've got a lock held on the orders row while every other request that needs it waits.&lt;/p&gt;

&lt;p&gt;Move it out:&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="nx"&gt;trx&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;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inventory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stock&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"But what if the email fails?" Putting it inside the transaction doesn't actually solve that. If sendEmail throws between the two DB calls, the whole transaction rolls back — now your order isn't confirmed AND the email didn't send. Worse than before. Better approach: commit the DB work, send the email separately, handle failures with a retry queue or dead letter table. Side effects and transactions don't mix.&lt;/p&gt;

&lt;p&gt;Transactions should be short and contain only DB operations. HTTP calls, file I/O, email sends — none of that belongs in there.&lt;/p&gt;




&lt;h2&gt;
  
  
  SELECT * Considered Harmful (Sometimes)
&lt;/h2&gt;

&lt;p&gt;Problem with &lt;code&gt;SELECT *&lt;/code&gt; is that it pulls back everything on the row. Every column. Including that massive TEXT field full of HTML from the CMS feature someone bolted on two years ago that nobody remembers. You need id, name, and price for a product list — but you're also dragging 10KB of product description HTML across the wire for every single row.&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;-- Grabs everything including the giant description column&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;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;category_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Just what you need&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thumbnail_url&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;category_id&lt;/span&gt; &lt;span class="o"&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;p&gt;Non-issue on small tables. But wide tables returning hundreds of rows — the bandwidth waste shows up in response times. Postgres also has this thing called covering indexes where if every column in your SELECT is already in the index, it never has to touch the actual table. Really fast when it kicks in. &lt;code&gt;SELECT *&lt;/code&gt; prevents that from ever happening because obviously no index has every column.&lt;/p&gt;

&lt;p&gt;ORMs select everything by default. Prisma has &lt;code&gt;select&lt;/code&gt;, Sequelize has &lt;code&gt;attributes&lt;/code&gt;. Worth using for list endpoints where you don't need every field.&lt;/p&gt;




&lt;h2&gt;
  
  
  Before You Add Infrastructure
&lt;/h2&gt;

&lt;p&gt;Before Redis. Before read replicas. Before anyone brings up sharding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Run EXPLAIN ANALYZE on your slowest queries.
2. Hunt for N+1 patterns in your ORM code.
3. Add indexes on columns in WHERE, JOIN, ORDER BY.
4. Turn on the slow query log.
5. Check pool size against real traffic.
6. Nothing slow inside transactions.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2 or 3 alone fixes most apps. Rest is cleanup.&lt;/p&gt;

&lt;p&gt;New infrastructure looks great on an architecture diagram and ships fast. But if the actual problem is a missing index or a loop full of queries, none of it matters. A bad query behind three caches is still a bad query — now you just also have three caches to debug.&lt;/p&gt;

&lt;p&gt;This also isn't a one-time thing. Queries that work at 100K rows collapse at 10M. An index you added for a feature that got killed last quarter is just slowing down writes now. Check the slow query log every few weeks, not just during outages. It's ongoing work.&lt;/p&gt;

&lt;p&gt;The query plan doesn't lie.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Headless Browsers Get Detected: A Technical Breakdown</title>
      <dc:creator>Emir</dc:creator>
      <pubDate>Fri, 13 Mar 2026 17:31:42 +0000</pubDate>
      <link>https://dev.to/imkyssa/why-headless-browsers-get-detected-a-technical-breakdown-12op</link>
      <guid>https://dev.to/imkyssa/why-headless-browsers-get-detected-a-technical-breakdown-12op</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;howtocenterdiv.com — "Software engineering is more than just centering a div."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Puppeteer rats itself out in at least 11 different ways the moment it starts up — and that's before it's even loaded a single page. Scraping tutorials almost never bring this up, then act shocked when the same script that ran perfectly on localhost gets hammered in production.&lt;/p&gt;




&lt;p&gt;Here's what people get wrong: bot detection isn't a single if-statement checking one flag. It's a scoring system. Each signal you leak adds weight to a total, and once that total crosses a threshold, you're done — blocked, CAPTCHAed, or worst of all, quietly served garbage data so you don't even know it happened.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What It Checks&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TLS Fingerprint (JA3)&lt;/td&gt;
&lt;td&gt;Cipher suite order, extensions&lt;/td&gt;
&lt;td&gt;TCP handshake — before HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP/2 Fingerprint&lt;/td&gt;
&lt;td&gt;Frame settings, header order&lt;/td&gt;
&lt;td&gt;First request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;navigator&lt;/code&gt; properties&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;webdriver&lt;/code&gt;, &lt;code&gt;plugins&lt;/code&gt;, &lt;code&gt;languages&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;JS runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canvas / WebGL&lt;/td&gt;
&lt;td&gt;Rendering entropy, GPU string&lt;/td&gt;
&lt;td&gt;JS runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mouse &amp;amp; keyboard&lt;/td&gt;
&lt;td&gt;Movement patterns, timing&lt;/td&gt;
&lt;td&gt;Behavioral&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP reputation&lt;/td&gt;
&lt;td&gt;ASN, datacenter range&lt;/td&gt;
&lt;td&gt;DNS / IP layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most developers fixate on the &lt;code&gt;navigator&lt;/code&gt; layer. They patch &lt;code&gt;webdriver&lt;/code&gt;, maybe fake the user agent, and call it a day. They have no idea TLS fingerprinting has already clocked them before a single line of JavaScript ran.&lt;/p&gt;

&lt;p&gt;Detection is cumulative and concurrent. Failing one check won't get you blocked. Getting blocked happens because a handful of small failures push the score over the threshold together. You can dodge &lt;code&gt;navigator.webdriver&lt;/code&gt; perfectly and still get caught — because your JA3, canvas fingerprint, and plugin list aren't telling the same story.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #1 — &lt;code&gt;navigator.webdriver&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webdriver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Headless → true   (instant detection)&lt;/span&gt;
&lt;span class="c1"&gt;// Real browser → undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value itself isn't even the whole story. Detectors also inspect the property descriptor's configurability — that's a fingerprint of its own.&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="c1"&gt;// Looks like a fix, still detectable&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webdriver&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="na"&gt;get&lt;/span&gt;&lt;span class="p"&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="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// What a detector actually sees&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webdriver&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// → { value: false, writable: true, configurable: true }&lt;/span&gt;
&lt;span class="c1"&gt;// In real Chrome, the property doesn't exist at all — just undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic mistake is patching properties after the page loads instead of injecting before. The clock doesn't wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #2 — The &lt;code&gt;plugins&lt;/code&gt; Array
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&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="c1"&gt;// Real Chrome → 3–7&lt;/span&gt;
&lt;span class="c1"&gt;// Headless    → 0  ← one-line detection&lt;/span&gt;

&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mimeTypes&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="c1"&gt;// Real Chrome → 2+&lt;/span&gt;
&lt;span class="c1"&gt;// Headless    → 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any halfway-decent detection script checks &lt;code&gt;navigator.plugins.length === 0&lt;/code&gt; and stops right there. But stuffing in fake plugins isn't a real fix either. The names, descriptions, and mime types inside each plugin object all have to be internally consistent — and they have to match the user-agent you claimed. If your UA says Chrome 120 on macOS but your plugin list looks like Chrome on Windows, that mismatch is itself a signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #3 — Canvas Fingerprinting
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&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;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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;11pt "Times New Roman"&lt;/span&gt;&lt;span class="dl"&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;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cwm fjordbank&lt;/span&gt;&lt;span class="dl"&gt;'&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="mi"&gt;15&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;fingerprint&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;toDataURL&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Real machine   → unique hash, varies by hardware&lt;/span&gt;
&lt;span class="c1"&gt;// Headless Chrome → identical hash, every single time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Headless Chrome produces pixel-perfect identical output for identical code, no matter what machine it's running on. There's no GPU variance. Detection systems maintain databases of known headless canvas hashes. Yours is already in there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #4 — WebGL Renderer String
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt; &lt;span class="o"&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&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;webgl&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;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEBGL_debug_renderer_info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNMASKED_VENDOR_WEBGL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Real machine → "Intel Inc." / "NVIDIA Corporation"&lt;/span&gt;
&lt;span class="c1"&gt;// Headless     → "Google SwiftShader"  ← banned everywhere&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SwiftShader is Google's software renderer, built for display-less environments. That string has been identified and blacklisted across detection systems everywhere. If SwiftShader shows up, you're flagged — doesn't matter what else you've cleaned up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #5 — TLS / JA3 Fingerprint
&lt;/h2&gt;

&lt;p&gt;The first move in a TLS handshake is the client sending a &lt;code&gt;ClientHello&lt;/code&gt;. Inside it: the list of cipher suites, extensions, and elliptic curves the client supports. The ordering of those items is dictated by the underlying TLS library — not the user-agent string you set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;JA3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SSLVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Ciphers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Extensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EllipticCurves&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ECPointFormats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;TLS Library&lt;/th&gt;
&lt;th&gt;JA3 Hash&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome 120 / macOS&lt;/td&gt;
&lt;td&gt;BoringSSL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cd08e31494f9531f560d64c695473da9&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js 20 (axios/got)&lt;/td&gt;
&lt;td&gt;OpenSSL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;b32309a26951912be7dba376398abc3b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python requests&lt;/td&gt;
&lt;td&gt;Python ssl&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3b5074b1b5d032e5620f69f9f700ff0e&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can set &lt;code&gt;User-Agent: Chrome/120&lt;/code&gt; all you want. The TLS handshake already announced Node.js before any JavaScript touched the page. There is no JS-layer fix for this one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #6 — HTTP/2 Fingerprint
&lt;/h2&gt;

&lt;p&gt;Real Chrome and Node's http2 module send different &lt;code&gt;SETTINGS&lt;/code&gt; frames:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Chrome&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;:  &lt;span class="n"&gt;HEADER_TABLE_SIZE&lt;/span&gt;=&lt;span class="m"&gt;65536&lt;/span&gt;, &lt;span class="n"&gt;ENABLE_PUSH&lt;/span&gt;=&lt;span class="m"&gt;0&lt;/span&gt;, &lt;span class="n"&gt;INITIAL_WINDOW_SIZE&lt;/span&gt;=&lt;span class="m"&gt;6291456&lt;/span&gt;
&lt;span class="n"&gt;Node&lt;/span&gt;.&lt;span class="n"&gt;js&lt;/span&gt;:     &lt;span class="n"&gt;HEADER_TABLE_SIZE&lt;/span&gt;=&lt;span class="m"&gt;4096&lt;/span&gt;,  &lt;span class="n"&gt;ENABLE_PUSH&lt;/span&gt;=&lt;span class="m"&gt;1&lt;/span&gt;, &lt;span class="n"&gt;INITIAL_WINDOW_SIZE&lt;/span&gt;=&lt;span class="m"&gt;65535&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gets extracted at the load balancer level, well before any application logic sees the request.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signal #7 — Behavioral Entropy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bot movement&lt;/span&gt;
&lt;span class="nx"&gt;mousemove&lt;/span&gt;&lt;span class="p"&gt;:&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;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// perfect L-shapes, instant&lt;/span&gt;

&lt;span class="c1"&gt;// Human movement&lt;/span&gt;
&lt;span class="nx"&gt;mousemove&lt;/span&gt;&lt;span class="p"&gt;:&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;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;138&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="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;228&lt;/span&gt;&lt;span class="p"&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;// curved, variable speed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mouse movement is just one piece. Detection systems also profile keystroke timing (real humans: 50–200ms between keystrokes), scroll behavior, and how long someone spends on a page before doing anything. A bot that clicks 80 milliseconds after page load is a bot.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Math.random()&lt;/code&gt; delays don't fix this. A straight-line mouse path with randomized timing is still a straight-line mouse path.&lt;/p&gt;

&lt;p&gt;Entropy scores accumulate across full sessions, not just individual events. That's why some bots clear the first checkpoint and get flagged 30 seconds later — the score built up over time, not all at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mistake Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Why It Fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Only patching &lt;code&gt;navigator.webdriver&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;10+ signals still leak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using &lt;code&gt;got&lt;/code&gt;/&lt;code&gt;axios&lt;/code&gt; with spoofed headers&lt;/td&gt;
&lt;td&gt;JA3 still says Node.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;--disable-blink-features=AutomationControlled&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;window.chrome&lt;/code&gt; exposes automation flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datacenter proxies (AWS/GCP/Azure)&lt;/td&gt;
&lt;td&gt;ASN is blacklisted before fingerprint checks even run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;User-Agent&lt;/code&gt; without matching &lt;code&gt;sec-ch-ua&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Header contradiction — caught immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Math.random()&lt;/code&gt; delays only&lt;/td&gt;
&lt;td&gt;Timing variance isn't behavioral entropy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  How the Score Actually Adds Up
&lt;/h2&gt;

&lt;p&gt;All checks run simultaneously, scores stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IP reputation:       +0.1  (clean, residential)
JA3 mismatch:        +0.6  (Node.js TLS on Chrome UA)
navigator.webdriver: +0.0  (patched correctly)
Canvas hash:         +0.4  (known headless hash)
Plugin count:        +0.3  (empty plugins)
Mouse entropy:       +0.5  (straight-line movement)
─────────────────────────────────────────────
Total: 1.9  →  Block threshold: 1.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You nailed &lt;code&gt;navigator&lt;/code&gt;. Doesn't matter — TLS and canvas alone already pushed it over.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;sec-ch-ua&lt;/code&gt; Problem
&lt;/h2&gt;

&lt;p&gt;Chrome 90+ attaches client hints to every request. A real Chrome session looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;sec-ch-ua: "Chromium";v="120", "Google Chrome";v="120", "Not-A.Brand";v="99"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Puppeteer session that sets a modern user-agent but sends no client hints looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...
# sec-ch-ua: missing entirely
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Chrome 90+ user-agent without &lt;code&gt;sec-ch-ua&lt;/code&gt; is a physical impossibility. Flagged on arrival. And just being present isn't enough — the brand token order and version numbers have to be consistent with the full UA string.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tools vs. What They Actually Cover
&lt;/h2&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;Fixes&lt;/th&gt;
&lt;th&gt;Doesn't Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;puppeteer-extra-plugin-stealth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JS-layer signals&lt;/td&gt;
&lt;td&gt;TLS/HTTP2 fingerprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rebrowser-puppeteer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CDP leaks, runtime injection&lt;/td&gt;
&lt;td&gt;TLS, behavioral&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go + CycleTLS&lt;/td&gt;
&lt;td&gt;JA3 fingerprint&lt;/td&gt;
&lt;td&gt;Behavioral, canvas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real Chrome via CDP&lt;/td&gt;
&lt;td&gt;TLS, canvas, GPU&lt;/td&gt;
&lt;td&gt;Proxy/IP reputation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only client that passes every layer by default is a real Chrome browser, running on consumer hardware, behind a residential IP.&lt;/p&gt;

&lt;p&gt;Most guides don't even touch this. They treat detection as a checklist — knock off each item, done. But detection systems are probabilistic. They don't need certainty, just confidence. Fix 9 out of 10 signals and you can still get blocked if that last signal carries enough weight. JA3 mismatches typically score 0.5–0.7. One leak can be all it takes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
