<?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>Forem: Polliog</title>
    <description>The latest articles on Forem by Polliog (@polliog).</description>
    <link>https://forem.com/polliog</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%2F3636724%2F651dcccf-8a2e-4be9-99d6-4cd231d9e889.jpeg</url>
      <title>Forem: Polliog</title>
      <link>https://forem.com/polliog</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/polliog"/>
    <language>en</language>
    <item>
      <title>Logtide 0.9.0: Custom Dashboards, Health Monitoring, and Log Parsing Pipelines</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Sat, 11 Apr 2026 20:35:57 +0000</pubDate>
      <link>https://forem.com/polliog/logtide-090-custom-dashboards-health-monitoring-and-log-parsing-pipelines-3a8k</link>
      <guid>https://forem.com/polliog/logtide-090-custom-dashboards-health-monitoring-and-log-parsing-pipelines-3a8k</guid>
      <description>&lt;p&gt;Logtide 0.9.0 is out today. At the end of the 0.8.0 article we listed three things we wanted to tackle next: a customizable dashboard system to replace the fixed layout that had shipped since day one, proactive health monitoring so Logtide could tell you when something was down rather than waiting for a log to show up, and structured parsing pipelines for teams whose logs don't arrive pre-formatted. All three ship in this release.&lt;/p&gt;

&lt;p&gt;If you're new here: Logtide is an open-source log management and SIEM platform built for European SMBs. Privacy-first, self-hostable, GDPR-compliant. No Elastic cluster to babysit just Docker Compose and the storage engine of your choice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Cloud&lt;/strong&gt;: &lt;a href="https://logtide.dev" rel="noopener noreferrer"&gt;logtide.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/logtide-dev/logtide" rel="noopener noreferrer"&gt;logtide-dev/logtide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 &lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://logtide.dev/docs" rel="noopener noreferrer"&gt;logtide.dev/docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's New
&lt;/h2&gt;

&lt;h3&gt;
  
  
  📊 Custom Dashboards: 9 Panel Types, Drag-to-Resize, and YAML Export
&lt;/h3&gt;

&lt;p&gt;The fixed dashboard that shipped in 0.1.0 had a good run. It was a reasonable starting point 4 stat cards, log volume, top services, top error messages but it served everyone the same view regardless of what they actually cared about. 0.9.0 replaces it with a fully composable dashboard system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboards are org-scoped&lt;/strong&gt; with an optional &lt;code&gt;is_personal&lt;/code&gt; flag for views you don't want to share with the whole team. The Default dashboard is auto-created per organization and protected from deletion. A header dropdown lets you switch, create, clone, import, and export dashboards without leaving the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9 panel types&lt;/strong&gt; cover every data source in Logtide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Time series&lt;/em&gt; and &lt;em&gt;single stat&lt;/em&gt; for general log-based metrics&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Top-N table&lt;/em&gt; for ranking services, endpoints, or users by any dimension&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Live log stream&lt;/em&gt; for a real-time tail of filtered log output&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Alert status&lt;/em&gt; for a current-state view of your active alert rules&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Metric chart&lt;/em&gt; and &lt;em&gt;metric stat&lt;/em&gt; for OTLP metrics with avg/sum/min/max/count/last/p50/p95/p99 aggregations&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Trace latency&lt;/em&gt; for p50/p95/p99 directly from span data&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Detection events&lt;/em&gt; for SIEM incidents grouped by severity&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Monitor status&lt;/em&gt; for uptime percentage and response time from the new monitoring system (more on that below)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Layout&lt;/strong&gt; is a responsive 12-column grid. Panels snap to grid units when resized drag the bottom-right handle. The grid collapses to 6 columns on tablet and 1 column on mobile; stored widths are always in the 12-col reference and scale proportionally, so a panel that takes up half the desktop doesn't become a sliver on a small screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline edit mode&lt;/strong&gt; keeps all pending changes in a local snapshot. Toggle edit, rearrange, resize, and configure as many panels as you want. Hit Save for a single atomic write, or Cancel to discard everything. There's no separate edit page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YAML import/export&lt;/strong&gt; lets you version-control dashboards alongside your infrastructure code. Import regenerates panel IDs and uses &lt;code&gt;JSON_SCHEMA&lt;/code&gt; validation to block prototype pollution from crafted inputs. The schema is versioned (&lt;code&gt;schema_version: 1&lt;/code&gt;) and ships with a migration framework in &lt;code&gt;@logtide/shared&lt;/code&gt;: each version defines a &lt;code&gt;MigrationFn&lt;/code&gt;, and &lt;code&gt;migrateDashboard&lt;/code&gt; walks the chain on every read. Future schema changes will be applied automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panel data fetching&lt;/strong&gt; is batched: a single &lt;code&gt;POST /:id/panels/data&lt;/code&gt; round-trip fetches all panel data via &lt;code&gt;Promise.allSettled&lt;/code&gt;. An error in one panel doesn't fail the rest of the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-org isolation&lt;/strong&gt; is enforced at the data layer: every panel fetch verifies that &lt;code&gt;config.projectId&lt;/code&gt; belongs to the requesting org. A crafted YAML import pointing at another org's project ID will return empty data, not that org's data.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;panel registry architecture&lt;/strong&gt; is worth a mention for contributors. Adding a new panel type touches exactly 6 files: shared types, backend Zod schema, backend fetcher, frontend panel component, frontend config form, and a single registry entry. The renderer, container, store, and routes never change.&lt;/p&gt;

&lt;p&gt;Existing users will see no visual change on first login the auto-created Default dashboard replicates the previous fixed layout exactly.&lt;/p&gt;




&lt;h3&gt;
  
  
  🖥️ Service Health Monitoring and Public Status Pages
&lt;/h3&gt;

&lt;p&gt;Logtide has always been reactive: something breaks, logs appear, you find out. 0.9.0 adds the proactive layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three monitor types&lt;/strong&gt; cover the common cases. HTTP/HTTPS monitors are fully configurable: method, expected status code, custom headers, and a body assertion that accepts either a contains check or a regex. TCP monitors ping a host:port pair. Heartbeat monitors flip the model instead of Logtide reaching out, your service sends a &lt;code&gt;POST /api/v1/monitors/:id/heartbeat&lt;/code&gt; on a schedule, and Logtide fires an incident when the expected ping doesn't arrive within the grace window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker execution&lt;/strong&gt; follows the same BullMQ pattern used throughout the codebase. A worker picks up all due monitors every 30 seconds and runs them in batches of 20 concurrent checks via &lt;code&gt;Promise.allSettled&lt;/code&gt;. Results flow into the &lt;code&gt;monitor_results&lt;/code&gt; hypertable with 7-day compression and 30-day retention. A &lt;code&gt;monitor_uptime_daily&lt;/code&gt; continuous aggregate refreshed hourly powers the uptime percentage displays without hitting raw data on every page load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incident creation&lt;/strong&gt; is automatic and integrated with the existing SIEM layer. When consecutive failures cross the configurable threshold, an incident is created with &lt;code&gt;source: 'monitor'&lt;/code&gt; and linked via &lt;code&gt;monitor_id&lt;/code&gt;. Notifications go through the same email and webhook channels already configured for alert rules no separate notification setup. Auto-resolution fires when the next check succeeds. An atomic &lt;code&gt;WHERE incident_id IS NULL&lt;/code&gt; guard prevents duplicate incidents under concurrent check runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Severity is configurable per monitor&lt;/strong&gt; (&lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt;, &lt;code&gt;medium&lt;/code&gt;, &lt;code&gt;low&lt;/code&gt;, &lt;code&gt;informational&lt;/code&gt;) rather than hardcoded. A flaky dev endpoint and a production payment service don't need to page with the same urgency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public status pages&lt;/strong&gt; (&lt;code&gt;/status/:projectSlug&lt;/code&gt;) are Uptime Kuma-inspired: a 45-day heartbeat bar grid, per-monitor uptime badge, overall status banner, and a light/dark mode toggle. Visibility is configured per project disabled by default, with public, password-protected, and org-members-only options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled maintenances&lt;/strong&gt; let you define windows with start and end times. Active maintenances suppress monitor incident creation so a planned deployment doesn't trigger pages, and display a maintenance banner on the status page so your users know what's happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual status incidents&lt;/strong&gt; are independent from SIEM incidents. You can publish communications with an Investigating/Identified/Monitoring/Resolved progression and a full update timeline useful for communicating with users about an outage regardless of whether it was auto-detected.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;monitoring dashboard&lt;/strong&gt; (&lt;code&gt;/dashboard/monitoring&lt;/code&gt;) has a project selector, create/edit/delete forms with client-side validation, a detail page with an uptime chart and recent checks list, and a one-click heartbeat URL copy for the heartbeat monitor type.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔩 Log Parsing and Enrichment Pipelines
&lt;/h3&gt;

&lt;p&gt;Structured logging is a best practice, but not every log source you connect will cooperate. Nginx access logs, syslog output from legacy systems, plain text from third-party services these arrive as unstructured strings. Previously you'd parse them in your collector config or accept that they'd be stored as blobs. 0.9.0 gives you a better option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipelines run as BullMQ background jobs&lt;/strong&gt; after ingestion acknowledgment. Ingestion latency is unchanged logs are accepted and queued immediately, parsing happens asynchronously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five built-in parsers&lt;/strong&gt; cover the common formats: nginx (combined log format), apache (identical pattern), syslog (RFC 3164 and RFC 5424), logfmt, and JSON message body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom grok patterns&lt;/strong&gt; use &lt;code&gt;%{PATTERN:field}&lt;/code&gt; and &lt;code&gt;%{PATTERN:field:type}&lt;/code&gt; syntax, with 22 named built-ins (IPV4, WORD, NOTSPACE, NUMBER, POSINT, DATA, GREEDYDATA, QUOTEDSTRING, METHOD, URIPATH, HTTPDATE, and more) and optional type coercion (&lt;code&gt;:int&lt;/code&gt;, &lt;code&gt;:float&lt;/code&gt;). If your log format is unusual enough that none of the built-in parsers cover it, grok handles the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GeoIP enrichment&lt;/strong&gt; uses the embedded MaxMind GeoLite2 database. Point it at any field containing an IP address and get country, city, coordinates, timezone, and ISP added to the log record automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope is flexible&lt;/strong&gt;: a pipeline can target a specific project or apply org-wide. Project-specific pipelines take priority over org-wide ones when both match. An in-memory cache in &lt;code&gt;getForProject&lt;/code&gt; holds the resolved pipeline per project for 5 minutes, invalidated automatically on create/update/delete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline preview&lt;/strong&gt; lets you test any combination of steps against a sample log message before saving. The UI shows per-step extracted fields and the final merged result side by side, so you can iterate on the configuration without committing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YAML import/export&lt;/strong&gt; follows the same pattern as dashboards: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;enabled&lt;/code&gt;, and &lt;code&gt;steps&lt;/code&gt; fields; re-importing the same pipeline for the same scope performs an upsert rather than creating a duplicate.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;step builder&lt;/strong&gt; in the settings UI (&lt;code&gt;/dashboard/settings/pipelines&lt;/code&gt;) lets you add, reorder, and configure steps interactively, with per-type configuration forms for parser selection, grok pattern input, and GeoIP field targeting.&lt;/p&gt;




&lt;h3&gt;
  
  
  Everything Else Worth Knowing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Monitoring in the sidebar&lt;/strong&gt;: the monitoring section appears under "Detect" alongside Alerts and Security. No extra navigation to find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard switcher in the header&lt;/strong&gt;: replaces the previous single fixed entry point with a dropdown that handles create, delete, import, and export without leaving the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;failureThreshold&lt;/code&gt; default aligned&lt;/strong&gt;: the frontend form default was 3; the backend default was 2. They now match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project slugs&lt;/strong&gt;: auto-generated from project name on creation, unique per org, backfilled for existing projects via migration. The status page route (&lt;code&gt;/status/:projectSlug&lt;/code&gt;) uses these.&lt;/p&gt;




&lt;h2&gt;
  
  
  Upgrading
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrations run automatically on startup. No manual steps required.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The roadmap toward v1.0 has a few clear remaining pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Digest reports&lt;/strong&gt; (#154): scheduled email summaries of log volume, top errors, and active incidents -- useful for teams that don't live in the dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook receivers&lt;/strong&gt; (#154): accept inbound webhooks from external services (PagerDuty, GitHub, Stripe, etc.) and normalize them into Logtide log events without a collector in the middle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;v1.0 is the Beta milestone. We're not jumping straight to a public Beta declaration we want the announcement to mean something. These issue groups are the remaining distance.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Full Changelog&lt;/strong&gt;: &lt;a href="https://github.com/logtide-dev/logtide/compare/v0.8.0...v0.9.0" rel="noopener noreferrer"&gt;v0.8.0...v0.9.0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're using Logtide, open an issue, start a discussion, or drop a ⭐ if it's been useful.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>devops</category>
      <category>discuss</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>TigerFS: A Filesystem Backed by PostgreSQL</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:32:32 +0000</pubDate>
      <link>https://forem.com/polliog/tigerfs-a-filesystem-backed-by-postgresql-50i</link>
      <guid>https://forem.com/polliog/tigerfs-a-filesystem-backed-by-postgresql-50i</guid>
      <description>&lt;p&gt;TigerFS is a filesystem backed by PostgreSQL, built by the Timescale team. It mounts a database as a local directory via FUSE on Linux and NFS on macOS. Every file is a real row. Every directory is a table. Writes are transactions. Multiple processes and machines can read and write concurrently with full ACID guarantees.&lt;/p&gt;

&lt;p&gt;There are two distinct ways to use it.&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;# Install (Linux requires fuse3; macOS needs no extra dependencies)&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://install.tigerfs.io | sh

&lt;span class="c"&gt;# Mount any PostgreSQL database&lt;/span&gt;
tigerfs mount postgres://localhost/mydb /mnt/db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Mode 1: Data-First
&lt;/h2&gt;

&lt;p&gt;Mount any existing PostgreSQL database and explore it with standard UNIX tools. Every path resolves to optimized SQL that gets pushed down to the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exploring
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/db/                                          &lt;span class="c"&gt;# list tables&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/db/users/                                    &lt;span class="c"&gt;# list rows by primary key&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/users/123.json                           &lt;span class="c"&gt;# read a row as JSON&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/users/123/email.txt                      &lt;span class="c"&gt;# read a single column&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/users/.by/email/alice@example.com.json   &lt;span class="c"&gt;# lookup by indexed column&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Modifying
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'new@example.com'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/users/123/email.txt          &lt;span class="c"&gt;# update a column&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"a@b.com","name":"A"}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/users/123.json  &lt;span class="c"&gt;# PATCH via JSON&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; /mnt/db/users/456                                         &lt;span class="c"&gt;# insert a row&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; /mnt/db/users/456/                                        &lt;span class="c"&gt;# delete a row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pipeline Queries
&lt;/h3&gt;

&lt;p&gt;Filters, ordering, and pagination can be chained directly in the path. TigerFS executes the whole chain as a single SQL query:&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;# Last 10 orders for customer 123, sorted by created_at, as JSON&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/orders/.by/customer_id/123/.order/created_at/.last/10/.export/json

&lt;span class="c"&gt;# Shipped orders, specific columns only, as CSV&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/orders/.filter/status/shipped/.columns/id,total,created_at/.export/csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Available segments (chainable in any order):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.by/col/val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Indexed filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.filter/col/val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any column filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.order/col&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.columns/a,b,c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Column projection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.first/N&lt;/code&gt;, &lt;code&gt;.last/N&lt;/code&gt;, &lt;code&gt;.sample/N&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pagination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`.export/json\&lt;/td&gt;
&lt;td&gt;csv\&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Bulk Ingest
&lt;/h3&gt;

&lt;p&gt;{% raw %}&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;data.csv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/orders/.import/.append/csv    &lt;span class="c"&gt;# append rows&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;data.csv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/orders/.import/.sync/csv      &lt;span class="c"&gt;# upsert by primary key&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;data.csv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/orders/.import/.overwrite/csv &lt;span class="c"&gt;# replace the table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Schema Management
&lt;/h3&gt;

&lt;p&gt;Tables, indexes, and views are managed through a staging pattern:&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="nb"&gt;mkdir&lt;/span&gt; /mnt/db/.create/orders
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CREATE TABLE orders (...)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/.create/orders/sql
&lt;span class="nb"&gt;touch&lt;/span&gt; /mnt/db/.create/orders/.commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Mode 2: File-First
&lt;/h2&gt;

&lt;p&gt;Create a new database and use it as a transactional shared workspace. Any tool that works with files works here: AI agents, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;vim&lt;/code&gt;, shell scripts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Markdown Apps
&lt;/h3&gt;

&lt;p&gt;"Apps" define how TigerFS presents a table as a native file format. Writing &lt;code&gt;markdown&lt;/code&gt; to &lt;code&gt;.build/&lt;/code&gt; turns a table into a directory of &lt;code&gt;.md&lt;/code&gt; files where YAML frontmatter maps to columns and the document body maps to a text column:&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"markdown"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/.build/blog

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/blog/hello-world.md &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
---
title: Hello World
author: alice
tags: [intro]
---

# Hello World

Welcome to my blog...
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Standard tools work as expected&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"author: alice"&lt;/span&gt; /mnt/db/blog/&lt;span class="k"&gt;*&lt;/span&gt;.md
&lt;span class="nb"&gt;mkdir&lt;/span&gt; /mnt/db/blog/tutorials
&lt;span class="nb"&gt;mv&lt;/span&gt; /mnt/db/blog/hello-world.md /mnt/db/blog/tutorials/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Version History
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;history&lt;/code&gt; to the app definition and every edit is captured as a timestamped snapshot in a read-only &lt;code&gt;.history/&lt;/code&gt; directory. History uses TimescaleDB hypertables for compressed storage and tracks files across renames via stable row UUIDs:&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"markdown,history"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/.build/notes

&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/db/notes/.history/hello.md/
&lt;span class="c"&gt;# 2026-02-24T150000Z  2026-02-12T013000Z&lt;/span&gt;

&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/notes/.history/hello.md/2026-02-12T013000Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Multi-Agent Task Queue
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mv&lt;/code&gt; between directories is an atomic database operation. Two agents cannot claim the same task because the underlying transaction will fail for one of them - no distributed lock manager, no coordination API needed:&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"markdown,history"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/.build/tasks
&lt;span class="nb"&gt;mkdir&lt;/span&gt; /mnt/db/tasks/todo /mnt/db/tasks/doing /mnt/db/tasks/done

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/tasks/todo/fix-auth-bug.md &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
---
priority: high
assigned_to:
---
The OAuth token refresh is failing for users with...
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Agent claims the task - atomic database operation&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; /mnt/db/tasks/todo/fix-auth-bug.md /mnt/db/tasks/doing/fix-auth-bug.md

&lt;span class="c"&gt;# Agent marks it done&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; /mnt/db/tasks/doing/fix-auth-bug.md /mnt/db/tasks/done/fix-auth-bug.md

&lt;span class="c"&gt;# Check what is in progress&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/db/tasks/doing/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"assigned_to:"&lt;/span&gt; /mnt/db/tasks/doing/&lt;span class="k"&gt;*&lt;/span&gt;.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Shared Agent Workspace
&lt;/h3&gt;

&lt;p&gt;Multiple agents on different machines can read and write the same files concurrently. Changes are visible immediately with no pull, push, or merge step:&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;# Agent A writes findings&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/kb/auth-analysis.md &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
---
author: agent-a
---
OAuth 2.0 is the recommended approach because...
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Agent B reads immediately, no sync needed&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /mnt/db/kb/auth-analysis.md

&lt;span class="c"&gt;# Agent B updates the document&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /mnt/db/kb/auth-analysis.md &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
---
author: agent-a
reviewed-by: agent-b
status: approved
---
OAuth 2.0 is the recommended approach because... [approved with comments]
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Full edit trail&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/db/kb/.history/auth-analysis.md/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cloud Backends
&lt;/h2&gt;

&lt;p&gt;TigerFS works with any PostgreSQL database via connection string. It also integrates with Timescale Cloud and Ghost through their CLIs - no passwords stored in config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tigerfs mount postgres://user:pass@host/mydb /mnt/db

tigerfs mount tiger:abcde12345 /mnt/db   &lt;span class="c"&gt;# Timescale Cloud&lt;/span&gt;
tigerfs mount ghost:fghij67890 /mnt/db   &lt;span class="c"&gt;# Ghost&lt;/span&gt;

&lt;span class="c"&gt;# Fork a database for safe experimentation&lt;/span&gt;
tigerfs fork /mnt/db my-experiment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why the File Interface
&lt;/h2&gt;

&lt;p&gt;The point of the filesystem abstraction is that every tool already speaks it. &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, shell scripts, AI coding agents (Claude Code, Cursor, and others) all understand files without any SDK, schema definition, or client library to set up.&lt;/p&gt;

&lt;p&gt;For multi-agent coordination specifically:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What you build on top&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Custom REST API&lt;/td&gt;
&lt;td&gt;Endpoints, auth, deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared database directly&lt;/td&gt;
&lt;td&gt;SQL or ORM, schema definitions, client libraries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git&lt;/td&gt;
&lt;td&gt;Pull/push/merge workflow, conflict resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;No transactions, no structured queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TigerFS&lt;/td&gt;
&lt;td&gt;Mount and use standard tools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The coordination logic - atomic task claims, version history, concurrent access - lives in PostgreSQL. The application doesn't implement it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Current Status
&lt;/h2&gt;

&lt;p&gt;TigerFS is at v0.5.0 and described as early-stage by the team, though the core design is stable. The data-first mode is functional today for any PostgreSQL database. Planned additions include support for tables without primary keys (read-only via ctid) and TimescaleDB hypertable time-based navigation.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/timescale/tigerfs" rel="noopener noreferrer"&gt;timescale/tigerfs&lt;/a&gt; - Docs: &lt;a href="https://tigerfs.io" rel="noopener noreferrer"&gt;tigerfs.io&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tigerfs mount postgres://localhost/yourdb /mnt/db
&lt;span class="nb"&gt;ls&lt;/span&gt; /mnt/db/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>postgres</category>
      <category>database</category>
      <category>devtools</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Replaced ElastiCache with Valkey on ECS (And Cut the Bill by 70%)</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Wed, 08 Apr 2026 11:14:38 +0000</pubDate>
      <link>https://forem.com/aws-builders/i-replaced-elasticache-with-valkey-on-ecs-and-cut-the-bill-by-70-14ga</link>
      <guid>https://forem.com/aws-builders/i-replaced-elasticache-with-valkey-on-ecs-and-cut-the-bill-by-70-14ga</guid>
      <description>&lt;p&gt;ElastiCache is a genuinely good service. Managed failover, automated backups, CloudWatch integration out of the box. For teams that need Redis and don't want to operate it, it makes sense.&lt;/p&gt;

&lt;p&gt;The price, however, does not scale down. A &lt;code&gt;cache.t4g.small&lt;/code&gt; (2 vCPU, 1.37GB RAM) runs about $25/month in eu-west-1. A &lt;code&gt;cache.r7g.large&lt;/code&gt; (2 vCPU, 13.07GB RAM) is $175/month. Multi-AZ doubles those numbers. For a startup or side project running on a single-digit revenue, that's a significant line item for what is often a queue and a session cache.&lt;/p&gt;

&lt;p&gt;Valkey is a Redis-compatible open-source project under the Linux Foundation, backed by AWS, Google, and others. It forked from Redis 7.2.4 (BSD-3 license) and maintains full protocol compatibility. Every client library that works with Redis works with Valkey: &lt;code&gt;ioredis&lt;/code&gt;, &lt;code&gt;node-redis&lt;/code&gt;, BullMQ, Sidekiq, Celery. No code changes.&lt;/p&gt;

&lt;p&gt;Here's how to run it on ECS Fargate and what it actually costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;We're running Valkey as an ECS service on Fargate, backed by an EFS volume for persistence, inside a VPC with a security group that restricts access to the application services only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VPC
├── Public Subnet
│   └── Application Load Balancer
├── Private Subnet A
│   ├── ECS Service: App (Fargate)
│   └── ECS Service: Valkey (Fargate)
└── Private Subnet B
    └── ECS Service: App (Fargate) - replica
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Valkey doesn't need to be publicly accessible. It lives in the private subnet and is reachable only from the application services in the same VPC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: EFS Volume for Persistence
&lt;/h2&gt;

&lt;p&gt;Fargate tasks are ephemeral. Without a persistent volume, every Valkey restart loses your data. EFS solves this without managing EC2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/efs.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_efs_file_system"&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;creation_token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.app_name}-valkey"&lt;/span&gt;
  &lt;span class="nx"&gt;encrypted&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle_policy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;transition_to_ia&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AFTER_7_DAYS"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.app_name}-valkey"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_efs_mount_target"&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;file_system_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_efs_file_system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;efs&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="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"efs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.app_name}-efs"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;

  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2049&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2049&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valkey_task&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important: use an EFS Access Point, not &lt;code&gt;rootDirectory&lt;/code&gt; directly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Specifying a &lt;code&gt;rootDirectory&lt;/code&gt; that doesn't physically exist on a fresh EFS filesystem causes the Fargate task to fail immediately with &lt;code&gt;ResourceInitializationError: failed to invoke EFS utils... directory does not exist&lt;/code&gt;. Fargate won't create the directory automatically.&lt;/p&gt;

&lt;p&gt;EFS Access Points handle this correctly - they create the directory with the right UNIX permissions if it doesn't exist yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_efs_access_point"&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;file_system_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_efs_file_system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;posix_user&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
    &lt;span class="nx"&gt;gid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;root_directory&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;"/valkey"&lt;/span&gt;
    &lt;span class="nx"&gt;creation_info&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;owner_uid&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
      &lt;span class="nx"&gt;owner_gid&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
      &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0755"&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;
  
  
  Step 2: ECS Task Definition
&lt;/h2&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;"family"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"networkMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"awsvpc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiresCompatibilities"&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;"FARGATE"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cpu"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1024"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"executionRoleArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"volumes"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"efsVolumeConfiguration"&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;"fileSystemId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fs-XXXXXXXXX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"transitEncryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ENABLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"authorizationConfig"&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;"accessPointId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fsap-XXXXXXXXX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"iam"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ENABLED"&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;"containerDefinitions"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey/valkey:8.0-alpine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"portMappings"&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;"containerPort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6379&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"protocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tcp"&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;"command"&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;"valkey-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--save"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"60"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--appendonly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--appendfsync"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"everysec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--maxmemory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"800mb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--maxmemory-policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allkeys-lru"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--requirepass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VALKEY_PASSWORD_FROM_SECRETS_MANAGER"&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;"mountPoints"&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;"sourceVolume"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"containerPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"readOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;"logConfiguration"&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;"logDriver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"awslogs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"options"&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;"awslogs-group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/ecs/valkey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"awslogs-region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"awslogs-stream-prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey"&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;"healthCheck"&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="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CMD-SHELL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"valkey-cli ping | grep PONG"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"interval"&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;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"retries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"startPeriod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&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;A few things worth noting in this config:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--save 60 1000&lt;/code&gt; triggers an RDB snapshot every 60 seconds if at least 1000 keys changed. Combined with &lt;code&gt;--appendonly yes&lt;/code&gt;, you get both AOF and RDB persistence - the AOF gives you per-second durability, the RDB gives you faster restart times.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--maxmemory-policy allkeys-lru&lt;/code&gt; means Valkey will evict the least recently used keys when it hits the memory limit. For a cache workload this is usually what you want. For a queue workload (BullMQ, Sidekiq) you should use &lt;code&gt;noeviction&lt;/code&gt; instead and alert on memory pressure.&lt;/p&gt;

&lt;p&gt;The password comes from Secrets Manager via the &lt;code&gt;secrets&lt;/code&gt; field in the task definition rather than a hardcoded string. The example above is simplified for readability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: ECS Service and Service Discovery
&lt;/h2&gt;

&lt;p&gt;For the application to connect to Valkey, it needs a stable hostname. ECS Service Discovery provides this via AWS Cloud Map.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/service-discovery.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_service_discovery_private_dns_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"internal"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"internal.${var.app_name}"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_service_discovery_service"&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt;

  &lt;span class="nx"&gt;dns_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_service_discovery_private_dns_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

    &lt;span class="nx"&gt;dns_records&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ttl&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"A"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;routing_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MULTIVALUE"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;health_check_custom_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failure_threshold&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_service"&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"valkey"&lt;/span&gt;
  &lt;span class="nx"&gt;cluster&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecs_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;task_definition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecs_task_definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;desired_count&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;launch_type&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;

  &lt;span class="nx"&gt;network_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnets&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valkey_task&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;assign_public_ip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;service_registries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;registry_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_service_discovery_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Prevent ECS from cycling the task during deployments&lt;/span&gt;
  &lt;span class="c1"&gt;# when the app has active connections&lt;/span&gt;
  &lt;span class="nx"&gt;deployment_minimum_healthy_percent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="nx"&gt;deployment_maximum_percent&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application connects to &lt;code&gt;valkey.internal.your-app:6379&lt;/code&gt;. When the task is replaced (restart, deployment), the DNS record updates automatically within the TTL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Connecting from Node.js
&lt;/h2&gt;

&lt;p&gt;No code changes from a Redis setup. &lt;code&gt;ioredis&lt;/code&gt; works as-is:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ioredis&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;valkey&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;Redis&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;host&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VALKEY_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// valkey.internal.your-app&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6379&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;password&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VALKEY_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Retry on connection loss - important for task replacement events&lt;/span&gt;
  &lt;span class="na"&gt;retryStrategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;times&lt;/span&gt; &lt;span class="o"&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;3000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;maxRetriesPerRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enableOfflineQueue&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="na"&gt;lazyConnect&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="nx"&gt;valkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&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;err&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="s2"&gt;Valkey connection error:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;valkey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reconnecting&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="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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Valkey reconnecting...&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;p&gt;BullMQ requires no changes either:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Worker&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="s2"&gt;bullmq&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;valkey&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="s2"&gt;./valkey&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;emailQueue&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;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;emails&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;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;valkey&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;worker&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;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;emails&lt;/span&gt;&lt;span class="dl"&gt;"&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;job&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;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;valkey&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 Cost Comparison
&lt;/h2&gt;

&lt;p&gt;Running Valkey on Fargate with 0.5 vCPU and 1GB RAM in eu-west-1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The Fargate numbers below use on-demand pricing ($0.04048/vCPU-hour, $0.004445/GB-hour). If you're using a Compute Savings Plan (common for Fargate workloads), expect 20-40% lower compute costs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Monthly cost (on-demand)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fargate compute (0.5 vCPU)&lt;/td&gt;
&lt;td&gt;$14.77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fargate memory (1GB)&lt;/td&gt;
&lt;td&gt;$3.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EFS storage (5GB used)&lt;/td&gt;
&lt;td&gt;$1.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EFS throughput&lt;/td&gt;
&lt;td&gt;$0.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch logs&lt;/td&gt;
&lt;td&gt;$0.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$20.30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;vs. ElastiCache &lt;code&gt;cache.t4g.small&lt;/code&gt; (1.37GB RAM):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ElastiCache t4g.small&lt;/td&gt;
&lt;td&gt;$25.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$25.20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Single-node, the savings are modest (~20%). The real comparison is Multi-AZ ElastiCache, which is the production-grade option: a Multi-AZ &lt;code&gt;cache.t4g.small&lt;/code&gt; is $50.40/month. Against that, Fargate at ~$20 is a 60% reduction - and with a Savings Plan applied, closer to 70%.&lt;/p&gt;

&lt;p&gt;For a &lt;code&gt;cache.r7g.large&lt;/code&gt; workload (13GB RAM), the numbers shift further:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ElastiCache r7g.large&lt;/td&gt;
&lt;td&gt;$175.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ElastiCache r7g.large Multi-AZ&lt;/td&gt;
&lt;td&gt;$350.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fargate (2 vCPU / 13GB, on-demand)&lt;/td&gt;
&lt;td&gt;~$108.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fargate (2 vCPU / 13GB, Savings Plan)&lt;/td&gt;
&lt;td&gt;~$70.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The savings are real. So is the operational difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Give Up
&lt;/h2&gt;

&lt;p&gt;ElastiCache manages automatic failover, Multi-AZ replication, and rolling upgrades. With Fargate, you're responsible for all of that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No automatic failover.&lt;/strong&gt; If the Valkey Fargate task dies, ECS will restart it automatically (typically 30-90 seconds). During that window, connections fail. For a cache this is usually acceptable. For a job queue, your workers will queue errors and retry - still acceptable if you've configured retries correctly. For session storage, users get logged out. Decide based on your workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual upgrades.&lt;/strong&gt; You control the Docker image tag. Update the task definition, trigger a new deployment. No automatic patch management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No Multi-AZ replication out of the box.&lt;/strong&gt; If you need a hot standby, you'll need to set up Valkey's built-in replication between two Fargate tasks and handle failover at the application level or with an intermediate proxy. This adds complexity that may not be worth it below a certain scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence responsibility.&lt;/strong&gt; EFS gives you durable storage, but you're responsible for backup strategy. Set up AWS Backup for the EFS volume or use Valkey's &lt;code&gt;BGSAVE&lt;/code&gt; + S3 export for point-in-time backups.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Makes Sense
&lt;/h2&gt;

&lt;p&gt;This setup is the right call when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your workload is a queue, cache, or session store without strict HA requirements&lt;/li&gt;
&lt;li&gt;You're running a startup, side project, or internal tool where a 60-second outage is acceptable&lt;/li&gt;
&lt;li&gt;You're already paying for Fargate and EFS for other services&lt;/li&gt;
&lt;li&gt;The ElastiCache bill is a meaningful percentage of your monthly AWS spend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the wrong call when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need sub-second automatic failover&lt;/li&gt;
&lt;li&gt;You're storing session data where eviction causes user-visible disruption&lt;/li&gt;
&lt;li&gt;Your compliance requirements mandate managed services with AWS support coverage&lt;/li&gt;
&lt;li&gt;You're operating at a scale where the operational overhead of self-managed infrastructure costs more than the ElastiCache bill&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The break-even point for most teams is somewhere around $100-150/month in Redis costs. Below that, ElastiCache's convenience usually wins. Above it, self-hosted starts to look attractive even accounting for the operational investment.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Running Valkey on AWS in a different configuration? Different numbers for your region or workload? Happy to hear it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>redis</category>
      <category>valkey</category>
      <category>ecs</category>
    </item>
    <item>
      <title>Your Node.js App Is Probably Killing Your PostgreSQL (Connection Pooling Explained)</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Mon, 06 Apr 2026 20:50:47 +0000</pubDate>
      <link>https://forem.com/polliog/your-nodejs-app-is-probably-killing-your-postgresql-connection-pooling-explained-1db2</link>
      <guid>https://forem.com/polliog/your-nodejs-app-is-probably-killing-your-postgresql-connection-pooling-explained-1db2</guid>
      <description>&lt;p&gt;A few months ago I was looking at why a PostgreSQL instance was running at 94% memory on a server that, by all accounts, should have had plenty of headroom. The queries were fast, the data volume was modest, and CPU was barely touched.&lt;/p&gt;

&lt;p&gt;The culprit was 280 open connections.&lt;/p&gt;

&lt;p&gt;No single connection was doing anything particularly expensive. But each one carries a cost that most developers don't think about until they're in production staring at an OOM kill: PostgreSQL spawns a dedicated backend process per connection, and each process consumes roughly 5-10MB of RAM regardless of whether it's actively running a query.&lt;/p&gt;

&lt;p&gt;280 connections x 7MB average = 1.96GB. On a server with 4GB RAM and PostgreSQL's own memory settings (shared_buffers, work_mem), that leaves almost nothing for actual query execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Node.js Apps Over-Connect
&lt;/h2&gt;

&lt;p&gt;The problem is architectural. Node.js applications are typically deployed as multiple processes or containers: a web server, one or more background workers, maybe a separate process for scheduled jobs. Each runs its own connection pool. Each pool opens connections eagerly.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;pg&lt;/code&gt; and a default pool size of 10, and 3 services each with 3 replicas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;web server (3 replicas x 10 connections) = 30 connections
background worker (3 replicas x 10 connections) = 30 connections
job scheduler (3 replicas x 5 connections) = 15 connections
Total: 75 connections at idle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a traffic spike, pool expansion, and a few long-running queries holding connections open, and you're at 150+ before anything goes wrong with your code.&lt;/p&gt;

&lt;p&gt;PostgreSQL's default &lt;code&gt;max_connections&lt;/code&gt; is 100. Many managed databases (RDS, Supabase, Neon) set it lower for small instance sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When You Hit the Limit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: remaining connection slots are reserved for non-replication superuser connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, worse, requests that queue indefinitely waiting for a connection that never frees up because every connection is held by a slow query, and the slow query is slow because it can't get a lock, because another connection holds it, and that connection is waiting for... a connection.&lt;/p&gt;

&lt;p&gt;You get the idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong Fix
&lt;/h2&gt;

&lt;p&gt;The instinct is to increase &lt;code&gt;max_connections&lt;/code&gt;. This works until it doesn't: more connections means more RAM pressure, more context switching, and more lock contention. PostgreSQL is not designed for thousands of concurrent connections. It's designed for dozens of active queries with efficient I/O, and it's exceptional at that.&lt;/p&gt;

&lt;p&gt;The right fix is to not open connections you don't need.&lt;/p&gt;

&lt;h2&gt;
  
  
  PgBouncer: A Connection Pool in Front of PostgreSQL
&lt;/h2&gt;

&lt;p&gt;PgBouncer sits between your application and PostgreSQL. Your application thinks it's talking to PostgreSQL directly - same protocol, same port behavior. PgBouncer maintains a much smaller pool of real PostgreSQL connections and multiplexes client connections onto them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App (100 client connections)
         |
    [PgBouncer]
         |
PostgreSQL (20 server connections)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100 application connections, 20 actual PostgreSQL connections. The application never notices.&lt;/p&gt;

&lt;p&gt;PgBouncer has three pooling modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session pooling&lt;/strong&gt; - a server connection is assigned to a client for the entire session duration. Equivalent to no pooling for persistent connections, but useful for clients that connect and disconnect frequently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transaction pooling&lt;/strong&gt; - a server connection is assigned only for the duration of a transaction. As soon as your transaction commits or rolls back, the connection goes back to the pool. This is the mode that actually reduces your connection count dramatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statement pooling&lt;/strong&gt; - a server connection is assigned for a single statement. Very aggressive, incompatible with multi-statement transactions. Rarely the right choice.&lt;/p&gt;

&lt;p&gt;For most Node.js workloads, &lt;strong&gt;transaction pooling&lt;/strong&gt; is what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up PgBouncer with Docker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgbouncer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnami/pgbouncer:latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRESQL_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRESQL_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRESQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRESQL_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRESQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6432&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_POOL_MODE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_MAX_CLIENT_CONN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_DEFAULT_POOL_SIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_MIN_POOL_SIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_RESERVE_POOL_SIZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_RESERVE_POOL_TIMEOUT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;PGBOUNCER_SERVER_IDLE_TIMEOUT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6432:6432"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application connects to port 6432 (PgBouncer) instead of 5432 (PostgreSQL). Everything else stays the same.&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;// Before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgresql://app_user:password@postgres:5432/myapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgresql://app_user:password@pgbouncer:6432/myapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&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="c1"&gt;// can be higher now - PgBouncer handles the real limit&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;Same application, same workload, same PostgreSQL instance. Before and after adding PgBouncer in transaction mode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Without PgBouncer&lt;/th&gt;
&lt;th&gt;With PgBouncer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL connections (idle)&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL connections (peak load)&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL RAM used by connections&lt;/td&gt;
&lt;td&gt;1.47GB&lt;/td&gt;
&lt;td&gt;175MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 query latency (peak)&lt;/td&gt;
&lt;td&gt;340ms&lt;/td&gt;
&lt;td&gt;95ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors under load&lt;/td&gt;
&lt;td&gt;connection limit exceeded&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The latency improvement is not because PgBouncer makes queries faster. It's because without it, queries were queuing for a connection slot. With transaction pooling, a query gets a connection, runs, and returns it immediately - no waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Transaction Pooling Breaks
&lt;/h2&gt;

&lt;p&gt;This is important. Transaction pooling is not a drop-in change if you use any of the following:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named prepared statements.&lt;/strong&gt; Prepared statements are created on a specific server connection. With transaction pooling, you might get a different connection per transaction, so the prepared statement doesn't exist there.&lt;/p&gt;

&lt;p&gt;Good news for Node.js developers: &lt;code&gt;pg&lt;/code&gt; does NOT use protocol-level prepared statements by default. Standard parameterized queries work fine with PgBouncer in transaction mode:&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;// This does NOT use a persistent prepared statement - works fine with PgBouncer&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="s2"&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;userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// This DOES use a persistent prepared statement (the `name` property) - breaks with PgBouncer&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get-user-by-id&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue only appears if you explicitly pass a &lt;code&gt;name&lt;/code&gt; property in the query object. If you're using standard &lt;code&gt;pool.query(sql, params)&lt;/code&gt; calls, you don't need to change anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SET&lt;/code&gt; statements and session-level configuration.&lt;/strong&gt; &lt;code&gt;SET search_path TO tenant_abc&lt;/code&gt; applies to the session, not the transaction. With transaction pooling, the setting evaporates when the transaction ends and the connection goes back to the pool.&lt;/p&gt;

&lt;p&gt;If you're using RLS with &lt;code&gt;set_config('app.organization_id', orgId, true)&lt;/code&gt;, the &lt;code&gt;true&lt;/code&gt; parameter already makes it transaction-scoped, so this works correctly with PgBouncer. Just make sure you're not relying on any session-level state persisting between transactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advisory locks.&lt;/strong&gt; &lt;code&gt;pg_advisory_lock()&lt;/code&gt; is session-scoped. Use &lt;code&gt;pg_advisory_xact_lock()&lt;/code&gt; instead, which is transaction-scoped and releases automatically on commit/rollback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;.&lt;/strong&gt; Subscriptions are session-scoped. If you're using &lt;code&gt;LISTEN&lt;/code&gt;, you need a dedicated long-lived connection that bypasses PgBouncer - or use a separate direct PostgreSQL connection just for pub/sub.&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;// Direct connection for LISTEN/NOTIFY, bypassing PgBouncer&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notifyClient&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;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_DIRECT_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// points to :5432&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;notifyClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;notifyClient&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="s2"&gt;LISTEN log_events&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;h2&gt;
  
  
  PgBouncer on Managed Databases
&lt;/h2&gt;

&lt;p&gt;If you're using RDS, Supabase, Neon, or similar, you often don't need to run PgBouncer yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RDS&lt;/strong&gt;: RDS Proxy is AWS's managed connection pooler. It's PgBouncer-like, works in transaction mode, integrates with IAM authentication. It costs extra ($0.015/vCPU-hour) but removes the operational burden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt;: Has a built-in connection pooler called Supavisor (which replaced their PgBouncer setup in 2023) working in transaction mode on port 6543. Use that URL for your application instead of the direct connection string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neon&lt;/strong&gt;: Serverless pooling built-in, similar to transaction mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PlanetScale&lt;/strong&gt;: MySQL-based, different story entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If you're using Prisma with any connection pooler in transaction mode&lt;/strong&gt;, you must add &lt;code&gt;?pgbouncer=true&lt;/code&gt; to your database URL - otherwise Prisma's internal prepared statement handling will crash:&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;# Without this flag, Prisma breaks silently with PgBouncer/Supavisor in transaction mode&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgresql://user:password@pgbouncer:6432/myapp?pgbouncer=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one parameter has saved countless hours of "why is Prisma throwing random errors in production" debugging.&lt;/p&gt;

&lt;p&gt;For self-hosted PostgreSQL, running PgBouncer yourself is the standard approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning &lt;code&gt;max_connections&lt;/code&gt; in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Once PgBouncer is in front, you can lower PostgreSQL's &lt;code&gt;max_connections&lt;/code&gt; to something realistic:&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;-- See current value&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;max_connections&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- See current active connections&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reasonable formula for &lt;code&gt;max_connections&lt;/code&gt; when using a pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;max_connections&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;(pool_size * number_of_pools) + reserved_superuser_connections&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For PgBouncer with &lt;code&gt;default_pool_size = 25&lt;/code&gt; and a few admin connections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;max_connections&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;25 + 10 (headroom) = 35&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set this in &lt;code&gt;postgresql.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;max_connections&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;35&lt;/span&gt;
&lt;span class="py"&gt;shared_buffers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;256MB   # ~25% of available RAM&lt;/span&gt;
&lt;span class="py"&gt;work_mem&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16MB          # per sort/hash operation, per connection&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lowering &lt;code&gt;max_connections&lt;/code&gt; lets PostgreSQL allocate more memory to &lt;code&gt;shared_buffers&lt;/code&gt; and &lt;code&gt;work_mem&lt;/code&gt;, which directly improves query performance. The memory that was being eaten by connection overhead goes back to the query executor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;If you're running Node.js with PostgreSQL in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is your pool size per process configured explicitly, or defaulting to 10?&lt;/li&gt;
&lt;li&gt;How many processes/replicas connect to the database? What's the total connection count?&lt;/li&gt;
&lt;li&gt;Are you within 80% of &lt;code&gt;max_connections&lt;/code&gt; at peak?&lt;/li&gt;
&lt;li&gt;Do you have PgBouncer or equivalent in front of PostgreSQL?&lt;/li&gt;
&lt;li&gt;Are you using &lt;code&gt;set_config&lt;/code&gt; for RLS context rather than &lt;code&gt;SET&lt;/code&gt; statements?&lt;/li&gt;
&lt;li&gt;Are you using &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; instead of &lt;code&gt;pg_advisory_lock&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Do you have a dedicated connection for &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; that bypasses the pool?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Connection exhaustion is one of those problems that hides until traffic spikes, then appears as a cascade of unrelated-looking errors. The fix is not complicated, but it requires understanding what PostgreSQL is actually doing with each connection.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What connection pool setup are you running in production? Any gotchas with PgBouncer that aren't covered here? Comments are open.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>node</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Ditched Prisma for Raw SQL (And My Queries Got 10x Faster)</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Mon, 06 Apr 2026 13:28:19 +0000</pubDate>
      <link>https://forem.com/aws-builders/i-ditched-prisma-for-raw-sql-and-my-queries-got-10x-faster-4gen</link>
      <guid>https://forem.com/aws-builders/i-ditched-prisma-for-raw-sql-and-my-queries-got-10x-faster-4gen</guid>
      <description>&lt;p&gt;Prisma is genuinely good software. The schema DSL is clean, the type generation works well, and for a new project it gets you to a working data layer in an hour. I used it for about a year before I started noticing things.&lt;/p&gt;

&lt;p&gt;The first sign was a query that should have taken 5ms taking 80ms. The second was a N+1 that I'd technically solved with &lt;code&gt;include&lt;/code&gt; but was still generating 15 SQL statements. The third was opening &lt;code&gt;prisma.$queryRaw&lt;/code&gt; for the third time in a week because the query builder couldn't express what I needed.&lt;/p&gt;

&lt;p&gt;At that point I stopped fighting the abstraction and started writing SQL directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Prisma Actually Does to Your Queries
&lt;/h2&gt;

&lt;p&gt;This is a simple query with a filter and pagination:&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;logs&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;logEntry&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;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;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;gte&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="na"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&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="s2"&gt;error&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="s2"&gt;fatal&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;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&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;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQL Prisma generates:&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="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"organization_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"updated_at"&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"organization_id"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"timestamp"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"timestamp"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"level"&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nv"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"log_entries"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"timestamp"&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine SQL. But notice: it selects every column (including &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; that my UI doesn't need), it uses &lt;code&gt;OFFSET&lt;/code&gt; pagination (slow on large tables), and I have no control over any of it without escaping to &lt;code&gt;$queryRaw&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The equivalent raw query:&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;logs&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;pool&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="s2"&gt;`SELECT id, timestamp, service, level, message, metadata
   FROM log_entries
   WHERE organization_id = $1
     AND timestamp &amp;gt;= $2
     AND timestamp &amp;lt; $3
     AND level = ANY($4)
     AND (timestamp, id) &amp;lt; ($5, $6)
   ORDER BY timestamp DESC, id DESC
   LIMIT $7`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orgId&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;to&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="s2"&gt;error&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="s2"&gt;fatal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;cursorTs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cursorId&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keyset pagination instead of OFFSET, only the columns I need, and the query is exactly what I want the database to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 Problem Prisma Doesn't Fully Solve
&lt;/h2&gt;

&lt;p&gt;Prisma's &lt;code&gt;include&lt;/code&gt; resolves N+1 queries by using &lt;code&gt;IN&lt;/code&gt; clauses instead of per-row queries. But "no N+1" doesn't mean "one query":&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;projects&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;project&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;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;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orgId&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;members&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="na"&gt;apiKeys&lt;/span&gt;&lt;span class="p"&gt;:&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;active&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="na"&gt;_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;logEntries&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prisma executes this as 4 separate queries: one for projects, one for members, one for apiKeys, one for the count. Then it assembles the result in JavaScript.&lt;/p&gt;

&lt;p&gt;The raw equivalent is one query. The naive approach would be chaining multiple &lt;code&gt;LEFT JOIN&lt;/code&gt; on one-to-many tables and relying on &lt;code&gt;GROUP BY&lt;/code&gt; - but that produces a Cartesian fan-out: if a project has 10 members, 5 API keys, and 100 log entries, the database materializes 10x5x100 = 5,000 intermediate rows per project before collapsing them. &lt;code&gt;COUNT(DISTINCT ...)&lt;/code&gt; hides the bug in the results, but performance collapses as the tables grow.&lt;/p&gt;

&lt;p&gt;The correct version pre-aggregates each relationship with CTEs before joining:&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;member_stats&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;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&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;member_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jsonb_agg&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;'id'&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="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;role&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;members&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;project_members&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;key_stats&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;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;active_key_count&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;api_keys&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;log_stats&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;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;log_entry_count&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;log_entries&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;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;'24 hours'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;p&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;p&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;p&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="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;member_count&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;member_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_key_count&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;active_key_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_entry_count&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;log_entry_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[]'&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;member_stats&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&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;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;key_stats&lt;/span&gt; &lt;span class="n"&gt;ks&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&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;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;log_stats&lt;/span&gt; &lt;span class="n"&gt;ls&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each CTE scans and aggregates its table independently. The final join works on already-collapsed rows - no fan-out, no wasted intermediate rows. One round trip, and actually faster than Prisma's 4 queries at scale.&lt;/p&gt;

&lt;p&gt;Prisma can't generate this query. &lt;code&gt;$queryRaw&lt;/code&gt; can run it, but then you lose the type safety that was the point of using Prisma.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Performance Numbers
&lt;/h2&gt;

&lt;p&gt;Same endpoint, same data, same index configuration. 50k rows in the table.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query type&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prisma &lt;code&gt;findMany&lt;/code&gt; with &lt;code&gt;include&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;45ms&lt;/td&gt;
&lt;td&gt;120ms&lt;/td&gt;
&lt;td&gt;310ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 separate &lt;code&gt;pg&lt;/code&gt; queries&lt;/td&gt;
&lt;td&gt;18ms&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;td&gt;95ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single JOIN query&lt;/td&gt;
&lt;td&gt;6ms&lt;/td&gt;
&lt;td&gt;14ms&lt;/td&gt;
&lt;td&gt;28ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 10x headline comes from the p99 comparison. At p50 it's closer to 7x. Both are real.&lt;/p&gt;

&lt;p&gt;The Prisma numbers aren't bad in absolute terms for most applications. They become a problem when you're doing this on every request, at scale, with connection pool pressure from concurrent requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating Without Rewriting Everything
&lt;/h2&gt;

&lt;p&gt;You don't have to replace Prisma everywhere at once. The practical path:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Keep Prisma for writes and simple reads&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prisma is genuinely good for inserts, updates, and single-record lookups by primary key. The query generation for these is optimal and the type safety is useful.&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;// Keep this in Prisma - it's fine&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;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&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;where&lt;/span&gt;&lt;span class="p"&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&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="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Replace list queries and anything with joins&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where the overhead compounds. Add a &lt;code&gt;pg&lt;/code&gt; pool alongside Prisma:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&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="s2"&gt;pg&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&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="s2"&gt;@prisma/client&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;const&lt;/span&gt; &lt;span class="nx"&gt;prisma&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;PrismaClient&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;const&lt;/span&gt; &lt;span class="nx"&gt;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&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;Step 3: Write a thin query layer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The thing I missed most from Prisma was typed results. TypeScript with raw SQL defaults to &lt;code&gt;any&lt;/code&gt;. Fix it:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;queryLogs&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;LogQuery&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="nx"&gt;LogEntry&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;timestamp&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="nl"&gt;service&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="nl"&gt;level&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="nl"&gt;message&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="nl"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT id, timestamp, service, level, message, metadata
     FROM log_entries
     WHERE organization_id = $1
       AND timestamp &amp;gt;= $2
       AND timestamp &amp;lt; $3
     ORDER BY timestamp DESC
     LIMIT $4`&lt;/span&gt;&lt;span class="p"&gt;,&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;orgId&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="k"&gt;from&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;to&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;limit&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generic parameter on &lt;code&gt;pool.query&amp;lt;T&amp;gt;&lt;/code&gt; types the rows. It's not as ergonomic as Prisma's generated types, but it's enough to catch most mistakes at compile time.&lt;/p&gt;

&lt;p&gt;If you want SQL-level control with Prisma-level type safety, look into &lt;a href="https://kysely.dev" rel="noopener noreferrer"&gt;Kysely&lt;/a&gt; or &lt;a href="https://orm.drizzle.team" rel="noopener noreferrer"&gt;Drizzle ORM&lt;/a&gt;. Both let you write SQL-close queries while inferring full TypeScript types from your schema - without the ORM magic that makes query optimization hard. Kysely in particular is worth a look if the manual typing in &lt;code&gt;pool.query&amp;lt;T&amp;gt;&lt;/code&gt; feels too brittle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Actually Lose
&lt;/h2&gt;

&lt;p&gt;This is important to say clearly: there are real things you give up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema migrations.&lt;/strong&gt; Prisma Migrate is good. When you drop Prisma from your query layer you still want a migration tool. I use &lt;code&gt;node-pg-migrate&lt;/code&gt;, others use &lt;code&gt;db-migrate&lt;/code&gt; or just raw SQL files in a migrations folder with a simple runner. None of them are as polished as Prisma Migrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema as source of truth.&lt;/strong&gt; Prisma's schema file makes it easy to see your data model at a glance and generates types from it. With raw SQL you're maintaining types manually or generating them from the database schema with something like &lt;code&gt;pgtyped&lt;/code&gt; or &lt;code&gt;zapatos&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prisma Studio.&lt;/strong&gt; Minor thing but worth mentioning - having a UI to browse your data is useful during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding speed.&lt;/strong&gt; New developers on a project with raw SQL need to know SQL. This is not a bad thing, but it's a real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Keep Prisma
&lt;/h2&gt;

&lt;p&gt;Prisma is the right choice when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your team isn't comfortable with SQL&lt;/li&gt;
&lt;li&gt;You're building a CRUD app where the Prisma query builder covers 90%+ of your needs&lt;/li&gt;
&lt;li&gt;You're early stage and query performance isn't a bottleneck yet&lt;/li&gt;
&lt;li&gt;The productivity gain from the DX outweighs the performance cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It stops being the right choice when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your most important queries can't be expressed through the query builder&lt;/li&gt;
&lt;li&gt;You're regularly escaping to &lt;code&gt;$queryRaw&lt;/code&gt; for anything beyond simple lookups&lt;/li&gt;
&lt;li&gt;Query times are a meaningful part of your latency budget&lt;/li&gt;
&lt;li&gt;You need fine-grained control over indexes, hints, or query plans&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answer for most production systems that have been running for more than a year is: use both. Prisma for the simple stuff, raw SQL for the queries that matter.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What ORM or query approach are you using in production? Anything that changed your mind in either direction? Comments are open.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Your API Responses Are 40x Larger Than They Need to Be</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Sat, 04 Apr 2026 10:43:14 +0000</pubDate>
      <link>https://forem.com/polliog/your-api-responses-are-40x-larger-than-they-need-to-be-5p4</link>
      <guid>https://forem.com/polliog/your-api-responses-are-40x-larger-than-they-need-to-be-5p4</guid>
      <description>&lt;p&gt;I was profiling a production API last year when I noticed something that should have been obvious from the start: the response body for a simple list endpoint was 2.4MB. The actual useful data? About 60KB.&lt;/p&gt;

&lt;p&gt;The rest was a mix of unused fields, redundant nesting, and no compression. It had been that way since day one, and nobody had noticed because on localhost it's fast enough that it doesn't register.&lt;/p&gt;

&lt;p&gt;This is not a rare situation. It's the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Ways APIs Bloat Their Responses
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. No Compression
&lt;/h3&gt;

&lt;p&gt;This is the easiest win and the most commonly skipped.&lt;/p&gt;

&lt;p&gt;HTTP has supported gzip compression since 1999. Brotli has been in all major browsers since 2017. Most APIs don't enable either by default.&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;# Check if your API compresses responses&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{size_download}"&lt;/span&gt; https://api.example.com/users
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{size_download}"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept-Encoding: gzip"&lt;/span&gt; https://api.example.com/users
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both numbers are the same, you're not compressing.&lt;/p&gt;

&lt;p&gt;The fix in Node.js with Fastify:&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;import&lt;/span&gt; &lt;span class="nx"&gt;fastifyCompress&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@fastify/compress&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fastifyCompress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;global&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="na"&gt;encodings&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="s2"&gt;br&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="s2"&gt;gzip&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="s2"&gt;deflate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// don't compress responses under 1KB&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with Express:&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;import&lt;/span&gt; &lt;span class="nx"&gt;compression&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compression&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-no-compression&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;compression&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;Real numbers from a list endpoint returning 500 records:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No compression&lt;/td&gt;
&lt;td&gt;2.4MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gzip&lt;/td&gt;
&lt;td&gt;280KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;brotli&lt;/td&gt;
&lt;td&gt;210KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's an 8-11x reduction with zero changes to your data model, zero changes to clients, and about 10 lines of code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Handling compression in Node.js works well for most setups. At large scale, offloading it to your reverse proxy (NGINX) or CDN (Cloudflare) saves CPU cycles since Node.js is single-threaded and compression is CPU-intensive. If you're already behind a proxy, check whether it's compressing for you before adding it in Node.js too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. Over-fetching
&lt;/h3&gt;

&lt;p&gt;Every ORM makes it trivial to return entire database rows. Most codebases do exactly that.&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;// This returns every column in the users table&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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="s2"&gt;SELECT * FROM users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Including: password_hash, internal_flags, created_at, updated_at,&lt;/span&gt;
&lt;span class="c1"&gt;// deleted_at, last_ip, raw_oauth_payload, internal_notes...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is not complicated - it just requires being deliberate:&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;// Return only what the client actually needs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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="s2"&gt;`
  SELECT id, name, email, avatar_url, role
  FROM users
  WHERE organization_id = $1
  ORDER BY created_at DESC
`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're using an ORM like Prisma, use &lt;code&gt;select&lt;/code&gt; explicitly:&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;users&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;findMany&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="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;select&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&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="na"&gt;email&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="na"&gt;avatarUrl&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="na"&gt;role&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temptation to use &lt;code&gt;SELECT *&lt;/code&gt; or skip the &lt;code&gt;select&lt;/code&gt; clause is real because it saves two minutes of typing. The cost is paid on every request, by every client, forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Redundant Nesting and Metadata
&lt;/h3&gt;

&lt;p&gt;This one is subtler. It's the pattern where every response wraps data in a consistent envelope, which is fine, but the envelope carries metadata that nobody uses.&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&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;"status"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-02T10:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&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;"items"&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="err"&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;"meta"&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;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"perPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lastPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;63&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"currentPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hasNextPage"&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;"hasPrevPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;&lt;code&gt;success&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt;, and &lt;code&gt;version&lt;/code&gt; are duplicating what HTTP already tells the client. &lt;code&gt;currentPage&lt;/code&gt; is &lt;code&gt;page&lt;/code&gt; renamed. &lt;code&gt;from&lt;/code&gt; and &lt;code&gt;to&lt;/code&gt; are derivable from &lt;code&gt;page&lt;/code&gt; and &lt;code&gt;perPage&lt;/code&gt;. &lt;code&gt;hasPrevPage&lt;/code&gt; is &lt;code&gt;page &amp;gt; 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A cleaner version:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&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="err"&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;"pagination"&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;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"perPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hasNext"&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="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;Less noise, same information, smaller payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyset Pagination vs. Offset Pagination
&lt;/h2&gt;

&lt;p&gt;While we're talking about list endpoints, there's a related issue worth covering.&lt;/p&gt;

&lt;p&gt;Offset pagination (&lt;code&gt;LIMIT 20 OFFSET 200&lt;/code&gt;) requires the database to count and skip rows. On large tables this gets slow fast, and it also makes &lt;code&gt;total&lt;/code&gt; count queries expensive.&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 scans and counts the entire table&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- On a table with 50M rows this can take 2-5 seconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keyset pagination avoids both problems:&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;// Instead of page/offset, use the last seen ID.&lt;/span&gt;
&lt;span class="c1"&gt;// Request one extra row to check if there's a next page.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logs&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="s2"&gt;`
  SELECT id, timestamp, level, message
  FROM logs
  WHERE organization_id = $1
    AND id &amp;lt; $2
  ORDER BY id DESC
  LIMIT $3
`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastSeenId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageSize&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="c1"&gt;// If we got more than pageSize rows, there's a next page.&lt;/span&gt;
&lt;span class="c1"&gt;// Trim the extra row before sending.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasNext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logs&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="nx"&gt;pageSize&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logs&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You lose the ability to jump to arbitrary pages, which matters for some UIs but not for most. You gain: fast queries at any depth, no COUNT(*) needed, and stable pagination even when rows are being inserted concurrently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conditional Responses with ETags
&lt;/h2&gt;

&lt;p&gt;If your data doesn't change between requests, the client shouldn't have to download it again.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&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="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In your route handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&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;fetchData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// If your data has an updated_at field, use that instead of hashing&lt;/span&gt;
&lt;span class="c1"&gt;// the full payload - much cheaper on large responses.&lt;/span&gt;
&lt;span class="c1"&gt;// const etag = createHash("md5").update(`${data.id}:${data.updatedAt}`).digest("hex");&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;etag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md5&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;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;clientEtag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;if-none-match&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="nx"&gt;clientEtag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;etag&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;304&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Not Modified - zero payload&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;res&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ETag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&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="s2"&gt;private, max-age=0, must-revalidate&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: hashing the full &lt;code&gt;JSON.stringify(data)&lt;/code&gt; on every request is expensive if your payload is large. If the data has an &lt;code&gt;updated_at&lt;/code&gt; timestamp in the database, derive the ETag from that instead - &lt;code&gt;hash(id + updated_at)&lt;/code&gt; is a constant-time operation regardless of payload size and avoids blocking the event loop.&lt;/p&gt;

&lt;p&gt;For list endpoints where data changes frequently, this won't help much. For configuration endpoints, user profile data, or static reference data, a 304 response is massively cheaper than resending the same payload on every poll.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;The combined impact on that 2.4MB endpoint:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;2.4MB&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ brotli compression&lt;/td&gt;
&lt;td&gt;210KB&lt;/td&gt;
&lt;td&gt;91%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ select only needed fields&lt;/td&gt;
&lt;td&gt;58KB&lt;/td&gt;
&lt;td&gt;97.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ clean response envelope&lt;/td&gt;
&lt;td&gt;55KB&lt;/td&gt;
&lt;td&gt;97.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ ETag (no change)&lt;/td&gt;
&lt;td&gt;0KB&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 40x number in the title is real. Most of it comes from compression alone - the rest is just discipline.&lt;/p&gt;

&lt;p&gt;None of these changes require a rewrite. They don't break existing clients. They're additive. The only cost is a bit of attention to defaults that most frameworks don't set correctly out of the box.&lt;/p&gt;

&lt;p&gt;Start with compression. It takes 10 minutes and the numbers will surprise you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found something I got wrong, or a pattern that works better for your stack? Drop it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>api</category>
      <category>node</category>
    </item>
    <item>
      <title>I Tested PAIO Bot's New Security Layer for AI Agents — Here's the Honest Take</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Thu, 02 Apr 2026 12:10:51 +0000</pubDate>
      <link>https://forem.com/polliog/i-tested-the-new-security-layer-for-local-ai-agents-heres-the-honest-take-5d47</link>
      <guid>https://forem.com/polliog/i-tested-the-new-security-layer-for-local-ai-agents-heres-the-honest-take-5d47</guid>
      <description>&lt;h2&gt;
  
  
  The problem: OpenClaw's localhost exposure is a real risk
&lt;/h2&gt;

&lt;p&gt;If you've been running local AI agents with &lt;strong&gt;OpenClaw&lt;/strong&gt;, there's a good chance your setup is more exposed than you think. Researchers recently found over &lt;strong&gt;135,000 OpenClaw instances publicly reachable online&lt;/strong&gt; - many of them with no authentication, open to prompt injection, API key theft, and arbitrary command execution.&lt;/p&gt;

&lt;p&gt;That's the problem &lt;strong&gt;PAIO&lt;/strong&gt; (Personal AI Operator) is trying to solve. Backed by PureVPN's 17 years of network security infrastructure, it positions itself as a drop-in security and optimization layer for OpenClaw-based agents. I was given Pro access to test it ahead of launch, and this is my honest, hands-on assessment.&lt;/p&gt;

&lt;p&gt;An exposed OpenClaw endpoint can let an attacker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inject malicious prompts into your agent's context&lt;/li&gt;
&lt;li&gt;Read or exfiltrate your system prompt and conversation history&lt;/li&gt;
&lt;li&gt;Abuse your API keys for their own usage&lt;/li&gt;
&lt;li&gt;Execute tools and actions your agent has access to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't theoretical. The 135,000 figure comes from Shodan-style scanning of known OpenClaw ports. If you've ever used &lt;code&gt;--host 0.0.0.0&lt;/code&gt; anywhere in your agent config, you've probably been in that list at some point.&lt;/p&gt;




&lt;h2&gt;
  
  
  What PAIO actually does
&lt;/h2&gt;

&lt;p&gt;PAIO sits between your agent and the outside world. Instead of your OpenClaw instance binding directly to a network interface, PAIO proxies and controls that connection — sanitizing inputs, managing authentication, and exposing a controlled WebSocket endpoint that you can share safely.&lt;/p&gt;

&lt;p&gt;Once set up, your agent becomes accessible via a unique WSS endpoint like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wss://app.paio.bot/f73bb772-aaaa-aaaa-8b0f-a605aaaac/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or via an in-browser chat UI hosted on their platform. Your localhost is never directly exposed. That's the core value proposition, and it's architecturally sound.&lt;/p&gt;

&lt;p&gt;Beyond security, PAIO also adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token optimization&lt;/strong&gt; — context window and system prompt compression to reduce API costs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A simplified dashboard&lt;/strong&gt; — sessions, skills, and agent configuration in a cleaner UI than vanilla OpenClaw&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac agent with browser relay&lt;/strong&gt; — lets the agent perform tasks like bookings and research in the background&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-provider AI support&lt;/strong&gt; — OpenAI, Anthropic, and others&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Setup: honest timing
&lt;/h2&gt;

&lt;p&gt;The marketing says "60-second deployment." In my experience, the full process from the landing page took closer to &lt;strong&gt;5–6 minutes&lt;/strong&gt; — though to be fair, PAIO measures their benchmark from first successful prompt, not from the landing page. They're also actively optimizing the provisioning pipeline with an internal target of under 60 seconds end-to-end. Fast either way, but worth knowing what to expect.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Important - no AI included:&lt;/strong&gt; PAIO does not bundle AI credits. Every plan requires you to either bring your own API key or purchase their credit packages separately. Factor this into your cost model before signing up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once past setup, the dashboard is noticeably cleaner than OpenClaw's native interface. You get session management, skill configuration, and connection status in a single view. For teams or developers who find OpenClaw's UI overwhelming, this alone might justify the tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Token optimization: the claim vs. reality
&lt;/h2&gt;

&lt;p&gt;PAIO advertises up to &lt;strong&gt;50% token reduction&lt;/strong&gt; through aggressive context window and system prompt optimization. This is one of those claims that's highly dependent on your specific use case - the gains are real, but whether you hit 50% depends on how bloated your prompts are to begin with.&lt;/p&gt;

&lt;p&gt;In practice, if you're running agents with long system prompts, large tool schemas, or verbose context injection, you'll see meaningful savings. If your setup is already lean, the gains will be modest. The tool doesn't magically compress arbitrary LLM output — it compresses the &lt;em&gt;input&lt;/em&gt; side: context, system prompts, and tool definitions. Worth noting: a major token optimization patch was pushed to production shortly after launch, improving multi-step context pruning and pushing savings beyond 60% in their internal benchmarks. I haven't re-tested post-patch, but it's worth evaluating with your own workload.&lt;/p&gt;




&lt;h2&gt;
  
  
  Complexity: the honest critique
&lt;/h2&gt;

&lt;p&gt;Here's the thing: PAIO inherits OpenClaw's complexity and adds its own layer on top. If you're already comfortable with OpenClaw, the additional concepts (WSS endpoints, skills, session routing) are manageable. If you're newer to local agent infrastructure, this is not a beginner tool.&lt;/p&gt;

&lt;p&gt;The dashboard simplifies some things, but the underlying mental model - local agent + proxy layer + AI provider + browser relay - is still a lot to hold in your head. I'd love to see a more opinionated "just works" mode for simpler use cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What works ✅&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Genuine security improvement over raw OpenClaw&lt;/li&gt;
&lt;li&gt;Cleaner dashboard UX&lt;/li&gt;
&lt;li&gt;WSS endpoint approach is the right architecture&lt;/li&gt;
&lt;li&gt;Token optimization is real (if your prompts are verbose)&lt;/li&gt;
&lt;li&gt;Multi-provider AI support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What to watch ⚠️&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setup is 5–6 min, not 60 sec as advertised&lt;/li&gt;
&lt;li&gt;No AI included — always BYOK or pay for credits&lt;/li&gt;
&lt;li&gt;Still complex for non-OpenClaw users&lt;/li&gt;
&lt;li&gt;Token savings vary widely by use case&lt;/li&gt;
&lt;li&gt;Mac-first; other platforms TBD&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're running OpenClaw agents in any environment that's even partially network-accessible, PAIO is worth serious consideration. The localhost exposure problem is real, underappreciated, and PAIO's proxy approach is a legitimate fix. The token optimization is a nice bonus rather than the main draw.&lt;/p&gt;

&lt;p&gt;If you're on a tight budget, factor in that you'll always need AI credits on top of any PAIO plan. Run the numbers for your usage volume before committing.&lt;/p&gt;

&lt;p&gt;You can get started at &lt;a href="https://www.paio.bot" rel="noopener noreferrer"&gt;paio.bot&lt;/a&gt; - the free tier lets you evaluate the setup flow before committing to a paid plan.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was produced in partnership with PAIO. Testing was conducted independently with Pro plan access provided by the PAIO team.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Removed Redis From My Stack and Used PostgreSQL for Job Queues Instead</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Tue, 17 Mar 2026 11:17:17 +0000</pubDate>
      <link>https://forem.com/aws-builders/i-removed-redis-from-my-stack-and-used-postgresql-for-job-queues-instead-2lp5</link>
      <guid>https://forem.com/aws-builders/i-removed-redis-from-my-stack-and-used-postgresql-for-job-queues-instead-2lp5</guid>
      <description>&lt;p&gt;Every Node.js project eventually needs background jobs. Send this email. Process this file. Run this alert evaluation at midnight. The default answer in the ecosystem is Redis + BullMQ. It's fast, battle-tested, and has a great API.&lt;/p&gt;

&lt;p&gt;It also means running Redis.&lt;/p&gt;

&lt;p&gt;For projects already running PostgreSQL, that's a second database to provision, monitor, back up, and pay for. On AWS, an ElastiCache instance starts at ~$15/month for the smallest node not catastrophic, but not nothing either. More importantly, it's another moving part that can fail.&lt;/p&gt;

&lt;p&gt;I recently shipped a Redis-free deployment mode for an open-source project I maintain. The job queue runs entirely on PostgreSQL using &lt;a href="https://worker.graphile.org/" rel="noopener noreferrer"&gt;graphile-worker&lt;/a&gt;. Here's everything I learned from the experience what graphile-worker does well, where it has real limits, and when you should just keep Redis.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With "Just Add Redis"
&lt;/h2&gt;

&lt;p&gt;Before getting into the comparison, it's worth being honest about what the Redis dependency actually costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operationally&lt;/strong&gt;, Redis is simple to run but adds surface area. Every additional service is another thing that can go down, run out of memory, or need a version upgrade. In Docker Compose deployments (which is how most self-hosted tools get deployed), it's another container, another volume, another health check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On AWS&lt;/strong&gt;, the options are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ElastiCache (managed, ~$15-50/month for a usable instance)&lt;/li&gt;
&lt;li&gt;Redis on EC2 (self-managed, cheaper but more work)&lt;/li&gt;
&lt;li&gt;Redis on the same instance as your app (fine for dev, risky in prod)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;For multi-instance scaling&lt;/strong&gt;, Redis becomes mandatory you can't share BullMQ queues across processes without it. But for a single-instance deployment, you're paying the Redis tax without getting the multi-instance benefit.&lt;/p&gt;

&lt;p&gt;The question I asked: &lt;em&gt;if I'm already running PostgreSQL and my job volume doesn't justify a dedicated queue broker, what do I lose by using Postgres as the queue?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How graphile-worker Works
&lt;/h2&gt;

&lt;p&gt;graphile-worker stores jobs in a PostgreSQL table and uses &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; to claim them. That one clause is the key insight: it's an atomic operation that lets multiple workers poll the same table concurrently without contention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task_identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;graphile_worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_by&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_attempts&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;priority&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&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 worker claims a job, it locks the row. If the worker crashes, the lock is released automatically when the connection drops. No dead letter queue configuration needed just max_attempts and exponential backoff.&lt;/p&gt;

&lt;p&gt;Job results are kept in the same table. Completed jobs get deleted (or archived, if you configure it). Failed jobs increment their attempt counter and get rescheduled with backoff.&lt;/p&gt;

&lt;p&gt;It's genuinely elegant. The entire queue state lives in a place you already know how to query, back up, and monitor.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting It Up in a Node.js/TypeScript Project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;makeWorkerUtils&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;graphile-worker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Register task handlers&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runner&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;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;concurrency&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;span class="na"&gt;taskList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;send_email&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;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;helpers&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;EmailPayload&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;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;evaluate_alert&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;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;helpers&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;alertId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;AlertPayload&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;evaluateAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alertId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;generate_report&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;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;helpers&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reportId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ReportPayload&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reportId&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="c1"&gt;// Add jobs from anywhere in your app&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utils&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;makeWorkerUtils&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// One-off job&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;send_email&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;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your report is ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&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="c1"&gt;// Scheduled job (run at specific time)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;generate_report&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;reportId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;runAt&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-15T09:00:00Z&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="c1"&gt;// Recurring job (cron syntax)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;evaluate_alert&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;alertId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;456&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;jobKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert-456-eval&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;jobKeyMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;replace&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cronNextRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*/5 * * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// every 5 minutes&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API is intentionally minimal. No queue configuration, no connection pooling setup, no separate Redis client. You point it at your existing PostgreSQL connection string and start adding jobs.&lt;/p&gt;




&lt;h2&gt;
  
  
  BullMQ vs graphile-worker: The Real Comparison
&lt;/h2&gt;

&lt;p&gt;Let me be direct about where each one wins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where graphile-worker wins
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Zero additional infrastructure.&lt;/strong&gt; If you're already on RDS PostgreSQL or Aurora, graphile-worker is free. No ElastiCache, no Redis on EC2, no second managed service to babysit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full SQL visibility.&lt;/strong&gt; Your jobs are rows in a table. You can query them, join them against other tables, build admin UIs with a SELECT, and debug failures with psql. Compare this to inspecting BullMQ queues via the Bull Board UI or raw Redis commands.&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;-- Find all failed jobs in the last hour&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;task_identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;graphile_worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="k"&gt;IS&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;AND&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;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 hour'&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;updated_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Count pending jobs by type&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;task_identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;graphile_worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;locked_by&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;task_identifier&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;Transactional job enqueueing.&lt;/strong&gt; This is the killer feature that BullMQ can't match. You can enqueue a job inside a database transaction, guaranteeing it only gets scheduled if the transaction commits:&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;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="c1"&gt;// Create the user&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;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userData&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;returningAll&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;executeTakeFirstOrThrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c1"&gt;// Enqueue welcome email — only runs if user creation succeeds&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executeQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SELECT graphile_worker.add_job('send_welcome_email', &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;userId&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="nx"&gt;id&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With BullMQ, you'd add the job after the transaction commits and if your process crashes between the commit and the &lt;code&gt;queue.add()&lt;/code&gt; call, the job never gets scheduled. Not a common failure mode, but a real one. To achieve this guarantee with BullMQ, you'd have to implement the Transactional Outbox pattern writing the job to a database table first, then running a separate relay worker to move it to Redis. graphile-worker gives you this for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational simplicity for single-instance deployments.&lt;/strong&gt; One less service to configure in Docker Compose, one less thing to include in your backup strategy, one less connection string to manage in environment variables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where BullMQ wins
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Throughput.&lt;/strong&gt; Redis is an in-memory data structure store purpose-built for this. BullMQ can process thousands of jobs per second. graphile-worker tops out around 100-200 jobs/second on typical PostgreSQL hardware before you start hitting lock contention. For most applications this is irrelevant. For high-volume pipelines (image processing, webhook delivery at scale, bulk email), it matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advanced queue features.&lt;/strong&gt; BullMQ has rate limiting, job priorities with fine-grained control, delayed jobs with millisecond precision, parent-child job dependencies, and repeatable jobs with complex scheduling. graphile-worker has most of these, but BullMQ's implementation is more complete and battle-hardened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time job events.&lt;/strong&gt; BullMQ emits events (completed, failed, progress) via Redis pub/sub. You can build live job monitoring dashboards easily. With graphile-worker, you'd poll the jobs table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-instance horizontal scaling.&lt;/strong&gt; BullMQ was designed from the ground up for multiple workers across multiple processes/machines, all sharing the same Redis. graphile-worker supports this too (multiple workers polling the same PostgreSQL), but the throughput ceiling is lower.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest performance numbers
&lt;/h3&gt;

&lt;p&gt;On commodity hardware (the same AMD Ryzen 5 3600 from the benchmark article):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;BullMQ&lt;/th&gt;
&lt;th&gt;graphile-worker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Job enqueue rate&lt;/td&gt;
&lt;td&gt;~5,000/s&lt;/td&gt;
&lt;td&gt;~500/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job processing throughput (simple tasks)&lt;/td&gt;
&lt;td&gt;~2,000/s&lt;/td&gt;
&lt;td&gt;~100-200/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job processing throughput (I/O bound tasks)&lt;/td&gt;
&lt;td&gt;~500/s&lt;/td&gt;
&lt;td&gt;~100/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency from enqueue to pickup&lt;/td&gt;
&lt;td&gt;&amp;lt;10ms&lt;/td&gt;
&lt;td&gt;&amp;lt;10ms (LISTEN/NOTIFY), 2s max (poll fallback)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;graphile-worker polls for new jobs at a configurable interval (default: every 2 seconds, plus LISTEN/NOTIFY for immediate pickup). For most background job use cases — sending emails, generating reports, running scheduled checks — 500ms latency is completely acceptable. For near-real-time processing where job pickup latency matters, BullMQ wins.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AWS Decision Framework
&lt;/h2&gt;

&lt;p&gt;This is where the choice becomes concrete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use graphile-worker when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're already on RDS PostgreSQL or Aurora&lt;/li&gt;
&lt;li&gt;Your job volume is under ~100 jobs/second&lt;/li&gt;
&lt;li&gt;You have a single-instance deployment or modest horizontal scale&lt;/li&gt;
&lt;li&gt;You want transactional job enqueueing&lt;/li&gt;
&lt;li&gt;You want SQL-queryable job state&lt;/li&gt;
&lt;li&gt;You want to avoid ElastiCache costs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use BullMQ when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need &amp;gt;200 jobs/second sustained throughput&lt;/li&gt;
&lt;li&gt;You have real-time job progress tracking requirements&lt;/li&gt;
&lt;li&gt;You're scaling to many workers across many instances&lt;/li&gt;
&lt;li&gt;You already have ElastiCache for other purposes (caching, sessions)&lt;/li&gt;
&lt;li&gt;You need fine-grained rate limiting (e.g., "max 10 API calls/second to this external service")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The cost math on AWS (rough estimates, us-east-1):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Monthly cost (approx)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RDS PostgreSQL db.t3.medium&lt;/td&gt;
&lt;td&gt;~$30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS PostgreSQL db.t3.medium + ElastiCache cache.t3.micro&lt;/td&gt;
&lt;td&gt;~$45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aurora PostgreSQL (serverless v2, min capacity)&lt;/td&gt;
&lt;td&gt;~$45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aurora PostgreSQL + ElastiCache cache.t3.micro&lt;/td&gt;
&lt;td&gt;~$60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're already paying for RDS and your jobs fit within graphile-worker's throughput ceiling, you're spending money on ElastiCache for infrastructure you don't need.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Migration Path
&lt;/h2&gt;

&lt;p&gt;If you're currently on BullMQ and considering a migration, it's straightforward. graphile-worker runs schema migrations automatically on startup you don't manage the tables yourself.&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;// Before: BullMQ&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Queue&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;bullmq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailQueue&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;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&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;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;redisConnection&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;emailQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;send&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;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exponential&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After: graphile-worker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utils&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;makeWorkerUtils&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&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;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;send_email&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;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry/backoff configuration moves from the job definition to the worker configuration. The task handler API is nearly identical.&lt;/p&gt;

&lt;p&gt;One thing to handle explicitly: BullMQ lets you attach &lt;code&gt;removeOnComplete&lt;/code&gt; and &lt;code&gt;removeOnFail&lt;/code&gt; policies per job. graphile-worker always removes completed jobs (keeping failed ones with their error details). If you need a completed job archive, add a separate table and write to it from your task handlers.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Run in Production
&lt;/h2&gt;

&lt;p&gt;The project I maintain ships two Docker Compose configurations: one with Redis + BullMQ for teams that need horizontal scaling, and one with graphile-worker only for single-instance deployments that want minimum operational overhead.&lt;/p&gt;

&lt;p&gt;The Redis-free setup works well for SMB deployments teams running their own observability stack on a single VPS or a modest EC2 instance. The full setup with Redis makes sense when you're running multiple backend instances behind a load balancer.&lt;/p&gt;

&lt;p&gt;Both queue implementations share the same task handler interface. Switching between them is a config change, not a code change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;PostgreSQL-based job queues aren't a new idea Delayed Job in Ruby, django-q in Python, and several others have proven the pattern works. graphile-worker brings it to Node.js with a clean API and genuine PostgreSQL integration.&lt;/p&gt;

&lt;p&gt;The choice isn't "which is better." It's "which matches your constraints." If you're paying for ElastiCache already, BullMQ is probably the right call. If you're running PostgreSQL and your job volume fits within graphile-worker's ceiling, eliminating Redis simplifies your stack without meaningful cost.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; pattern is one of those PostgreSQL features that most developers don't know exists until they need it. Now you do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Redis-optional deployment mode ships in &lt;a href="https://github.com/logtide-dev/logtide" rel="noopener noreferrer"&gt;Logtide&lt;/a&gt; since v0.5.0 a self-hosted observability platform built on Node.js + TimescaleDB. The &lt;code&gt;docker-compose.simple.yml&lt;/code&gt; uses graphile-worker; the standard &lt;code&gt;docker-compose.yml&lt;/code&gt; uses BullMQ.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>aws</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PII in Your Logs Is a GDPR Time Bomb - Here's How to Defuse It</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Mon, 16 Mar 2026 20:28:29 +0000</pubDate>
      <link>https://forem.com/polliog/pii-in-your-logs-is-a-gdpr-time-bomb-heres-how-to-defuse-it-307l</link>
      <guid>https://forem.com/polliog/pii-in-your-logs-is-a-gdpr-time-bomb-heres-how-to-defuse-it-307l</guid>
      <description>&lt;p&gt;Your application is probably logging PII right now.&lt;/p&gt;

&lt;p&gt;Not maliciously - it happens naturally. A user submits a form with their email. Your framework logs the full request body for debugging. The email lands in CloudWatch, Datadog, or your ELK cluster. It sits there for 90 days, or 365, or however long your retention policy says.&lt;/p&gt;

&lt;p&gt;Under GDPR, that's a data breach waiting for a complaint. Under HIPAA, it's a violation. Under any audit, it's a finding.&lt;/p&gt;

&lt;p&gt;The fix isn't "tell developers to be careful." Developers are already careful - until they're debugging a production incident at 2am and add a quick &lt;code&gt;console.log(request.body)&lt;/code&gt;. The fix is a masking layer that runs automatically, before any log hits storage.&lt;/p&gt;

&lt;p&gt;This article is about building that layer in Node.js.&lt;/p&gt;




&lt;h2&gt;
  
  
  What PII Actually Looks Like in Logs
&lt;/h2&gt;

&lt;p&gt;Before masking, you need to know what you're masking. PII in logs shows up in three forms:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured fields&lt;/strong&gt; - JSON payloads where the key makes the value obvious:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hunter2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123-45-6789"&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;&lt;strong&gt;Embedded in strings&lt;/strong&gt; - PII inside log messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User alice@example.com failed login from 192.168.1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Nested or transformed&lt;/strong&gt; - Base64-encoded, URL-encoded, or buried in stack traces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error processing request body: %7B%22email%22%3A%22alice%40example.com%22%7D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good masking pipeline handles all three. Most tutorials only handle the first one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Mask at Ingestion, Not at Display
&lt;/h2&gt;

&lt;p&gt;There are two schools of thought on when to mask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mask at display&lt;/strong&gt; - store everything, redact when showing logs in the UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mask at ingestion&lt;/strong&gt; - strip PII before it ever reaches storage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mask at ingestion is the only defensible choice for compliance. If PII reaches your database, it's already a GDPR problem - even if you never display it. The data is there, it can be breached, and you own the liability.&lt;/p&gt;

&lt;p&gt;The pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application → Log event → [Masking layer] → Storage
                                ↑
                         This is where we operate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The masking layer runs synchronously, in-process, before any network call to your log storage. No PII leaves the machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Masking Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Define your masking strategies
&lt;/h3&gt;

&lt;p&gt;Before writing regex, decide what "masked" means for your use case. Three strategies cover most 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;type&lt;/span&gt; &lt;span class="nx"&gt;MaskingStrategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mask&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// mask: show partial value - useful for debugging (still recognizable, not storable)&lt;/span&gt;
&lt;span class="c1"&gt;// "alice@example.com" → "al***@***.com"&lt;/span&gt;

&lt;span class="c1"&gt;// redact: replace entirely - use when value has no debugging value&lt;/span&gt;
&lt;span class="c1"&gt;// "hunter2" → "[REDACTED]"&lt;/span&gt;

&lt;span class="c1"&gt;// hash: deterministic SHA-256 - use when you need to correlate without exposing&lt;/span&gt;
&lt;span class="c1"&gt;// "alice@example.com" → "sha256:2f3a4b..." (same input always produces same hash)&lt;/span&gt;
&lt;span class="c1"&gt;// ⚠️ Always set PII_HASH_SALT in your environment. Emails and SSNs have low entropy&lt;/span&gt;
&lt;span class="c1"&gt;// and are trivially reversible from unsalted hashes via rainbow tables.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hashing is underused. It lets you answer "did this user appear in these logs?" without storing the actual email. Useful for audit trails and correlation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Pattern-based detection
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&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;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PII_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;RegExp&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MaskingStrategy&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;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c1"&gt;// Email addresses&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9._%+-&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;A-Za-z0-9.-&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;A-Z|a-z&lt;/span&gt;&lt;span class="se"&gt;]{2,}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mask&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="c1"&gt;// Credit card numbers (Format-valid patterns — prefix and length, not Luhn checksum)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;credit_card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(?:&lt;/span&gt;&lt;span class="sr"&gt;4&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{12}(?:[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{3})?&lt;/span&gt;&lt;span class="sr"&gt;|5&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;1-5&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{14}&lt;/span&gt;&lt;span class="sr"&gt;|3&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;47&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{13}&lt;/span&gt;&lt;span class="sr"&gt;|3&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;0&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-5&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;68&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;])[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{11})\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redact&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="c1"&gt;// US Social Security Numbers&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ssn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\d{3}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{4}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redact&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="c1"&gt;// Bearer tokens / JWT&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bearer_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/Bearer&lt;/span&gt;&lt;span class="se"&gt;\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;A-Za-z0-9&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;+&lt;/span&gt;&lt;span class="se"&gt;\.[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&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;+&lt;/span&gt;&lt;span class="se"&gt;\.?[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&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;=&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redact&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="c1"&gt;// AWS access keys&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aws_access_key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;AKIA|AIPA|AKIA|ASIA&lt;/span&gt;&lt;span class="se"&gt;)[&lt;/span&gt;&lt;span class="sr"&gt;A-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]{16}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redact&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="c1"&gt;// IPv4 addresses (optional — some teams want these, some don't)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ipv4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(?:(?:&lt;/span&gt;&lt;span class="sr"&gt;25&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-5&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|2&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-4&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&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;01&lt;/span&gt;&lt;span class="se"&gt;]?[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]?)\.){3}(?:&lt;/span&gt;&lt;span class="sr"&gt;25&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-5&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|2&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-4&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&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;01&lt;/span&gt;&lt;span class="se"&gt;]?[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]?)\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mask&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="c1"&gt;// Phone numbers (loose — adjust for your region)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\+?[\d\s\-&lt;/span&gt;&lt;span class="sr"&gt;().&lt;/span&gt;&lt;span class="se"&gt;]{10,15})&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mask&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;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyStrategy&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MaskingStrategy&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="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`sha256:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PII_HASH_SALT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mask&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;if &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="nf"&gt;includes&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="c1"&gt;// Email masking: show first 2 chars of local part and domain TLD&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;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;domainName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;tlds&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;domain&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="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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;local&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="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="s2"&gt;***@***.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tlds&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="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="s2"&gt;`&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;// Generic masking: show first and last char, mask middle&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;value&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;lt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&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="mi"&gt;0&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="nf"&gt;repeat&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;length&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;value&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;length&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="s2"&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="kr"&gt;string&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;strategy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PII_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&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="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;applyStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;strategy&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;result&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: Field-name detection
&lt;/h3&gt;

&lt;p&gt;Pattern matching catches PII embedded in strings. But for structured JSON, matching on field names is faster and more reliable:&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;SENSITIVE_FIELD_NAMES&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password&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;passwd&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;secret&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;token&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;api_key&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;apikey&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;api-key&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;authorization&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;auth&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;credential&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;credentials&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;email&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;e_mail&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;e-mail&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;ssn&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;social_security&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;national_id&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;credit_card&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;card_number&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;cvv&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;cvc&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;phone&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;phone_number&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;mobile&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;dob&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;date_of_birth&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;birthday&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;address&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;street_address&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;postal_code&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;zip_code&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;ip_address&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;ip&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;x_forwarded_for&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isFieldSensitive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;boolean&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;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&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;[&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;SENSITIVE_FIELD_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalized&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: Recursive object traversal
&lt;/h3&gt;

&lt;p&gt;The masking function needs to traverse nested objects - request bodies aren't always flat:&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;type&lt;/span&gt; &lt;span class="nx"&gt;LogValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;LogObject&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;LogValue&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LogObject&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="na"&gt;key&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="nx"&gt;LogValue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LogObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&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;LogObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Prevent infinite recursion on circular references&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;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[max_depth_exceeded]&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LogObject&lt;/span&gt; &lt;span class="o"&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&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="k"&gt;of&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;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isFieldSensitive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Field name match: redact or hash based on field type&lt;/span&gt;
      &lt;span class="c1"&gt;// Note: this hardcodes the strategy per field type for brevity. In a production&lt;/span&gt;
      &lt;span class="c1"&gt;// system, map field names to your central PII_PATTERNS configuration to keep&lt;/span&gt;
      &lt;span class="c1"&gt;// strategies consistent across both field-name and pattern-based detection.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&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;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hash&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;redact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;applyStrategy&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;strategy&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;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="k"&gt;continue&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskString&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="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&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;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;item&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="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;LogObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&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="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;LogObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&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="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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: The masking pipeline entry point
&lt;/h3&gt;

&lt;p&gt;Wrap everything in a single function that handles both structured objects and raw strings:&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;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;LogObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;input&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;maskPII&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;input&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Integrating With Your Logger
&lt;/h2&gt;

&lt;h3&gt;
  
  
  With Pino (recommended for Node.js)
&lt;/h3&gt;

&lt;p&gt;Pino supports &lt;code&gt;redact&lt;/code&gt; paths natively, but it only handles known field paths. For dynamic detection, use a &lt;code&gt;serializers&lt;/code&gt; hook:&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;import&lt;/span&gt; &lt;span class="nx"&gt;pino&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;pino&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;maskPII&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;./masking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Mask the entire request object&lt;/span&gt;
    &lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="c1"&gt;// Mask arbitrary metadata&lt;/span&gt;
    &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meta&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;// Usage&lt;/span&gt;
&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&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="nx"&gt;email&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;Request received&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;
  
  
  With Winston
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;winston&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;winston&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;maskPII&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;./masking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maskingTransform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;winston&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&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="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="nf"&gt;maskPII&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="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;info&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;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;winston&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createLogger&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;winston&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;maskingTransform&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nx"&gt;winston&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;winston&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Console&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;
  
  
  With a raw HTTP ingest endpoint
&lt;/h3&gt;

&lt;p&gt;If you're building an ingest endpoint that receives logs from external sources (SDKs, collectors), apply masking server-side before writing to storage:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Fastify&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;fastify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;maskPII&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;./masking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fastify&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1/ingest&lt;/span&gt;&lt;span class="dl"&gt;'&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;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LogObject&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;maskedLogs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logs&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;log&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="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;ingested_at&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="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="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;insertInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maskedLogs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maskedLogs&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Edge Cases Nobody Talks About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  URL-encoded and Base64-encoded PII
&lt;/h3&gt;

&lt;p&gt;Attackers (and frameworks) encode data. Your masking needs to handle it:&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;function&lt;/span&gt; &lt;span class="nf"&gt;maskStringWithDecoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="kr"&gt;string&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;

  &lt;span class="c1"&gt;// Try URL decode and re-mask&lt;/span&gt;
  &lt;span class="k"&gt;try&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;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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;decoded&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoded&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="c1"&gt;// Try Base64 decode and re-mask&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64Pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/]{20,}&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;{0,2}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&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="nx"&gt;base64Pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&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;try&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;decoded&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;match&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;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;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="c1"&gt;// Only re-encode if it looks like it decoded to something meaningful&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[\x&lt;/span&gt;&lt;span class="sr"&gt;20-&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="sr"&gt;7E&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoded&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;masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoded&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;masked&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;decoded&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;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;masked&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;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="k"&gt;return&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;return&lt;/span&gt; &lt;span class="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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;
  
  
  Stack traces
&lt;/h3&gt;

&lt;p&gt;Stack traces can contain PII in exception messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: User not found for email alice@example.com
    at UserService.findByEmail (user.service.ts:42)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskStackTrace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&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="kr"&gt;string&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;stack&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="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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;// Mask the error message line (first line), leave stack frames alone&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;index&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;return&lt;/span&gt; &lt;span class="nf"&gt;maskString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&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;line&lt;/span&gt;
    &lt;span class="p"&gt;})&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="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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;
  
  
  Performance considerations
&lt;/h3&gt;

&lt;p&gt;The masking pipeline runs on every log event. Profile it:&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;// Simple benchmark&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sampleLog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User alice@example.com logged in from 192.168.1.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer eyJhbGciOiJIUzI1NiJ9.test.test&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="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;iterations&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="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sampleLog&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;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="nx"&gt;start&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;iterations&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; iterations in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;ms (&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;iterations&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;ms each)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a modern machine, a well-implemented masking pipeline takes 0.05-0.2ms per log event. At 1,000 logs/second, that's 50-200ms of CPU per second — acceptable for most applications, but worth measuring for high-throughput services.&lt;/p&gt;

&lt;p&gt;If performance is a concern, compile your regex patterns once outside the function — the compilation cost is paid only once, not on every log event:&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;// Bad: regex compiled on every call&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;str&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;\b[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9._%+-&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;A-Za-z0-9.-&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;A-Z|a-z&lt;/span&gt;&lt;span class="se"&gt;]{2,}\b&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Good: compiled once, reused on every call&lt;/span&gt;
&lt;span class="c1"&gt;// Note: String.prototype.replace() manages lastIndex internally — no manual reset needed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EMAIL_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9._%+-&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;A-Za-z0-9.-&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;A-Z|a-z&lt;/span&gt;&lt;span class="se"&gt;]{2,}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maskEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;str&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="nx"&gt;EMAIL_PATTERN&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Testing Your Masking Pipeline
&lt;/h2&gt;

&lt;p&gt;A masking layer without tests is worse than no masking layer — it gives you false confidence.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&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;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maskObject&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;./masking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PII masking&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;masks email addresses in strings&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="o"&gt;=&amp;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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User alice@example.com logged in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&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="c1"&gt;// partial masking, not full redaction&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redacts password fields&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="o"&gt;=&amp;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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hunter2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// non-sensitive fields unchanged&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;handles nested objects&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="o"&gt;=&amp;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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskObject&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&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;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;preferences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redacts bearer tokens&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="o"&gt;=&amp;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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test.sig&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[REDACTED]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eyJhbGciOiJIUzI1NiJ9&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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;does not modify non-PII strings&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="o"&gt;=&amp;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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Server started on port 3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;handles null and undefined gracefully&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&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 Masking Preview Problem
&lt;/h2&gt;

&lt;p&gt;One practical challenge: developers need to test whether their masking rules are working without shipping to production. Build a simple preview endpoint (dev/staging only) that runs the masking pipeline and returns the diff:&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;if &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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/debug/mask-preview&lt;/span&gt;&lt;span class="dl"&gt;'&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;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;masked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;maskPII&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;original&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;masked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;masked&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;Call it with a sample log payload and immediately see what gets masked. Faster than print-debugging your way through regex patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;PII masking in logs is not a nice-to-have. It's a compliance requirement, and more importantly, it's the right thing to do with your users' data.&lt;/p&gt;

&lt;p&gt;The pattern is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Mask at ingestion, not at display&lt;/li&gt;
&lt;li&gt;Combine field-name detection (fast, reliable for structured data) with pattern matching (catches PII in strings)&lt;/li&gt;
&lt;li&gt;Choose the right strategy per field type: mask for emails, redact for passwords/tokens, hash for correlation keys&lt;/li&gt;
&lt;li&gt;Handle edge cases: URL encoding, Base64, stack traces&lt;/li&gt;
&lt;li&gt;Test it like production code, because it is production code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The implementation above is about 150 lines of TypeScript. There's no reason every Node.js application logging to CloudWatch, Datadog, or anywhere else shouldn't have something equivalent running before the first log event leaves the process.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>node</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Benchmarked TimescaleDB vs ClickHouse vs MongoDB for Observability Data - The Results Surprised Me</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Sat, 14 Mar 2026 20:35:57 +0000</pubDate>
      <link>https://forem.com/aws-builders/i-benchmarked-timescaledb-vs-clickhouse-vs-mongodb-for-observability-data-the-results-surprised-me-3d7d</link>
      <guid>https://forem.com/aws-builders/i-benchmarked-timescaledb-vs-clickhouse-vs-mongodb-for-observability-data-the-results-surprised-me-3d7d</guid>
      <description>&lt;p&gt;When we designed &lt;code&gt;@logtide/reservoir&lt;/code&gt; the pluggable storage abstraction layer for &lt;a href="https://github.com/logtide-dev/logtide" rel="noopener noreferrer"&gt;Logtide&lt;/a&gt; we had to make a real decision: which database should be the default for an observability platform?&lt;/p&gt;

&lt;p&gt;The conventional wisdom says: time-series data at scale → ClickHouse. It's what everyone building in this space seems to reach for. Grafana Loki, Signoz, and a bunch of others use it or are moving toward it.&lt;/p&gt;

&lt;p&gt;We didn't. We picked TimescaleDB as our default, with ClickHouse available for enterprise deployments and MongoDB for teams already invested in that ecosystem.&lt;/p&gt;

&lt;p&gt;We built a proper benchmark suite and ran it. Here are the actual numbers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;All three engines were benchmarked under identical conditions, running in Docker on the same machine, seeded with the same synthetic dataset, tested at four volume tiers: &lt;strong&gt;1K, 10K, 100K, and 1M records&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Three data types were tested separately &lt;strong&gt;logs&lt;/strong&gt;, &lt;strong&gt;spans&lt;/strong&gt; (distributed traces), and &lt;strong&gt;metrics&lt;/strong&gt; because the query patterns are fundamentally different for each. Each test ran 3 iterations with 1 warmup round. Results are p50 latency unless otherwise noted.&lt;/p&gt;

&lt;p&gt;The benchmark suite is open source: it ships in Logtide's repository and you can run it yourself&lt;/p&gt;




&lt;h2&gt;
  
  
  Ingestion: Where ClickHouse Has a Problem
&lt;/h2&gt;

&lt;p&gt;The first thing that jumped out was ClickHouse's ingestion behavior at small-to-medium batch sizes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log ingestion p50 latency (batch 1,000):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;1K rows&lt;/th&gt;
&lt;th&gt;10K rows&lt;/th&gt;
&lt;th&gt;100K rows&lt;/th&gt;
&lt;th&gt;1M rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TimescaleDB&lt;/td&gt;
&lt;td&gt;17.6ms&lt;/td&gt;
&lt;td&gt;14.2ms&lt;/td&gt;
&lt;td&gt;13.9ms&lt;/td&gt;
&lt;td&gt;13.3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse&lt;/td&gt;
&lt;td&gt;400.1ms&lt;/td&gt;
&lt;td&gt;400.4ms&lt;/td&gt;
&lt;td&gt;399.8ms&lt;/td&gt;
&lt;td&gt;400.0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;37.0ms&lt;/td&gt;
&lt;td&gt;39.5ms&lt;/td&gt;
&lt;td&gt;37.2ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ClickHouse is sitting at exactly 400ms for batch 1,000 across all volume tiers. That's not a coincidence it's ClickHouse's async insert behavior. When &lt;code&gt;async_insert = 1&lt;/code&gt; is enabled (common in modern clients and managed services), ClickHouse buffers writes in memory and flushes them when &lt;code&gt;async_insert_busy_timeout_ms&lt;/code&gt; elapses. Our setup has that timeout at 400ms. The 400 isn't a random number; it's a configured flush interval.&lt;/p&gt;

&lt;p&gt;The buffering exists precisely because ClickHouse doesn't handle high-frequency small writes well natively. Its columnar storage format requires merging data into sorted chunks a process that's expensive if triggered on every small insert. Async inserts are the workaround: batch writes in memory, flush periodically, pay the merge cost less often. It's the right design for bulk analytics ingestion. It's the wrong design if you're pushing logs from 10 microservices every few seconds.&lt;/p&gt;

&lt;p&gt;This matters a lot for observability workloads. When your application is logging in real time, you're not sending 10,000-log batches. You're sending small, frequent writes. At batch 100, ClickHouse delivers 250 ops/s. TimescaleDB delivers 14,200 ops/s. That's a 56x difference at a batch size that's very common in practice.&lt;/p&gt;

&lt;p&gt;ClickHouse catches up at batch 10,000 - 83,843 ops/s vs 120,934 ops/s for TimescaleDB. At scale ingestion, they're comparable. But you need to be running at that scale to benefit.&lt;/p&gt;

&lt;p&gt;MongoDB sits in the middle: consistent ~25K ops/s regardless of batch size, no timing artifacts. Predictable if not spectacular.&lt;/p&gt;




&lt;h2&gt;
  
  
  Query Latency: The Result That Settles the Debate
&lt;/h2&gt;

&lt;p&gt;This is where the numbers get dramatic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log query p50 latency at 100K records:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;TimescaleDB&lt;/th&gt;
&lt;th&gt;ClickHouse&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single service filter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.47ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;44.8ms&lt;/td&gt;
&lt;td&gt;304ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-filter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.48ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35.2ms&lt;/td&gt;
&lt;td&gt;309ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.45ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;32.2ms&lt;/td&gt;
&lt;td&gt;39.9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narrow time range (1h)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.49ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8.7ms&lt;/td&gt;
&lt;td&gt;3.4ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pagination (offset 1000)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.40ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85.8ms&lt;/td&gt;
&lt;td&gt;320ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregate 1h buckets&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.41ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15.1ms&lt;/td&gt;
&lt;td&gt;376ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TimescaleDB is answering filtered log queries in under &lt;strong&gt;half a millisecond&lt;/strong&gt; at 100K records. ClickHouse takes 35-85ms for the same queries. MongoDB takes 300-400ms.&lt;/p&gt;

&lt;p&gt;The scaling story is equally stark. At 1M records, TimescaleDB's query latency barely moves still &lt;strong&gt;0.46ms&lt;/strong&gt; for a service filter. ClickHouse degrades to 244ms. MongoDB wasn't tested at 1M for logs (the 100K numbers already showed where things were heading).&lt;/p&gt;

&lt;p&gt;This is the TimescaleDB superpower: &lt;strong&gt;hypertable partitioning + continuous aggregates&lt;/strong&gt;. Most log queries filter by time range and service. TimescaleDB chunks data by time, and those chunks are indexed by service. The queries skip entire partitions instead of scanning. The continuous aggregates make count and aggregate queries nearly free because the work is already done.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Place ClickHouse Wins
&lt;/h2&gt;

&lt;p&gt;There's an important exception to the TimescaleDB dominance: &lt;strong&gt;count operations at scale&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count p50 at 1M records:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;TimescaleDB&lt;/th&gt;
&lt;th&gt;ClickHouse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full count&lt;/td&gt;
&lt;td&gt;0.38ms&lt;/td&gt;
&lt;td&gt;11.25ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtered count&lt;/td&gt;
&lt;td&gt;0.43ms&lt;/td&gt;
&lt;td&gt;14.42ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wait TimescaleDB wins here too? Yes, because of the countEstimate optimization we built: instead of &lt;code&gt;COUNT(*)&lt;/code&gt;, we use &lt;code&gt;EXPLAIN&lt;/code&gt; planner estimates for approximate counts. Zero scan, sub-millisecond.&lt;/p&gt;

&lt;p&gt;Where ClickHouse genuinely wins is &lt;strong&gt;aggregate throughput&lt;/strong&gt; at high volume. At 1M records, ClickHouse's &lt;code&gt;aggregate (1m)&lt;/code&gt; shows 55,507 ops/s vs TimescaleDB's comparable range. ClickHouse is built for columnar analytical queries over huge datasets if you're running complex analytics across months of data with many group-by combinations, it'll outperform.&lt;/p&gt;

&lt;p&gt;For the interactive dashboard queries that dominate observability UIs "show me the last hour filtered by this service" TimescaleDB is not even close to a fair fight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Spans: The Interesting Reversal
&lt;/h2&gt;

&lt;p&gt;The span (distributed tracing) results tell a different story from logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace query p50 at 10K records:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;TimescaleDB&lt;/th&gt;
&lt;th&gt;ClickHouse&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query all traces&lt;/td&gt;
&lt;td&gt;2.5ms&lt;/td&gt;
&lt;td&gt;23.6ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.6ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query error traces&lt;/td&gt;
&lt;td&gt;1.6ms&lt;/td&gt;
&lt;td&gt;22.6ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.3ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get trace by ID&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.29ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.3ms&lt;/td&gt;
&lt;td&gt;0.40ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service dependencies&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.42ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;179ms&lt;/td&gt;
&lt;td&gt;444ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MongoDB is faster than TimescaleDB on some trace queries at this scale. The reason: MongoDB's document model fits trace data naturally. A trace is a document with nested spans. The &lt;code&gt;queryTraces (all)&lt;/code&gt; query maps directly to a collection scan with a simple index lookup. TimescaleDB has to join spans to reconstruct traces.&lt;/p&gt;

&lt;p&gt;Both MongoDB and TimescaleDB stay well ahead of ClickHouse on span queries. ClickHouse at 10K concurrent span queries (50 parallel) takes &lt;strong&gt;1.76 seconds&lt;/strong&gt;. TimescaleDB handles the same load in &lt;strong&gt;10ms&lt;/strong&gt;. That's what "not designed for point lookups" looks like in practice.&lt;/p&gt;

&lt;p&gt;At 100K spans, the MongoDB advantage on trace queries disappears: &lt;code&gt;querySpans (by service)&lt;/code&gt; goes from 82ms to 159ms, while TimescaleDB holds at 0.65ms. The document model helps at smaller scales but doesn't index-skip the way hypertables do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Concurrency: The Story Nobody Tells
&lt;/h2&gt;

&lt;p&gt;Single-query latency is fine for benchmarks. Production workloads are concurrent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent log queries (50 parallel) p50:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Volume&lt;/th&gt;
&lt;th&gt;TimescaleDB&lt;/th&gt;
&lt;th&gt;ClickHouse&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1K&lt;/td&gt;
&lt;td&gt;6.8ms&lt;/td&gt;
&lt;td&gt;334ms&lt;/td&gt;
&lt;td&gt;665ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;6.7ms&lt;/td&gt;
&lt;td&gt;401ms&lt;/td&gt;
&lt;td&gt;792ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;6.2ms&lt;/td&gt;
&lt;td&gt;895ms&lt;/td&gt;
&lt;td&gt;2,380ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;6.2ms&lt;/td&gt;
&lt;td&gt;6,307ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TimescaleDB's concurrency numbers are remarkably flat. 50 parallel queries at 100K records: 6.2ms. Same 50 parallel queries at 1M records: still 6.2ms.&lt;/p&gt;

&lt;p&gt;ClickHouse at 50 parallel queries on 1M records: &lt;strong&gt;6.3 seconds&lt;/strong&gt;. PostgreSQL's connection-per-query model and MVCC handle concurrent readers without degradation. ClickHouse's columnar engine serializes heavy queries and saturates threads.&lt;/p&gt;

&lt;p&gt;This matters if you're running Logtide for a team. Multiple people with dashboards open, alert evaluations running in the background, scheduled reports firing that's concurrent load. TimescaleDB absorbs it. ClickHouse struggles with it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metrics: MongoDB's Surprise
&lt;/h2&gt;

&lt;p&gt;Metrics data was the unexpected MongoDB story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent metric queries (50 parallel) at 100K:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TimescaleDB&lt;/td&gt;
&lt;td&gt;6.3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse&lt;/td&gt;
&lt;td&gt;284.9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.7ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MongoDB beats ClickHouse on concurrent metric queries by 5x. The reason: our MongoDB metrics implementation uses the native &lt;code&gt;$percentile&lt;/code&gt; aggregation pipeline, which MongoDB handles efficiently in-memory at this scale. ClickHouse's columnar approach adds overhead for the many small aggregations typical of metrics dashboards.&lt;/p&gt;

&lt;p&gt;At 1K and 10K records, MongoDB's metric aggregations (avg, sum, min, max, percentiles) are all in the 11-17ms range faster than ClickHouse's 8-21ms range, and only slightly behind TimescaleDB's sub-millisecond performance.&lt;/p&gt;

&lt;p&gt;The catch that these latency numbers don't show: MongoDB stores metrics as BSON documents without time-series-specific compression. TimescaleDB uses columnar compression on hypertables, and ClickHouse uses Gorilla encoding (delta-of-delta) for floats and Delta encoding for timestamps algorithms designed specifically for the repetitive patterns in metrics data. In practice, the same year of metrics data will occupy significantly less disk on TimescaleDB or ClickHouse than on MongoDB. If storage cost matters at your scale, that tradeoff should factor into the decision.&lt;/p&gt;

&lt;p&gt;MongoDB won 4 out of 52 benchmark categories at 1K records, 2 at 10K. Small wins, but real ones mostly around span lookups by trace ID and narrow time range queries, where its document indexing shines.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Decision Framework
&lt;/h2&gt;

&lt;p&gt;After seeing these numbers, here's how we think about the choice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use TimescaleDB (default) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're running Logtide for a single team or SMB&lt;/li&gt;
&lt;li&gt;You're already comfortable with PostgreSQL operationally&lt;/li&gt;
&lt;li&gt;You want the lowest query latency across the board&lt;/li&gt;
&lt;li&gt;You have mixed concurrent load (dashboards + alerts + searches)&lt;/li&gt;
&lt;li&gt;You're on AWS RDS for PostgreSQL with TimescaleDB extension, or Aurora PostgreSQL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use ClickHouse when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're ingesting exclusively in large batches (10K+ per request)&lt;/li&gt;
&lt;li&gt;Your primary use case is analytical queries over months of historical data&lt;/li&gt;
&lt;li&gt;You have a dedicated ops team managing ClickHouse infrastructure&lt;/li&gt;
&lt;li&gt;You're on AWS EC2 with a self-managed ClickHouse cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use MongoDB when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're already running MongoDB in your infrastructure (DocumentDB, Atlas, FerretDB, Cosmos DB in Mongo mode)&lt;/li&gt;
&lt;li&gt;Your workload is trace-heavy with many individual document lookups&lt;/li&gt;
&lt;li&gt;You want to avoid running a separate database just for observability&lt;/li&gt;
&lt;li&gt;You're on AWS DocumentDB and don't want another managed service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;@logtide/reservoir&lt;/code&gt; abstraction means the application code doesn't care which engine you pick. You swap the config, run the migrations, and the same Logtide instance works on all three.&lt;/p&gt;




&lt;h2&gt;
  
  
  What These Numbers Don't Tell You
&lt;/h2&gt;

&lt;p&gt;Benchmarks lie in specific ways, and this one has a scale ceiling you should be aware of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1M records is not a large dataset.&lt;/strong&gt; A moderately busy production service can generate 1M logs in minutes. At 100M or 1B rows where real enterprise observability workloads live the picture changes. TimescaleDB's B-tree indexes eventually stop fitting in RAM. When that happens, queries start hitting disk and latency climbs non-linearly. ClickHouse's columnar format and extreme compression (often 10:1 or better for log data) means its working set stays in RAM much longer. At billion-row scale, the engines invert: ClickHouse's full-table scans become faster than TimescaleDB's index-misses.&lt;/p&gt;

&lt;p&gt;These benchmarks represent &lt;strong&gt;SMB-scale workloads&lt;/strong&gt; teams generating tens of millions of log entries per day, not hundreds of millions per hour. That's exactly Logtide's target. But if you're evaluating engines for a platform that will eventually ingest at Datadog or Cloudflare scale, treat the 1M results as a floor, not a ceiling.&lt;/p&gt;

&lt;p&gt;The other caveats: these tests ran on a single machine, fresh database, warm connection pool, no competing load. Production has network latency, shared compute, background vacuum processes (TimescaleDB), and background part merges (ClickHouse). The 400ms ClickHouse ingestion artifact gets worse under real-world conditions with high-frequency small writes from multiple SDK clients simultaneously.&lt;/p&gt;

&lt;p&gt;MongoDB's metrics performance advantage at small scale comes with a storage cost that isn't visible in these benchmarks: MongoDB doesn't compress numeric time-series data the way TimescaleDB (using columnar compression) or ClickHouse (using Gorilla/Delta-Delta encoding) do. The same metrics dataset will use significantly more disk and RAM on MongoDB at production scale.&lt;/p&gt;

&lt;p&gt;The benchmark suite is in the repo if you want to run it against your own infrastructure with your own dataset shapes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why TimescaleDB Won 96% of Tests
&lt;/h2&gt;

&lt;p&gt;The summary from the benchmark runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timescale     50 wins ( 96%)
clickhouse     0 wins (  0%)
mongodb        4 wins (  4%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero wins for ClickHouse isn't a bug in the benchmark it's a reflection of the workload. Observability query patterns are point lookups, short time ranges, service filters, and dashboard aggregations. That's TimescaleDB's wheelhouse.&lt;/p&gt;

&lt;p&gt;ClickHouse excels at full-table analytics. When you're doing &lt;code&gt;SELECT service, sum(errors) FROM logs WHERE month = 'February'&lt;/code&gt; across 500 million rows, ClickHouse will leave TimescaleDB behind. That query pattern doesn't dominate an observability dashboard. It dominates a data warehouse.&lt;/p&gt;

&lt;p&gt;We made the right call. But we're glad we have the numbers to prove it now.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;code&gt;@logtide/reservoir&lt;/code&gt; is open source&lt;/strong&gt; TimescaleDB, ClickHouse, and MongoDB adapters ship in &lt;a href="https://github.com/logtide-dev/logtide" rel="noopener noreferrer"&gt;Logtide 0.8.0&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;If you run it against your own setup and get different results, open an issue. We'd genuinely like to know.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>database</category>
      <category>aws</category>
      <category>performance</category>
    </item>
    <item>
      <title>Logtide 0.8.0: Browser Observability, MongoDB Support, and Golden Signals</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Sat, 14 Mar 2026 20:10:01 +0000</pubDate>
      <link>https://forem.com/polliog/logtide-080-browser-observability-mongodb-support-and-golden-signals-87i</link>
      <guid>https://forem.com/polliog/logtide-080-browser-observability-mongodb-support-and-golden-signals-87i</guid>
      <description>&lt;p&gt;Logtide 0.8.0 is out today. It's a release that started with a single promise from the 0.7.0 article: "full dashboard integration is the first thing on the 0.8.x list." We kept that promise, and then kept going.&lt;/p&gt;

&lt;p&gt;This is the release that closes three major open threads at once: browser observability, MongoDB support for &lt;code&gt;@logtide/reservoir&lt;/code&gt;, and Golden Signals with real percentile data. Plus a benchmark suite, smart project selectors, and enough performance work to make dashboards feel instant on large deployments.&lt;/p&gt;

&lt;p&gt;If you're new here: Logtide is an open-source log management and SIEM platform built for European SMBs. Privacy-first, self-hostable, GDPR-compliant. No Elastic cluster to babysit just Docker Compose and the storage engine of your choice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Cloud&lt;/strong&gt;: &lt;a href="https://logtide.dev" rel="noopener noreferrer"&gt;logtide.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/logtide-dev/logtide" rel="noopener noreferrer"&gt;logtide-dev/logtide&lt;/a&gt; (345+ ⭐)&lt;/li&gt;
&lt;li&gt;📖 &lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://logtide.dev/docs" rel="noopener noreferrer"&gt;logtide.dev/docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's New
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🌐 Browser SDK: Observability for Your Frontend
&lt;/h3&gt;

&lt;p&gt;Backend observability was already solid. Browser instrumentation was the gap. 0.8.0 closes it with &lt;code&gt;@logtide/browser&lt;/code&gt; a dedicated browser SDK built from the ground up, available as a drop-in addition to all existing framework packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session tracking&lt;/strong&gt; assigns a &lt;code&gt;session_id&lt;/code&gt; to each browser tab via &lt;code&gt;sessionStorage&lt;/code&gt;. That ID flows through the full stack SDK → ingestion → database column → reservoir layer → UI filter so you can slice any view by session and see exactly what a user experienced before an error fired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core Web Vitals&lt;/strong&gt; are collected automatically: LCP, INP, and CLS via the &lt;code&gt;web-vitals&lt;/code&gt; library, with a configurable sampling rate so you're not flooding your instance for low-traffic pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breadcrumbs&lt;/strong&gt; work on two axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Click breadcrumbs&lt;/em&gt; use event delegation to track click and input interactions. &lt;code&gt;data-testid&lt;/code&gt; attributes are captured when present. Input values are never captured.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Network breadcrumbs&lt;/em&gt; patch &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;XMLHttpRequest&lt;/code&gt; to record method, URL, status code, and duration. Query params are stripped by default; you can add a deny list for sensitive endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Offline resilience&lt;/strong&gt; wraps the transport layer with an &lt;code&gt;OfflineTransport&lt;/code&gt; that buffers logs and spans when connectivity drops (bounded queue, no unbounded memory growth), flushes on reconnect, and uses &lt;code&gt;sendBeacon&lt;/code&gt; on page unload so nothing is lost when the tab closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source maps&lt;/strong&gt; ship with a new &lt;code&gt;@logtide/cli&lt;/code&gt; package and a &lt;code&gt;logtide sourcemaps upload&lt;/code&gt; command. Upload your build artifacts once, and stack frames in error reports automatically show the original file, line, column, and function name. You can toggle between minified and original frames directly in the UI.&lt;/p&gt;

&lt;p&gt;Each framework got targeted improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt;: RSC error detection tagged with &lt;code&gt;mechanism: 'react.server-component'&lt;/code&gt;, route params from &lt;code&gt;__NEXT_DATA__&lt;/code&gt; in navigation breadcrumbs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nuxt&lt;/strong&gt;: &lt;code&gt;logtidePiniaPlugin&lt;/code&gt; for automatic Pinia action breadcrumbs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SvelteKit&lt;/strong&gt;: route context in &lt;code&gt;handleError&lt;/code&gt;, &lt;code&gt;createBoundaryHandler()&lt;/code&gt; for &lt;code&gt;&amp;lt;svelte:boundary&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Angular&lt;/strong&gt;: &lt;code&gt;NgZone&lt;/code&gt; context detection tagging errors as &lt;code&gt;angular.zone: 'inside'/'outside'&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Projects using the browser SDK automatically get two new dashboard tabs: &lt;strong&gt;Performance&lt;/strong&gt; (Web Vitals over time) and &lt;strong&gt;Sessions&lt;/strong&gt; (session-based filtering and replay context). The &lt;strong&gt;Capabilities API&lt;/strong&gt; (&lt;code&gt;GET /api/v1/projects/:id/capabilities&lt;/code&gt;) auto-detects whether a project has Web Vitals or Sessions data and shows those tabs only when relevant.&lt;/p&gt;




&lt;h3&gt;
  
  
  📈 Metrics Dashboard: The Dashboard We Promised in 0.7.0
&lt;/h3&gt;

&lt;p&gt;We shipped OTLP metrics ingestion in 0.7.0 with the store and API client ready but no visualization layer. 0.8.0 delivers it.&lt;/p&gt;

&lt;p&gt;The redesigned metrics page has two tabs: &lt;strong&gt;Overview&lt;/strong&gt; and &lt;strong&gt;Explorer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Overview groups your metrics by service. Each service gets a card with a sparkline (ECharts), plus latest, avg, min, and max values at a glance. The cards cross-link to traces and logs click a data point on a chart and jump straight to the traces in that time window. Service selection and time range are in a persistent header that stays in sync with URL parameters.&lt;/p&gt;

&lt;p&gt;Under the hood, Overview is powered by pre-aggregated rollups rather than scanning raw data on every load:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TimescaleDB&lt;/strong&gt;: &lt;code&gt;metrics_hourly_stats&lt;/code&gt; and &lt;code&gt;metrics_daily_stats&lt;/code&gt; continuous aggregates with automatic refresh policies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClickHouse&lt;/strong&gt;: &lt;code&gt;metrics_hourly_rollup&lt;/code&gt; and &lt;code&gt;metrics_daily_rollup&lt;/code&gt; materialized views&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB&lt;/strong&gt;: on-the-fly aggregation pipeline (no separate materialized views needed at this scale)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The query layer uses smart rollup routing: if your request is asking for 1h or 1d intervals with a compatible aggregation function, it hits the pre-aggregated table. Otherwise it falls back to raw data. You get dashboard speed without sacrificing query flexibility.&lt;/p&gt;




&lt;h3&gt;
  
  
  🍃 MongoDB Storage Adapter: &lt;code&gt;@logtide/reservoir&lt;/code&gt; Is Now a Tri-Engine System
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@logtide/reservoir&lt;/code&gt; launched with TimescaleDB and ClickHouse. 0.8.0 adds the third engine: MongoDB.&lt;/p&gt;

&lt;p&gt;All 33 &lt;code&gt;StorageEngine&lt;/code&gt; interface methods are implemented logs, spans, traces, metrics, and exemplars. The adapter ships with &lt;code&gt;MongoDBQueryTranslator&lt;/code&gt; for filter translation, a Docker Compose profile-gated MongoDB 7.0 service for local development, and full admin dashboard integration showing health status for all three engines.&lt;/p&gt;

&lt;p&gt;It also auto-detects MongoDB 5.0+ features: &lt;code&gt;$dateTrunc&lt;/code&gt; for time bucketing and native time-series collections when available, with fallback for older versions.&lt;/p&gt;

&lt;p&gt;The adapter comes with 100 tests: 34 unit tests and 66 integration tests covering the full query surface.&lt;/p&gt;

&lt;p&gt;Practical compatibility: if you're running DocumentDB, FerretDB, or Cosmos DB in MongoDB compatibility mode, the adapter works with those too. The storage layer stays fully abstracted swapping engines doesn't touch a line of application code.&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;// reservoir.config.ts&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;createStorageEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mongodb&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;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mongodb://localhost:27017/logtide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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;
  
  
  📊 Golden Signals with Percentiles
&lt;/h3&gt;

&lt;p&gt;Rate, errors, duration the four golden signals of observability. Duration without percentiles is noise. 0.8.0 adds P50, P95, and P99 aggregation across all three storage engines.&lt;/p&gt;

&lt;p&gt;The new Golden Signals panel has dedicated charts for request rate, error rate, and latency percentiles side by side. The percentile aggregation implementation is engine-native: &lt;code&gt;percentile_cont&lt;/code&gt; on TimescaleDB, &lt;code&gt;quantile&lt;/code&gt; on ClickHouse, &lt;code&gt;$percentile&lt;/code&gt; on MongoDB. No application-level approximation.&lt;/p&gt;

&lt;p&gt;You can filter by service name and additional attributes, and all three charts load in parallel.&lt;/p&gt;




&lt;h3&gt;
  
  
  Everything Else Worth Knowing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Smart project selectors&lt;/strong&gt;: project dropdowns throughout the app now only show projects that actually have data in the relevant category. If a project has no traces, it won't appear in the traces page selector. A new &lt;code&gt;GET /api/v1/projects/data-availability&lt;/code&gt; endpoint powers this, with graceful fallback to all projects if the check fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reservoir benchmark suite&lt;/strong&gt;: k6-based benchmarking scripts for ingestion and query workloads across all three engines. Seed up to 100k events per run. If you want to make an informed decision between TimescaleDB, ClickHouse, and MongoDB for your specific workload, this gives you a reproducible way to test it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom time range picker&lt;/strong&gt;: the &lt;code&gt;TimeRangePicker&lt;/code&gt; now supports arbitrary custom ranges, synced to URL parameters. Bookmark any time window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DSN copy on API key creation&lt;/strong&gt;: when you create a new API key, the dialog now shows the full DSN string (&lt;code&gt;https://KEY@host&lt;/code&gt;) ready to copy. One step instead of three.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance Work
&lt;/h2&gt;

&lt;p&gt;0.8.0 has more targeted performance work than any previous release. A few highlights:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TimescaleDB skip-scan via Recursive CTEs&lt;/strong&gt;: distinct queries on high-cardinality fields like &lt;code&gt;service&lt;/code&gt; were doing full table scans. Recursive CTEs implement the index skip-scan pattern PostgreSQL lacks natively, dropping execution time from minutes to milliseconds on large tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard intelligent optimization&lt;/strong&gt;: all three engines now support &lt;code&gt;countEstimate&lt;/code&gt; for approximate counts, bypassing heavy &lt;code&gt;COUNT(*)&lt;/code&gt; operations on high-volume projects. The dashboard loads instantly regardless of log volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MongoDB-specific&lt;/strong&gt;: &lt;code&gt;insertMany({ordered: false})&lt;/code&gt; for maximum write throughput, compound indexes matching actual query patterns, sparse indexes on nullable fields, atomic trace upsert with a single &lt;code&gt;bulkWrite&lt;/code&gt; (one network round trip), and cursor-based keyset pagination with &lt;code&gt;(time, id)&lt;/code&gt; tuples for consistent pagination under concurrent writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capabilities detection&lt;/strong&gt;: reduced the scanning range from 7 days to 24 hours for Web Vitals and Sessions detection, making the initial project dashboard load instant.&lt;/p&gt;




&lt;h2&gt;
  
  
  Upgrading
&lt;/h2&gt;

&lt;p&gt;No breaking changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis-free deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.simple.yml pull
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.simple.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To use the MongoDB adapter, enable the profile in your Compose setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; mongodb up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;0.8.0 closes the observability foundation. What's left before v1.0 (our beta milestone):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log parsing pipelines&lt;/strong&gt; (#152): structured extraction for syslog, legacy formats, and custom patterns without writing VRL transforms by hand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook receivers&lt;/strong&gt; (#154): ingest external events from GitHub, PagerDuty, Stripe, and others without custom code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proactive health monitoring&lt;/strong&gt; (#151): status pages built from the data already in Logtide, with uptime history and alerting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled digest reports&lt;/strong&gt; (#153): weekly email summaries of error trends, anomalies, and key metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The query abstraction layer is also a candidate for extraction as a standalone open-source library if you have thoughts on that, open a discussion.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Full Changelog&lt;/strong&gt;: &lt;a href="https://github.com/logtide-dev/logtide/compare/v0.7.0...v0.8.0" rel="noopener noreferrer"&gt;v0.7.0...v0.8.0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Star the project, open an issue, or just try it the Docker setup takes about 5 minutes.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>discuss</category>
    </item>
    <item>
      <title>PostgreSQL as a Vector Database: When to Use pgvector vs Pinecone vs Weaviate</title>
      <dc:creator>Polliog</dc:creator>
      <pubDate>Wed, 04 Mar 2026 11:12:38 +0000</pubDate>
      <link>https://forem.com/polliog/postgresql-as-a-vector-database-when-to-use-pgvector-vs-pinecone-vs-weaviate-4kfi</link>
      <guid>https://forem.com/polliog/postgresql-as-a-vector-database-when-to-use-pgvector-vs-pinecone-vs-weaviate-4kfi</guid>
      <description>&lt;p&gt;"Should we use PostgreSQL as our vector database?"&lt;/p&gt;

&lt;p&gt;I've heard this question &lt;strong&gt;a lot&lt;/strong&gt; in 2026. pgvector is everywhere. Every Postgres instance now has vector search capabilities.&lt;/p&gt;

&lt;p&gt;But is it &lt;strong&gt;actually&lt;/strong&gt; better than Pinecone or Weaviate?&lt;/p&gt;

&lt;p&gt;I tested all three with 10 million vectors (1536 dimensions, OpenAI embeddings). Here's what I found.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Vector Database Landscape in 2026
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quick summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pinecone&lt;/strong&gt;: Fully managed, serverless, 70% market share&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weaviate&lt;/strong&gt;: Hybrid search (vectors + BM25), open-source&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pgvector&lt;/strong&gt;: PostgreSQL extension, ACID compliance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The big shift in 2026:&lt;/strong&gt; pgvector is no longer "the slow option."&lt;/p&gt;

&lt;p&gt;With pgvectorscale (Timescale's addition), PostgreSQL now delivers &lt;strong&gt;471 QPS at 99% recall&lt;/strong&gt; on 50M vectors. That's &lt;strong&gt;11.4x better than Qdrant&lt;/strong&gt; and competitive with Pinecone.&lt;/p&gt;

&lt;p&gt;Let's break this down.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Even Is a Vector Database?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vectors&lt;/strong&gt; are just arrays of numbers that represent meaning:&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;# Text → Vector embedding
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;machine learning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.23&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.41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# 1536 dimensions
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deep learning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.21&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.39&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.91&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="mf"&gt;0.13&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Similar vector!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vector databases&lt;/strong&gt; let you find &lt;strong&gt;similar&lt;/strong&gt; vectors fast:&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;-- Find documents similar to "machine learning"&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;documents&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;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0.23, -0.41, ...]'&lt;/span&gt; 
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Use cases:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt;: Give LLMs relevant context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic search&lt;/strong&gt;: "Find products like this"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommendations&lt;/strong&gt;: "Users similar to you also liked..."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anomaly detection&lt;/strong&gt;: "This behavior is unusual"&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  pgvector: PostgreSQL's Vector Extension
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What It Is
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;pgvector&lt;/strong&gt; is an extension that adds vector data types and similarity search to PostgreSQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&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;documents&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;content&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;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- OpenAI embedding size&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Create HNSW index for fast similarity search&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The 2026 Performance Revolution
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before 2025:&lt;/strong&gt; pgvector was slow. "Use it for &amp;lt;1M vectors, then switch to Pinecone."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;March 2026:&lt;/strong&gt; pgvector + &lt;strong&gt;pgvectorscale&lt;/strong&gt; (from Timescale) changed everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmark results&lt;/strong&gt; (50M vectors, 1536 dims, 99% recall):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgvectorscale&lt;/strong&gt;: 471 QPS, &lt;strong&gt;p95 latency: 28ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinecone s1&lt;/strong&gt;: 471 QPS, p95 latency: &lt;strong&gt;784ms&lt;/strong&gt; (28x slower)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant&lt;/strong&gt;: 41 QPS (11.4x slower than pgvector)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source: &lt;a href="https://www.firecrawl.dev/blog/best-vector-databases" rel="noopener noreferrer"&gt;Timescale benchmarks, May 2025&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Features (pgvector 0.8.0, March 2026)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. HNSW Index&lt;/strong&gt; (Hierarchical Navigable Small World)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-layer graph for fast approximate search&lt;/li&gt;
&lt;li&gt;Sub-millisecond latency at high recall&lt;/li&gt;
&lt;li&gt;Configurable &lt;code&gt;m&lt;/code&gt; (connections) and &lt;code&gt;ef_construction&lt;/code&gt; (quality)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Iterative Scan&lt;/strong&gt; (New in 0.8.0)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fixes "overfiltering" problem with metadata filters&lt;/li&gt;
&lt;li&gt;Returns &lt;strong&gt;complete&lt;/strong&gt; result sets (not partial)&lt;/li&gt;
&lt;li&gt;5.7x query performance improvement over 0.7.4&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. ACID Transactions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full transactional guarantees&lt;/li&gt;
&lt;li&gt;Rollback support&lt;/li&gt;
&lt;li&gt;Consistency for vectors + relational data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. SQL Integration&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Combine vector search with JOINs, WHERE clauses, CTEs&lt;/li&gt;
&lt;li&gt;No context switching between databases&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Single-Node Scaling&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tested reliably up to &lt;strong&gt;10-50M vectors&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Beyond that, you need sharding (Citus, manual partitioning)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Infrastructure Requirements at Scale&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&amp;gt;10M vectors&lt;/strong&gt; requires optimized hardware:

&lt;ul&gt;
&lt;li&gt;Fast NVMe SSDs (index I/O is critical)&lt;/li&gt;
&lt;li&gt;High RAM (32-64GB+ for index caching)&lt;/li&gt;
&lt;li&gt;Parameter tuning (&lt;code&gt;m&lt;/code&gt;, &lt;code&gt;ef_search&lt;/code&gt;, &lt;code&gt;maintenance_work_mem&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;"Zero ops" is misleading at scale you'll spend time on infrastructure&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. No BuiltIn Embedding Models&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You bring your own embeddings&lt;/li&gt;
&lt;li&gt;Pinecone/Weaviate have hosted inference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. Performance Degrades with High Write Volume&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HNSW rebuild overhead&lt;/li&gt;
&lt;li&gt;Less optimized than purpose built vector DBs&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Pinecone: The Managed Leader
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What It Is
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pinecone&lt;/strong&gt; is a fully managed, serverless vector database. No infrastructure, no ops.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pinecone&lt;/span&gt;

&lt;span class="n"&gt;pinecone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pinecone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Upsert vectors
&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vectors&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id1&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="mf"&gt;0.23&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.41&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&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;ml&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;id2&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="mf"&gt;0.21&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.39&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&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;dl&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="c1"&gt;# Query
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&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="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.23&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.41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
    &lt;span class="n"&gt;top_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="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;category&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;ml&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pricing (2026 Serverless)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost Type&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.33/GB/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Read Units&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$8.25 per 1M reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write Units&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$2.00 per 1M writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Minimum&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$50/month (Standard), $500/month (Enterprise)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt; (5M vectors, 500K queries/month):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Storage: 10GB × $0.33 = &lt;strong&gt;$3.30/month&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Reads: 500K × $8.25/M = &lt;strong&gt;$4.13/month&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Writes: 50K × $2/M = &lt;strong&gt;$0.10/month&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total: ~$58/month&lt;/strong&gt; (including minimum)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Read units are &lt;strong&gt;unpredictable&lt;/strong&gt;. A query with metadata filters can consume &lt;strong&gt;5-10 read units&lt;/strong&gt;, not 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pros
&lt;/h3&gt;

&lt;p&gt;✅ &lt;strong&gt;Zero ops&lt;/strong&gt; - No servers, no tuning, no maintenance&lt;br&gt;
✅ &lt;strong&gt;Auto-scaling&lt;/strong&gt; - Handle traffic spikes automatically&lt;br&gt;
✅ &lt;strong&gt;Compliance&lt;/strong&gt; - SOC 2, HIPAA, GDPR out-of-the-box&lt;br&gt;
✅ &lt;strong&gt;Consistent latency&lt;/strong&gt; - 20-100ms p95 (production-ready)&lt;br&gt;
✅ &lt;strong&gt;Hosted embeddings&lt;/strong&gt; - Pinecone Inference for models&lt;/p&gt;
&lt;h3&gt;
  
  
  Cons
&lt;/h3&gt;

&lt;p&gt;❌ &lt;strong&gt;Expensive at scale&lt;/strong&gt; - Above 10M vectors, costs escalate&lt;br&gt;
❌ &lt;strong&gt;Vendor lock-in&lt;/strong&gt; - Proprietary API, migration is painful&lt;br&gt;
❌ &lt;strong&gt;Read unit unpredictability&lt;/strong&gt; - Hard to forecast costs&lt;br&gt;
❌ &lt;strong&gt;No ACID transactions&lt;/strong&gt; - Purpose-built, not general DB&lt;/p&gt;


&lt;h2&gt;
  
  
  Weaviate: The Hybrid Search Specialist
&lt;/h2&gt;
&lt;h3&gt;
  
  
  What It Is
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Weaviate&lt;/strong&gt; combines &lt;strong&gt;vector similarity&lt;/strong&gt; with &lt;strong&gt;BM25 keyword search&lt;/strong&gt;. Best for semantic + keyword hybrid retrieval.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;weaviate&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weaviate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-cluster.weaviate.network&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Hybrid search (vector + keyword)
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Document&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;content&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="nf"&gt;with_hybrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;machine learning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;  &lt;span class="c1"&gt;# 75% vector, 25% BM25
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt; \
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_limit&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;do&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pricing (2026 Shared Cloud)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Features&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flex&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$45/month&lt;/td&gt;
&lt;td&gt;Shared, HA, 99.5% uptime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$280/month (annual)&lt;/td&gt;
&lt;td&gt;Shared or Dedicated, 99.9% uptime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Premium&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Dedicated, 99.95% uptime, HIPAA&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Pricing dimensions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector dimensions&lt;/strong&gt;: $0.095 per 1M dimensions/month (Standard tier)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt;: Variable by region/compression&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt;: Based on volume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt; (10M vectors, 1536 dims):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dimensions: 10M × 1536 = 15.36B dims&lt;/li&gt;
&lt;li&gt;Cost: 15,360M dims × $0.095/M = &lt;strong&gt;~$1,459/month&lt;/strong&gt; (ballpark)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pros
&lt;/h3&gt;

&lt;p&gt;✅ &lt;strong&gt;Hybrid search&lt;/strong&gt; - Combine semantic + keyword (unique strength)&lt;br&gt;
✅ &lt;strong&gt;Multi-modal&lt;/strong&gt; - Text, images, audio in one index&lt;br&gt;
✅ &lt;strong&gt;Open-source&lt;/strong&gt; - Selfhost option for full control&lt;br&gt;
✅ &lt;strong&gt;GraphQL API&lt;/strong&gt; - Powerful filtering/aggregation&lt;br&gt;
✅ &lt;strong&gt;BM25 built-in&lt;/strong&gt; - No separate keyword index needed&lt;/p&gt;

&lt;h3&gt;
  
  
  Cons
&lt;/h3&gt;

&lt;p&gt;❌ &lt;strong&gt;Complexity&lt;/strong&gt; - Steeper learning curve than Pinecone&lt;br&gt;
❌ &lt;strong&gt;Higher costs&lt;/strong&gt; - Vector dimensions pricing scales fast&lt;br&gt;
❌ &lt;strong&gt;Self-hosting burden&lt;/strong&gt; - Need DevOps for production&lt;br&gt;
❌ &lt;strong&gt;No ACID&lt;/strong&gt; - Like Pinecone, not a general database&lt;/p&gt;




&lt;h2&gt;
  
  
  Head-to-Head Benchmark (10M Vectors)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test setup:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dataset&lt;/strong&gt;: 10M vectors, 1536 dimensions (OpenAI &lt;code&gt;text-embedding-3-small&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware&lt;/strong&gt;: AWS r6g.xlarge (4 vCPU, 32GB RAM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query&lt;/strong&gt;: Top-10 similarity search, 95% recall target&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filters&lt;/strong&gt;: 20% of queries with metadata filters (&lt;code&gt;category = 'X'&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Query Latency
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;P50 Latency&lt;/th&gt;
&lt;th&gt;P95 Latency&lt;/th&gt;
&lt;th&gt;P99 Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pgvector 0.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;22ms&lt;/td&gt;
&lt;td&gt;78ms&lt;/td&gt;
&lt;td&gt;142ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pinecone (serverless)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;45ms&lt;/td&gt;
&lt;td&gt;103ms&lt;/td&gt;
&lt;td&gt;187ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Weaviate (shared)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;38ms&lt;/td&gt;
&lt;td&gt;95ms&lt;/td&gt;
&lt;td&gt;168ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Winner&lt;/strong&gt;: pgvector (fastest p50/p95)&lt;/p&gt;

&lt;h3&gt;
  
  
  Write Throughput (Inserts/sec)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pgvector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8,500/sec&lt;/td&gt;
&lt;td&gt;HNSW rebuild overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pinecone&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12,000/sec&lt;/td&gt;
&lt;td&gt;Write units charged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Weaviate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10,200/sec&lt;/td&gt;
&lt;td&gt;Depends on compression&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Winner&lt;/strong&gt;: Pinecone (optimized for writes)&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage Efficiency
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Raw Size&lt;/th&gt;
&lt;th&gt;Compressed&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pgvector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;60 GB&lt;/td&gt;
&lt;td&gt;60 GB&lt;/td&gt;
&lt;td&gt;1x (no compression)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pinecone&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;~55 GB&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Weaviate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;62 GB&lt;/td&gt;
&lt;td&gt;42 GB&lt;/td&gt;
&lt;td&gt;1.5x (with PQ)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Winner&lt;/strong&gt;: Weaviate (best compression)&lt;/p&gt;

&lt;h3&gt;
  
  
  Recall @ Top-10
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pgvector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;95.2%&lt;/td&gt;
&lt;td&gt;HNSW m=16, ef_search=40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pinecone&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;96.1%&lt;/td&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Weaviate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;95.8%&lt;/td&gt;
&lt;td&gt;Default HNSW&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Winner&lt;/strong&gt;: Pinecone (slightly better recall)&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Comparison (Real Production Numbers)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario&lt;/strong&gt;: 10M vectors, 1536 dims, 500K queries/month&lt;/p&gt;

&lt;h3&gt;
  
  
  pgvector (Self-Hosted)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS RDS PostgreSQL&lt;/strong&gt; (r6g.xlarge): $180/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt; (60GB): $14/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt;: $8/month&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: $202/month&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Plus:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DevOps time: ~4 hours/month (monitoring, updates)&lt;/li&gt;
&lt;li&gt;No per-query costs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pinecone (Serverless)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt; (55GB): $18/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads&lt;/strong&gt; (500K): $41/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes&lt;/strong&gt; (50K): $1/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total: $60/month&lt;/strong&gt; (but scales with usage)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At 5M queries/month:&lt;/strong&gt; ~$430/month&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaviate (Shared Cloud, Plus Plan)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base plan&lt;/strong&gt;: $280/month (annual)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector dimensions&lt;/strong&gt; (15.36B): ~$1,459/month&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: ~$1,740/month&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(Note: Pricing varies by region/compression; this is approximate)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost Winner
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small scale (&amp;lt;1M vectors, &amp;lt;100K queries/month):&lt;/strong&gt; Pinecone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium scale (1-10M vectors, 500K queries/month):&lt;/strong&gt; pgvector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large scale (10M+ vectors, high query volume):&lt;/strong&gt; Pinecone or self-hosted Weaviate&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Use Each
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Choose &lt;strong&gt;pgvector&lt;/strong&gt; if:
&lt;/h3&gt;

&lt;p&gt;✅ You &lt;strong&gt;already run PostgreSQL&lt;/strong&gt; (zero new infrastructure)&lt;br&gt;
✅ You need &lt;strong&gt;ACID transactions&lt;/strong&gt; (vectors + relational data)&lt;br&gt;
✅ You want &lt;strong&gt;SQL flexibility&lt;/strong&gt; (JOINs, complex queries)&lt;br&gt;
✅ Cost matters (&lt;strong&gt;75% cheaper than Pinecone&lt;/strong&gt; self-hosted)&lt;br&gt;
✅ Your scale is &lt;strong&gt;&amp;lt;10M vectors&lt;/strong&gt; (proven sweet spot)&lt;br&gt;
✅ You have &lt;strong&gt;DevOps capacity&lt;/strong&gt; for Postgres management&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Reality check for &amp;gt;10M vectors:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You'll need &lt;strong&gt;optimized hardware&lt;/strong&gt; (fast NVMe SSDs, 32-64GB RAM)&lt;/li&gt;
&lt;li&gt;Expect to tune &lt;strong&gt;HNSW parameters&lt;/strong&gt; (&lt;code&gt;m&lt;/code&gt;, &lt;code&gt;ef_search&lt;/code&gt;, &lt;code&gt;maintenance_work_mem&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Index builds can take &lt;strong&gt;hours&lt;/strong&gt; on large datasets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinecone's "zero ops" sometimes justifies the cost&lt;/strong&gt; to avoid these infrastructure headaches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Startups with existing Postgres infrastructure&lt;/li&gt;
&lt;li&gt;Apps needing vectors + relational data together&lt;/li&gt;
&lt;li&gt;Cost-sensitive projects (&amp;lt;10M vectors)&lt;/li&gt;
&lt;li&gt;RAG with SQL-heavy data pipelines&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Choose &lt;strong&gt;Pinecone&lt;/strong&gt; if:
&lt;/h3&gt;

&lt;p&gt;✅ You want &lt;strong&gt;zero ops&lt;/strong&gt; (fully managed, no servers)&lt;br&gt;
✅ You need to &lt;strong&gt;ship fast&lt;/strong&gt; (production in hours, not weeks)&lt;br&gt;
✅ &lt;strong&gt;Compliance&lt;/strong&gt; is non-negotiable (SOC 2, HIPAA built-in)&lt;br&gt;
✅ You don't have &lt;strong&gt;DevOps capacity&lt;/strong&gt;&lt;br&gt;
✅ &lt;strong&gt;Consistent latency&lt;/strong&gt; is critical (p95 &amp;lt;100ms guaranteed)&lt;br&gt;
✅ Your scale is &lt;strong&gt;unpredictable&lt;/strong&gt; (auto-scaling)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enterprises with strict compliance requirements&lt;/li&gt;
&lt;li&gt;Teams without infrastructure expertise&lt;/li&gt;
&lt;li&gt;MVP/prototype that needs to scale fast&lt;/li&gt;
&lt;li&gt;Apps with variable traffic (serverless shines)&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Choose &lt;strong&gt;Weaviate&lt;/strong&gt; if:
&lt;/h3&gt;

&lt;p&gt;✅ You need &lt;strong&gt;hybrid search&lt;/strong&gt; (vectors + BM25 keywords)&lt;br&gt;
✅ You're working with &lt;strong&gt;multi-modal data&lt;/strong&gt; (text, images, audio)&lt;br&gt;
✅ You want &lt;strong&gt;open-source&lt;/strong&gt; with self-host option&lt;br&gt;
✅ &lt;strong&gt;Advanced filtering&lt;/strong&gt; is critical (payload-aware HNSW)&lt;br&gt;
✅ You need &lt;strong&gt;GraphQL&lt;/strong&gt; for complex queries&lt;br&gt;
✅ Cost predictability matters (resource-based, not per-query)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enterprise RAG with strict data sovereignty&lt;/li&gt;
&lt;li&gt;Multi-modal AI applications&lt;/li&gt;
&lt;li&gt;Teams with strong DevOps (self-hosted)&lt;/li&gt;
&lt;li&gt;Apps needing semantic + keyword search&lt;/li&gt;
&lt;/ul&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;pgvector&lt;/th&gt;
&lt;th&gt;Pinecone&lt;/th&gt;
&lt;th&gt;Weaviate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zero ops&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ (cloud) / ❌ (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ACID transactions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hybrid search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost (&amp;lt;10M vectors)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compliance (built-in)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL integration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-modal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scalability (&amp;gt;50M)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Migration Paths
&lt;/h2&gt;

&lt;h3&gt;
  
  
  From Pinecone → pgvector
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; Cost savings (75%+ reduction)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Export vectors from Pinecone (use their API)&lt;/li&gt;
&lt;li&gt;Load into PostgreSQL with COPY&lt;/li&gt;
&lt;li&gt;Create HNSW index&lt;/li&gt;
&lt;li&gt;Dual-write during transition&lt;/li&gt;
&lt;li&gt;Cutover reads incrementally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Pinecone's metadata filters → PostgreSQL WHERE clauses&lt;/p&gt;

&lt;h3&gt;
  
  
  From pgvector → Pinecone
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; Scale beyond 10M vectors, reduce ops burden&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Export from PostgreSQL&lt;/li&gt;
&lt;li&gt;Upsert to Pinecone (batch API)&lt;/li&gt;
&lt;li&gt;Dual-write during transition&lt;/li&gt;
&lt;li&gt;Validate recall/latency&lt;/li&gt;
&lt;li&gt;Cutover&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; SQL queries → Pinecone API calls (rewrite logic)&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-World Use Cases (2026)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  RAG for Customer Support (Weaviate)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Company&lt;/strong&gt;: Neople (game publisher)&lt;br&gt;
&lt;strong&gt;Scale&lt;/strong&gt;: 5M support documents&lt;br&gt;
&lt;strong&gt;Why Weaviate&lt;/strong&gt;: Hybrid search (semantic + keyword) for accurate retrieval&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;40% reduction in false positives&lt;/li&gt;
&lt;li&gt;Sub-50ms query latency&lt;/li&gt;
&lt;li&gt;Native BM25 for exact phrase matching&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Internal Document Search (pgvector)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Company&lt;/strong&gt;: Mid-size SaaS (15M ARR)&lt;br&gt;
&lt;strong&gt;Scale&lt;/strong&gt;: 2M documents&lt;br&gt;
&lt;strong&gt;Why pgvector&lt;/strong&gt;: Already running Postgres, needed ACID&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$0 new infrastructure costs&lt;/li&gt;
&lt;li&gt;Combined vector search with user permissions (SQL JOINs)&lt;/li&gt;
&lt;li&gt;95% recall, &amp;lt;100ms p95 latency&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Product Recommendations (Pinecone)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Company&lt;/strong&gt;: E-commerce (50M products)&lt;br&gt;
&lt;strong&gt;Scale&lt;/strong&gt;: 50M vectors&lt;br&gt;
&lt;strong&gt;Why Pinecone&lt;/strong&gt;: Auto-scaling for Black Friday traffic&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero ops overhead&lt;/li&gt;
&lt;li&gt;Handled 100K QPS spike (auto-scaled)&lt;/li&gt;
&lt;li&gt;99.9% uptime SLA&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2026 Industry Trends
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. pgvector adoption exploded&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every major Postgres hosting platform now supports pgvector&lt;/li&gt;
&lt;li&gt;Supabase, Neon, Timescale, AWS RDS, GCP Cloud SQL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Hybrid search is table-stakes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Weaviate's BM25 + vector is now expected&lt;/li&gt;
&lt;li&gt;Pinecone added sparse vectors (SPLADE) in 2024&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Serverless won&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pinecone eliminated "pod management" in 2024&lt;/li&gt;
&lt;li&gt;Pay-as-you-go is the default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. Compliance pressure&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GDPR, HIPAA, SOC 2 are non-negotiable for enterprise&lt;/li&gt;
&lt;li&gt;Pinecone/Weaviate have certifications; pgvector = DIY&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Myths Debunked
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Myth 1:&lt;/strong&gt; "pgvector is too slow for production"&lt;br&gt;
&lt;strong&gt;Truth:&lt;/strong&gt; pgvectorscale delivers 471 QPS at 99% recall (competitive with Pinecone)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myth 2:&lt;/strong&gt; "Purpose-built vector DBs are always faster"&lt;br&gt;
&lt;strong&gt;Truth:&lt;/strong&gt; At &amp;lt;10M vectors, pgvector matches or beats dedicated DBs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myth 3:&lt;/strong&gt; "Pinecone is expensive"&lt;br&gt;
&lt;strong&gt;Truth:&lt;/strong&gt; Serverless pricing is competitive &lt;strong&gt;at low scale&lt;/strong&gt;; costs escalate above 10M vectors&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Myth 4:&lt;/strong&gt; "You need a vector DB for RAG"&lt;br&gt;
&lt;strong&gt;Truth:&lt;/strong&gt; For &amp;lt;1M documents, pgvector is perfect (and you're already running Postgres)&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pgvector is no longer "the slow option."&lt;/strong&gt; In 2026, it's a &lt;strong&gt;legitimate competitor&lt;/strong&gt; to Pinecone and Weaviate for most use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But&lt;/strong&gt; — at scale (&amp;gt;10M vectors), the "zero cost" advantage diminishes when you factor in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hardware upgrades (fast SSDs, high RAM)&lt;/li&gt;
&lt;li&gt;DevOps time (index tuning, performance monitoring)&lt;/li&gt;
&lt;li&gt;Operational complexity (backup strategies, index rebuilds)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pinecone's "zero ops" can justify the 2-3x cost premium&lt;/strong&gt; if infrastructure headaches aren't worth your team's time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision framework:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prototype/MVP&lt;/strong&gt; → Start with pgvector (if you have Postgres)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise compliance&lt;/strong&gt; → Pinecone (SOC 2/HIPAA out-of-box)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search&lt;/strong&gt; → Weaviate (semantic + keyword)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost-sensitive (&amp;lt;10M vectors)&lt;/strong&gt; → pgvector (self-hosted)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale &amp;gt;10M vectors&lt;/strong&gt; → Pinecone (unless you have strong DevOps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale &amp;gt;50M vectors&lt;/strong&gt; → Pinecone or self-hosted Weaviate (dedicated team)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;My take:&lt;/strong&gt; If you're already running PostgreSQL and staying under 10M vectors, pgvector is a no-brainer. Beyond that, seriously evaluate whether &lt;strong&gt;saving money is worth the infrastructure complexity&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's your vector database setup?&lt;/strong&gt; Share your experience in the comments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Resources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.pinecone.io" rel="noopener noreferrer"&gt;Pinecone Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://weaviate.io/developers/weaviate" rel="noopener noreferrer"&gt;Weaviate Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.timescale.com/blog/pgvector-vs-pinecone" rel="noopener noreferrer"&gt;Timescale pgvectorscale Benchmarks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>rag</category>
      <category>vectordatabase</category>
    </item>
  </channel>
</rss>
